|
|
|
@@ -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) {}
|
|
|
|
|
}
|