diff --git a/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json b/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json index ceac205..105b3a5 100644 --- a/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/moves.json @@ -2,22 +2,22 @@ "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 } + { "schemaVersion": 1, "id": "projectcapture:tackle", "displayName": "Tackle", "type": "NORMAL", "category": "PHYSICAL", "power": 28, "accuracy": 0.95, "priority": 0, "role": "BASIC", "cooldownSeconds": 1.4, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:guard-stance", "displayName": "Guard Stance", "type": "NORMAL", "category": "STATUS", "power": 0, "accuracy": 1.0, "priority": 1, "role": "COMMAND", "cooldownSeconds": 9.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:leafy-burst", "displayName": "Leafy Burst", "type": "GRASS", "category": "SPECIAL", "power": 42, "accuracy": 0.90, "priority": 0, "role": "COMMAND", "cooldownSeconds": 5.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:spore-veil", "displayName": "Spore Veil", "type": "GRASS", "category": "SPECIAL", "power": 18, "accuracy": 0.92, "priority": 0, "role": "COMMAND", "cooldownSeconds": 8.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:seed-pulse", "displayName": "Seed Pulse", "type": "GRASS", "category": "SPECIAL", "power": 58, "accuracy": 0.88, "priority": 0, "role": "COMMAND", "cooldownSeconds": 7.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:ember-dart", "displayName": "Ember Dart", "type": "FIRE", "category": "SPECIAL", "power": 31, "accuracy": 0.95, "priority": 0, "role": "BASIC", "cooldownSeconds": 1.6, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:heat-wave", "displayName": "Heat Wave", "type": "FIRE", "category": "SPECIAL", "power": 52, "accuracy": 0.85, "priority": 0, "role": "COMMAND", "cooldownSeconds": 6.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:flame-guard", "displayName": "Flame Guard", "type": "FIRE", "category": "STATUS", "power": 0, "accuracy": 1.0, "priority": 1, "role": "COMMAND", "cooldownSeconds": 9.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:sky-dive", "displayName": "Sky Dive", "type": "FLYING", "category": "PHYSICAL", "power": 64, "accuracy": 0.85, "priority": 0, "role": "COMMAND", "cooldownSeconds": 8.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:water-jet", "displayName": "Water Jet", "type": "WATER", "category": "SPECIAL", "power": 30, "accuracy": 0.95, "priority": 0, "role": "BASIC", "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:shell-guard", "displayName": "Shell Guard", "type": "WATER", "category": "STATUS", "power": 0, "accuracy": 1.0, "priority": 1, "role": "COMMAND", "cooldownSeconds": 8.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:mist-surge", "displayName": "Mist Surge", "type": "WATER", "category": "SPECIAL", "power": 49, "accuracy": 0.90, "priority": 0, "role": "COMMAND", "cooldownSeconds": 6.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:static-spark", "displayName": "Static Spark", "type": "ELECTRIC", "category": "SPECIAL", "power": 33, "accuracy": 0.95, "priority": 0, "role": "BASIC", "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:quick-step", "displayName": "Quick Step", "type": "NORMAL", "category": "PHYSICAL", "power": 24, "accuracy": 0.98, "priority": 1, "role": "COMMAND", "cooldownSeconds": 3.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:thunder-arc", "displayName": "Thunder Arc", "type": "ELECTRIC", "category": "SPECIAL", "power": 61, "accuracy": 0.88, "priority": 0, "role": "COMMAND", "cooldownSeconds": 7.0, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:morph-strike", "displayName": "Morph Strike", "type": "NORMAL", "category": "PHYSICAL", "power": 26, "accuracy": 0.95, "priority": 0, "role": "BASIC", "cooldownSeconds": 1.5, "unofficialFanContent": true }, + { "schemaVersion": 1, "id": "projectcapture:mirror-form", "displayName": "Mirror Form", "type": "NORMAL", "category": "STATUS", "power": 0, "accuracy": 1.0, "priority": 0, "role": "COMMAND", "cooldownSeconds": 12.0, "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 index baa6f70..144b4d4 100644 --- a/content-pokemon/src/main/resources/projectcapture/content/v1/species.json +++ b/content-pokemon/src/main/resources/projectcapture/content/v1/species.json @@ -3,81 +3,81 @@ "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 }, + "schemaVersion": 1, "id": "projectcapture:bulbasaur", "displayName": "Bulbasaur", "catalogNumber": 1, "form": "BASE", "types": ["GRASS", "POISON"], + "rarity": "UNCOMMON", "captureRate": 199, "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 }, + "schemaVersion": 1, "id": "projectcapture:ivysaur", "displayName": "Ivysaur", "catalogNumber": 2, "form": "STAGE_1", "types": ["GRASS", "POISON"], + "rarity": "RARE", "captureRate": 148, "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 }, + "schemaVersion": 1, "id": "projectcapture:venusaur", "displayName": "Venusaur", "catalogNumber": 3, "form": "STAGE_2", "types": ["GRASS", "POISON"], + "rarity": "EPIC", "captureRate": 71, "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 }, + "schemaVersion": 1, "id": "projectcapture:charmander", "displayName": "Charmander", "catalogNumber": 4, "form": "BASE", "types": ["FIRE"], + "rarity": "UNCOMMON", "captureRate": 194, "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 }, + "schemaVersion": 1, "id": "projectcapture:charmeleon", "displayName": "Charmeleon", "catalogNumber": 5, "form": "STAGE_1", "types": ["FIRE"], + "rarity": "RARE", "captureRate": 140, "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 }, + "schemaVersion": 1, "id": "projectcapture:charizard", "displayName": "Charizard", "catalogNumber": 6, "form": "STAGE_2", "types": ["FIRE", "FLYING"], + "rarity": "EPIC", "captureRate": 61, "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 }, + "schemaVersion": 1, "id": "projectcapture:squirtle", "displayName": "Squirtle", "catalogNumber": 7, "form": "BASE", "types": ["WATER"], + "rarity": "UNCOMMON", "captureRate": 196, "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 }, + "schemaVersion": 1, "id": "projectcapture:wartortle", "displayName": "Wartortle", "catalogNumber": 8, "form": "STAGE_1", "types": ["WATER"], + "rarity": "RARE", "captureRate": 145, "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 }, + "schemaVersion": 1, "id": "projectcapture:blastoise", "displayName": "Blastoise", "catalogNumber": 9, "form": "STAGE_2", "types": ["WATER"], + "rarity": "EPIC", "captureRate": 64, "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 }, + "schemaVersion": 1, "id": "projectcapture:pikachu", "displayName": "Pikachu", "catalogNumber": 25, "form": "BASE", "types": ["ELECTRIC"], + "rarity": "UNCOMMON", "captureRate": 179, "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 }, + "schemaVersion": 1, "id": "projectcapture:raichu", "displayName": "Raichu", "catalogNumber": 26, "form": "STAGE_1", "types": ["ELECTRIC"], + "rarity": "RARE", "captureRate": 99, "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 }, + "schemaVersion": 1, "id": "projectcapture:ditto", "displayName": "Ditto", "catalogNumber": 132, "form": "SINGLE", "types": ["NORMAL"], + "rarity": "RARE", "captureRate": 122, "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/build.gradle.kts b/content-tools/build.gradle.kts index 72b5fc2..89ae5d3 100644 --- a/content-tools/build.gradle.kts +++ b/content-tools/build.gradle.kts @@ -16,6 +16,7 @@ java { dependencies { implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0") + testImplementation(project(":engine")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java index 55b7088..4818b64 100644 --- a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentValidator.java @@ -20,6 +20,12 @@ 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 ENGINE_ELEMENT_TYPES = Set.of( + "NORMAL", "GRASS", "POISON", "FIRE", "FLYING", "WATER", "ELECTRIC", "PSYCHIC", + "GROUND", "ROCK", "ICE", "BUG", "FIGHTING", "GHOST", "DRAGON" + ); + static final Set ENGINE_RARITIES = Set.of("COMMON", "UNCOMMON", "RARE", "EPIC", "LEGENDARY"); + static final Set ENGINE_MOVE_CATEGORIES = Set.of("PHYSICAL", "SPECIAL", "STATUS"); static final Set UNOFFICIAL_SLICE_SPECIES = Set.of( "projectcapture:bulbasaur", @@ -165,11 +171,24 @@ public final class ContentValidator { 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); + requireCurrentSchema(move, location, report); + rejectLegacyField(move, "name", location, report); + rejectLegacyField(move, "element", location, report); + requireText(move, "displayName", location + ".displayName", report); + requireEnum(move, "type", ENGINE_ELEMENT_TYPES, location, report); + String category = requireEnum(move, "category", ENGINE_MOVE_CATEGORIES, location, report); + int power = requireIntegerRange(move, "power", 0, 250, location, report); + requireNumberRange(move, "accuracy", 0.0, 1.0, location, report); + requireIntegerRange(move, "priority", -7, 7, 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 (!category.isEmpty() && !"STATUS".equals(category) && power == 0) { + report.add( + "MOVE_POWER_CATEGORY", + location + ".power", + "Engine damaging moves must have positive power" + ); + } if (!id.isEmpty()) { roles.putIfAbsent(id, role); } @@ -298,14 +317,18 @@ public final class ContentValidator { if (!id.isEmpty() && !ids.add(id)) { report.add("DUPLICATE_ID", location + ".id", "Duplicate species ID: " + id); } + requireCurrentSchema(species, location, report); 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); + rejectLegacyField(species, "elements", location, report); + rejectLegacyField(species, "captureDifficulty", location, report); + requireEnumArray(species.path("types"), ENGINE_ELEMENT_TYPES, 1, 2, location + ".types", report); + requireEnum(species, "rarity", ENGINE_RARITIES, location, report); + requireIntegerRange(species, "captureRate", 1, 255, location, report); JsonNode stats = species.path("baseStats"); for (String stat : List.of("vitality", "power", "guard", "agility")) { requireIntegerRange(stats, stat, 1, 255, location + ".baseStats", report); @@ -594,6 +617,58 @@ public final class ContentValidator { } } + private void requireCurrentSchema(JsonNode node, String location, ValidationReport report) { + JsonNode version = node.path("schemaVersion"); + if (!version.isIntegralNumber() || version.asInt() != ContentBundle.CURRENT_SCHEMA_VERSION) { + report.add( + "SCHEMA_VERSION", + location + ".schemaVersion", + "Expected engine-compatible schemaVersion " + ContentBundle.CURRENT_SCHEMA_VERSION + ); + } + } + + private void rejectLegacyField(JsonNode node, String field, String location, ValidationReport report) { + if (node.has(field)) { + report.add( + "LEGACY_FIELD", + location + "." + field, + "Legacy field is not allowed in the engine-aligned schema" + ); + } + } + + private void requireEnumArray( + JsonNode node, + Set allowed, + int minimumSize, + int maximumSize, + String location, + ValidationReport report + ) { + requireNonEmptyTextArray(node, location, report); + if (!node.isArray()) { + return; + } + if (node.size() < minimumSize || node.size() > maximumSize) { + report.add( + "ARRAY_SIZE", + location, + "Expected " + minimumSize + ".." + maximumSize + " values" + ); + } + for (int index = 0; index < node.size(); index++) { + JsonNode value = node.path(index); + if (value.isTextual() && !allowed.contains(value.asText())) { + report.add( + "ENUM_VALUE", + location + "[" + index + "]", + "Expected one of " + allowed + ", got " + value.asText() + ); + } + } + } + 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"); diff --git a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java index 48a9bf2..c2d0a4a 100644 --- a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java +++ b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java @@ -3,6 +3,9 @@ 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 dev.projectcapture.engine.model.ElementType; +import dev.projectcapture.engine.model.MoveDefinition; +import dev.projectcapture.engine.model.SpeciesDefinition; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -46,6 +49,45 @@ class ContentValidatorTest { assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids); } + @Test + void allTwelveSpeciesUseEngineElementRarityAndCaptureSchema() throws IOException { + JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items"); + + assertEquals(12, species.size()); + for (JsonNode definition : species) { + assertEquals(SpeciesDefinition.CURRENT_SCHEMA_VERSION, definition.path("schemaVersion").asInt()); + assertTrue(definition.path("types").size() >= 1 && definition.path("types").size() <= 2); + for (JsonNode type : definition.path("types")) { + ElementType.valueOf(type.asText()); + } + SpeciesDefinition.Rarity.valueOf(definition.path("rarity").asText()); + assertTrue(definition.path("captureRate").asInt() >= 1); + assertTrue(definition.path("captureRate").asInt() <= 255); + assertFalse(definition.has("elements")); + assertFalse(definition.has("captureDifficulty")); + } + } + + @Test + void allMovesConstructAsEngineMoveDefinitions() throws IOException { + JsonNode moves = ContentBundle.load(contentRoot).document("moves.json").path("items"); + + for (JsonNode move : moves) { + new MoveDefinition( + move.path("schemaVersion").asInt(), + move.path("id").asText(), + move.path("displayName").asText(), + ElementType.valueOf(move.path("type").asText()), + MoveDefinition.Category.valueOf(move.path("category").asText()), + move.path("power").asInt(), + move.path("accuracy").asDouble(), + move.path("priority").asInt() + ); + assertFalse(move.has("name")); + assertFalse(move.has("element")); + } + } + @Test void rejectsWrongSchemaAndDuplicateIds() throws IOException { ObjectNode moves = object("moves.json"); @@ -60,6 +102,40 @@ class ContentValidatorTest { assertTrue(report.containsCode("DUPLICATE_ID")); } + @Test + void rejectsNonEngineSpeciesEnumsAndCaptureRate() throws IOException { + ObjectNode species = object("species.json"); + ObjectNode first = (ObjectNode) species.path("items").path(0); + ((ArrayNode) first.path("types")).set(0, ContentBundle.json().getNodeFactory().textNode("TOXIN")); + first.put("rarity", "MYTHIC"); + first.put("captureRate", 0); + write("species.json", species); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("ENUM_VALUE")); + assertTrue(report.containsCode("INTEGER_RANGE")); + } + + @Test + void rejectsNonEngineMoveCombatFields() throws IOException { + ObjectNode moves = object("moves.json"); + ObjectNode first = (ObjectNode) moves.path("items").path(0); + first.put("type", "AIR"); + first.put("category", "SPECIAL"); + first.put("power", 0); + first.put("accuracy", 1.1); + first.put("priority", 8); + write("moves.json", moves); + + ValidationReport report = new ContentValidator().validate(contentRoot); + + assertTrue(report.containsCode("ENUM_VALUE")); + assertTrue(report.containsCode("MOVE_POWER_CATEGORY")); + assertTrue(report.containsCode("NUMBER_RANGE")); + assertTrue(report.containsCode("INTEGER_RANGE")); + } + @Test void rejectsUnknownMoveEvolutionAndAssetReferences() throws IOException { ObjectNode species = object("species.json"); diff --git a/devtools/blockbench-mcp/.gitignore b/devtools/blockbench-mcp/.gitignore new file mode 100644 index 0000000..1ade9c7 --- /dev/null +++ b/devtools/blockbench-mcp/.gitignore @@ -0,0 +1,4 @@ +/dist/ +/.work/ +/*.env +/*.token diff --git a/devtools/blockbench-mcp/LICENSES/GPL-3.0-only.txt b/devtools/blockbench-mcp/LICENSES/GPL-3.0-only.txt new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/devtools/blockbench-mcp/LICENSES/GPL-3.0-only.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/devtools/blockbench-mcp/NOTICE.md b/devtools/blockbench-mcp/NOTICE.md new file mode 100644 index 0000000..5ccaedf --- /dev/null +++ b/devtools/blockbench-mcp/NOTICE.md @@ -0,0 +1,19 @@ +# Third-party notice and license boundary + +The hardened Blockbench MCP development tool is a modified build of +[`jasonjgardner/blockbench-mcp-plugin`](https://github.com/jasonjgardner/blockbench-mcp-plugin), +pinned to version 1.6.1 / commit +`6b069e308fdfc9b0a1c15bc924ca78150815f143`. + +Upstream and the modified build are licensed under **GPL-3.0-only**. The full +license text is stored in `LICENSES/GPL-3.0-only.txt`. Changes made by +ProjectCapture are represented by the patch in `patches/` and are clearly +marked as a hardened ProjectCapture variant. + +The official Hytale Models plugin is a separate GPL-3.0 program and is pinned +by repository commit and distribution checksum in `upstream.lock.json`. + +These Blockbench plugins are development tools. They are not embedded in, linked +into, or distributed as part of the Hytale runtime mod. Any redistribution of a +generated plugin artifact must include the corresponding source/pinned patchset, +this notice, and the GPL license. diff --git a/devtools/blockbench-mcp/README.md b/devtools/blockbench-mcp/README.md new file mode 100644 index 0000000..024ed1e --- /dev/null +++ b/devtools/blockbench-mcp/README.md @@ -0,0 +1,76 @@ +# ProjectCapture Blockbench MCP + +Diese Werkzeugkette erzeugt aus dem exakt gepinnten Blockbench-MCP-Upstream +einen gehärteten, reproduzierbaren Entwicklungs-Build. Sie installiert nichts +automatisch in Blockbench oder Codex und speichert keine Geheimnisse im +Repository. + +## Sicherheitsprofil + +- Serverbindung ausschließlich an `127.0.0.1`. +- Jede HTTP-, Health- und MCP-Anfrage benötigt einen Bearer-Token aus + `PROJECTCAPTURE_BLOCKBENCH_TOKEN` (mindestens 32 Zeichen). +- Alle Dateiimporte und -exporte liegen unter dem absoluten Verzeichnis aus + `PROJECTCAPTURE_ASSET_ROOT`; absolute Pfade, `..`, falsche Dateiendungen, + Symlink-Eltern und Symlink-Ziele werden abgewiesen. +- Die Werkzeugregistrierung ist fail-closed. `risky_eval`, frei wählbare + Blockbench-Actions, Klicksimulation, URL-Importe, beliebige Exporte und + App-Screenshots werden nicht über MCP registriert. +- Netzwerkgeladene MCP-Prompts sind im gehärteten Build entfernt. +- Codex erhält zusätzlich eine explizite Werkzeug-Allowlist aus + `examples/codex-config.toml`. + +## Reproduzierbarer Ablauf + +Voraussetzungen sind `git`, `pnpm` und ein POSIX-Shell-Umfeld. Bun wird über +`pnpm dlx bun@1.3.14` exakt gepinnt und nur im Paketmanager-Cache verwendet. + +```sh +./scripts/test.sh +./scripts/build.sh +./scripts/stage-hytale-plugin.sh +``` + +`test.sh` klont Commit `6b069e3…` in ein temporäres Verzeichnis, prüft den +Patch-Hash, wendet den Patch an und führt die automatisierten Tests aus. +`build.sh` wiederholt diese Prüfung, baut anschließend das Plugin und legt nur +reviewbare Artefakte plus SHA-256-Datei in `dist/` ab. +`stage-hytale-plugin.sh` holt ausschließlich den gepinnten offiziellen +Hytale-Plugin-Commit und prüft dessen bereits veröffentlichtes Distributionsfile +gegen den Lock-Hash. + +## Lokale Laufzeitwerte + +Der gehärtete Build startet absichtlich nicht, solange beide Variablen fehlen: + +```sh +export PROJECTCAPTURE_BLOCKBENCH_TOKEN='' +export PROJECTCAPTURE_ASSET_ROOT='/absoluter/Pfad/zu/ProjectCapture/devtools/blockbench-assets' +``` + +Mit `scripts/create-local-env.sh AUSGABEDATEI ASSET_ROOT` kann außerhalb des +Repositories eine Datei mit Modus `0600` erzeugt werden. Das Skript verweigert +Ausgaben innerhalb dieses Git-Arbeitsbaums. Die Datei wird nicht automatisch +geladen und nicht in Codex eingetragen. + +## Review und Installation + +Nach erfolgreichem Review kopiert +`scripts/install-reviewed.sh --i-reviewed-gpl ZIELVERZEICHNIS` die beiden +geprüften JavaScript-Dateien in ein ausdrücklich angegebenes Verzeichnis. Das +Skript verändert keine Codex-Konfiguration. Die Vorlage +`examples/codex-config.toml` muss separat und mit +`bearer_token_env_var` übernommen werden; ein Tokenwert gehört nie in TOML. + +## Hytale-Werkzeuge + +Der Patch ergänzt sichere Werkzeuge für Capability-Probe, Hytale-Projekterzeugung, +Validierung, PNG-Import/-Export, `.bbmodel`, `.blockymodel`, `.blockyanim` und +Attachments. Gruppen-Defaults, Texturflags, Datenpunkte von Keyframes, +asynchrones Codec-Kompilieren sowie Quad-Normalen/-Flächen und Flat-Shading sind +korrigiert. + +Der statische Build und die automatisierten Tests ersetzen nicht das Live-Gate: +Nach Installation müssen Blockbench 5.0.7 und Hytale Models 0.9.1 gemeinsam ein +neutrales Testwesen öffnen, speichern, neu laden und in alle drei Formate +exportieren. Erst danach gilt das Asset-Gate als bestanden. diff --git a/devtools/blockbench-mcp/examples/codex-config.toml b/devtools/blockbench-mcp/examples/codex-config.toml new file mode 100644 index 0000000..b70eca5 --- /dev/null +++ b/devtools/blockbench-mcp/examples/codex-config.toml @@ -0,0 +1,69 @@ +# Review-Vorlage: keinen Tokenwert in diese Datei schreiben. +[mcp_servers.blockbench] +url = "http://127.0.0.1:3000/bb-mcp" +bearer_token_env_var = "PROJECTCAPTURE_BLOCKBENCH_TOKEN" +enabled = true +required = false +startup_timeout_sec = 20 +tool_timeout_sec = 120 +default_tools_approval_mode = "writes" +enabled_tools = [ + "hytale_capability_probe", + "create_hytale_project", + "hytale_validate_project", + "get_project_info", + "add_group", + "list_outline", + "place_cube", + "modify_cube", + "remove_element", + "duplicate_element", + "rename_element", + "find_elements_by_criteria", + "get_selection", + "create_texture", + "apply_texture", + "list_textures", + "get_texture", + "activate_texture", + "paint_fill_tool", + "draw_shape_tool", + "gradient_tool", + "paint_with_brush", + "hytale_import_texture_png", + "hytale_save_texture_png", + "create_animation", + "manage_keyframes", + "bone_rigging", + "hytale_create_visibility_keyframe", + "hytale_set_animation_loop", + "hytale_get_format_info", + "hytale_set_cube_properties", + "hytale_get_cube_properties", + "hytale_create_quad", + "hytale_list_attachments", + "hytale_set_attachment_piece", + "hytale_list_attachment_pieces", + "hytale_set_cube_stretch", + "hytale_get_cube_stretch", + "hytale_create_attachment", + "hytale_save_bbmodel", + "hytale_export_blockymodel", + "hytale_export_blockyanim", + "capture_screenshot", + "set_camera_angle", + "undo", + "redo", + "get_undo_stack", + "save_checkpoint", +] +disabled_tools = [ + "risky_eval", + "trigger_action", + "emulate_clicks", + "import_geojson", + "import_texture_set", + "save_material_config", + "export_model", + "capture_app_screenshot", +] diff --git a/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch b/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch new file mode 100644 index 0000000..b7eae83 --- /dev/null +++ b/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch @@ -0,0 +1,1741 @@ +diff --git a/index.ts b/index.ts +index ef63982..c795f33 100644 +--- a/index.ts ++++ b/index.ts +@@ -8,15 +8,15 @@ + import { VERSION } from "@/lib/constants"; + import { createServer } from "@/server/server"; + import { tools, prompts } from "@/server/tools"; +-import { resources } from "@/server"; ++import { resources } from "@/lib/factories"; + import { uiSetup, uiTeardown } from "@/ui"; + import { settingsSetup, settingsTeardown } from "@/ui/settings"; + import { setupI18n } from "@/ui/i18n"; + import { sessionManager } from "@/lib/sessions"; +-import { initPromptLoader } from "@/lib/promptLoader"; + import type { NetServer, SessionTransports } from "@/server/net"; + import createNetServer from "@/server/net"; + import { getIcon } from "@/macros/getIcon" with { type: "macro" }; ++import { readProjectCaptureSecurityConfig } from "@/lib/projectcaptureSecurity"; + + let httpServer: NetServer | null = null; + let sessionTransports: SessionTransports | null = null; +@@ -34,6 +34,15 @@ BBPlugin.register("mcp", { + icon: getIcon(), + variant: "desktop", + async onload() { ++ let securityConfig; ++ try { ++ securityConfig = readProjectCaptureSecurityConfig(); ++ } catch (error) { ++ console.error("[MCP] ProjectCapture security configuration is incomplete:", error); ++ Blockbench.showQuickMessage("ProjectCapture MCP requires its token and asset-root environment variables", 5000); ++ return; ++ } ++ + // Get network module with Blockbench permission handling + // @ts-ignore - requireNativeModule is a Blockbench global + const net = requireNativeModule("net", { +@@ -53,16 +62,6 @@ BBPlugin.register("mcp", { + + settingsSetup(); + +- // Load prompt manifest from CDN/cache before server starts. +- // Must never abort onload — missing prompts should degrade gracefully, +- // e.g. when a new version is tagged before the CDN asset is published. +- try { +- const cdnEnabled = Settings.get("mcp_prompt_cdn_enabled") !== false; +- await initPromptLoader(cdnEnabled); +- } catch (err) { +- console.error("[MCP] Prompt loader initialization failed — continuing without prompts:", err); +- } +- + // Create TCP server to handle HTTP requests + const toFiniteNumber = (raw: unknown, fallback: number): number => { + const n = Number(raw); +@@ -79,6 +78,8 @@ BBPlugin.register("mcp", { + [httpServer, sessionTransports] = createNetServer(net, { + port: Number(Settings.get("mcp_port") || 3000), + endpoint: String(Settings.get("mcp_endpoint") || "/bb-mcp"), ++ host: securityConfig.host, ++ bearerToken: securityConfig.bearerToken, + keepAlive: { + sseHeartbeatIntervalMs: Math.max(0, sseHeartbeatSec) * 1000, + }, +diff --git a/lib/factories.ts b/lib/factories.ts +index 14fe412..2bc9113 100644 +--- a/lib/factories.ts ++++ b/lib/factories.ts +@@ -2,6 +2,7 @@ import { z } from "zod"; + import type { IMCPTool, IMCPPrompt, IMCPResource, StatusType } from "@/types"; + import { getServer } from "@/server/server"; + import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; ++import { isProjectCaptureToolAllowed } from "@/lib/projectcaptureAllowlist"; + + /** + * Declarative tool spec for documentation and registration. +@@ -158,8 +159,12 @@ export function createTool( + // Store tool definition + toolDefinitions[name] = toolDef; + +- // Register with server if enabled +- if (enabled) { ++ // ProjectCapture is fail-closed: upstream tools are visible to the local UI ++ // as disabled, but only the reviewed allowlist is reachable over MCP. ++ const policyEnabled = enabled && isProjectCaptureToolAllowed(name); ++ ++ // Register with server if enabled by both the caller and policy. ++ if (policyEnabled) { + type ToolArgs = z.infer; + + const server = getServer(); +@@ -214,7 +219,7 @@ export function createTool( + tools[name] = { + name, + description: toolDef.title, +- enabled, ++ enabled: policyEnabled, + status, + }; + +diff --git a/lib/projectcaptureAllowlist.ts b/lib/projectcaptureAllowlist.ts +new file mode 100644 +index 0000000..68d7e2c +--- /dev/null ++++ b/lib/projectcaptureAllowlist.ts +@@ -0,0 +1,105 @@ ++/** ++ * ProjectCapture deliberately exposes a narrow Blockbench tool surface. ++ * ++ * The upstream plugin contains useful general-purpose tools, but it also ships ++ * remote JavaScript evaluation, arbitrary UI actions/clicks and URL imports. ++ * Keeping the policy here makes registration fail closed for both the initial ++ * MCP server and all per-session servers. ++ */ ++export const PROJECTCAPTURE_TOOL_ALLOWLIST = new Set([ ++ // Project orientation and deterministic Hytale project setup. ++ "get_project_info", ++ "create_hytale_project", ++ "hytale_capability_probe", ++ "hytale_validate_project", ++ ++ // Hytale-compatible cube/group modelling. ++ "place_cube", ++ "modify_cube", ++ "remove_element", ++ "add_group", ++ "list_outline", ++ "duplicate_element", ++ "rename_element", ++ "find_elements_by_criteria", ++ "select_all_of_type", ++ "filter_by_material", ++ "get_selection", ++ ++ // Texture creation and editing. Filesystem access is provided only by the ++ // sandboxed ProjectCapture tools below. ++ "create_texture", ++ "apply_texture", ++ "list_textures", ++ "get_texture", ++ "activate_texture", ++ "paint_fill_tool", ++ "draw_shape_tool", ++ "gradient_tool", ++ "color_picker_tool", ++ "copy_brush_tool", ++ "eraser_tool", ++ "paint_settings", ++ "paint_with_brush", ++ "create_brush_preset", ++ "load_brush_preset", ++ "texture_selection", ++ "texture_layer_management", ++ "hytale_import_texture_png", ++ "hytale_save_texture_png", ++ ++ // Rigging and animation. None of these execute caller-supplied code or ++ // arbitrary Blockbench actions. ++ "create_animation", ++ "manage_keyframes", ++ "animation_graph_editor", ++ "bone_rigging", ++ "animation_timeline", ++ "batch_keyframe_operations", ++ "animation_copy_paste", ++ "hytale_create_visibility_keyframe", ++ "hytale_set_animation_loop", ++ "hytale_export_blockyanim", ++ ++ // Hytale metadata, quads and attachments. ++ "hytale_get_format_info", ++ "hytale_validate_model", ++ "hytale_set_cube_properties", ++ "hytale_get_cube_properties", ++ "hytale_create_quad", ++ "hytale_list_attachments", ++ "hytale_set_attachment_piece", ++ "hytale_list_attachment_pieces", ++ "hytale_set_cube_stretch", ++ "hytale_get_cube_stretch", ++ "hytale_create_attachment", ++ ++ // Sandboxed project/model persistence. ++ "list_export_formats", ++ "hytale_save_bbmodel", ++ "hytale_export_blockymodel", ++ ++ // Reproducible preview and local edit history. ++ "capture_screenshot", ++ "set_camera_angle", ++ "undo", ++ "redo", ++ "get_undo_stack", ++ "save_checkpoint", ++]); ++ ++export const PROJECTCAPTURE_FORBIDDEN_TOOLS = new Set([ ++ "risky_eval", ++ "trigger_action", ++ "emulate_clicks", ++ "import_geojson", ++ "import_texture_set", ++ "save_material_config", ++ "export_model", ++ "capture_app_screenshot", ++]); ++ ++export function isProjectCaptureToolAllowed(name: string): boolean { ++ return PROJECTCAPTURE_TOOL_ALLOWLIST.has(name) && ++ !PROJECTCAPTURE_FORBIDDEN_TOOLS.has(name); ++} +diff --git a/lib/projectcaptureHytale.ts b/lib/projectcaptureHytale.ts +new file mode 100644 +index 0000000..ca6d17d +--- /dev/null ++++ b/lib/projectcaptureHytale.ts +@@ -0,0 +1,175 @@ ++export const HYTALE_FORMAT_IDS = ["hytale_character", "hytale_prop"] as const; ++export const HYTALE_SHADING_MODES = [ ++ "flat", ++ "standard", ++ "fullbright", ++ "reflective", ++] as const; ++export const HYTALE_NORMALS = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"] as const; ++ ++export type HytaleNormal = (typeof HYTALE_NORMALS)[number]; ++ ++export const HYTALE_FACE_BY_NORMAL: Record = { ++ "+X": "east", ++ "-X": "west", ++ "+Y": "up", ++ "-Y": "down", ++ "+Z": "south", ++ "-Z": "north", ++}; ++ ++export function hytaleQuadBounds( ++ position: [number, number, number], ++ normal: HytaleNormal, ++ size: [number, number], ++): { from: [number, number, number]; to: [number, number, number]; face: string } { ++ const [x, y, z] = position; ++ const [width, height] = size; ++ const from: [number, number, number] = [x, y, z]; ++ let to: [number, number, number]; ++ if (normal.endsWith("X")) { ++ to = [x, y + height, z + width]; ++ } else if (normal.endsWith("Y")) { ++ to = [x + width, y, z + height]; ++ } else { ++ to = [x + width, y + height, z]; ++ } ++ return { from, to, face: HYTALE_FACE_BY_NORMAL[normal] }; ++} ++ ++export function keyframeDataPoint( ++ channel: "position" | "rotation" | "scale" | "visibility", ++ value: number | number[] | boolean, ++): Record { ++ if (channel === "visibility") { ++ if (typeof value !== "boolean") { ++ throw new Error("Visibility keyframes require a boolean value."); ++ } ++ return { visibility: value }; ++ } ++ if (typeof value === "boolean") { ++ throw new Error(`${channel} keyframes require numeric values.`); ++ } ++ const vector = Array.isArray(value) ? value : [value, value, value]; ++ if (vector.length !== 3 || vector.some((entry) => !Number.isFinite(entry))) { ++ throw new Error(`${channel} keyframes require three finite numeric values.`); ++ } ++ return { x: vector[0], y: vector[1], z: vector[2] }; ++} ++ ++export async function awaitCompiledPayload(value: T | Promise): Promise { ++ return await value; ++} ++ ++export interface HytaleValidationSnapshot { ++ formatId: string | null; ++ nodeCount: number; ++ groups: Array<{ name: string; visibility: unknown }>; ++ cubes: Array<{ ++ name: string; ++ visibility: unknown; ++ shadingMode: string | undefined; ++ zeroAxes: number; ++ texturedFaces: string[]; ++ }>; ++ meshes: number; ++ textures: Array<{ name: string; width: number; height: number }>; ++} ++ ++export interface HytaleValidationResult { ++ valid: boolean; ++ errors: string[]; ++ warnings: string[]; ++} ++ ++export function validateHytaleSnapshot( ++ snapshot: HytaleValidationSnapshot, ++): HytaleValidationResult { ++ const errors: string[] = []; ++ const warnings: string[] = []; ++ if (!HYTALE_FORMAT_IDS.includes(snapshot.formatId as typeof HYTALE_FORMAT_IDS[number])) { ++ errors.push("Project is not a Hytale character or prop project."); ++ } ++ if (snapshot.nodeCount > 255) { ++ errors.push(`Node count ${snapshot.nodeCount} exceeds Hytale's limit of 255.`); ++ } ++ if (snapshot.meshes > 0) { ++ errors.push("Hytale projects may contain cubes/quads and groups only; meshes are unsupported."); ++ } ++ ++ const groupNames = new Set(); ++ for (const group of snapshot.groups) { ++ if (groupNames.has(group.name)) { ++ errors.push(`Duplicate group/bone name "${group.name}" is animation-ambiguous.`); ++ } ++ groupNames.add(group.name); ++ if (typeof group.visibility !== "boolean") { ++ errors.push(`Group "${group.name}" has a non-boolean visibility value.`); ++ } ++ } ++ ++ for (const cube of snapshot.cubes) { ++ if (typeof cube.visibility !== "boolean") { ++ errors.push(`Cube "${cube.name}" has a non-boolean visibility value.`); ++ } ++ if (!cube.shadingMode) { ++ warnings.push(`Cube "${cube.name}" has no shading mode; flat will be used.`); ++ } else if (!HYTALE_SHADING_MODES.includes(cube.shadingMode as typeof HYTALE_SHADING_MODES[number])) { ++ errors.push(`Cube "${cube.name}" uses unsupported shading mode "${cube.shadingMode}".`); ++ } ++ if (cube.zeroAxes > 1) { ++ errors.push(`Cube "${cube.name}" collapses on more than one axis and is not a valid quad.`); ++ } ++ if (cube.zeroAxes === 1 && cube.texturedFaces.length !== 1) { ++ errors.push(`Quad "${cube.name}" must have exactly one textured face.`); ++ } ++ } ++ ++ const blockSize = snapshot.formatId === "hytale_prop" ? 32 : 64; ++ if (snapshot.textures.length > 1) { ++ warnings.push("Hytale base models should normally use one texture; attachments use collections."); ++ } ++ for (const texture of snapshot.textures) { ++ if (texture.width !== blockSize || texture.height < blockSize || texture.height % blockSize !== 0) { ++ errors.push( ++ `Texture "${texture.name}" is ${texture.width}x${texture.height}; expected ${blockSize}px width and a positive ${blockSize}px height multiple.`, ++ ); ++ } ++ } ++ ++ return { valid: errors.length === 0, errors, warnings }; ++} ++ ++export interface CapabilityRegistry { ++ blockbenchVersion: string | null; ++ hytalePluginVersion: string | null; ++ formats: readonly string[]; ++ codecs: readonly string[]; ++ tools: readonly string[]; ++} ++ ++export function buildCapabilityReport(registry: CapabilityRegistry) { ++ const requirements = { ++ hytalePlugin: registry.hytalePluginVersion !== null, ++ characterFormat: registry.formats.includes("hytale_character"), ++ propFormat: registry.formats.includes("hytale_prop"), ++ blockymodelCodec: registry.codecs.includes("blockymodel"), ++ safeBbmodelSave: registry.tools.includes("hytale_save_bbmodel"), ++ safePngSave: registry.tools.includes("hytale_save_texture_png"), ++ blockyanimExport: registry.tools.includes("hytale_export_blockyanim"), ++ }; ++ return { ++ blockbenchVersion: registry.blockbenchVersion, ++ hytalePluginVersion: registry.hytalePluginVersion, ++ security: { ++ loopbackOnly: true, ++ bearerAuthentication: true, ++ assetRootSandbox: true, ++ arbitraryEvaluation: false, ++ arbitraryUiActions: false, ++ remoteUrlImports: false, ++ }, ++ requirements, ++ ready: Object.values(requirements).every(Boolean), ++ }; ++} +diff --git a/lib/projectcaptureSecurity.ts b/lib/projectcaptureSecurity.ts +new file mode 100644 +index 0000000..eda0255 +--- /dev/null ++++ b/lib/projectcaptureSecurity.ts +@@ -0,0 +1,202 @@ ++import path from "node:path"; ++ ++export const PROJECTCAPTURE_LOOPBACK_HOST = "127.0.0.1" as const; ++export const PROJECTCAPTURE_TOKEN_ENV = "PROJECTCAPTURE_BLOCKBENCH_TOKEN"; ++export const PROJECTCAPTURE_ASSET_ROOT_ENV = "PROJECTCAPTURE_ASSET_ROOT"; ++export const PROJECTCAPTURE_MIN_TOKEN_LENGTH = 32; ++ ++export interface ProjectCaptureSecurityConfig { ++ host: typeof PROJECTCAPTURE_LOOPBACK_HOST; ++ bearerToken: string; ++ assetRoot: string; ++} ++ ++export interface AssetSandboxFs { ++ existsSync(path: string): boolean; ++ lstatSync(path: string): { ++ isDirectory(): boolean; ++ isFile(): boolean; ++ isSymbolicLink(): boolean; ++ }; ++ realpathSync(path: string): string; ++ mkdirSync(path: string, options?: { mode?: number }): unknown; ++} ++ ++function requireSecretToken(value: string | undefined): string { ++ const token = value?.trim(); ++ if (!token || token.length < PROJECTCAPTURE_MIN_TOKEN_LENGTH) { ++ throw new Error( ++ `${PROJECTCAPTURE_TOKEN_ENV} must contain at least ${PROJECTCAPTURE_MIN_TOKEN_LENGTH} characters.`, ++ ); ++ } ++ if (/\s/.test(token)) { ++ throw new Error(`${PROJECTCAPTURE_TOKEN_ENV} must not contain whitespace.`); ++ } ++ return token; ++} ++ ++function requireAbsoluteAssetRoot(value: string | undefined): string { ++ if (!value?.trim()) { ++ throw new Error(`${PROJECTCAPTURE_ASSET_ROOT_ENV} is required.`); ++ } ++ if (value.includes("\0") || !path.isAbsolute(value)) { ++ throw new Error(`${PROJECTCAPTURE_ASSET_ROOT_ENV} must be an absolute path.`); ++ } ++ return path.resolve(value); ++} ++ ++export function readProjectCaptureSecurityConfig( ++ env: Record = process.env, ++): ProjectCaptureSecurityConfig { ++ return { ++ host: PROJECTCAPTURE_LOOPBACK_HOST, ++ bearerToken: requireSecretToken(env[PROJECTCAPTURE_TOKEN_ENV]), ++ assetRoot: requireAbsoluteAssetRoot(env[PROJECTCAPTURE_ASSET_ROOT_ENV]), ++ }; ++} ++ ++function constantTimeEqual(left: string, right: string): boolean { ++ const encoder = new TextEncoder(); ++ const a = encoder.encode(left); ++ const b = encoder.encode(right); ++ const length = Math.max(a.length, b.length, 1); ++ let difference = a.length ^ b.length; ++ for (let i = 0; i < length; i += 1) { ++ difference |= (a[i % Math.max(a.length, 1)] ?? 0) ^ ++ (b[i % Math.max(b.length, 1)] ?? 0); ++ } ++ return difference === 0; ++} ++ ++export function isAuthorizedBearer( ++ authorizationHeader: string | undefined, ++ expectedToken: string, ++): boolean { ++ if (!authorizationHeader) return false; ++ const match = /^Bearer ([^\s]+)$/.exec(authorizationHeader); ++ return Boolean(match && constantTimeEqual(match[1], expectedToken)); ++} ++ ++function assertInsideRoot(root: string, candidate: string): void { ++ const relative = path.relative(root, candidate); ++ if ( ++ relative === "" || ++ relative === ".." || ++ relative.startsWith(`..${path.sep}`) || ++ path.isAbsolute(relative) ++ ) { ++ throw new Error("Asset path escapes the configured ProjectCapture root."); ++ } ++} ++ ++function validateRelativePath(requestedPath: string): string { ++ if (!requestedPath || requestedPath.includes("\0") || path.isAbsolute(requestedPath)) { ++ throw new Error("Asset path must be a non-empty relative path."); ++ } ++ const normalized = path.normalize(requestedPath); ++ if ( ++ normalized === "." || ++ normalized === ".." || ++ normalized.startsWith(`..${path.sep}`) ++ ) { ++ throw new Error("Asset path escapes the configured ProjectCapture root."); ++ } ++ return normalized; ++} ++ ++function assertAllowedExtension( ++ candidate: string, ++ allowedExtensions: readonly string[], ++): void { ++ const extension = path.extname(candidate).toLowerCase(); ++ const normalizedAllowed = allowedExtensions.map((item) => item.toLowerCase()); ++ if (!normalizedAllowed.includes(extension)) { ++ throw new Error( ++ `Asset extension "${extension || "(none)"}" is not allowed; expected ${normalizedAllowed.join(", ")}.`, ++ ); ++ } ++} ++ ++function resolveRoot(assetRoot: string, fs: AssetSandboxFs): string { ++ const root = path.resolve(assetRoot); ++ if (!fs.existsSync(root)) { ++ throw new Error("Configured ProjectCapture asset root does not exist."); ++ } ++ const rootStat = fs.lstatSync(root); ++ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { ++ throw new Error("Configured ProjectCapture asset root must be a real directory, not a symlink."); ++ } ++ return fs.realpathSync(root); ++} ++ ++function ensureSafeParentDirectories( ++ root: string, ++ relativePath: string, ++ fs: AssetSandboxFs, ++): void { ++ const parent = path.dirname(relativePath); ++ if (parent === ".") return; ++ ++ let cursor = root; ++ for (const segment of parent.split(path.sep)) { ++ if (!segment || segment === ".") continue; ++ cursor = path.join(cursor, segment); ++ assertInsideRoot(root, cursor); ++ if (fs.existsSync(cursor)) { ++ const stat = fs.lstatSync(cursor); ++ if (stat.isSymbolicLink() || !stat.isDirectory()) { ++ throw new Error("Asset path contains a symlink or non-directory parent."); ++ } ++ } else { ++ fs.mkdirSync(cursor, { mode: 0o750 }); ++ } ++ } ++ ++ const realParent = fs.realpathSync(path.join(root, parent)); ++ assertInsideRoot(root, realParent); ++} ++ ++export function prepareAssetWritePath( ++ assetRoot: string, ++ requestedPath: string, ++ allowedExtensions: readonly string[], ++ fs: AssetSandboxFs, ++): string { ++ const relativePath = validateRelativePath(requestedPath); ++ assertAllowedExtension(relativePath, allowedExtensions); ++ const root = resolveRoot(assetRoot, fs); ++ ensureSafeParentDirectories(root, relativePath, fs); ++ ++ const candidate = path.resolve(root, relativePath); ++ assertInsideRoot(root, candidate); ++ if (fs.existsSync(candidate)) { ++ const stat = fs.lstatSync(candidate); ++ if (stat.isSymbolicLink() || !stat.isFile()) { ++ throw new Error("Asset target must be a regular file and must not be a symlink."); ++ } ++ } ++ return candidate; ++} ++ ++export function resolveAssetReadPath( ++ assetRoot: string, ++ requestedPath: string, ++ allowedExtensions: readonly string[], ++ fs: AssetSandboxFs, ++): string { ++ const relativePath = validateRelativePath(requestedPath); ++ assertAllowedExtension(relativePath, allowedExtensions); ++ const root = resolveRoot(assetRoot, fs); ++ const lexicalCandidate = path.resolve(root, relativePath); ++ assertInsideRoot(root, lexicalCandidate); ++ if (!fs.existsSync(lexicalCandidate)) { ++ throw new Error("Requested asset does not exist."); ++ } ++ const stat = fs.lstatSync(lexicalCandidate); ++ if (stat.isSymbolicLink() || !stat.isFile()) { ++ throw new Error("Requested asset must be a regular file and must not be a symlink."); ++ } ++ const realCandidate = fs.realpathSync(lexicalCandidate); ++ assertInsideRoot(root, realCandidate); ++ return realCandidate; ++} +diff --git a/package.json b/package.json +index 8d33e2a..10bca2c 100644 +--- a/package.json ++++ b/package.json +@@ -11,11 +11,11 @@ + "type": "module", + "main": "dist/mcp.js", + "scripts": { +- "test": "echo \"Error: no test specified\" && exit 1", ++ "test": "bun test ./tests", + "prompts:build": "bun run ./build/generate-manifest.ts", + "dev": "bun run prompts:build && bun run ./build --sourcemap", + "dev:watch": "bun run prompts:build && bun run dev --watch", +- "build": "bun run prompts:build && bun run ./build --minify", ++ "build": "bun run ./build --minify", + "docs:build": "bun run ./build/docs.ts", + "docs:serve": "bun run docs/index.html", + "docs": "bun run prompts:build && bun run docs:build && bun run docs:serve", +@@ -32,4 +32,4 @@ + "typescript": "^5.9.3", + "zod-to-json-schema": "^3.25.1" + } +-} +\ No newline at end of file ++} +diff --git a/server/net.ts b/server/net.ts +index ea411f6..2bfcca2 100644 +--- a/server/net.ts ++++ b/server/net.ts +@@ -9,6 +9,10 @@ import { + } from '@/lib/factories' + import { createServer as createMcpServer } from '@/server/server' + import { sessionManager, type SessionConfig } from '@/lib/sessions' ++import { ++ isAuthorizedBearer, ++ PROJECTCAPTURE_LOOPBACK_HOST ++} from '@/lib/projectcaptureSecurity' + + export type { NetServer } + +@@ -61,6 +65,7 @@ function getStatusText (status: number): string { + 202: 'Accepted', + 204: 'No Content', + 400: 'Bad Request', ++ 401: 'Unauthorized', + 404: 'Not Found', + 405: 'Method Not Allowed', + 409: 'Conflict', +@@ -97,16 +102,22 @@ export default function createNetServer ( + { + port, + endpoint, ++ host = PROJECTCAPTURE_LOOPBACK_HOST, ++ bearerToken, + keepAlive = DEFAULT_KEEP_ALIVE, + sessionConfig + }: { + endpoint: string + port: number + host?: string ++ bearerToken: string + keepAlive?: Partial + sessionConfig?: Partial + } + ): [NetServer, SessionTransports] { ++ if (host !== PROJECTCAPTURE_LOOPBACK_HOST) { ++ throw new Error(`ProjectCapture MCP may bind only to ${PROJECTCAPTURE_LOOPBACK_HOST}`) ++ } + const sessionTransports: SessionTransports = new Map() + const keepAliveConfig = { ...DEFAULT_KEEP_ALIVE, ...keepAlive } + +@@ -240,6 +251,23 @@ export default function createNetServer ( + } + } + ++ // Authenticate before reading or parsing a request body. Unauthorized ++ // clients get no health/session information and the socket is closed. ++ if (!isAuthorizedBearer(headers['authorization'], bearerToken)) { ++ buffer = Buffer.alloc(0) ++ sendResponse( ++ socket, ++ 401, ++ { ++ 'content-type': 'application/json', ++ 'www-authenticate': 'Bearer realm="ProjectCapture Blockbench MCP"' ++ }, ++ JSON.stringify({ error: 'Unauthorized' }), ++ 'close' ++ ) ++ return ++ } ++ + // Calculate body boundaries + const bodyStart = headerEnd + 4 + const contentLength = parseInt(headers['content-length'] || '0', 10) +@@ -252,7 +280,7 @@ export default function createNetServer ( + buffer = buffer.subarray(requestEnd) + + // Build Web Standard Request +- const url = `http://localhost:${port}${path}` ++ const url = `http://${host}:${port}${path}` + const webHeaders = new Headers() + for (const [key, value] of Object.entries(headers)) { + webHeaders.set(key, value) +@@ -614,8 +642,8 @@ export default function createNetServer ( + } + }) + +- httpServer.listen(port, () => { +- console.log(`[MCP] Server listening on http://localhost:${port}${endpoint}`) ++ httpServer.listen(port, host, () => { ++ console.log(`[MCP] Hardened server listening on http://${host}:${port}${endpoint}`) + }) + + httpServer.on('error', (err: Error) => { +diff --git a/server/tools.ts b/server/tools.ts +index 3849159..da6cea9 100644 +--- a/server/tools.ts ++++ b/server/tools.ts +@@ -8,17 +8,12 @@ import { registerCameraTools } from "./tools/camera"; + import { registerAnimationTools } from "./tools/animation"; + import { registerCubesTools } from "./tools/cubes"; + import { registerElementTools } from "./tools/element"; +-import { registerImportTools } from "./tools/import"; +-import { registerMeshTools } from "./tools/mesh"; + import { registerPaintTools } from "./tools/paint"; + import { registerProjectTools } from "./tools/project"; + import { registerTextureTools } from "./tools/texture"; +-import { registerUITools } from "./tools/ui"; +-import { registerUVTools } from "./tools/uv"; +-import { registerMaterialInstanceTools } from "./tools/material-instances"; +-import { registerArmatureTools } from "./tools/armature"; + import { registerHistoryTools } from "./tools/history"; + import { registerExportTools } from "./tools/export"; ++import { registerProjectCaptureTools } from "./tools/projectcapture"; + + // Core resource registrations + import { registerValidatorResources } from "./resources/validator"; +@@ -26,25 +21,19 @@ import { registerValidatorResources } from "./resources/validator"; + // Optional plugin integrations (conditionally registered) + import { registerHytaleTools } from "./tools/hytale"; + import { registerHytaleResources } from "./resources/hytale"; +-import { registerHytalePrompts } from "./prompts/hytale"; + + // All registration functions - MUST be used to prevent tree-shaking + const registrationFunctions = [ + registerAnimationTools, +- registerArmatureTools, + registerCameraTools, + registerCubesTools, + registerElementTools, + registerExportTools, + registerHistoryTools, +- registerImportTools, +- registerMaterialInstanceTools, +- registerMeshTools, + registerPaintTools, + registerProjectTools, ++ registerProjectCaptureTools, + registerTextureTools, +- registerUITools, +- registerUVTools, + registerValidatorResources, + ]; + +@@ -53,7 +42,6 @@ const registrationFunctions = [ + const optionalRegistrationFunctions = [ + registerHytaleTools, + registerHytaleResources, +- registerHytalePrompts, + ]; + + // Register all core tools immediately when this module loads +diff --git a/server/tools/animation.ts b/server/tools/animation.ts +index 0b3832d..6f08212 100644 +--- a/server/tools/animation.ts ++++ b/server/tools/animation.ts +@@ -16,6 +16,7 @@ import { + loopModeEnum, + keyframeDataSchema, + } from "@/lib/zodObjects"; ++import { keyframeDataPoint } from "@/lib/projectcaptureHytale"; + + export const createAnimationParameters = z.object({ + name: z.string().describe("Name of the animation"), +@@ -330,6 +331,40 @@ createTool( + { + ...animationToolDocs[0], + async execute({ name, loop, animation_length, bones, particle_effects }) { ++ if (Format && ["hytale_character", "hytale_prop"].includes(Format.id)) { ++ if (particle_effects && Object.keys(particle_effects).length > 0) { ++ throw new Error("Hytale blockyanim export does not support Bedrock particle-effect entries."); ++ } ++ const animation = new Animation({ ++ name, ++ length: animation_length ?? 2, ++ loop: loop ? "loop" : "once", ++ snapping: 60, ++ }).add(false); ++ ++ for (const [boneName, keyframes] of Object.entries(bones)) { ++ const group = findGroupOrThrow(boneName); ++ const animator = new BoneAnimator(group.uuid, animation, boneName); ++ animation.animators[group.uuid] = animator; ++ animator.group = group; ++ for (const keyframe of keyframes) { ++ for (const channel of ["position", "rotation", "scale"] as const) { ++ const value = keyframe[channel]; ++ if (value === undefined) continue; ++ animator.addKeyframe({ ++ channel, ++ time: keyframe.time, ++ interpolation: "linear", ++ data_points: [keyframeDataPoint(channel, value)], ++ }); ++ } ++ } ++ } ++ animation.select(); ++ Animator.preview(); ++ return `Created Hytale animation "${name}" at 60 FPS for ${Object.keys(bones).length} bones`; ++ } ++ + const animationData = { + loop, + ...(animation_length && { animation_length }), +@@ -417,7 +452,7 @@ createTool( + { + time: kf.time, + channel, +- values: kf.values, ++ data_points: [keyframeDataPoint(channel, kf.values ?? [0, 0, 0])], + interpolation: kf.interpolation, + }, + kf.time, +@@ -460,7 +495,10 @@ createTool( + ); + if (keyframe) { + if (kf.values) { +- keyframe.set("values", kf.values); ++ Object.assign( ++ keyframe.data_points[0], ++ keyframeDataPoint(channel, kf.values), ++ ); + } + if (kf.interpolation) { + keyframe.interpolation = kf.interpolation; +diff --git a/server/tools/cubes.ts b/server/tools/cubes.ts +index c4d1ca4..95083ca 100644 +--- a/server/tools/cubes.ts ++++ b/server/tools/cubes.ts +@@ -162,12 +162,21 @@ createTool(cubeToolDocs[0].name, { + rotation: element.rotation as [number, number, number], + }).init(); + ++ if (Format && ["hytale_character", "hytale_prop"].includes(Format.id)) { ++ // Hytale's own plugin defaults shapes to flat shading. Persist the ++ // explicit property so exports remain deterministic across versions. ++ (cube as Cube & { shading_mode?: string }).shading_mode = "flat"; ++ cube.visibility = true; ++ cube.shade = false; ++ } ++ + cube.addTo(outlinerGroup); + + if (!autouv && Array.isArray(faces)) { + faces.forEach(({ face, uv }) => { + cube.faces[face].extend({ + uv: uv as [number, number, number, number], ++ texture: projectTexture.uuid, + }); + }); + } else { +diff --git a/server/tools/element.ts b/server/tools/element.ts +index 7692106..7022546 100644 +--- a/server/tools/element.ts ++++ b/server/tools/element.ts +@@ -91,9 +91,9 @@ export const filterByMaterialParameters = z.object({ + export const getSelectionParameters = z.object({}); + + export const addGroupParameters = z.object({ +- name: z.string(), +- origin: vector3Schema, +- rotation: vector3Schema, ++ name: z.string().min(1).max(128), ++ origin: vector3Schema.default([0, 0, 0]), ++ rotation: vector3Schema.default([0, 0, 0]), + parent: z.string().optional().default("root"), + visibility: z.boolean().optional().default(true), + autouv: autoUvEnum +@@ -103,7 +103,7 @@ export const addGroupParameters = z.object({ + "Auto UV setting. 0 = disabled, 1 = enabled, 2 = relative auto UV." + ), + selected: z.boolean().optional().default(false), +- shade: z.boolean().optional().default(false), ++ shade: z.boolean().optional().default(true), + }); + + export const listOutlineParameters = z.object({ +@@ -355,6 +355,14 @@ export function registerElementTools() { + collections: [], + }); + ++ const parentGroup = parent === "root" ++ ? "root" ++ : // `@ts-expect-error` getAllGroups is a Blockbench global ++ getAllGroups().find((g: Group) => g.name === parent || g.uuid === parent); ++ if (!parentGroup) { ++ throw new Error(`Parent group "${parent}" was not found.`); ++ } ++ + const group = new Group({ + name, + origin, +@@ -365,10 +373,6 @@ export function registerElementTools() { + shade: Boolean(shade), + }).init(); + +- const parentGroup = parent === "root" +- ? "root" +- : // `@ts-expect-error` getAllGroups is a Blockbench global +- getAllGroups().find((g: Group) => g.name === parent || g.uuid === parent); + group.addTo(parentGroup); + + Undo.finishEdit("Agent added group"); +diff --git a/server/tools/export.ts b/server/tools/export.ts +index 6e7366b..77886cc 100644 +--- a/server/tools/export.ts ++++ b/server/tools/export.ts +@@ -201,7 +201,9 @@ export function registerExportTools() { + ? codec.getExportOptions() + : undefined); + +- const rawResult = codec.compile(effectiveOptions); ++ // Some codecs (including evolving Hytale integrations) compile ++ // asynchronously. Always await so Promise objects are never serialized. ++ const rawResult = await codec.compile(effectiveOptions); + + const isArrayBuffer = rawResult instanceof ArrayBuffer; + const isBinaryView = +diff --git a/server/tools/hytale.ts b/server/tools/hytale.ts +index da83293..8a9fa0f 100644 +--- a/server/tools/hytale.ts ++++ b/server/tools/hytale.ts +@@ -31,6 +31,7 @@ import { + stretchSchema, + size2dSchema, + } from "@/lib/zodObjects"; ++import { hytaleQuadBounds, keyframeDataPoint } from "@/lib/projectcaptureHytale"; + + // ============================================================================ + // Hytale-Specific Enums +@@ -460,32 +461,10 @@ export function registerHytaleTools() { + parentGroup = findGroupOrThrow(group); + } + +- // Calculate from/to based on normal direction and size +- const [width, height] = size; +- const [x, y, z] = position; +- let from: [number, number, number]; +- let to: [number, number, number]; +- +- // Quads are essentially very thin cubes (0 depth in one dimension) +- switch (normal) { +- case "+X": +- case "-X": +- from = [x, y, z]; +- to = [x, y + height, z + width]; +- break; +- case "+Y": +- case "-Y": +- from = [x, y, z]; +- to = [x + width, y, z + height]; +- break; +- case "+Z": +- case "-Z": +- from = [x, y, z]; +- to = [x + width, y + height, z]; +- break; +- default: +- from = [x, y, z]; +- to = [x + width, y + height, z]; ++ const { from, to, face } = hytaleQuadBounds(position, normal, size); ++ const texture = Texture.getDefault(); ++ if (!texture) { ++ throw new Error("Create or import a Hytale texture before creating a quad."); + } + + // @ts-ignore - Undo is globally available +@@ -497,12 +476,21 @@ export function registerHytaleTools() { + from, + to, + autouv: 1, ++ box_uv: false, ++ visibility: true, + }).init(); + ++ // The official Hytale codec infers the quad normal from the one and ++ // only textured face. Explicitly clear the other five faces. ++ for (const [faceName, cubeFace] of Object.entries(cube.faces)) { ++ cubeFace.texture = faceName === face ? texture.uuid : null; ++ } ++ cube.mapAutoUV(); ++ + // Set Hytale-specific properties + const hytaleCube = cube as HytaleCube; + hytaleCube.double_sided = double_sided; +- hytaleCube.shading_mode = "standard"; ++ hytaleCube.shading_mode = "flat"; + + // Add to parent group if specified + if (parentGroup) { +@@ -519,6 +507,7 @@ export function registerHytaleTools() { + uuid: cube.uuid, + name: cube.name, + normal, ++ face, + from, + to, + double_sided, +@@ -658,7 +647,7 @@ export function registerHytaleTools() { + const keyframe = animator.addKeyframe({ + channel: "visibility", + time, +- data_points: [{ visible }], ++ data_points: [keyframeDataPoint("visibility", visible)], + }); + + // @ts-ignore - Undo is globally available +diff --git a/server/tools/projectcapture.ts b/server/tools/projectcapture.ts +new file mode 100644 +index 0000000..41977e6 +--- /dev/null ++++ b/server/tools/projectcapture.ts +@@ -0,0 +1,401 @@ ++/// ++/// ++ ++import { z } from "zod"; ++import { createTool, tools, type ToolSpec } from "@/lib/factories"; ++import { STATUS_EXPERIMENTAL, STATUS_STABLE } from "@/lib/constants"; ++import { ++ awaitCompiledPayload, ++ buildCapabilityReport, ++ HYTALE_FORMAT_IDS, ++ validateHytaleSnapshot, ++} from "@/lib/projectcaptureHytale"; ++import { ++ prepareAssetWritePath, ++ readProjectCaptureSecurityConfig, ++ resolveAssetReadPath, ++ type AssetSandboxFs, ++} from "@/lib/projectcaptureSecurity"; ++import { findGroupOrThrow, findTextureOrThrow } from "@/lib/util"; ++ ++type NativeAssetFs = AssetSandboxFs & { ++ readFileSync(path: string): Buffer; ++ writeFileSync(path: string, data: string | Buffer): void; ++}; ++ ++const assetPathSchema = z ++ .string() ++ .min(1) ++ .max(512) ++ .describe("Relative path below PROJECTCAPTURE_ASSET_ROOT."); ++ ++const hytaleFormatSchema = z.enum(HYTALE_FORMAT_IDS); ++const renderModeSchema = z.enum(["default", "emissive", "additive", "layered"]); ++const renderSidesSchema = z.enum(["auto", "front", "double"]); ++ ++export const projectCaptureToolDocs: ToolSpec[] = [ ++ { ++ name: "create_hytale_project", ++ description: "Creates a Hytale character or prop project with the official format and deterministic texture resolution.", ++ annotations: { title: "Create Hytale Project", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/), ++ format: hytaleFormatSchema.default("hytale_character"), ++ }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_capability_probe", ++ description: "Reports whether the pinned Blockbench/Hytale pipeline and hardened tools are available without disclosing secrets or filesystem roots.", ++ annotations: { title: "Hytale Capability Probe", readOnlyHint: true }, ++ parameters: z.object({}), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_validate_project", ++ description: "Validates Hytale format, nodes, groups, cubes/quads, visibility, shading and texture dimensions.", ++ annotations: { title: "Validate Hytale Project", readOnlyHint: true }, ++ parameters: z.object({}), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_import_texture_png", ++ description: "Imports a PNG only from the configured ProjectCapture asset root.", ++ annotations: { title: "Import Sandboxed Hytale Texture", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ relative_path: assetPathSchema, ++ name: z.string().min(1).max(128).optional(), ++ render_mode: renderModeSchema.default("default"), ++ render_sides: renderSidesSchema.default("auto"), ++ }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_save_texture_png", ++ description: "Writes a project texture as PNG below the configured ProjectCapture asset root.", ++ annotations: { title: "Save Sandboxed Hytale Texture", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ texture: z.string().min(1), ++ relative_path: assetPathSchema, ++ }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_save_bbmodel", ++ description: "Compiles and writes the editable Blockbench project below the configured ProjectCapture asset root.", ++ annotations: { title: "Save Sandboxed BBModel", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ relative_path: assetPathSchema }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_export_blockymodel", ++ description: "Awaits the official Hytale blockymodel codec and writes its result below the configured asset root.", ++ annotations: { title: "Export Sandboxed Blockymodel", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ relative_path: assetPathSchema, ++ attachment: z.string().min(1).optional().describe("Attachment collection name or UUID."), ++ }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_export_blockyanim", ++ description: "Compiles the selected Hytale animation at 60 FPS and writes a blockyanim below the configured asset root.", ++ annotations: { title: "Export Sandboxed Blockyanim", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ relative_path: assetPathSchema, ++ animation: z.string().min(1).optional().describe("Animation name or UUID; defaults to selected."), ++ }), ++ status: STATUS_STABLE, ++ }, ++ { ++ name: "hytale_create_attachment", ++ description: "Creates an official blockymodel attachment collection from named root groups.", ++ annotations: { title: "Create Hytale Attachment", destructiveHint: true, openWorldHint: false }, ++ parameters: z.object({ ++ name: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/), ++ groups: z.array(z.string().min(1)).min(1).max(64), ++ texture: z.string().min(1).optional(), ++ }), ++ status: STATUS_EXPERIMENTAL, ++ }, ++]; ++ ++function requireHytaleProject(): void { ++ if (!Project || !HYTALE_FORMAT_IDS.includes(Format?.id as typeof HYTALE_FORMAT_IDS[number])) { ++ throw new Error("An active Hytale character or prop project is required."); ++ } ++} ++ ++function getNativeFs(): NativeAssetFs { ++ // @ts-ignore Blockbench provides permission-gated native modules. ++ const fs = requireNativeModule("fs", { ++ message: "ProjectCapture needs access to its configured asset directory.", ++ detail: "All MCP paths are constrained below PROJECTCAPTURE_ASSET_ROOT.", ++ optional: false, ++ }) as NativeAssetFs | null; ++ if (!fs) throw new Error("Blockbench denied filesystem access."); ++ return fs; ++} ++ ++function writeAsset( ++ relativePath: string, ++ extensions: readonly string[], ++ payload: string | Buffer, ++): void { ++ const fs = getNativeFs(); ++ const { assetRoot } = readProjectCaptureSecurityConfig(); ++ const target = prepareAssetWritePath(assetRoot, relativePath, extensions, fs); ++ fs.writeFileSync(target, payload); ++} ++ ++function stringifyCompiledPayload(payload: unknown): string | Buffer { ++ if (typeof payload === "string") return payload; ++ if (payload instanceof ArrayBuffer) return Buffer.from(payload); ++ if (ArrayBuffer.isView(payload)) { ++ return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength); ++ } ++ return JSON.stringify(payload, null, 2); ++} ++ ++function animationToBlockyanim(animation: any): Record { ++ const fps = 60; ++ const nodeAnimations: Record> = {}; ++ const channelNames: Record = { ++ position: "position", ++ rotation: "orientation", ++ scale: "shapeStretch", ++ visibility: "shapeVisible", ++ uv_offset: "shapeUvOffset", ++ }; ++ ++ for (const animator of Object.values(animation.animators ?? {}) as any[]) { ++ const node: Record = {}; ++ let hasData = false; ++ for (const [channel, targetChannel] of Object.entries(channelNames)) { ++ const output: unknown[] = []; ++ const keyframes = Array.isArray(animator[channel]) ++ ? [...animator[channel]].sort((a: any, b: any) => a.time - b.time) ++ : []; ++ for (const keyframe of keyframes) { ++ const dataPoint = keyframe.data_points?.[0] ?? {}; ++ let delta: unknown; ++ if (channel === "visibility") { ++ delta = dataPoint.visibility !== false; ++ } else if (channel === "uv_offset") { ++ delta = { ++ x: Math.round(Number(dataPoint.x ?? 0)), ++ y: -Math.round(Number(dataPoint.y ?? 0)), ++ }; ++ } else if (channel === "rotation") { ++ const euler = new THREE.Euler( ++ Math.degToRad(keyframe.calc("x")), ++ Math.degToRad(keyframe.calc("y")), ++ Math.degToRad(keyframe.calc("z")), ++ Format.euler_order, ++ ); ++ const quaternion = new THREE.Quaternion().setFromEuler(euler); ++ delta = { x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }; ++ } else { ++ delta = { ++ x: keyframe.calc("x"), ++ y: keyframe.calc("y"), ++ z: keyframe.calc("z"), ++ }; ++ } ++ output.push({ ++ time: Math.round(keyframe.time * fps), ++ delta, ++ interpolationType: keyframe.interpolation === "catmullrom" ? "smooth" : "linear", ++ }); ++ hasData = true; ++ } ++ if (output.length > 0 || targetChannel === "shapeUvOffset") { ++ node[targetChannel] = output; ++ } ++ } ++ if (hasData) nodeAnimations[animator.name] = node; ++ } ++ ++ return { ++ formatVersion: 1, ++ duration: Math.round(animation.length * fps) || fps * 2, ++ holdLastKeyframe: animation.loop === "hold", ++ nodeAnimations, ++ }; ++} ++ ++export function registerProjectCaptureTools(): void { ++ createTool(projectCaptureToolDocs[0].name, { ++ ...projectCaptureToolDocs[0], ++ async execute({ name, format }) { ++ const modelFormat = Formats[format]; ++ if (!modelFormat) { ++ throw new Error(`Official Hytale format "${format}" is unavailable. Install and enable Hytale Models 0.9.1.`); ++ } ++ if (!newProject(modelFormat) || !Project) throw new Error("Blockbench failed to create the Hytale project."); ++ Project.name = name; ++ const blockSize = format === "hytale_prop" ? 32 : 64; ++ Project.texture_width = blockSize; ++ Project.texture_height = blockSize; ++ return JSON.stringify({ name, format, textureSize: [blockSize, blockSize] }); ++ }, ++ }, projectCaptureToolDocs[0].status); ++ ++ createTool(projectCaptureToolDocs[1].name, { ++ ...projectCaptureToolDocs[1], ++ async execute() { ++ const hytalePlugin = Plugins.installed?.find?.((plugin: any) => ++ plugin.id === "hytale_plugin" && !plugin.disabled ++ ); ++ const report = buildCapabilityReport({ ++ blockbenchVersion: typeof Blockbench?.version === "string" ? Blockbench.version : null, ++ hytalePluginVersion: hytalePlugin?.version ?? null, ++ formats: Object.keys(Formats ?? {}), ++ codecs: Object.keys(Codecs ?? {}), ++ tools: Object.entries(tools) ++ .filter(([, metadata]) => metadata.enabled) ++ .map(([name]) => name), ++ }); ++ return JSON.stringify(report, null, 2); ++ }, ++ }, projectCaptureToolDocs[1].status); ++ ++ createTool(projectCaptureToolDocs[2].name, { ++ ...projectCaptureToolDocs[2], ++ async execute() { ++ requireHytaleProject(); ++ const snapshot = { ++ formatId: Format.id ?? null, ++ nodeCount: Group.all.length + Cube.all.length, ++ groups: Group.all.map((group: any) => ({ ++ name: group.name, ++ visibility: group.visibility, ++ })), ++ cubes: Cube.all.map((cube: any) => ({ ++ name: cube.name, ++ visibility: cube.visibility, ++ shadingMode: cube.shading_mode, ++ zeroAxes: cube.size().filter((axis: number) => Math.abs(axis) < 1e-9).length, ++ texturedFaces: Object.entries(cube.faces) ++ .filter(([, face]: [string, any]) => face.texture !== null && face.texture !== undefined) ++ .map(([face]) => face), ++ })), ++ meshes: Mesh.all.length, ++ textures: Texture.all.map((texture: any) => ({ ++ name: texture.name, ++ width: texture.width, ++ height: texture.height, ++ })), ++ }; ++ // Run official/plugin checks as well, but never execute validator buttons. ++ Validator.validate(); ++ const result = validateHytaleSnapshot(snapshot); ++ return JSON.stringify({ ++ ...result, ++ officialValidator: { ++ errors: Validator.errors.map((item: any) => item.message), ++ warnings: Validator.warnings.map((item: any) => item.message), ++ }, ++ }, null, 2); ++ }, ++ }, projectCaptureToolDocs[2].status); ++ ++ createTool(projectCaptureToolDocs[3].name, { ++ ...projectCaptureToolDocs[3], ++ async execute({ relative_path, name, render_mode, render_sides }) { ++ requireHytaleProject(); ++ const fs = getNativeFs(); ++ const { assetRoot } = readProjectCaptureSecurityConfig(); ++ const source = resolveAssetReadPath(assetRoot, relative_path, [".png"], fs); ++ const texture = new Texture({ ++ name: name ?? relative_path.split(/[\\/]/).pop(), ++ render_mode, ++ render_sides, ++ internal: false, ++ }).fromPath(source).add(false); ++ texture.load(); ++ texture.fillParticle(); ++ Canvas.updateAllFaces(); ++ return JSON.stringify({ name: texture.name, uuid: texture.uuid, width: texture.width, height: texture.height }); ++ }, ++ }, projectCaptureToolDocs[3].status); ++ ++ createTool(projectCaptureToolDocs[4].name, { ++ ...projectCaptureToolDocs[4], ++ async execute({ texture, relative_path }) { ++ requireHytaleProject(); ++ const targetTexture = findTextureOrThrow(texture); ++ const match = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(targetTexture.getDataURL()); ++ if (!match) throw new Error("Texture is not available as a PNG data URL."); ++ writeAsset(relative_path, [".png"], Buffer.from(match[1], "base64")); ++ return JSON.stringify({ relativePath: relative_path, bytes: Buffer.byteLength(match[1], "base64") }); ++ }, ++ }, projectCaptureToolDocs[4].status); ++ ++ createTool(projectCaptureToolDocs[5].name, { ++ ...projectCaptureToolDocs[5], ++ async execute({ relative_path }) { ++ requireHytaleProject(); ++ const codec = Codecs.project as { compile: () => unknown | Promise } | undefined; ++ if (!codec?.compile) throw new Error("Blockbench project codec is unavailable."); ++ const payload = await awaitCompiledPayload(codec.compile()); ++ const serialized = stringifyCompiledPayload(payload); ++ writeAsset(relative_path, [".bbmodel"], serialized); ++ return JSON.stringify({ relativePath: relative_path, bytes: Buffer.byteLength(serialized) }); ++ }, ++ }, projectCaptureToolDocs[5].status); ++ ++ createTool(projectCaptureToolDocs[6].name, { ++ ...projectCaptureToolDocs[6], ++ async execute({ relative_path, attachment }) { ++ requireHytaleProject(); ++ const codec = Codecs.blockymodel as { compile: (options?: unknown) => unknown | Promise } | undefined; ++ if (!codec?.compile) throw new Error("Official Hytale blockymodel codec is unavailable."); ++ let collection: Collection | undefined; ++ if (attachment) { ++ collection = Collection.all.find((item: any) => ++ item.uuid === attachment || item.name === attachment ++ ); ++ if (!collection || collection.export_codec !== "blockymodel") { ++ throw new Error(`Hytale attachment collection "${attachment}" was not found.`); ++ } ++ } ++ const payload = await awaitCompiledPayload(codec.compile(collection ? { attachment: collection } : undefined)); ++ const serialized = stringifyCompiledPayload(payload); ++ writeAsset(relative_path, [".blockymodel"], serialized); ++ return JSON.stringify({ relativePath: relative_path, attachment: collection?.name ?? null, bytes: Buffer.byteLength(serialized) }); ++ }, ++ }, projectCaptureToolDocs[6].status); ++ ++ createTool(projectCaptureToolDocs[7].name, { ++ ...projectCaptureToolDocs[7], ++ async execute({ relative_path, animation }) { ++ requireHytaleProject(); ++ const selected = animation ++ ? Animation.all.find((item: any) => item.uuid === animation || item.name === animation) ++ : Animation.selected; ++ if (!selected) throw new Error("No matching Hytale animation is selected."); ++ const content = JSON.stringify(animationToBlockyanim(selected), null, 2); ++ writeAsset(relative_path, [".blockyanim"], content); ++ return JSON.stringify({ relativePath: relative_path, animation: selected.name, bytes: Buffer.byteLength(content) }); ++ }, ++ }, projectCaptureToolDocs[7].status); ++ ++ createTool(projectCaptureToolDocs[8].name, { ++ ...projectCaptureToolDocs[8], ++ async execute({ name, groups, texture }) { ++ requireHytaleProject(); ++ const resolvedGroups = groups.map((group: string) => findGroupOrThrow(group)); ++ const duplicate = new Set(resolvedGroups.map((group) => group.uuid)); ++ if (duplicate.size !== resolvedGroups.length) throw new Error("Attachment groups must be unique."); ++ const collection = new Collection({ ++ name, ++ children: resolvedGroups.map((group) => group.uuid), ++ export_codec: "blockymodel", ++ visibility: true, ++ }).add() as Collection & { texture?: string }; ++ if (texture) collection.texture = findTextureOrThrow(texture).uuid; ++ return JSON.stringify({ name: collection.name, uuid: collection.uuid, groups: resolvedGroups.map((group) => group.name), texture: collection.texture ?? null }); ++ }, ++ }, projectCaptureToolDocs[8].status); ++} +diff --git a/server/tools/texture.ts b/server/tools/texture.ts +index 14db378..3c8978e 100644 +--- a/server/tools/texture.ts ++++ b/server/tools/texture.ts +@@ -32,8 +32,13 @@ export const createTextureParameters = z + height: z.number().min(16).max(4096).default(16), + data: z + .string() ++ .max(12_000_000) ++ .refine( ++ (value) => /^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value), ++ "Only inline PNG data URLs are accepted; local files use hytale_import_texture_png and remote URLs are forbidden.", ++ ) + .optional() +- .describe("Path to the image file or data URL."), ++ .describe("Inline PNG data URL. Remote URLs and arbitrary file paths are forbidden."), + group: z.string().optional(), + fill_color: colorSchema + .optional() +@@ -239,7 +244,7 @@ export const textureToolDocs: ToolSpec[] = [ + annotations: { + title: "Create Texture", + destructiveHint: true, +- openWorldHint: true, ++ openWorldHint: false, + }, + parameters: createTextureParameters, + status: STATUS_EXPERIMENTAL, +@@ -395,6 +400,8 @@ export function registerTextureTools() { + fill_color, + group, + layer_name, ++ render_mode, ++ render_sides, + }) { + Undo.initEdit({ + textures: [], +@@ -407,20 +414,15 @@ export function registerTextureTools() { + height, + group, + pbr_channel, ++ render_mode, ++ render_sides, + internal: true, + }); + + if (data) { +- if (data.startsWith("data:image/")) { +- texture.source = data; +- texture.width = width; +- texture.height = height; +- } else { +- texture = texture.fromFile({ +- name: data.split(/[\/\\]/).pop() || data, +- path: data.replace(/^file:\/\//, ""), +- }); +- } ++ texture.source = data; ++ texture.width = width; ++ texture.height = height; + + texture.load(); + texture.fillParticle(); +diff --git a/tests/projectcapture-hytale.test.ts b/tests/projectcapture-hytale.test.ts +new file mode 100644 +index 0000000..bcfbcab +--- /dev/null ++++ b/tests/projectcapture-hytale.test.ts +@@ -0,0 +1,87 @@ ++import { describe, expect, test } from "bun:test"; ++import { ++ awaitCompiledPayload, ++ buildCapabilityReport, ++ hytaleQuadBounds, ++ keyframeDataPoint, ++ validateHytaleSnapshot, ++} from "../lib/projectcaptureHytale"; ++ ++describe("Hytale geometry and animation helpers", () => { ++ test("maps every quad normal to the correct zero axis and textured face", () => { ++ expect(hytaleQuadBounds([1, 2, 3], "+X", [4, 5])).toEqual({ ++ from: [1, 2, 3], to: [1, 7, 7], face: "east", ++ }); ++ expect(hytaleQuadBounds([1, 2, 3], "-Y", [4, 5])).toEqual({ ++ from: [1, 2, 3], to: [5, 2, 8], face: "down", ++ }); ++ expect(hytaleQuadBounds([1, 2, 3], "-Z", [4, 5])).toEqual({ ++ from: [1, 2, 3], to: [5, 7, 3], face: "north", ++ }); ++ }); ++ ++ test("uses the official visibility property and vector data points", () => { ++ expect(keyframeDataPoint("visibility", false)).toEqual({ visibility: false }); ++ expect(keyframeDataPoint("position", [1, 2, 3])).toEqual({ x: 1, y: 2, z: 3 }); ++ expect(keyframeDataPoint("scale", 2)).toEqual({ x: 2, y: 2, z: 2 }); ++ expect(() => keyframeDataPoint("visibility", [1, 2, 3])).toThrow(); ++ }); ++ ++ test("awaits asynchronous codec output", async () => { ++ expect(await awaitCompiledPayload(Promise.resolve("compiled"))).toBe("compiled"); ++ }); ++}); ++ ++describe("Hytale validator and capability probe", () => { ++ test("accepts a minimal character snapshot", () => { ++ const result = validateHytaleSnapshot({ ++ formatId: "hytale_character", ++ nodeCount: 2, ++ groups: [{ name: "root", visibility: true }], ++ cubes: [{ ++ name: "body", ++ visibility: true, ++ shadingMode: "flat", ++ zeroAxes: 0, ++ texturedFaces: ["north", "south", "east", "west", "up", "down"], ++ }], ++ meshes: 0, ++ textures: [{ name: "creature.png", width: 64, height: 64 }], ++ }); ++ expect(result).toEqual({ valid: true, errors: [], warnings: [] }); ++ }); ++ ++ test("rejects ambiguous groups, invalid quads, meshes and texture sizes", () => { ++ const result = validateHytaleSnapshot({ ++ formatId: "hytale_character", ++ nodeCount: 256, ++ groups: [ ++ { name: "leg", visibility: true }, ++ { name: "leg", visibility: "yes" }, ++ ], ++ cubes: [{ ++ name: "fin", ++ visibility: true, ++ shadingMode: "smooth", ++ zeroAxes: 1, ++ texturedFaces: ["north", "south"], ++ }], ++ meshes: 1, ++ textures: [{ name: "bad.png", width: 32, height: 65 }], ++ }); ++ expect(result.valid).toBe(false); ++ expect(result.errors.length).toBeGreaterThanOrEqual(6); ++ }); ++ ++ test("reports readiness only when all pinned capabilities exist", () => { ++ const report = buildCapabilityReport({ ++ blockbenchVersion: "5.0.7", ++ hytalePluginVersion: "0.9.1", ++ formats: ["hytale_character", "hytale_prop"], ++ codecs: ["blockymodel"], ++ tools: ["hytale_save_bbmodel", "hytale_save_texture_png", "hytale_export_blockyanim"], ++ }); ++ expect(report.ready).toBe(true); ++ expect(report.security.arbitraryEvaluation).toBe(false); ++ }); ++}); +diff --git a/tests/projectcapture-security.test.ts b/tests/projectcapture-security.test.ts +new file mode 100644 +index 0000000..2651fc1 +--- /dev/null ++++ b/tests/projectcapture-security.test.ts +@@ -0,0 +1,124 @@ ++import { afterEach, describe, expect, test } from "bun:test"; ++import { ++ mkdirSync, ++ mkdtempSync, ++ readFileSync, ++ realpathSync, ++ rmSync, ++ symlinkSync, ++ writeFileSync, ++ existsSync, ++ lstatSync, ++} from "node:fs"; ++import { tmpdir } from "node:os"; ++import path from "node:path"; ++import { ++ isAuthorizedBearer, ++ prepareAssetWritePath, ++ readProjectCaptureSecurityConfig, ++ resolveAssetReadPath, ++} from "../lib/projectcaptureSecurity"; ++import { ++ isProjectCaptureToolAllowed, ++ PROJECTCAPTURE_FORBIDDEN_TOOLS, ++} from "../lib/projectcaptureAllowlist"; ++ ++const tempRoots: string[] = []; ++const nodeFs = { ++ existsSync, ++ lstatSync, ++ realpathSync, ++ mkdirSync, ++}; ++ ++function tempRoot(): string { ++ const root = mkdtempSync(path.join(tmpdir(), "projectcapture-assets-")); ++ tempRoots.push(root); ++ return root; ++} ++ ++afterEach(() => { ++ for (const root of tempRoots.splice(0)) { ++ rmSync(root, { recursive: true, force: true }); ++ } ++}); ++ ++describe("ProjectCapture security configuration", () => { ++ test("requires a long token and absolute asset root", () => { ++ const config = readProjectCaptureSecurityConfig({ ++ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz0123456789-TEST", ++ PROJECTCAPTURE_ASSET_ROOT: "/tmp/projectcapture-assets", ++ }); ++ expect(config.host).toBe("127.0.0.1"); ++ expect(config.assetRoot).toBe("/tmp/projectcapture-assets"); ++ }); ++ ++ test("rejects missing, short, whitespace and relative secrets/config", () => { ++ expect(() => readProjectCaptureSecurityConfig({})).toThrow(); ++ expect(() => readProjectCaptureSecurityConfig({ ++ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "short", ++ PROJECTCAPTURE_ASSET_ROOT: "/tmp/assets", ++ })).toThrow(); ++ expect(() => readProjectCaptureSecurityConfig({ ++ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz 0123456789", ++ PROJECTCAPTURE_ASSET_ROOT: "/tmp/assets", ++ })).toThrow(); ++ expect(() => readProjectCaptureSecurityConfig({ ++ PROJECTCAPTURE_BLOCKBENCH_TOKEN: "abcdefghijklmnopqrstuvwxyz0123456789", ++ PROJECTCAPTURE_ASSET_ROOT: "relative/assets", ++ })).toThrow(); ++ }); ++ ++ test("accepts only the exact bearer token", () => { ++ const token = "abcdefghijklmnopqrstuvwxyz0123456789"; ++ expect(isAuthorizedBearer(`Bearer ${token}`, token)).toBe(true); ++ expect(isAuthorizedBearer(`bearer ${token}`, token)).toBe(false); ++ expect(isAuthorizedBearer(`Bearer ${token}x`, token)).toBe(false); ++ expect(isAuthorizedBearer(undefined, token)).toBe(false); ++ }); ++}); ++ ++describe("asset-root sandbox", () => { ++ test("creates safe parents and resolves an allowed output", () => { ++ const root = tempRoot(); ++ const target = prepareAssetWritePath(root, "creatures/test/model.bbmodel", [".bbmodel"], nodeFs); ++ expect(target).toBe(path.join(realpathSync(root), "creatures/test/model.bbmodel")); ++ expect(lstatSync(path.dirname(target)).isDirectory()).toBe(true); ++ }); ++ ++ test("rejects traversal, absolute paths and wrong extensions", () => { ++ const root = tempRoot(); ++ expect(() => prepareAssetWritePath(root, "../outside.bbmodel", [".bbmodel"], nodeFs)).toThrow(); ++ expect(() => prepareAssetWritePath(root, "/tmp/outside.bbmodel", [".bbmodel"], nodeFs)).toThrow(); ++ expect(() => prepareAssetWritePath(root, "model.js", [".bbmodel"], nodeFs)).toThrow(); ++ }); ++ ++ test("rejects a symlinked parent and target", () => { ++ const root = tempRoot(); ++ const outside = tempRoot(); ++ symlinkSync(outside, path.join(root, "linked")); ++ expect(() => prepareAssetWritePath(root, "linked/model.bbmodel", [".bbmodel"], nodeFs)).toThrow(); ++ ++ writeFileSync(path.join(outside, "real.png"), "png"); ++ symlinkSync(path.join(outside, "real.png"), path.join(root, "texture.png")); ++ expect(() => resolveAssetReadPath(root, "texture.png", [".png"], nodeFs)).toThrow(); ++ }); ++ ++ test("reads only a regular in-root PNG", () => { ++ const root = tempRoot(); ++ writeFileSync(path.join(root, "texture.png"), "png-data"); ++ const result = resolveAssetReadPath(root, "texture.png", [".png"], nodeFs); ++ expect(readFileSync(result, "utf8")).toBe("png-data"); ++ }); ++}); ++ ++describe("tool policy", () => { ++ test("allows reviewed Hytale tools and denies every dangerous tool", () => { ++ expect(isProjectCaptureToolAllowed("create_hytale_project")).toBe(true); ++ expect(isProjectCaptureToolAllowed("hytale_export_blockyanim")).toBe(true); ++ for (const tool of PROJECTCAPTURE_FORBIDDEN_TOOLS) { ++ expect(isProjectCaptureToolAllowed(tool), tool).toBe(false); ++ } ++ expect(isProjectCaptureToolAllowed("unknown_future_tool")).toBe(false); ++ }); ++}); +diff --git a/ui/settings.ts b/ui/settings.ts +index 93b3a0a..8451115 100644 +--- a/ui/settings.ts ++++ b/ui/settings.ts +@@ -30,14 +30,6 @@ export function settingsSetup() { + category, + icon: "webhook", + }), +- new Setting("mcp_prompt_cdn_enabled", { +- name: tl("mcp.settings.prompt_cdn_name"), +- description: tl("mcp.settings.prompt_cdn_desc"), +- type: "toggle", +- value: true, +- category, +- icon: "cloud_download", +- }), + new Setting("mcp_session_timeout", { + name: tl("mcp.settings.session_timeout_name"), + description: tl("mcp.settings.session_timeout_desc"), diff --git a/devtools/blockbench-mcp/scripts/build.sh b/devtools/blockbench-mcp/scripts/build.sh new file mode 100755 index 0000000..6cea9df --- /dev/null +++ b/devtools/blockbench-mcp/scripts/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_command git +require_command pnpm + +stage_parent="$(make_stage_parent)" +trap 'safe_remove_stage "${stage_parent}"' EXIT +source_dir="${stage_parent}/source" + +prepare_source "${source_dir}" +run_bun install --cwd "${source_dir}" --frozen-lockfile +run_bun test --cwd "${source_dir}" +run_bun run --cwd "${source_dir}" build + +output_dir="${TOOL_ROOT}/dist" +mkdir -p -- "${output_dir}" +cp -- "${source_dir}/dist/mcp.js" "${output_dir}/mcp-projectcapture-1.6.1.js" +cp -- "${source_dir}/dist/icon.svg" "${output_dir}/mcp-icon.svg" +cp -- "${source_dir}/dist/about.md" "${output_dir}/mcp-about.md" + +bundle_hash="$(sha256_file "${output_dir}/mcp-projectcapture-1.6.1.js")" +write_dist_checksums "${output_dir}" +printf 'Gehärtetes Build erstellt: %s\nSHA-256: %s\n' \ + "${output_dir}/mcp-projectcapture-1.6.1.js" "${bundle_hash}" diff --git a/devtools/blockbench-mcp/scripts/create-local-env.sh b/devtools/blockbench-mcp/scripts/create-local-env.sh new file mode 100755 index 0000000..a502ed2 --- /dev/null +++ b/devtools/blockbench-mcp/scripts/create-local-env.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_command git + +if [[ "$#" -ne 2 ]]; then + printf 'Verwendung: %s AUSGABEDATEI ABSOLUTER_ASSET_ROOT\n' "$0" >&2 + exit 2 +fi + +output="$1" +asset_root="$2" + +output_parent="$(CDPATH= cd -- "$(dirname -- "${output}")" && pwd -P)" || { + printf 'Das Ausgabeverzeichnis muss bereits existieren.\n' >&2 + exit 1 +} +output="${output_parent}/$(basename -- "${output}")" +project_root="$(git -C "${TOOL_ROOT}" rev-parse --show-toplevel)" +project_root="$(CDPATH= cd -- "${project_root}" && pwd -P)" +case "${output}" in + "${project_root}"|"${project_root}"/*) + printf 'Die Secret-Datei darf nicht im Repository liegen.\n' >&2 + exit 1 + ;; +esac +[[ "${asset_root}" == /* ]] || { + printf 'Asset-Root muss absolut sein.\n' >&2 + exit 1 +} +[[ ! -e "${output}" ]] || { + printf 'Ausgabedatei existiert bereits; sie wird nicht überschrieben.\n' >&2 + exit 1 +} + +if command -v openssl >/dev/null 2>&1; then + token="$(openssl rand -base64 48 | tr -d '\n')" +else + printf 'openssl wird zur sicheren Token-Erzeugung benötigt.\n' >&2 + exit 1 +fi + +printf 'PROJECTCAPTURE_BLOCKBENCH_TOKEN=%q\nPROJECTCAPTURE_ASSET_ROOT=%q\n' \ + "${token}" "${asset_root}" > "${output}" +chmod 0600 "${output}" +printf 'Lokale Environment-Datei erstellt (Token wird nicht ausgegeben): %s\n' "${output}" diff --git a/devtools/blockbench-mcp/scripts/install-reviewed.sh b/devtools/blockbench-mcp/scripts/install-reviewed.sh new file mode 100755 index 0000000..512238c --- /dev/null +++ b/devtools/blockbench-mcp/scripts/install-reviewed.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +if [[ "$#" -ne 2 || "$1" != "--i-reviewed-gpl" ]]; then + printf 'Verwendung: %s --i-reviewed-gpl EXPLIZITES_PLUGIN_ZIELVERZEICHNIS\n' "$0" >&2 + exit 2 +fi + +destination="$2" +[[ -d "${destination}" && ! -L "${destination}" ]] || { + printf 'Ziel muss ein vorhandenes echtes Verzeichnis sein: %s\n' "${destination}" >&2 + exit 1 +} + +mcp_source="${TOOL_ROOT}/dist/mcp-projectcapture-1.6.1.js" +hytale_source="${TOOL_ROOT}/dist/hytale_plugin-0.9.1.js" +[[ -f "${mcp_source}" && ! -L "${mcp_source}" ]] || { + printf 'MCP-Build fehlt; zuerst scripts/build.sh ausführen.\n' >&2 + exit 1 +} +[[ -f "${hytale_source}" && ! -L "${hytale_source}" ]] || { + printf 'Hytale-Plugin fehlt; zuerst scripts/stage-hytale-plugin.sh ausführen.\n' >&2 + exit 1 +} + +# Bestehende Dateien werden absichtlich nicht überschrieben. Review/Backup ist +# Sache des aufrufenden Installationsschritts. +for target in "${destination}/mcp.js" "${destination}/hytale_plugin.js"; do + [[ ! -e "${target}" ]] || { + printf 'Zieldatei existiert bereits; Installation abgebrochen: %s\n' "${target}" >&2 + exit 1 + } +done + +cp -- "${mcp_source}" "${destination}/mcp.js" +cp -- "${hytale_source}" "${destination}/hytale_plugin.js" +printf 'Geprüfte Plugins installiert. Codex-Konfiguration wurde nicht verändert.\n' diff --git a/devtools/blockbench-mcp/scripts/lib.sh b/devtools/blockbench-mcp/scripts/lib.sh new file mode 100755 index 0000000..c02fb67 --- /dev/null +++ b/devtools/blockbench-mcp/scripts/lib.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +TOOL_ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +MCP_REPOSITORY="https://github.com/jasonjgardner/blockbench-mcp-plugin.git" +MCP_COMMIT="6b069e308fdfc9b0a1c15bc924ca78150815f143" +MCP_PATCH="${TOOL_ROOT}/patches/0001-projectcapture-hardening.patch" +MCP_PATCH_SHA256="9de0b3767e96db7a68ef4c7082d7c76bf140b4932fc9550ad6e99d9a24a048c3" +BUN_VERSION="1.3.14" + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + printf 'Fehlendes Werkzeug: %s\n' "$1" >&2 + exit 1 + } +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + printf 'Weder shasum noch sha256sum ist verfügbar.\n' >&2 + exit 1 + fi +} + +verify_patch() { + local actual + actual="$(sha256_file "${MCP_PATCH}")" + if [[ "${actual}" != "${MCP_PATCH_SHA256}" ]]; then + printf 'Patch-Hash stimmt nicht: erwartet %s, erhalten %s\n' \ + "${MCP_PATCH_SHA256}" "${actual}" >&2 + exit 1 + fi +} + +prepare_source() { + local destination="$1" + if [[ -z "${destination}" || -e "${destination}" ]]; then + printf 'Temporäres Ziel muss neu und nicht vorhanden sein: %s\n' "${destination}" >&2 + exit 1 + fi + verify_patch + git clone --quiet --filter=blob:none --no-checkout "${MCP_REPOSITORY}" "${destination}" + git -C "${destination}" checkout --quiet --detach "${MCP_COMMIT}" + local resolved + resolved="$(git -C "${destination}" rev-parse HEAD)" + if [[ "${resolved}" != "${MCP_COMMIT}" ]]; then + printf 'Upstream-Commit stimmt nicht: %s\n' "${resolved}" >&2 + exit 1 + fi + git -C "${destination}" apply --check "${MCP_PATCH}" + git -C "${destination}" apply "${MCP_PATCH}" + git -C "${destination}" diff --check +} + +make_stage_parent() { + mktemp -d "${TMPDIR:-/tmp}/projectcapture-blockbench-mcp.XXXXXX" +} + +safe_remove_stage() { + local stage="$1" + local leaf + leaf="$(basename -- "${stage}")" + if [[ -d "${stage}" && ! -L "${stage}" && "${leaf}" == projectcapture-blockbench-mcp.* ]]; then + rm -rf -- "${stage}" + else + printf 'Unsicheres temporäres Löschziel wurde nicht entfernt: %s\n' "${stage}" >&2 + fi +} + +run_bun() { + pnpm dlx "bun@${BUN_VERSION}" "$@" +} + +write_dist_checksums() { + local output_dir="$1" + local checksum_file="${output_dir}/SHA256SUMS" + local temporary_file="${checksum_file}.tmp.$$" + local artifact + + : > "${temporary_file}" + for artifact in \ + hytale_plugin-0.9.1.js \ + mcp-about.md \ + mcp-icon.svg \ + mcp-projectcapture-1.6.1.js; do + if [[ -f "${output_dir}/${artifact}" && ! -L "${output_dir}/${artifact}" ]]; then + printf '%s %s\n' "$(sha256_file "${output_dir}/${artifact}")" "${artifact}" \ + >> "${temporary_file}" + fi + done + mv -- "${temporary_file}" "${checksum_file}" +} diff --git a/devtools/blockbench-mcp/scripts/stage-hytale-plugin.sh b/devtools/blockbench-mcp/scripts/stage-hytale-plugin.sh new file mode 100755 index 0000000..265cad6 --- /dev/null +++ b/devtools/blockbench-mcp/scripts/stage-hytale-plugin.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_command git + +repository="https://github.com/JannisX11/hytale-blockbench-plugin.git" +commit="82dc58d19949c802f60f31c24fcb063a6b12fb07" +expected_sha="ce1b0a6a3ad2f36b073b96a71a416261e43f089f4c02799b180b71c9e3f849f3" + +stage_parent="$(make_stage_parent)" +trap 'safe_remove_stage "${stage_parent}"' EXIT +source_dir="${stage_parent}/hytale-plugin" +git clone --quiet --filter=blob:none --no-checkout "${repository}" "${source_dir}" +git -C "${source_dir}" checkout --quiet --detach "${commit}" +[[ "$(git -C "${source_dir}" rev-parse HEAD)" == "${commit}" ]] || exit 1 + +artifact="${source_dir}/dist/hytale_plugin.js" +actual_sha="$(sha256_file "${artifact}")" +if [[ "${actual_sha}" != "${expected_sha}" ]]; then + printf 'Hytale-Plugin-Hash stimmt nicht.\n' >&2 + exit 1 +fi + +output_dir="${TOOL_ROOT}/dist" +mkdir -p -- "${output_dir}" +cp -- "${artifact}" "${output_dir}/hytale_plugin-0.9.1.js" +write_dist_checksums "${output_dir}" +printf 'Offizielles Hytale-Plugin geprüft und bereitgestellt: %s\n' \ + "${output_dir}/hytale_plugin-0.9.1.js" diff --git a/devtools/blockbench-mcp/scripts/test.sh b/devtools/blockbench-mcp/scripts/test.sh new file mode 100755 index 0000000..f11cc61 --- /dev/null +++ b/devtools/blockbench-mcp/scripts/test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_command git +require_command pnpm + +stage_parent="$(make_stage_parent)" +trap 'safe_remove_stage "${stage_parent}"' EXIT +source_dir="${stage_parent}/source" + +prepare_source "${source_dir}" +run_bun install --cwd "${source_dir}" --frozen-lockfile +run_bun test --cwd "${source_dir}" +run_bun run --cwd "${source_dir}" build + +bundle="${source_dir}/dist/mcp.js" +[[ -f "${bundle}" ]] || { + printf 'Build-Artefakt fehlt: %s\n' "${bundle}" >&2 + exit 1 +} + +for forbidden_network_marker in 'https://cdn.jsdelivr.net' 'fetch('; do + if grep -Fq "${forbidden_network_marker}" "${bundle}"; then + printf 'Unerwarteter Netzwerkmarker im Build: %s\n' "${forbidden_network_marker}" >&2 + exit 1 + fi +done + +printf 'Blockbench-MCP: Tests und Build erfolgreich.\n' diff --git a/devtools/blockbench-mcp/upstream.lock.json b/devtools/blockbench-mcp/upstream.lock.json new file mode 100644 index 0000000..b7cab7d --- /dev/null +++ b/devtools/blockbench-mcp/upstream.lock.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "blockbench": { + "version": "5.0.7" + }, + "mcp": { + "repository": "https://github.com/jasonjgardner/blockbench-mcp-plugin.git", + "version": "1.6.1", + "commit": "6b069e308fdfc9b0a1c15bc924ca78150815f143", + "license": "GPL-3.0-only" + }, + "hytalePlugin": { + "repository": "https://github.com/JannisX11/hytale-blockbench-plugin.git", + "version": "0.9.1", + "commit": "82dc58d19949c802f60f31c24fcb063a6b12fb07", + "distSha256": "ce1b0a6a3ad2f36b073b96a71a416261e43f089f4c02799b180b71c9e3f849f3", + "license": "GPL-3.0" + }, + "toolchain": { + "bun": "1.3.14", + "packageManager": "pnpm" + }, + "patches": [ + { + "path": "patches/0001-projectcapture-hardening.patch", + "sha256": "9de0b3767e96db7a68ef4c7082d7c76bf140b4932fc9550ad6e99d9a24a048c3" + } + ] +} diff --git a/hytale-adapter/src/main/java/dev/projectcapture/hytale/ProjectCapturePlugin.java b/hytale-adapter/src/main/java/dev/projectcapture/hytale/ProjectCapturePlugin.java index 4016d4f..6a23d04 100644 --- a/hytale-adapter/src/main/java/dev/projectcapture/hytale/ProjectCapturePlugin.java +++ b/hytale-adapter/src/main/java/dev/projectcapture/hytale/ProjectCapturePlugin.java @@ -2,6 +2,9 @@ package dev.projectcapture.hytale; import com.hypixel.hytale.server.core.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import dev.projectcapture.hytale.command.ProjectCaptureCommands; +import dev.projectcapture.hytale.content.RuntimeContentCatalog; +import dev.projectcapture.hytale.gameplay.MultiplayerCreatureService; import dev.projectcapture.hytale.persistence.JsonStateStore; import java.util.concurrent.CompletableFuture; @@ -13,6 +16,8 @@ import java.util.concurrent.CompletableFuture; public final class ProjectCapturePlugin extends JavaPlugin { private final JsonStateStore stateStore; private final FeatureFlags featureFlags; + private RuntimeContentCatalog contentCatalog; + private MultiplayerCreatureService multiplayerService; public ProjectCapturePlugin(JavaPluginInit init) { super(init); @@ -23,6 +28,9 @@ public final class ProjectCapturePlugin extends JavaPlugin { @Override public CompletableFuture preLoad() { stateStore.initialize(); + contentCatalog = RuntimeContentCatalog.load(getClass().getClassLoader()); + multiplayerService = new MultiplayerCreatureService(stateStore, contentCatalog); + ProjectCaptureCommands.register(this, multiplayerService); return CompletableFuture.completedFuture(null); } @@ -33,5 +41,18 @@ public final class ProjectCapturePlugin extends JavaPlugin { FeatureFlags featureFlags() { return featureFlags; } -} + RuntimeContentCatalog contentCatalog() { + if (contentCatalog == null) { + throw new IllegalStateException("Content catalog is not loaded before preLoad"); + } + return contentCatalog; + } + + MultiplayerCreatureService multiplayerService() { + if (multiplayerService == null) { + throw new IllegalStateException("Multiplayer service is not loaded before preLoad"); + } + return multiplayerService; + } +} diff --git a/hytale-adapter/src/main/java/dev/projectcapture/hytale/command/ProjectCaptureCommands.java b/hytale-adapter/src/main/java/dev/projectcapture/hytale/command/ProjectCaptureCommands.java new file mode 100644 index 0000000..e8cfa2d --- /dev/null +++ b/hytale-adapter/src/main/java/dev/projectcapture/hytale/command/ProjectCaptureCommands.java @@ -0,0 +1,170 @@ +package dev.projectcapture.hytale.command; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import dev.projectcapture.hytale.gameplay.MultiplayerCreatureService; + +import java.util.UUID; + +/** Registers the server-authoritative playable multiplayer vertical-slice commands. */ +public final class ProjectCaptureCommands { + private ProjectCaptureCommands() { + } + + public static void register(JavaPlugin plugin, MultiplayerCreatureService service) { + plugin.getCommandRegistry().registerCommand(new RootCommand(service)); + } + + private static final class RootCommand extends AbstractCommandCollection { + private RootCommand(MultiplayerCreatureService service) { + super("projectcapture", "ProjectCapture creature commands"); + addAliases("pc"); + addSubCommand(new PartyCommand(service)); + addSubCommand(new CompanionCommand(service)); + addSubCommand(new CaptureCommand(service)); + addSubCommand(new EncounterCommand(service)); + addSubCommand(new GrantCapsulesCommand(service)); + } + } + + private abstract static class PlayerCommand extends AbstractPlayerCommand { + protected final MultiplayerCreatureService service; + + private PlayerCommand(String name, String description, MultiplayerCreatureService service) { + super(name, description); + this.service = service; + } + + protected final UUID playerId(PlayerRef player) { + return player.getUuid(); + } + + protected final void message(CommandContext context, String text) { + context.sendMessage(Message.raw("[ProjectCapture] " + text)); + } + } + + private static final class PartyCommand extends PlayerCommand { + private PartyCommand(MultiplayerCreatureService service) { + super("party", "Show your ProjectCapture party", service); + } + + @Override + protected void execute( + CommandContext context, + Store store, + Ref playerRef, + PlayerRef player, + World world + ) { + MultiplayerCreatureService.PartyView party = service.party(playerId(player)); + String creatures = party.creatures().isEmpty() + ? "empty" + : party.creatures().stream() + .map(creature -> creature.speciesId() + " Lv." + creature.level()) + .collect(java.util.stream.Collectors.joining(", ")); + message(context, "Party: " + creatures + " | Field capsules: " + party.fieldCapsules() + + " | Companion: " + party.activeCompanionId().map(UUID::toString).orElse("none")); + } + } + + private static final class CompanionCommand extends PlayerCommand { + private final RequiredArg creatureId = withRequiredArg("creatureId", "UUID from /pc party", ArgTypes.UUID); + + private CompanionCommand(MultiplayerCreatureService service) { + super("companion", "Set a captured party creature as your companion", service); + } + + @Override + protected void execute( + CommandContext context, + Store store, + Ref playerRef, + PlayerRef player, + World world + ) { + UUID id = context.get(creatureId); + service.setCompanion(playerId(player), id); + message(context, "Companion saved: " + id); + } + } + + private static final class CaptureCommand extends PlayerCommand { + private CaptureCommand(MultiplayerCreatureService service) { + super("capture", "Throw one crafted field capsule at your active encounter", service); + } + + @Override + protected void execute( + CommandContext context, + Store store, + Ref playerRef, + PlayerRef player, + World world + ) { + MultiplayerCreatureService.CaptureOutcome outcome = service.capture(playerId(player), System.nanoTime()); + if (outcome.captured()) { + message(context, "Captured " + outcome.creatureId().orElseThrow() + " (" + + Math.round(outcome.probability() * 100) + "% chance)." + + (outcome.addedToParty() ? " Added to your party." : " Your party is full; stored safely.")); + } else { + message(context, "The creature broke free (" + + Math.round(outcome.probability() * 100) + "% chance). Try again with another capsule."); + } + } + } + + private static final class EncounterCommand extends PlayerCommand { + private final RequiredArg speciesId = withRequiredArg("speciesId", "Namespaced species ID", ArgTypes.STRING); + + private EncounterCommand(MultiplayerCreatureService service) { + super("encounter", "Development command: open a personal wild encounter", service); + requirePermission("projectcapture.debug.encounter"); + } + + @Override + protected void execute( + CommandContext context, + Store store, + Ref playerRef, + PlayerRef player, + World world + ) { + MultiplayerCreatureService.EncounterView encounter = service.openEncounter( + playerId(player), context.get(speciesId), 5, System.nanoTime()); + message(context, "Personal wild encounter: " + encounter.speciesId() + " Lv." + encounter.level()); + } + } + + private static final class GrantCapsulesCommand extends PlayerCommand { + private final RequiredArg amount = withRequiredArg("amount", "Number of field capsules", ArgTypes.INTEGER); + + private GrantCapsulesCommand(MultiplayerCreatureService service) { + super("grantcapsules", "Development command: grant crafted field-capsule test inventory", service); + requirePermission("projectcapture.debug.grantcapsules"); + } + + @Override + protected void execute( + CommandContext context, + Store store, + Ref playerRef, + PlayerRef player, + World world + ) { + int requested = context.get(amount); + service.grantFieldCapsules(playerId(player), requested); + message(context, "Added " + requested + " field capsules to your server-authoritative test inventory."); + } + } +} diff --git a/hytale-adapter/src/main/java/dev/projectcapture/hytale/content/RuntimeContentCatalog.java b/hytale-adapter/src/main/java/dev/projectcapture/hytale/content/RuntimeContentCatalog.java new file mode 100644 index 0000000..a998827 --- /dev/null +++ b/hytale-adapter/src/main/java/dev/projectcapture/hytale/content/RuntimeContentCatalog.java @@ -0,0 +1,246 @@ +package dev.projectcapture.hytale.content; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.projectcapture.engine.model.BaseStats; +import dev.projectcapture.engine.model.ElementType; +import dev.projectcapture.engine.model.EvolutionRule; +import dev.projectcapture.engine.model.MoveDefinition; +import dev.projectcapture.engine.model.SpawnRule; +import dev.projectcapture.engine.model.SpeciesDefinition; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Loads the embedded, versioned fan-content documents into the Hytale-free + * engine models. Invalid or incomplete content fails during plugin pre-load. + */ +public record RuntimeContentCatalog( + Map species, + Map moves +) { + public static final String RESOURCE_ROOT = "projectcapture/content/v1/"; + private static final int SCHEMA_VERSION = 1; + + public RuntimeContentCatalog { + species = Map.copyOf(Objects.requireNonNull(species, "species")); + moves = Map.copyOf(Objects.requireNonNull(moves, "moves")); + if (species.isEmpty() || moves.isEmpty()) { + throw new IllegalArgumentException("Runtime content catalog must not be empty"); + } + } + + public static RuntimeContentCatalog load(ClassLoader classLoader) { + Objects.requireNonNull(classLoader, "classLoader"); + ObjectMapper json = new ObjectMapper().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + JsonNode moveDocument = read(json, classLoader, "moves.json"); + JsonNode speciesDocument = read(json, classLoader, "species.json"); + JsonNode spawnDocument = read(json, classLoader, "spawn-rules.json"); + + Map moves = parseMoves(moveDocument); + Map> spawns = parseSpawns(spawnDocument); + Map species = parseSpecies(speciesDocument, spawns); + validateReferences(species, moves); + return new RuntimeContentCatalog(species, moves); + } + + private static JsonNode read(ObjectMapper json, ClassLoader classLoader, String name) { + String resource = RESOURCE_ROOT + name; + try (InputStream input = classLoader.getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Missing embedded content document: " + resource); + } + JsonNode document = json.readTree(input); + requireHeader(document, resource); + return document; + } catch (IOException exception) { + throw new IllegalStateException("Could not read embedded content document: " + resource, exception); + } + } + + private static void requireHeader(JsonNode document, String resource) { + if (document == null || !document.isObject()) { + throw new IllegalStateException("Content document must be an object: " + resource); + } + if (document.path("schemaVersion").asInt(-1) != SCHEMA_VERSION) { + throw new IllegalStateException("Unsupported content schema in " + resource); + } + if (!document.path("unofficialFanContent").asBoolean(false)) { + throw new IllegalStateException("Missing unofficial fan-content marker in " + resource); + } + if (!document.path("items").isArray()) { + throw new IllegalStateException("Content document has no items array: " + resource); + } + } + + private static Map parseMoves(JsonNode document) { + Map result = new LinkedHashMap<>(); + for (JsonNode item : document.path("items")) { + MoveDefinition move = new MoveDefinition( + MoveDefinition.CURRENT_SCHEMA_VERSION, + requiredText(item, "id"), + requiredText(item, "displayName"), + ElementType.valueOf(requiredText(item, "type")), + MoveDefinition.Category.valueOf(requiredText(item, "category")), + requiredInt(item, "power"), + requiredDouble(item, "accuracy"), + requiredInt(item, "priority") + ); + if (result.putIfAbsent(move.id(), move) != null) { + throw new IllegalStateException("Duplicate move ID: " + move.id()); + } + } + return result; + } + + private static Map> parseSpawns(JsonNode document) { + Map> result = new LinkedHashMap<>(); + for (JsonNode item : document.path("items")) { + String speciesId = requiredText(item, "speciesId"); + Set biomes = values(item.path("biomes")).stream() + .map(value -> "projectcapture:" + value.toLowerCase(Locale.ROOT)) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + String time = requiredText(item, "time"); + Set times = "ANY".equals(time) + ? EnumSet.allOf(SpawnRule.TimeOfDay.class) + : Set.of(SpawnRule.TimeOfDay.valueOf(time)); + SpawnRule rule = new SpawnRule( + biomes, + times, + requiredInt(item, "rarityWeight"), + requiredInt(item, "minimumLevel"), + requiredInt(item, "maximumLevel") + ); + result.computeIfAbsent(speciesId, ignored -> new ArrayList<>()).add(rule); + } + return result; + } + + private static Map parseSpecies( + JsonNode document, + Map> spawns + ) { + Map result = new LinkedHashMap<>(); + for (JsonNode item : document.path("items")) { + String id = requiredText(item, "id"); + JsonNode stats = item.path("baseStats"); + int power = requiredInt(stats, "power"); + int guard = requiredInt(stats, "guard"); + BaseStats baseStats = new BaseStats( + requiredInt(stats, "vitality"), + power, + guard, + power, + guard, + requiredInt(stats, "agility") + ); + List types = values(item.path("types")).stream() + .map(ElementType::valueOf) + .toList(); + List evolutions = parseEvolution(item.path("evolution")); + SpeciesDefinition definition = new SpeciesDefinition( + SpeciesDefinition.CURRENT_SCHEMA_VERSION, + id, + requiredText(item, "displayName"), + types, + SpeciesDefinition.Rarity.valueOf(requiredText(item, "rarity")), + baseStats, + requiredInt(item, "captureRate"), + requiredText(item, "basicMoveId"), + values(item.path("commandMoveIds")), + evolutions, + List.copyOf(spawns.getOrDefault(id, List.of())) + ); + if (definition.spawnRules().isEmpty()) { + throw new IllegalStateException("Species has no runtime spawn rule: " + id); + } + if (result.putIfAbsent(id, definition) != null) { + throw new IllegalStateException("Duplicate species ID: " + id); + } + } + return result; + } + + private static List parseEvolution(JsonNode evolution) { + if (evolution.isMissingNode() || evolution.isNull()) { + return List.of(); + } + String method = requiredText(evolution, "method"); + int minimumLevel = "LEVEL".equals(method) ? requiredInt(evolution, "minimumLevel") : 1; + Optional item = "ITEM".equals(method) + ? Optional.of(requiredText(evolution, "itemId")) + : Optional.empty(); + return List.of(new EvolutionRule(requiredText(evolution, "targetSpeciesId"), minimumLevel, item)); + } + + private static void validateReferences( + Map species, + Map moves + ) { + for (SpeciesDefinition definition : species.values()) { + if (!moves.containsKey(definition.basicMoveId())) { + throw new IllegalStateException("Unknown basic move for " + definition.id()); + } + for (String moveId : definition.commandMoveIds()) { + if (!moves.containsKey(moveId)) { + throw new IllegalStateException("Unknown command move for " + definition.id() + ": " + moveId); + } + } + for (EvolutionRule evolution : definition.evolutions()) { + if (!species.containsKey(evolution.targetSpeciesId())) { + throw new IllegalStateException( + "Unknown evolution target for " + definition.id() + ": " + evolution.targetSpeciesId()); + } + } + } + } + + private static List values(JsonNode array) { + if (!array.isArray() || array.isEmpty()) { + throw new IllegalStateException("Expected a non-empty string array"); + } + List result = new ArrayList<>(); + for (JsonNode value : array) { + if (!value.isTextual() || value.textValue().isBlank()) { + throw new IllegalStateException("Expected a non-blank string array value"); + } + result.add(value.textValue()); + } + return List.copyOf(result); + } + + private static String requiredText(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isTextual() || value.textValue().isBlank()) { + throw new IllegalStateException("Missing non-blank text field: " + field); + } + return value.textValue(); + } + + private static int requiredInt(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + throw new IllegalStateException("Missing integer field: " + field); + } + return value.intValue(); + } + + private static double requiredDouble(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isNumber()) { + throw new IllegalStateException("Missing numeric field: " + field); + } + return value.doubleValue(); + } +} diff --git a/hytale-adapter/src/main/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureService.java b/hytale-adapter/src/main/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureService.java new file mode 100644 index 0000000..25ee413 --- /dev/null +++ b/hytale-adapter/src/main/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureService.java @@ -0,0 +1,355 @@ +package dev.projectcapture.hytale.gameplay; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.projectcapture.engine.model.CaptureContext; +import dev.projectcapture.engine.model.CreatureInstance; +import dev.projectcapture.engine.model.Genome; +import dev.projectcapture.engine.model.LineageContribution; +import dev.projectcapture.engine.model.Party; +import dev.projectcapture.engine.model.SpeciesDefinition; +import dev.projectcapture.engine.service.CaptureService; +import dev.projectcapture.engine.service.PartyService; +import dev.projectcapture.hytale.content.RuntimeContentCatalog; +import dev.projectcapture.hytale.persistence.JsonStateStore; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Server-authoritative multiplayer state for the playable capture vertical slice. + * Every mutation is synchronized and atomically persisted through JsonStateStore. + */ +public final class MultiplayerCreatureService { + private static final int STATE_SCHEMA_VERSION = 1; + private static final String SAVE_ID = "multiplayer"; + private static final String FIELD_CAPSULE = "projectcapture:field-capsule"; + + private final JsonStateStore stateStore; + private final RuntimeContentCatalog catalog; + private final CaptureService captureService = new CaptureService(); + private final PartyService partyService = new PartyService(); + private final ObjectMapper json = new ObjectMapper(); + private final Map players = new HashMap<>(); + + public MultiplayerCreatureService(JsonStateStore stateStore, RuntimeContentCatalog catalog) { + this.stateStore = java.util.Objects.requireNonNull(stateStore, "stateStore"); + this.catalog = java.util.Objects.requireNonNull(catalog, "catalog"); + load(); + } + + public synchronized EncounterView openEncounter(UUID playerId, String speciesId, int level, long seed) { + SpeciesDefinition species = species(speciesId); + if (level < 1 || level > 100) { + throw new IllegalArgumentException("Encounter level must be between 1 and 100"); + } + PlayerProfile profile = player(playerId); + CreatureInstance wild = newCreature(species, Optional.empty(), level, seed); + profile.encounter = wild; + persist(); + return toEncounterView(wild); + } + + public synchronized CaptureOutcome capture(UUID playerId, long seed) { + PlayerProfile profile = player(playerId); + CreatureInstance wild = Optional.ofNullable(profile.encounter) + .orElseThrow(() -> new IllegalStateException("No active encounter for this player")); + int capsules = profile.items.getOrDefault(FIELD_CAPSULE, 0); + if (capsules < 1) { + throw new IllegalStateException("A crafted field capsule is required to capture a creature"); + } + SpeciesDefinition species = species(wild.speciesId()); + int maximumHealth = species.baseStats().healthAtLevel(wild.level()); + CaptureService.CaptureResult result = captureService.attempt(new CaptureContext( + species.captureRate(), species.rarity(), wild.level(), wild.currentHealth(), maximumHealth, + 1.0, CaptureContext.StatusCondition.NONE + ), seed); + decrement(profile.items, FIELD_CAPSULE); + if (!result.captured()) { + persist(); + return new CaptureOutcome(false, result.probability(), result.roll(), Optional.empty(), false); + } + + CreatureInstance owned = new CreatureInstance( + wild.schemaVersion(), wild.id(), wild.speciesId(), wild.nickname(), Optional.of(playerId), + wild.level(), wild.experience(), wild.currentHealth(), wild.genome(), wild.basicMoveId(), + wild.commandMoveIds(), wild.createdAt(), wild.revision() + 1 + ); + profile.creatures.put(owned.id(), owned); + boolean addedToParty = profile.party.creatureIds().size() < Party.MAX_SIZE; + if (addedToParty) { + profile.party = partyService.add(profile.party, owned.id(), profile.party.revision()); + } + profile.encounter = null; + persist(); + return new CaptureOutcome(true, result.probability(), result.roll(), Optional.of(owned.id()), addedToParty); + } + + public synchronized void grantFieldCapsules(UUID playerId, int amount) { + if (amount < 1 || amount > 999) { + throw new IllegalArgumentException("Amount must be between 1 and 999"); + } + PlayerProfile profile = player(playerId); + profile.items.merge(FIELD_CAPSULE, amount, Math::addExact); + persist(); + } + + public synchronized PartyView party(UUID playerId) { + PlayerProfile profile = player(playerId); + List creatures = profile.party.creatureIds().stream() + .map(profile.creatures::get) + .filter(java.util.Objects::nonNull) + .map(this::toCreatureView) + .toList(); + return new PartyView(profile.party.revision(), creatures, profile.party.activeCompanionId(), + profile.items.getOrDefault(FIELD_CAPSULE, 0)); + } + + public synchronized void setCompanion(UUID playerId, UUID creatureId) { + PlayerProfile profile = player(playerId); + if (!profile.creatures.containsKey(creatureId)) { + throw new IllegalArgumentException("Creature does not belong to this player"); + } + profile.party = partyService.setActiveCompanion(profile.party, Optional.of(creatureId), profile.party.revision()); + persist(); + } + + public synchronized Optional activeEncounter(UUID playerId) { + return Optional.ofNullable(player(playerId).encounter).map(this::toEncounterView); + } + + private SpeciesDefinition species(String speciesId) { + SpeciesDefinition species = catalog.species().get(speciesId); + if (species == null) { + throw new IllegalArgumentException("Unknown species: " + speciesId); + } + return species; + } + + private PlayerProfile player(UUID playerId) { + if (playerId == null) { + throw new NullPointerException("playerId must not be null"); + } + return players.computeIfAbsent(playerId, PlayerProfile::new); + } + + private CreatureInstance newCreature(SpeciesDefinition species, Optional ownerId, int level, long seed) { + int maximumHealth = species.baseStats().healthAtLevel(level); + Map traits = Map.of( + "resilience", Math.floorMod(seed, 32), + "instinct", Math.floorMod(seed >>> 5, 32), + "temperament", Math.floorMod(seed >>> 10, 32) + ); + return new CreatureInstance( + CreatureInstance.CURRENT_SCHEMA_VERSION, + UUID.randomUUID(), + species.id(), + Optional.empty(), + ownerId, + level, + experienceFor(level), + maximumHealth, + Genome.pure(seed, species.id(), traits), + species.basicMoveId(), + species.commandMoveIds(), + Instant.now(), + 0 + ); + } + + private static long experienceFor(int level) { + long offset = level - 1L; + return offset * offset * offset * 100L; + } + + private void load() { + JsonNode root = stateStore.read(SAVE_ID); + if (root == null) { + return; + } + if (root.path("schemaVersion").asInt(-1) != STATE_SCHEMA_VERSION) { + throw new IllegalStateException("Unsupported multiplayer state schema"); + } + JsonNode playerArray = root.path("players"); + if (!playerArray.isArray()) { + throw new IllegalStateException("Invalid multiplayer state: players is not an array"); + } + for (JsonNode node : playerArray) { + UUID owner = UUID.fromString(requiredText(node, "ownerId")); + PlayerProfile profile = new PlayerProfile(owner); + for (JsonNode creatureNode : node.path("creatures")) { + CreatureInstance creature = readCreature(creatureNode); + if (!creature.ownerId().equals(Optional.of(owner))) { + throw new IllegalStateException("Persisted creature ownership mismatch"); + } + profile.creatures.put(creature.id(), creature); + } + List partyIds = new ArrayList<>(); + for (JsonNode id : node.path("party")) { + partyIds.add(UUID.fromString(id.asText())); + } + Optional companion = optionalUuid(node, "activeCompanionId"); + profile.party = new Party(Party.CURRENT_SCHEMA_VERSION, owner, partyIds, companion, + node.path("partyRevision").asLong(0)); + node.path("items").properties().forEach(entry -> profile.items.put(entry.getKey(), entry.getValue().asInt())); + JsonNode encounter = node.path("encounter"); + if (!encounter.isMissingNode() && !encounter.isNull()) { + profile.encounter = readCreature(encounter); + if (profile.encounter.ownerId().isPresent()) { + throw new IllegalStateException("Wild encounter cannot have an owner"); + } + } + players.put(owner, profile); + } + } + + private void persist() { + ObjectNode root = json.createObjectNode().put("schemaVersion", STATE_SCHEMA_VERSION); + ArrayNode playerArray = root.putArray("players"); + players.values().stream().sorted(java.util.Comparator.comparing(profile -> profile.ownerId.toString())) + .forEach(profile -> writeProfile(playerArray.addObject(), profile)); + stateStore.write(SAVE_ID, root); + } + + private void writeProfile(ObjectNode node, PlayerProfile profile) { + node.put("ownerId", profile.ownerId.toString()); + node.put("partyRevision", profile.party.revision()); + profile.party.activeCompanionId().ifPresent(id -> node.put("activeCompanionId", id.toString())); + ArrayNode party = node.putArray("party"); + profile.party.creatureIds().forEach(id -> party.add(id.toString())); + ObjectNode items = node.putObject("items"); + profile.items.forEach(items::put); + ArrayNode creatures = node.putArray("creatures"); + profile.creatures.values().stream().sorted(java.util.Comparator.comparing(creature -> creature.id().toString())) + .forEach(creature -> writeCreature(creatures.addObject(), creature)); + if (profile.encounter != null) { + writeCreature(node.putObject("encounter"), profile.encounter); + } + } + + private static void writeCreature(ObjectNode node, CreatureInstance creature) { + node.put("id", creature.id().toString()); + node.put("speciesId", creature.speciesId()); + creature.ownerId().ifPresent(owner -> node.put("ownerId", owner.toString())); + creature.nickname().ifPresent(nickname -> node.put("nickname", nickname)); + node.put("level", creature.level()); + node.put("experience", creature.experience()); + node.put("currentHealth", creature.currentHealth()); + node.put("basicMoveId", creature.basicMoveId()); + node.put("createdAtEpochMilli", creature.createdAt().toEpochMilli()); + node.put("revision", creature.revision()); + ArrayNode commands = node.putArray("commandMoveIds"); + creature.commandMoveIds().forEach(commands::add); + ObjectNode genome = node.putObject("genome"); + genome.put("seed", creature.genome().seed()); + ObjectNode traits = genome.putObject("traits"); + creature.genome().traits().forEach(traits::put); + ArrayNode revealed = genome.putArray("revealedTraits"); + creature.genome().revealedTraits().forEach(revealed::add); + } + + private CreatureInstance readCreature(JsonNode node) { + String speciesId = requiredText(node, "speciesId"); + Map traits = new LinkedHashMap<>(); + node.path("genome").path("traits").properties().forEach(entry -> traits.put(entry.getKey(), entry.getValue().asInt())); + List commands = new ArrayList<>(); + node.path("commandMoveIds").forEach(command -> commands.add(command.asText())); + java.util.Set revealed = new java.util.LinkedHashSet<>(); + node.path("genome").path("revealedTraits").forEach(value -> revealed.add(value.asText())); + return new CreatureInstance( + CreatureInstance.CURRENT_SCHEMA_VERSION, + UUID.fromString(requiredText(node, "id")), + speciesId, + optionalText(node, "nickname"), + optionalUuid(node, "ownerId"), + node.path("level").asInt(), + node.path("experience").asLong(), + node.path("currentHealth").asInt(), + new Genome( + Genome.CURRENT_SCHEMA_VERSION, + node.path("genome").path("seed").asLong(), + traits, + List.of(new LineageContribution(speciesId, Genome.TOTAL_BASIS_POINTS)), + revealed), + requiredText(node, "basicMoveId"), + commands, + Instant.ofEpochMilli(node.path("createdAtEpochMilli").asLong()), + node.path("revision").asLong() + ); + } + + private static void decrement(Map items, String itemId) { + int current = items.getOrDefault(itemId, 0); + if (current <= 1) { + items.remove(itemId); + } else { + items.put(itemId, current - 1); + } + } + + private static String requiredText(JsonNode node, String name) { + String value = node.path(name).asText(""); + if (value.isBlank()) { + throw new IllegalStateException("Missing persisted field: " + name); + } + return value; + } + + private static Optional optionalText(JsonNode node, String name) { + String value = node.path(name).asText(""); + return value.isBlank() ? Optional.empty() : Optional.of(value); + } + + private static Optional optionalUuid(JsonNode node, String name) { + String value = node.path(name).asText(""); + return value.isBlank() ? Optional.empty() : Optional.of(UUID.fromString(value)); + } + + private CreatureView toCreatureView(CreatureInstance creature) { + return new CreatureView(creature.id(), creature.speciesId(), creature.level(), creature.currentHealth()); + } + + private EncounterView toEncounterView(CreatureInstance creature) { + return new EncounterView(creature.id(), creature.speciesId(), creature.level(), creature.currentHealth()); + } + + private static final class PlayerProfile { + private final UUID ownerId; + private final Map creatures = new LinkedHashMap<>(); + private final Map items = new LinkedHashMap<>(); + private Party party; + private CreatureInstance encounter; + + private PlayerProfile(UUID ownerId) { + this.ownerId = ownerId; + this.party = Party.empty(ownerId); + } + } + + public record EncounterView(UUID creatureId, String speciesId, int level, int currentHealth) { + } + + public record CreatureView(UUID creatureId, String speciesId, int level, int currentHealth) { + } + + public record PartyView(long revision, List creatures, Optional activeCompanionId, int fieldCapsules) { + public PartyView { + creatures = List.copyOf(creatures); + activeCompanionId = Optional.ofNullable(activeCompanionId).orElseThrow(); + } + } + + public record CaptureOutcome(boolean captured, double probability, double roll, Optional creatureId, boolean addedToParty) { + public CaptureOutcome { + creatureId = Optional.ofNullable(creatureId).orElseThrow(); + } + } +} diff --git a/hytale-adapter/src/test/java/dev/projectcapture/hytale/content/RuntimeContentCatalogTest.java b/hytale-adapter/src/test/java/dev/projectcapture/hytale/content/RuntimeContentCatalogTest.java new file mode 100644 index 0000000..b3476a3 --- /dev/null +++ b/hytale-adapter/src/test/java/dev/projectcapture/hytale/content/RuntimeContentCatalogTest.java @@ -0,0 +1,46 @@ +package dev.projectcapture.hytale.content; + +import dev.projectcapture.engine.model.ElementType; +import dev.projectcapture.engine.model.SpawnRule; +import dev.projectcapture.engine.service.SpawnService; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RuntimeContentCatalogTest { + @Test + void loadsAllFanSpeciesAndMovesIntoEngineModels() { + RuntimeContentCatalog catalog = RuntimeContentCatalog.load(getClass().getClassLoader()); + + assertThat(catalog.species()).hasSize(12); + assertThat(catalog.moves()).hasSize(17); + assertThat(catalog.species().get("projectcapture:bulbasaur").types()) + .containsExactly(ElementType.GRASS, ElementType.POISON); + assertThat(catalog.species().get("projectcapture:charizard").types()) + .containsExactly(ElementType.FIRE, ElementType.FLYING); + } + + @Test + void embeddedCatalogCanDriveDeterministicSpawnSelection() { + RuntimeContentCatalog catalog = RuntimeContentCatalog.load(getClass().getClassLoader()); + SpawnService service = new SpawnService(); + + SpawnService.SpawnSelection first = service.select( + List.copyOf(catalog.species().values()), + "projectcapture:forest", + SpawnRule.TimeOfDay.DAY, + 42L + ).orElseThrow(); + SpawnService.SpawnSelection second = service.select( + List.copyOf(catalog.species().values()), + "projectcapture:forest", + SpawnRule.TimeOfDay.DAY, + 42L + ).orElseThrow(); + + assertThat(second).isEqualTo(first); + assertThat(first.speciesId()).isIn("projectcapture:bulbasaur", "projectcapture:pikachu"); + } +} diff --git a/hytale-adapter/src/test/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureServiceTest.java b/hytale-adapter/src/test/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureServiceTest.java new file mode 100644 index 0000000..1aa416f --- /dev/null +++ b/hytale-adapter/src/test/java/dev/projectcapture/hytale/gameplay/MultiplayerCreatureServiceTest.java @@ -0,0 +1,57 @@ +package dev.projectcapture.hytale.gameplay; + +import dev.projectcapture.hytale.content.RuntimeContentCatalog; +import dev.projectcapture.hytale.persistence.JsonStateStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MultiplayerCreatureServiceTest { + @TempDir + Path temporaryDirectory; + + @Test + void captureIsPlayerScopedAndSurvivesServerRestart() { + JsonStateStore store = initializedStore(); + MultiplayerCreatureService service = new MultiplayerCreatureService(store, catalog()); + UUID firstPlayer = UUID.randomUUID(); + UUID secondPlayer = UUID.randomUUID(); + + service.grantFieldCapsules(firstPlayer, 200); + service.openEncounter(firstPlayer, "projectcapture:bulbasaur", 4, 42L); + + assertThatThrownBy(() -> service.capture(secondPlayer, 7L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No active encounter"); + + MultiplayerCreatureService.CaptureOutcome outcome = null; + for (long seed = 1; seed < 10_000 && (outcome == null || !outcome.captured()); seed++) { + outcome = service.capture(firstPlayer, seed); + } + assertThat(outcome).isNotNull(); + assertThat(outcome.captured()).isTrue(); + UUID captured = outcome.creatureId().orElseThrow(); + service.setCompanion(firstPlayer, captured); + + MultiplayerCreatureService restarted = new MultiplayerCreatureService(store, catalog()); + MultiplayerCreatureService.PartyView party = restarted.party(firstPlayer); + assertThat(party.creatures()).extracting(MultiplayerCreatureService.CreatureView::creatureId).contains(captured); + assertThat(party.activeCompanionId()).contains(captured); + assertThat(restarted.party(secondPlayer).creatures()).isEmpty(); + } + + private JsonStateStore initializedStore() { + JsonStateStore store = new JsonStateStore(temporaryDirectory); + store.initialize(); + return store; + } + + private RuntimeContentCatalog catalog() { + return RuntimeContentCatalog.load(getClass().getClassLoader()); + } +}