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

This commit is contained in:
2026-08-15 08:50:44 +02:00
parent 21371ee6e6
commit 098ce5caa4
18 changed files with 1428 additions and 1 deletions
@@ -0,0 +1,112 @@
package dev.projectcapture.contenttools;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ContentBundle {
public static final int CURRENT_SCHEMA_VERSION = 1;
public static final List<String> REQUIRED_DOCUMENTS = List.of(
"manifest.json",
"moves.json",
"species.json",
"spawn-rules.json",
"recipes.json",
"assets.json"
);
private static final ObjectMapper JSON = new ObjectMapper()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
private final Path root;
private final Map<String, JsonNode> documents;
private ContentBundle(Path root, Map<String, JsonNode> documents) {
this.root = root;
this.documents = Map.copyOf(documents);
}
public static ContentBundle load(Path requestedRoot) throws IOException {
Path root = requestedRoot.toAbsolutePath().normalize();
if (!Files.isDirectory(root)) {
throw new IOException("Content root is not a directory: " + root);
}
Map<String, JsonNode> documents = new LinkedHashMap<>();
for (String name : REQUIRED_DOCUMENTS) {
Path document = root.resolve(name).normalize();
if (!document.startsWith(root)) {
throw new IOException("Document escaped content root: " + name);
}
if (!Files.isRegularFile(document)) {
throw new IOException("Required content document is missing: " + document);
}
JsonNode parsed = JSON.readTree(document.toFile());
if (parsed == null || !parsed.isObject()) {
throw new IOException("Content document must contain one JSON object: " + document);
}
documents.put(name, parsed);
}
return new ContentBundle(root, documents);
}
public Path root() {
return root;
}
public JsonNode document(String name) {
JsonNode node = documents.get(name);
if (node == null) {
throw new IllegalArgumentException("Unknown content document: " + name);
}
return node;
}
public Map<String, JsonNode> documents() {
return documents;
}
public String digest() {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
REQUIRED_DOCUMENTS.stream().sorted().forEach(name -> {
try {
digest.update(name.getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(Files.readAllBytes(root.resolve(name)));
digest.update((byte) 0);
} catch (IOException exception) {
throw new DigestReadException(exception);
}
});
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
} catch (DigestReadException exception) {
throw new IllegalStateException("Could not calculate content digest", exception.getCause());
}
}
static ObjectMapper json() {
return JSON;
}
private static final class DigestReadException extends RuntimeException {
private static final long serialVersionUID = 1L;
private DigestReadException(IOException cause) {
super(cause);
}
}
}
@@ -0,0 +1,67 @@
package dev.projectcapture.contenttools;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Path;
public final class ContentTool {
private ContentTool() {}
public static void main(String[] args) {
int exitCode = run(args, System.out, System.err);
if (exitCode != 0) {
System.exit(exitCode);
}
}
static int run(String[] args, PrintStream out, PrintStream err) {
if (args.length != 2) {
printUsage(err);
return 64;
}
Path contentRoot = Path.of(args[1]).toAbsolutePath().normalize();
return switch (args[0]) {
case "validate" -> validate(contentRoot, out, err);
case "digest" -> digest(contentRoot, out, err);
default -> {
err.println("Unknown command: " + args[0]);
printUsage(err);
yield 64;
}
};
}
private static int validate(Path contentRoot, PrintStream out, PrintStream err) {
ValidationReport report = new ContentValidator().validate(contentRoot);
if (!report.isValid()) {
err.println("Content validation failed with " + report.issues().size() + " issue(s):");
report.issues().forEach(issue -> err.println("- " + issue));
return 2;
}
out.println("Content validation passed: " + contentRoot);
return 0;
}
private static int digest(Path contentRoot, PrintStream out, PrintStream err) {
ValidationReport report = new ContentValidator().validate(contentRoot);
if (!report.isValid()) {
err.println("Digest refused because content validation failed:");
report.issues().forEach(issue -> err.println("- " + issue));
return 2;
}
try {
out.println(ContentBundle.load(contentRoot).digest());
return 0;
} catch (IOException | RuntimeException exception) {
err.println("Could not calculate digest: " + exception.getMessage());
return 2;
}
}
private static void printUsage(PrintStream stream) {
stream.println("Usage:");
stream.println(" content-tools validate <content-root>");
stream.println(" content-tools digest <content-root>");
}
}
@@ -0,0 +1,626 @@
package dev.projectcapture.contenttools;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public final class ContentValidator {
private static final Pattern STABLE_ID = Pattern.compile("^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$");
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$");
static final Set<String> UNOFFICIAL_SLICE_SPECIES = Set.of(
"projectcapture:bulbasaur",
"projectcapture:ivysaur",
"projectcapture:venusaur",
"projectcapture:charmander",
"projectcapture:charmeleon",
"projectcapture:charizard",
"projectcapture:squirtle",
"projectcapture:wartortle",
"projectcapture:blastoise",
"projectcapture:pikachu",
"projectcapture:raichu",
"projectcapture:ditto"
);
public ValidationReport validate(Path root) {
ValidationReport report = new ValidationReport();
try {
report.addAll(validate(ContentBundle.load(root)));
} catch (IOException | RuntimeException exception) {
report.add("CONTENT_LOAD", root.toString(), exception.getMessage());
}
return report;
}
public ValidationReport validate(ContentBundle bundle) {
ValidationReport report = new ValidationReport();
bundle.documents().forEach((name, document) -> validateDocumentHeader(name, document, report));
JsonNode manifest = bundle.document("manifest.json");
Set<String> expectedSpecies = validateManifest(manifest, report);
Map<String, String> moveRoles = validateMoves(bundle.document("moves.json"), report);
Set<String> producedItems = validateRecipes(bundle.document("recipes.json"), report);
AssetIndex assets = validateAssets(bundle.document("assets.json"), report);
SpeciesIndex species = validateSpecies(
bundle.document("species.json"), moveRoles, producedItems, assets, report
);
if (!species.ids.equals(expectedSpecies)) {
Set<String> missing = new LinkedHashSet<>(expectedSpecies);
missing.removeAll(species.ids);
Set<String> unexpected = new LinkedHashSet<>(species.ids);
unexpected.removeAll(expectedSpecies);
report.add(
"SPECIES_SET",
"species.json.items",
"Species must exactly match manifest.expectedSpeciesIds; missing=" + missing + ", unexpected=" + unexpected
);
}
if ("UNOFFICIAL_TWELVE_FORM_SLICE".equals(text(manifest, "contentProfile"))
&& !expectedSpecies.equals(UNOFFICIAL_SLICE_SPECIES)) {
report.add(
"UNOFFICIAL_SLICE",
"manifest.json.expectedSpeciesIds",
"The unofficial twelve-form profile must contain exactly the selected twelve development forms"
);
}
validateEvolutionGraph(species.evolutionTargets, report);
validateAssetCoverage(species.ids, assets, report);
validateSpawns(bundle.document("spawn-rules.json"), species.ids, report);
return report;
}
private void validateDocumentHeader(String name, JsonNode document, ValidationReport report) {
if (document.path("schemaVersion").asInt(-1) != ContentBundle.CURRENT_SCHEMA_VERSION) {
report.add(
"SCHEMA_VERSION",
name + ".schemaVersion",
"Expected schemaVersion " + ContentBundle.CURRENT_SCHEMA_VERSION
);
}
if (!document.path("unofficialFanContent").isBoolean() || !document.path("unofficialFanContent").asBoolean()) {
report.add(
"FAN_CONTENT_MARKER",
name + ".unofficialFanContent",
"Fan-content documents must be explicitly marked unofficialFanContent=true"
);
}
}
private Set<String> validateManifest(JsonNode manifest, ValidationReport report) {
requireId(manifest, "contentSetId", "manifest.json.contentSetId", report);
requireText(manifest, "contentProfile", "manifest.json.contentProfile", report);
if (!"UNOFFICIAL_FAN_CONTENT".equals(text(manifest, "publicationStatus"))) {
report.add(
"PUBLICATION_STATUS",
"manifest.json.publicationStatus",
"Fan-content catalog must declare UNOFFICIAL_FAN_CONTENT"
);
}
if (!manifest.path("nonCommercial").asBoolean(false)) {
report.add("NON_COMMERCIAL", "manifest.json.nonCommercial", "Fan-content catalog must remain non-commercial");
}
if (!manifest.path("rightsReviewRequired").asBoolean(false)) {
report.add(
"RIGHTS_REVIEW",
"manifest.json.rightsReviewRequired",
"Fan-content catalog must require an explicit rights review"
);
}
JsonNode gameplay = manifest.path("gameplay");
requireExactInt(gameplay, "minimumLevel", 1, "manifest.json.gameplay", report);
requireExactInt(gameplay, "maximumLevel", 100, "manifest.json.gameplay", report);
requireExactInt(gameplay, "maximumPartySize", 6, "manifest.json.gameplay", report);
requireExactInt(gameplay, "visibleCompanionLimit", 1, "manifest.json.gameplay", report);
requireExactInt(gameplay, "commandedMoveSlots", 3, "manifest.json.gameplay", report);
if (!gameplay.path("starterGrantEnabled").isBoolean() || gameplay.path("starterGrantEnabled").asBoolean()) {
report.add(
"STARTER_POLICY",
"manifest.json.gameplay.starterGrantEnabled",
"The slice grants no starter; only capture-device crafting is unlocked"
);
}
Set<String> expected = new LinkedHashSet<>();
JsonNode list = manifest.path("expectedSpeciesIds");
if (!list.isArray() || list.isEmpty()) {
report.add("EXPECTED_SPECIES", "manifest.json.expectedSpeciesIds", "Expected species list must be non-empty");
return expected;
}
for (int index = 0; index < list.size(); index++) {
String location = "manifest.json.expectedSpeciesIds[" + index + "]";
String id = list.path(index).asText("");
validateIdValue(id, location, report);
if (!expected.add(id)) {
report.add("DUPLICATE_ID", location, "Duplicate expected species ID: " + id);
}
}
return expected;
}
private Map<String, String> validateMoves(JsonNode document, ValidationReport report) {
Map<String, String> roles = new LinkedHashMap<>();
ArrayNode items = requireItems(document, "moves.json", report);
for (int index = 0; index < items.size(); index++) {
JsonNode move = items.path(index);
String location = "moves.json.items[" + index + "]";
requireUnofficialItem(move, location, report);
String id = requireId(move, "id", location + ".id", report);
if (!id.isEmpty() && roles.containsKey(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate move ID: " + id);
}
requireText(move, "name", location + ".name", report);
requireEnum(move, "element", Set.of("NORMAL", "GRASS", "TOXIN", "FIRE", "AIR", "WATER", "ELECTRIC"), location, report);
String role = requireEnum(move, "role", Set.of("BASIC", "COMMAND"), location, report);
requireNumberRange(move, "power", 0, 250, location, report);
requireNumberRange(move, "cooldownSeconds", 0.1, 120, location, report);
if (!id.isEmpty()) {
roles.putIfAbsent(id, role);
}
}
if (roles.isEmpty()) {
report.add("MOVE_EMPTY", "moves.json.items", "At least one move is required");
}
return roles;
}
private Set<String> validateRecipes(JsonNode document, ValidationReport report) {
Set<String> ids = new HashSet<>();
Set<String> outputs = new LinkedHashSet<>();
boolean unlockedCaptureDevice = false;
ArrayNode items = requireItems(document, "recipes.json", report);
for (int index = 0; index < items.size(); index++) {
JsonNode recipe = items.path(index);
String location = "recipes.json.items[" + index + "]";
requireUnofficialItem(recipe, location, report);
String id = requireId(recipe, "id", location + ".id", report);
if (!id.isEmpty() && !ids.add(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate recipe ID: " + id);
}
String category = requireText(recipe, "category", location + ".category", report);
JsonNode output = recipe.path("output");
String outputId = requireId(output, "itemId", location + ".output.itemId", report);
requireIntegerRange(output, "quantity", 1, 999, location + ".output", report);
if (!outputId.isEmpty()) {
outputs.add(outputId);
}
JsonNode ingredients = recipe.path("ingredients");
if (!ingredients.isArray() || ingredients.isEmpty()) {
report.add("RECIPE_INGREDIENTS", location + ".ingredients", "Recipe ingredients must be non-empty");
} else {
for (int ingredientIndex = 0; ingredientIndex < ingredients.size(); ingredientIndex++) {
JsonNode ingredient = ingredients.path(ingredientIndex);
String ingredientLocation = location + ".ingredients[" + ingredientIndex + "]";
requireId(ingredient, "itemId", ingredientLocation + ".itemId", report);
requireIntegerRange(ingredient, "quantity", 1, 999, ingredientLocation, report);
}
}
if ("CAPTURE_DEVICE".equals(category) && recipe.path("unlockedByDefault").asBoolean(false)) {
unlockedCaptureDevice = true;
}
}
if (!unlockedCaptureDevice) {
report.add(
"CAPTURE_RECIPE",
"recipes.json.items",
"At least one capture-device recipe must be unlocked by default"
);
}
return outputs;
}
private AssetIndex validateAssets(JsonNode document, ValidationReport report) {
Set<String> ids = new LinkedHashSet<>();
Map<String, String> speciesByAsset = new LinkedHashMap<>();
Map<String, Integer> countBySpecies = new HashMap<>();
ArrayNode items = requireItems(document, "assets.json", report);
for (int index = 0; index < items.size(); index++) {
JsonNode asset = items.path(index);
String location = "assets.json.items[" + index + "]";
requireUnofficialItem(asset, location, report);
String id = requireId(asset, "id", location + ".id", report);
String speciesId = requireId(asset, "speciesId", location + ".speciesId", report);
if (!id.isEmpty() && !ids.add(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate asset ID: " + id);
}
if (!id.isEmpty()) {
speciesByAsset.putIfAbsent(id, speciesId);
}
if (!speciesId.isEmpty()) {
countBySpecies.merge(speciesId, 1, Integer::sum);
}
requireEnum(asset, "status", Set.of("PLANNED", "READY"), location, report);
requireIntegerRange(asset, "seed", 0, Integer.MAX_VALUE, location, report);
requireText(asset, "bodyPlan", location + ".bodyPlan", report);
requireNonEmptyTextArray(asset.path("sockets"), location + ".sockets", report);
JsonNode palette = asset.path("palette");
if (!palette.isArray() || palette.size() < 2) {
report.add("ASSET_PALETTE", location + ".palette", "Asset palette needs at least two colors");
} else {
for (int paletteIndex = 0; paletteIndex < palette.size(); paletteIndex++) {
if (!HEX_COLOR.matcher(palette.path(paletteIndex).asText("")).matches()) {
report.add("ASSET_PALETTE", location + ".palette[" + paletteIndex + "]", "Expected #RRGGBB");
}
}
}
validateAssetPath(asset, "source", ".bbmodel", location, report);
validateAssetPath(asset, "model", ".blockymodel", location, report);
validateAssetPath(asset, "texture", ".png", location, report);
validateAssetPath(asset, "recipe", ".asset.json", location, report);
JsonNode animations = asset.path("animations");
if (!animations.isObject()) {
report.add("ASSET_ANIMATIONS", location + ".animations", "Animations must be an object");
} else {
for (String animation : List.of("idle", "walk", "attack")) {
validateAssetPath(animations, animation, ".blockyanim", location + ".animations", report);
}
}
}
return new AssetIndex(ids, speciesByAsset, countBySpecies);
}
private SpeciesIndex validateSpecies(
JsonNode document,
Map<String, String> moveRoles,
Set<String> producedItems,
AssetIndex assets,
ValidationReport report
) {
Set<String> ids = new LinkedHashSet<>();
Set<Integer> catalogNumbers = new HashSet<>();
Map<String, String> evolutionTargets = new LinkedHashMap<>();
List<PendingSpeciesReferences> pending = new ArrayList<>();
ArrayNode items = requireItems(document, "species.json", report);
for (int index = 0; index < items.size(); index++) {
JsonNode species = items.path(index);
String location = "species.json.items[" + index + "]";
requireUnofficialItem(species, location, report);
String id = requireId(species, "id", location + ".id", report);
if (!id.isEmpty() && !ids.add(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate species ID: " + id);
}
requireText(species, "displayName", location + ".displayName", report);
int catalogNumber = requireIntegerRange(species, "catalogNumber", 1, 9999, location, report);
if (catalogNumber >= 0 && !catalogNumbers.add(catalogNumber)) {
report.add("DUPLICATE_CATALOG_NUMBER", location + ".catalogNumber", "Duplicate catalog number: " + catalogNumber);
}
requireEnum(species, "form", Set.of("BASE", "STAGE_1", "STAGE_2", "SINGLE"), location, report);
requireNonEmptyTextArray(species.path("elements"), location + ".elements", report);
requireNumberRange(species, "captureDifficulty", 0.01, 0.99, location, report);
JsonNode stats = species.path("baseStats");
for (String stat : List.of("vitality", "power", "guard", "agility")) {
requireIntegerRange(stats, stat, 1, 255, location + ".baseStats", report);
}
String basicMove = requireId(species, "basicMoveId", location + ".basicMoveId", report);
if (!basicMove.isEmpty() && !moveRoles.containsKey(basicMove)) {
report.add("UNKNOWN_MOVE", location + ".basicMoveId", "Unknown move: " + basicMove);
} else if (!basicMove.isEmpty() && !"BASIC".equals(moveRoles.get(basicMove))) {
report.add("MOVE_ROLE", location + ".basicMoveId", "Basic move must have role BASIC");
}
List<String> commandMoves = validateCommandMoves(species.path("commandMoveIds"), moveRoles, location, report);
if (commandMoves.contains(basicMove)) {
report.add("MOVE_ROLE", location + ".commandMoveIds", "Basic move cannot also occupy a command slot");
}
String assetId = requireId(species, "assetId", location + ".assetId", report);
if (!assetId.isEmpty() && !assets.ids.contains(assetId)) {
report.add("UNKNOWN_ASSET", location + ".assetId", "Unknown asset record: " + assetId);
}
JsonNode evolution = species.path("evolution");
String target = "";
if (!evolution.isMissingNode() && !evolution.isNull()) {
if (!evolution.isObject()) {
report.add("EVOLUTION_SHAPE", location + ".evolution", "Evolution must be an object");
} else {
String method = requireEnum(evolution, "method", Set.of("LEVEL", "ITEM"), location + ".evolution", report);
target = requireId(evolution, "targetSpeciesId", location + ".evolution.targetSpeciesId", report);
if ("LEVEL".equals(method)) {
requireIntegerRange(evolution, "minimumLevel", 2, 100, location + ".evolution", report);
} else if ("ITEM".equals(method)) {
String itemId = requireId(evolution, "itemId", location + ".evolution.itemId", report);
if (!itemId.isEmpty() && !producedItems.contains(itemId)) {
report.add("UNKNOWN_EVOLUTION_ITEM", location + ".evolution.itemId", "No recipe produces " + itemId);
}
}
if (!id.isEmpty() && !target.isEmpty()) {
evolutionTargets.put(id, target);
}
}
}
pending.add(new PendingSpeciesReferences(location, id, target, assetId));
}
for (PendingSpeciesReferences reference : pending) {
if (!reference.target.isEmpty() && !ids.contains(reference.target)) {
report.add("UNKNOWN_EVOLUTION_TARGET", reference.location + ".evolution.targetSpeciesId", "Unknown species: " + reference.target);
}
if (!reference.assetId.isEmpty()) {
String assetSpecies = assets.speciesByAsset.get(reference.assetId);
if (assetSpecies != null && !assetSpecies.equals(reference.id)) {
report.add("ASSET_SPECIES_MISMATCH", reference.location + ".assetId", "Asset belongs to " + assetSpecies);
}
}
}
return new SpeciesIndex(ids, evolutionTargets);
}
private List<String> validateCommandMoves(
JsonNode moves,
Map<String, String> moveRoles,
String location,
ValidationReport report
) {
List<String> result = new ArrayList<>();
if (!moves.isArray() || moves.size() != 3) {
report.add("COMMAND_SLOTS", location + ".commandMoveIds", "Exactly three commanded moves are required");
return result;
}
Set<String> unique = new HashSet<>();
for (int index = 0; index < moves.size(); index++) {
String move = moves.path(index).asText("");
String moveLocation = location + ".commandMoveIds[" + index + "]";
validateIdValue(move, moveLocation, report);
if (!unique.add(move)) {
report.add("DUPLICATE_COMMAND_MOVE", moveLocation, "Command moves must be unique");
}
if (!moveRoles.containsKey(move)) {
report.add("UNKNOWN_MOVE", moveLocation, "Unknown move: " + move);
} else if (!"COMMAND".equals(moveRoles.get(move))) {
report.add("MOVE_ROLE", moveLocation, "Command move must have role COMMAND");
}
result.add(move);
}
return result;
}
private void validateEvolutionGraph(Map<String, String> targets, ValidationReport report) {
Map<String, Integer> state = new HashMap<>();
for (String species : targets.keySet()) {
visitEvolution(species, targets, state, new ArrayList<>(), report);
}
}
private void visitEvolution(
String species,
Map<String, String> targets,
Map<String, Integer> state,
List<String> path,
ValidationReport report
) {
int currentState = state.getOrDefault(species, 0);
if (currentState == 2) {
return;
}
if (currentState == 1) {
path.add(species);
report.add("EVOLUTION_CYCLE", "species.json", "Evolution cycle: " + String.join(" -> ", path));
return;
}
state.put(species, 1);
path.add(species);
String target = targets.get(species);
if (target != null) {
visitEvolution(target, targets, state, new ArrayList<>(path), report);
}
state.put(species, 2);
}
private void validateAssetCoverage(Set<String> species, AssetIndex assets, ValidationReport report) {
for (String speciesId : species) {
int count = assets.countBySpecies.getOrDefault(speciesId, 0);
if (count != 1) {
report.add("ASSET_COVERAGE", "assets.json.items", speciesId + " must have exactly one asset record; found " + count);
}
}
for (String assetSpecies : assets.countBySpecies.keySet()) {
if (!species.contains(assetSpecies)) {
report.add("UNKNOWN_ASSET_SPECIES", "assets.json.items", "Asset references unknown species: " + assetSpecies);
}
}
}
private void validateSpawns(JsonNode document, Set<String> species, ValidationReport report) {
Set<String> ids = new HashSet<>();
Map<String, Integer> countBySpecies = new HashMap<>();
ArrayNode items = requireItems(document, "spawn-rules.json", report);
for (int index = 0; index < items.size(); index++) {
JsonNode spawn = items.path(index);
String location = "spawn-rules.json.items[" + index + "]";
requireUnofficialItem(spawn, location, report);
String id = requireId(spawn, "id", location + ".id", report);
if (!id.isEmpty() && !ids.add(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate spawn ID: " + id);
}
String speciesId = requireId(spawn, "speciesId", location + ".speciesId", report);
if (!speciesId.isEmpty() && !species.contains(speciesId)) {
report.add("UNKNOWN_SPAWN_SPECIES", location + ".speciesId", "Unknown species: " + speciesId);
}
countBySpecies.merge(speciesId, 1, Integer::sum);
requireNonEmptyTextArray(spawn.path("biomes"), location + ".biomes", report);
requireEnum(spawn, "time", Set.of("DAY", "NIGHT", "ANY"), location, report);
requireIntegerRange(spawn, "rarityWeight", 1, 100, location, report);
int minimum = requireIntegerRange(spawn, "minimumLevel", 1, 100, location, report);
int maximum = requireIntegerRange(spawn, "maximumLevel", 1, 100, location, report);
if (minimum >= 0 && maximum >= 0 && minimum > maximum) {
report.add("SPAWN_LEVEL_RANGE", location, "minimumLevel must not exceed maximumLevel");
}
}
for (String speciesId : species) {
if (countBySpecies.getOrDefault(speciesId, 0) < 1) {
report.add("SPAWN_COVERAGE", "spawn-rules.json.items", "No spawn rule for " + speciesId);
}
}
}
private void validateAssetPath(
JsonNode node,
String field,
String extension,
String location,
ValidationReport report
) {
String value = requireText(node, field, location + "." + field, report);
if (value.isEmpty()) {
return;
}
if (value.contains("\\") || value.contains("://") || value.matches("^[A-Za-z]:.*")) {
report.add("ASSET_PATH", location + "." + field, "Asset path must be a portable relative path");
return;
}
try {
Path path = Path.of(value);
String normalized = path.normalize().toString().replace('\\', '/');
if (path.isAbsolute() || !normalized.equals(value) || value.contains("../") || !value.startsWith("assets/fan/")) {
report.add("ASSET_PATH", location + "." + field, "Asset path must stay below assets/fan without traversal");
}
} catch (InvalidPathException exception) {
report.add("ASSET_PATH", location + "." + field, "Invalid asset path: " + exception.getMessage());
}
if (!value.endsWith(extension)) {
report.add("ASSET_EXTENSION", location + "." + field, "Expected extension " + extension);
}
}
private ArrayNode requireItems(JsonNode document, String name, ValidationReport report) {
JsonNode items = document.path("items");
if (!items.isArray()) {
report.add("ITEMS_SHAPE", name + ".items", "Expected an array");
return ContentBundle.json().createArrayNode();
}
return (ArrayNode) items;
}
private void requireUnofficialItem(JsonNode item, String location, ValidationReport report) {
if (!item.path("unofficialFanContent").isBoolean() || !item.path("unofficialFanContent").asBoolean()) {
report.add("FAN_CONTENT_MARKER", location + ".unofficialFanContent", "Fan-content entries must be explicitly marked");
}
}
private String requireId(JsonNode node, String field, String location, ValidationReport report) {
String value = requireText(node, field, location, report);
if (!value.isEmpty()) {
validateIdValue(value, location, report);
}
return value;
}
private void validateIdValue(String value, String location, ValidationReport report) {
if (!STABLE_ID.matcher(value).matches()) {
report.add("INVALID_ID", location, "Expected lowercase namespace:value stable ID");
}
}
private String requireText(JsonNode node, String field, String location, ValidationReport report) {
JsonNode value = node.path(field);
if (!value.isTextual() || value.asText().isBlank()) {
report.add("REQUIRED_TEXT", location, "Expected non-blank text");
return "";
}
return value.asText();
}
private String requireEnum(JsonNode node, String field, Set<String> allowed, String location, ValidationReport report) {
String value = requireText(node, field, location + "." + field, report);
if (!value.isEmpty() && !allowed.contains(value)) {
report.add("ENUM_VALUE", location + "." + field, "Expected one of " + allowed + ", got " + value);
}
return value;
}
private int requireIntegerRange(
JsonNode node,
String field,
int minimum,
int maximum,
String location,
ValidationReport report
) {
JsonNode value = node.path(field);
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
report.add("INTEGER_VALUE", location + "." + field, "Expected an integer");
return -1;
}
int number = value.asInt();
if (number < minimum || number > maximum) {
report.add("INTEGER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum);
}
return number;
}
private void requireNumberRange(
JsonNode node,
String field,
double minimum,
double maximum,
String location,
ValidationReport report
) {
JsonNode value = node.path(field);
if (!value.isNumber()) {
report.add("NUMBER_VALUE", location + "." + field, "Expected a number");
return;
}
double number = value.asDouble();
if (!Double.isFinite(number) || number < minimum || number > maximum) {
report.add("NUMBER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum);
}
}
private void requireExactInt(JsonNode node, String field, int expected, String location, ValidationReport report) {
JsonNode value = node.path(field);
if (!value.isIntegralNumber() || value.asInt() != expected) {
report.add("GAMEPLAY_METADATA", location + "." + field, "Expected " + expected);
}
}
private void requireNonEmptyTextArray(JsonNode node, String location, ValidationReport report) {
if (!node.isArray() || node.isEmpty()) {
report.add("TEXT_ARRAY", location, "Expected a non-empty text array");
return;
}
Set<String> values = new HashSet<>();
for (int index = 0; index < node.size(); index++) {
JsonNode value = node.path(index);
if (!value.isTextual() || value.asText().isBlank()) {
report.add("TEXT_ARRAY", location + "[" + index + "]", "Expected non-blank text");
} else if (!values.add(value.asText())) {
report.add("TEXT_ARRAY_DUPLICATE", location + "[" + index + "]", "Duplicate value: " + value.asText());
}
}
}
private static String text(JsonNode node, String field) {
return node.path(field).isTextual() ? node.path(field).asText() : "";
}
private record AssetIndex(
Set<String> ids,
Map<String, String> speciesByAsset,
Map<String, Integer> countBySpecies
) {}
private record SpeciesIndex(Set<String> ids, Map<String, String> evolutionTargets) {}
private record PendingSpeciesReferences(String location, String id, String target, String assetId) {}
}
@@ -0,0 +1,8 @@
package dev.projectcapture.contenttools;
public record ValidationIssue(String code, String location, String message) {
@Override
public String toString() {
return code + " at " + location + ": " + message;
}
}
@@ -0,0 +1,29 @@
package dev.projectcapture.contenttools;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ValidationReport {
private final List<ValidationIssue> issues = new ArrayList<>();
public void add(String code, String location, String message) {
issues.add(new ValidationIssue(code, location, message));
}
public void addAll(ValidationReport other) {
issues.addAll(other.issues);
}
public boolean isValid() {
return issues.isEmpty();
}
public List<ValidationIssue> issues() {
return Collections.unmodifiableList(issues);
}
public boolean containsCode(String code) {
return issues.stream().anyMatch(issue -> issue.code().equals(code));
}
}