feat(content): add twelve-form Pokemon fan catalog
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Content tools
|
||||
|
||||
`content-tools` is a Java 25 command-line validator for the ProjectCapture metadata catalogs.
|
||||
|
||||
Commands:
|
||||
|
||||
- `validate <content-root>` checks schema versions, stable IDs, references, evolution graphs, level and party metadata, spawn coverage, recipes and safe asset paths.
|
||||
- `digest <content-root>` prints a deterministic SHA-256 digest for reproducible review records.
|
||||
|
||||
The catalog must declare itself unofficial, non-commercial and subject to rights review. No Gradle publishing plugin, repository or upload target is configured here.
|
||||
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
java
|
||||
application
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = "dev.projectcapture.contenttools.ContentTool"
|
||||
}
|
||||
|
||||
val fanContentRoot = rootProject.layout.projectDirectory
|
||||
.dir("content-pokemon/src/main/resources/projectcapture/content/v1")
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
systemProperty("projectCapture.contentRoot", fanContentRoot.asFile.absolutePath)
|
||||
}
|
||||
|
||||
tasks.register<JavaExec>("validateFanContent") {
|
||||
group = "verification"
|
||||
description = "Validates the unofficial, non-commercial fan-content catalog."
|
||||
classpath = sourceSets.main.get().runtimeClasspath
|
||||
mainClass = application.mainClass
|
||||
args("validate", fanContentRoot.asFile.absolutePath)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn("validateFanContent")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ContentBundle {
|
||||
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
public static final List<String> REQUIRED_DOCUMENTS = List.of(
|
||||
"manifest.json",
|
||||
"moves.json",
|
||||
"species.json",
|
||||
"spawn-rules.json",
|
||||
"recipes.json",
|
||||
"assets.json"
|
||||
);
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION);
|
||||
|
||||
private final Path root;
|
||||
private final Map<String, JsonNode> documents;
|
||||
|
||||
private ContentBundle(Path root, Map<String, JsonNode> documents) {
|
||||
this.root = root;
|
||||
this.documents = Map.copyOf(documents);
|
||||
}
|
||||
|
||||
public static ContentBundle load(Path requestedRoot) throws IOException {
|
||||
Path root = requestedRoot.toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(root)) {
|
||||
throw new IOException("Content root is not a directory: " + root);
|
||||
}
|
||||
|
||||
Map<String, JsonNode> documents = new LinkedHashMap<>();
|
||||
for (String name : REQUIRED_DOCUMENTS) {
|
||||
Path document = root.resolve(name).normalize();
|
||||
if (!document.startsWith(root)) {
|
||||
throw new IOException("Document escaped content root: " + name);
|
||||
}
|
||||
if (!Files.isRegularFile(document)) {
|
||||
throw new IOException("Required content document is missing: " + document);
|
||||
}
|
||||
JsonNode parsed = JSON.readTree(document.toFile());
|
||||
if (parsed == null || !parsed.isObject()) {
|
||||
throw new IOException("Content document must contain one JSON object: " + document);
|
||||
}
|
||||
documents.put(name, parsed);
|
||||
}
|
||||
return new ContentBundle(root, documents);
|
||||
}
|
||||
|
||||
public Path root() {
|
||||
return root;
|
||||
}
|
||||
|
||||
public JsonNode document(String name) {
|
||||
JsonNode node = documents.get(name);
|
||||
if (node == null) {
|
||||
throw new IllegalArgumentException("Unknown content document: " + name);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
public Map<String, JsonNode> documents() {
|
||||
return documents;
|
||||
}
|
||||
|
||||
public String digest() {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
REQUIRED_DOCUMENTS.stream().sorted().forEach(name -> {
|
||||
try {
|
||||
digest.update(name.getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(Files.readAllBytes(root.resolve(name)));
|
||||
digest.update((byte) 0);
|
||||
} catch (IOException exception) {
|
||||
throw new DigestReadException(exception);
|
||||
}
|
||||
});
|
||||
return "sha256:" + HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
} catch (DigestReadException exception) {
|
||||
throw new IllegalStateException("Could not calculate content digest", exception.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
static ObjectMapper json() {
|
||||
return JSON;
|
||||
}
|
||||
|
||||
private static final class DigestReadException extends RuntimeException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private DigestReadException(IOException cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class ContentTool {
|
||||
private ContentTool() {}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int exitCode = run(args, System.out, System.err);
|
||||
if (exitCode != 0) {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
static int run(String[] args, PrintStream out, PrintStream err) {
|
||||
if (args.length != 2) {
|
||||
printUsage(err);
|
||||
return 64;
|
||||
}
|
||||
|
||||
Path contentRoot = Path.of(args[1]).toAbsolutePath().normalize();
|
||||
return switch (args[0]) {
|
||||
case "validate" -> validate(contentRoot, out, err);
|
||||
case "digest" -> digest(contentRoot, out, err);
|
||||
default -> {
|
||||
err.println("Unknown command: " + args[0]);
|
||||
printUsage(err);
|
||||
yield 64;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static int validate(Path contentRoot, PrintStream out, PrintStream err) {
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
if (!report.isValid()) {
|
||||
err.println("Content validation failed with " + report.issues().size() + " issue(s):");
|
||||
report.issues().forEach(issue -> err.println("- " + issue));
|
||||
return 2;
|
||||
}
|
||||
out.println("Content validation passed: " + contentRoot);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int digest(Path contentRoot, PrintStream out, PrintStream err) {
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
if (!report.isValid()) {
|
||||
err.println("Digest refused because content validation failed:");
|
||||
report.issues().forEach(issue -> err.println("- " + issue));
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
out.println(ContentBundle.load(contentRoot).digest());
|
||||
return 0;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
err.println("Could not calculate digest: " + exception.getMessage());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static void printUsage(PrintStream stream) {
|
||||
stream.println("Usage:");
|
||||
stream.println(" content-tools validate <content-root>");
|
||||
stream.println(" content-tools digest <content-root>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ContentValidator {
|
||||
private static final Pattern STABLE_ID = Pattern.compile("^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$");
|
||||
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$");
|
||||
|
||||
static final Set<String> UNOFFICIAL_SLICE_SPECIES = Set.of(
|
||||
"projectcapture:bulbasaur",
|
||||
"projectcapture:ivysaur",
|
||||
"projectcapture:venusaur",
|
||||
"projectcapture:charmander",
|
||||
"projectcapture:charmeleon",
|
||||
"projectcapture:charizard",
|
||||
"projectcapture:squirtle",
|
||||
"projectcapture:wartortle",
|
||||
"projectcapture:blastoise",
|
||||
"projectcapture:pikachu",
|
||||
"projectcapture:raichu",
|
||||
"projectcapture:ditto"
|
||||
);
|
||||
|
||||
public ValidationReport validate(Path root) {
|
||||
ValidationReport report = new ValidationReport();
|
||||
try {
|
||||
report.addAll(validate(ContentBundle.load(root)));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
report.add("CONTENT_LOAD", root.toString(), exception.getMessage());
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
public ValidationReport validate(ContentBundle bundle) {
|
||||
ValidationReport report = new ValidationReport();
|
||||
bundle.documents().forEach((name, document) -> validateDocumentHeader(name, document, report));
|
||||
|
||||
JsonNode manifest = bundle.document("manifest.json");
|
||||
Set<String> expectedSpecies = validateManifest(manifest, report);
|
||||
Map<String, String> moveRoles = validateMoves(bundle.document("moves.json"), report);
|
||||
Set<String> producedItems = validateRecipes(bundle.document("recipes.json"), report);
|
||||
AssetIndex assets = validateAssets(bundle.document("assets.json"), report);
|
||||
SpeciesIndex species = validateSpecies(
|
||||
bundle.document("species.json"), moveRoles, producedItems, assets, report
|
||||
);
|
||||
|
||||
if (!species.ids.equals(expectedSpecies)) {
|
||||
Set<String> missing = new LinkedHashSet<>(expectedSpecies);
|
||||
missing.removeAll(species.ids);
|
||||
Set<String> unexpected = new LinkedHashSet<>(species.ids);
|
||||
unexpected.removeAll(expectedSpecies);
|
||||
report.add(
|
||||
"SPECIES_SET",
|
||||
"species.json.items",
|
||||
"Species must exactly match manifest.expectedSpeciesIds; missing=" + missing + ", unexpected=" + unexpected
|
||||
);
|
||||
}
|
||||
if ("UNOFFICIAL_TWELVE_FORM_SLICE".equals(text(manifest, "contentProfile"))
|
||||
&& !expectedSpecies.equals(UNOFFICIAL_SLICE_SPECIES)) {
|
||||
report.add(
|
||||
"UNOFFICIAL_SLICE",
|
||||
"manifest.json.expectedSpeciesIds",
|
||||
"The unofficial twelve-form profile must contain exactly the selected twelve development forms"
|
||||
);
|
||||
}
|
||||
|
||||
validateEvolutionGraph(species.evolutionTargets, report);
|
||||
validateAssetCoverage(species.ids, assets, report);
|
||||
validateSpawns(bundle.document("spawn-rules.json"), species.ids, report);
|
||||
return report;
|
||||
}
|
||||
|
||||
private void validateDocumentHeader(String name, JsonNode document, ValidationReport report) {
|
||||
if (document.path("schemaVersion").asInt(-1) != ContentBundle.CURRENT_SCHEMA_VERSION) {
|
||||
report.add(
|
||||
"SCHEMA_VERSION",
|
||||
name + ".schemaVersion",
|
||||
"Expected schemaVersion " + ContentBundle.CURRENT_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
if (!document.path("unofficialFanContent").isBoolean() || !document.path("unofficialFanContent").asBoolean()) {
|
||||
report.add(
|
||||
"FAN_CONTENT_MARKER",
|
||||
name + ".unofficialFanContent",
|
||||
"Fan-content documents must be explicitly marked unofficialFanContent=true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> validateManifest(JsonNode manifest, ValidationReport report) {
|
||||
requireId(manifest, "contentSetId", "manifest.json.contentSetId", report);
|
||||
requireText(manifest, "contentProfile", "manifest.json.contentProfile", report);
|
||||
if (!"UNOFFICIAL_FAN_CONTENT".equals(text(manifest, "publicationStatus"))) {
|
||||
report.add(
|
||||
"PUBLICATION_STATUS",
|
||||
"manifest.json.publicationStatus",
|
||||
"Fan-content catalog must declare UNOFFICIAL_FAN_CONTENT"
|
||||
);
|
||||
}
|
||||
if (!manifest.path("nonCommercial").asBoolean(false)) {
|
||||
report.add("NON_COMMERCIAL", "manifest.json.nonCommercial", "Fan-content catalog must remain non-commercial");
|
||||
}
|
||||
if (!manifest.path("rightsReviewRequired").asBoolean(false)) {
|
||||
report.add(
|
||||
"RIGHTS_REVIEW",
|
||||
"manifest.json.rightsReviewRequired",
|
||||
"Fan-content catalog must require an explicit rights review"
|
||||
);
|
||||
}
|
||||
|
||||
JsonNode gameplay = manifest.path("gameplay");
|
||||
requireExactInt(gameplay, "minimumLevel", 1, "manifest.json.gameplay", report);
|
||||
requireExactInt(gameplay, "maximumLevel", 100, "manifest.json.gameplay", report);
|
||||
requireExactInt(gameplay, "maximumPartySize", 6, "manifest.json.gameplay", report);
|
||||
requireExactInt(gameplay, "visibleCompanionLimit", 1, "manifest.json.gameplay", report);
|
||||
requireExactInt(gameplay, "commandedMoveSlots", 3, "manifest.json.gameplay", report);
|
||||
if (!gameplay.path("starterGrantEnabled").isBoolean() || gameplay.path("starterGrantEnabled").asBoolean()) {
|
||||
report.add(
|
||||
"STARTER_POLICY",
|
||||
"manifest.json.gameplay.starterGrantEnabled",
|
||||
"The slice grants no starter; only capture-device crafting is unlocked"
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> expected = new LinkedHashSet<>();
|
||||
JsonNode list = manifest.path("expectedSpeciesIds");
|
||||
if (!list.isArray() || list.isEmpty()) {
|
||||
report.add("EXPECTED_SPECIES", "manifest.json.expectedSpeciesIds", "Expected species list must be non-empty");
|
||||
return expected;
|
||||
}
|
||||
for (int index = 0; index < list.size(); index++) {
|
||||
String location = "manifest.json.expectedSpeciesIds[" + index + "]";
|
||||
String id = list.path(index).asText("");
|
||||
validateIdValue(id, location, report);
|
||||
if (!expected.add(id)) {
|
||||
report.add("DUPLICATE_ID", location, "Duplicate expected species ID: " + id);
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
private Map<String, String> validateMoves(JsonNode document, ValidationReport report) {
|
||||
Map<String, String> roles = new LinkedHashMap<>();
|
||||
ArrayNode items = requireItems(document, "moves.json", report);
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
JsonNode move = items.path(index);
|
||||
String location = "moves.json.items[" + index + "]";
|
||||
requireUnofficialItem(move, location, report);
|
||||
String id = requireId(move, "id", location + ".id", report);
|
||||
if (!id.isEmpty() && roles.containsKey(id)) {
|
||||
report.add("DUPLICATE_ID", location + ".id", "Duplicate move ID: " + id);
|
||||
}
|
||||
requireText(move, "name", location + ".name", report);
|
||||
requireEnum(move, "element", Set.of("NORMAL", "GRASS", "TOXIN", "FIRE", "AIR", "WATER", "ELECTRIC"), location, report);
|
||||
String role = requireEnum(move, "role", Set.of("BASIC", "COMMAND"), location, report);
|
||||
requireNumberRange(move, "power", 0, 250, location, report);
|
||||
requireNumberRange(move, "cooldownSeconds", 0.1, 120, location, report);
|
||||
if (!id.isEmpty()) {
|
||||
roles.putIfAbsent(id, role);
|
||||
}
|
||||
}
|
||||
if (roles.isEmpty()) {
|
||||
report.add("MOVE_EMPTY", "moves.json.items", "At least one move is required");
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
private Set<String> validateRecipes(JsonNode document, ValidationReport report) {
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> outputs = new LinkedHashSet<>();
|
||||
boolean unlockedCaptureDevice = false;
|
||||
ArrayNode items = requireItems(document, "recipes.json", report);
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
JsonNode recipe = items.path(index);
|
||||
String location = "recipes.json.items[" + index + "]";
|
||||
requireUnofficialItem(recipe, location, report);
|
||||
String id = requireId(recipe, "id", location + ".id", report);
|
||||
if (!id.isEmpty() && !ids.add(id)) {
|
||||
report.add("DUPLICATE_ID", location + ".id", "Duplicate recipe ID: " + id);
|
||||
}
|
||||
String category = requireText(recipe, "category", location + ".category", report);
|
||||
JsonNode output = recipe.path("output");
|
||||
String outputId = requireId(output, "itemId", location + ".output.itemId", report);
|
||||
requireIntegerRange(output, "quantity", 1, 999, location + ".output", report);
|
||||
if (!outputId.isEmpty()) {
|
||||
outputs.add(outputId);
|
||||
}
|
||||
JsonNode ingredients = recipe.path("ingredients");
|
||||
if (!ingredients.isArray() || ingredients.isEmpty()) {
|
||||
report.add("RECIPE_INGREDIENTS", location + ".ingredients", "Recipe ingredients must be non-empty");
|
||||
} else {
|
||||
for (int ingredientIndex = 0; ingredientIndex < ingredients.size(); ingredientIndex++) {
|
||||
JsonNode ingredient = ingredients.path(ingredientIndex);
|
||||
String ingredientLocation = location + ".ingredients[" + ingredientIndex + "]";
|
||||
requireId(ingredient, "itemId", ingredientLocation + ".itemId", report);
|
||||
requireIntegerRange(ingredient, "quantity", 1, 999, ingredientLocation, report);
|
||||
}
|
||||
}
|
||||
if ("CAPTURE_DEVICE".equals(category) && recipe.path("unlockedByDefault").asBoolean(false)) {
|
||||
unlockedCaptureDevice = true;
|
||||
}
|
||||
}
|
||||
if (!unlockedCaptureDevice) {
|
||||
report.add(
|
||||
"CAPTURE_RECIPE",
|
||||
"recipes.json.items",
|
||||
"At least one capture-device recipe must be unlocked by default"
|
||||
);
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
private AssetIndex validateAssets(JsonNode document, ValidationReport report) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
Map<String, String> speciesByAsset = new LinkedHashMap<>();
|
||||
Map<String, Integer> countBySpecies = new HashMap<>();
|
||||
ArrayNode items = requireItems(document, "assets.json", report);
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
JsonNode asset = items.path(index);
|
||||
String location = "assets.json.items[" + index + "]";
|
||||
requireUnofficialItem(asset, location, report);
|
||||
String id = requireId(asset, "id", location + ".id", report);
|
||||
String speciesId = requireId(asset, "speciesId", location + ".speciesId", report);
|
||||
if (!id.isEmpty() && !ids.add(id)) {
|
||||
report.add("DUPLICATE_ID", location + ".id", "Duplicate asset ID: " + id);
|
||||
}
|
||||
if (!id.isEmpty()) {
|
||||
speciesByAsset.putIfAbsent(id, speciesId);
|
||||
}
|
||||
if (!speciesId.isEmpty()) {
|
||||
countBySpecies.merge(speciesId, 1, Integer::sum);
|
||||
}
|
||||
requireEnum(asset, "status", Set.of("PLANNED", "READY"), location, report);
|
||||
requireIntegerRange(asset, "seed", 0, Integer.MAX_VALUE, location, report);
|
||||
requireText(asset, "bodyPlan", location + ".bodyPlan", report);
|
||||
requireNonEmptyTextArray(asset.path("sockets"), location + ".sockets", report);
|
||||
|
||||
JsonNode palette = asset.path("palette");
|
||||
if (!palette.isArray() || palette.size() < 2) {
|
||||
report.add("ASSET_PALETTE", location + ".palette", "Asset palette needs at least two colors");
|
||||
} else {
|
||||
for (int paletteIndex = 0; paletteIndex < palette.size(); paletteIndex++) {
|
||||
if (!HEX_COLOR.matcher(palette.path(paletteIndex).asText("")).matches()) {
|
||||
report.add("ASSET_PALETTE", location + ".palette[" + paletteIndex + "]", "Expected #RRGGBB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateAssetPath(asset, "source", ".bbmodel", location, report);
|
||||
validateAssetPath(asset, "model", ".blockymodel", location, report);
|
||||
validateAssetPath(asset, "texture", ".png", location, report);
|
||||
validateAssetPath(asset, "recipe", ".asset.json", location, report);
|
||||
JsonNode animations = asset.path("animations");
|
||||
if (!animations.isObject()) {
|
||||
report.add("ASSET_ANIMATIONS", location + ".animations", "Animations must be an object");
|
||||
} else {
|
||||
for (String animation : List.of("idle", "walk", "attack")) {
|
||||
validateAssetPath(animations, animation, ".blockyanim", location + ".animations", report);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new AssetIndex(ids, speciesByAsset, countBySpecies);
|
||||
}
|
||||
|
||||
private SpeciesIndex validateSpecies(
|
||||
JsonNode document,
|
||||
Map<String, String> moveRoles,
|
||||
Set<String> producedItems,
|
||||
AssetIndex assets,
|
||||
ValidationReport report
|
||||
) {
|
||||
Set<String> ids = new LinkedHashSet<>();
|
||||
Set<Integer> catalogNumbers = new HashSet<>();
|
||||
Map<String, String> evolutionTargets = new LinkedHashMap<>();
|
||||
List<PendingSpeciesReferences> pending = new ArrayList<>();
|
||||
ArrayNode items = requireItems(document, "species.json", report);
|
||||
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
JsonNode species = items.path(index);
|
||||
String location = "species.json.items[" + index + "]";
|
||||
requireUnofficialItem(species, location, report);
|
||||
String id = requireId(species, "id", location + ".id", report);
|
||||
if (!id.isEmpty() && !ids.add(id)) {
|
||||
report.add("DUPLICATE_ID", location + ".id", "Duplicate species ID: " + id);
|
||||
}
|
||||
requireText(species, "displayName", location + ".displayName", report);
|
||||
int catalogNumber = requireIntegerRange(species, "catalogNumber", 1, 9999, location, report);
|
||||
if (catalogNumber >= 0 && !catalogNumbers.add(catalogNumber)) {
|
||||
report.add("DUPLICATE_CATALOG_NUMBER", location + ".catalogNumber", "Duplicate catalog number: " + catalogNumber);
|
||||
}
|
||||
requireEnum(species, "form", Set.of("BASE", "STAGE_1", "STAGE_2", "SINGLE"), location, report);
|
||||
requireNonEmptyTextArray(species.path("elements"), location + ".elements", report);
|
||||
requireNumberRange(species, "captureDifficulty", 0.01, 0.99, location, report);
|
||||
JsonNode stats = species.path("baseStats");
|
||||
for (String stat : List.of("vitality", "power", "guard", "agility")) {
|
||||
requireIntegerRange(stats, stat, 1, 255, location + ".baseStats", report);
|
||||
}
|
||||
|
||||
String basicMove = requireId(species, "basicMoveId", location + ".basicMoveId", report);
|
||||
if (!basicMove.isEmpty() && !moveRoles.containsKey(basicMove)) {
|
||||
report.add("UNKNOWN_MOVE", location + ".basicMoveId", "Unknown move: " + basicMove);
|
||||
} else if (!basicMove.isEmpty() && !"BASIC".equals(moveRoles.get(basicMove))) {
|
||||
report.add("MOVE_ROLE", location + ".basicMoveId", "Basic move must have role BASIC");
|
||||
}
|
||||
List<String> commandMoves = validateCommandMoves(species.path("commandMoveIds"), moveRoles, location, report);
|
||||
if (commandMoves.contains(basicMove)) {
|
||||
report.add("MOVE_ROLE", location + ".commandMoveIds", "Basic move cannot also occupy a command slot");
|
||||
}
|
||||
|
||||
String assetId = requireId(species, "assetId", location + ".assetId", report);
|
||||
if (!assetId.isEmpty() && !assets.ids.contains(assetId)) {
|
||||
report.add("UNKNOWN_ASSET", location + ".assetId", "Unknown asset record: " + assetId);
|
||||
}
|
||||
|
||||
JsonNode evolution = species.path("evolution");
|
||||
String target = "";
|
||||
if (!evolution.isMissingNode() && !evolution.isNull()) {
|
||||
if (!evolution.isObject()) {
|
||||
report.add("EVOLUTION_SHAPE", location + ".evolution", "Evolution must be an object");
|
||||
} else {
|
||||
String method = requireEnum(evolution, "method", Set.of("LEVEL", "ITEM"), location + ".evolution", report);
|
||||
target = requireId(evolution, "targetSpeciesId", location + ".evolution.targetSpeciesId", report);
|
||||
if ("LEVEL".equals(method)) {
|
||||
requireIntegerRange(evolution, "minimumLevel", 2, 100, location + ".evolution", report);
|
||||
} else if ("ITEM".equals(method)) {
|
||||
String itemId = requireId(evolution, "itemId", location + ".evolution.itemId", report);
|
||||
if (!itemId.isEmpty() && !producedItems.contains(itemId)) {
|
||||
report.add("UNKNOWN_EVOLUTION_ITEM", location + ".evolution.itemId", "No recipe produces " + itemId);
|
||||
}
|
||||
}
|
||||
if (!id.isEmpty() && !target.isEmpty()) {
|
||||
evolutionTargets.put(id, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.add(new PendingSpeciesReferences(location, id, target, assetId));
|
||||
}
|
||||
|
||||
for (PendingSpeciesReferences reference : pending) {
|
||||
if (!reference.target.isEmpty() && !ids.contains(reference.target)) {
|
||||
report.add("UNKNOWN_EVOLUTION_TARGET", reference.location + ".evolution.targetSpeciesId", "Unknown species: " + reference.target);
|
||||
}
|
||||
if (!reference.assetId.isEmpty()) {
|
||||
String assetSpecies = assets.speciesByAsset.get(reference.assetId);
|
||||
if (assetSpecies != null && !assetSpecies.equals(reference.id)) {
|
||||
report.add("ASSET_SPECIES_MISMATCH", reference.location + ".assetId", "Asset belongs to " + assetSpecies);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SpeciesIndex(ids, evolutionTargets);
|
||||
}
|
||||
|
||||
private List<String> validateCommandMoves(
|
||||
JsonNode moves,
|
||||
Map<String, String> moveRoles,
|
||||
String location,
|
||||
ValidationReport report
|
||||
) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (!moves.isArray() || moves.size() != 3) {
|
||||
report.add("COMMAND_SLOTS", location + ".commandMoveIds", "Exactly three commanded moves are required");
|
||||
return result;
|
||||
}
|
||||
Set<String> unique = new HashSet<>();
|
||||
for (int index = 0; index < moves.size(); index++) {
|
||||
String move = moves.path(index).asText("");
|
||||
String moveLocation = location + ".commandMoveIds[" + index + "]";
|
||||
validateIdValue(move, moveLocation, report);
|
||||
if (!unique.add(move)) {
|
||||
report.add("DUPLICATE_COMMAND_MOVE", moveLocation, "Command moves must be unique");
|
||||
}
|
||||
if (!moveRoles.containsKey(move)) {
|
||||
report.add("UNKNOWN_MOVE", moveLocation, "Unknown move: " + move);
|
||||
} else if (!"COMMAND".equals(moveRoles.get(move))) {
|
||||
report.add("MOVE_ROLE", moveLocation, "Command move must have role COMMAND");
|
||||
}
|
||||
result.add(move);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void validateEvolutionGraph(Map<String, String> targets, ValidationReport report) {
|
||||
Map<String, Integer> state = new HashMap<>();
|
||||
for (String species : targets.keySet()) {
|
||||
visitEvolution(species, targets, state, new ArrayList<>(), report);
|
||||
}
|
||||
}
|
||||
|
||||
private void visitEvolution(
|
||||
String species,
|
||||
Map<String, String> targets,
|
||||
Map<String, Integer> state,
|
||||
List<String> path,
|
||||
ValidationReport report
|
||||
) {
|
||||
int currentState = state.getOrDefault(species, 0);
|
||||
if (currentState == 2) {
|
||||
return;
|
||||
}
|
||||
if (currentState == 1) {
|
||||
path.add(species);
|
||||
report.add("EVOLUTION_CYCLE", "species.json", "Evolution cycle: " + String.join(" -> ", path));
|
||||
return;
|
||||
}
|
||||
state.put(species, 1);
|
||||
path.add(species);
|
||||
String target = targets.get(species);
|
||||
if (target != null) {
|
||||
visitEvolution(target, targets, state, new ArrayList<>(path), report);
|
||||
}
|
||||
state.put(species, 2);
|
||||
}
|
||||
|
||||
private void validateAssetCoverage(Set<String> species, AssetIndex assets, ValidationReport report) {
|
||||
for (String speciesId : species) {
|
||||
int count = assets.countBySpecies.getOrDefault(speciesId, 0);
|
||||
if (count != 1) {
|
||||
report.add("ASSET_COVERAGE", "assets.json.items", speciesId + " must have exactly one asset record; found " + count);
|
||||
}
|
||||
}
|
||||
for (String assetSpecies : assets.countBySpecies.keySet()) {
|
||||
if (!species.contains(assetSpecies)) {
|
||||
report.add("UNKNOWN_ASSET_SPECIES", "assets.json.items", "Asset references unknown species: " + assetSpecies);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSpawns(JsonNode document, Set<String> species, ValidationReport report) {
|
||||
Set<String> ids = new HashSet<>();
|
||||
Map<String, Integer> countBySpecies = new HashMap<>();
|
||||
ArrayNode items = requireItems(document, "spawn-rules.json", report);
|
||||
for (int index = 0; index < items.size(); index++) {
|
||||
JsonNode spawn = items.path(index);
|
||||
String location = "spawn-rules.json.items[" + index + "]";
|
||||
requireUnofficialItem(spawn, location, report);
|
||||
String id = requireId(spawn, "id", location + ".id", report);
|
||||
if (!id.isEmpty() && !ids.add(id)) {
|
||||
report.add("DUPLICATE_ID", location + ".id", "Duplicate spawn ID: " + id);
|
||||
}
|
||||
String speciesId = requireId(spawn, "speciesId", location + ".speciesId", report);
|
||||
if (!speciesId.isEmpty() && !species.contains(speciesId)) {
|
||||
report.add("UNKNOWN_SPAWN_SPECIES", location + ".speciesId", "Unknown species: " + speciesId);
|
||||
}
|
||||
countBySpecies.merge(speciesId, 1, Integer::sum);
|
||||
requireNonEmptyTextArray(spawn.path("biomes"), location + ".biomes", report);
|
||||
requireEnum(spawn, "time", Set.of("DAY", "NIGHT", "ANY"), location, report);
|
||||
requireIntegerRange(spawn, "rarityWeight", 1, 100, location, report);
|
||||
int minimum = requireIntegerRange(spawn, "minimumLevel", 1, 100, location, report);
|
||||
int maximum = requireIntegerRange(spawn, "maximumLevel", 1, 100, location, report);
|
||||
if (minimum >= 0 && maximum >= 0 && minimum > maximum) {
|
||||
report.add("SPAWN_LEVEL_RANGE", location, "minimumLevel must not exceed maximumLevel");
|
||||
}
|
||||
}
|
||||
for (String speciesId : species) {
|
||||
if (countBySpecies.getOrDefault(speciesId, 0) < 1) {
|
||||
report.add("SPAWN_COVERAGE", "spawn-rules.json.items", "No spawn rule for " + speciesId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAssetPath(
|
||||
JsonNode node,
|
||||
String field,
|
||||
String extension,
|
||||
String location,
|
||||
ValidationReport report
|
||||
) {
|
||||
String value = requireText(node, field, location + "." + field, report);
|
||||
if (value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (value.contains("\\") || value.contains("://") || value.matches("^[A-Za-z]:.*")) {
|
||||
report.add("ASSET_PATH", location + "." + field, "Asset path must be a portable relative path");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path path = Path.of(value);
|
||||
String normalized = path.normalize().toString().replace('\\', '/');
|
||||
if (path.isAbsolute() || !normalized.equals(value) || value.contains("../") || !value.startsWith("assets/fan/")) {
|
||||
report.add("ASSET_PATH", location + "." + field, "Asset path must stay below assets/fan without traversal");
|
||||
}
|
||||
} catch (InvalidPathException exception) {
|
||||
report.add("ASSET_PATH", location + "." + field, "Invalid asset path: " + exception.getMessage());
|
||||
}
|
||||
if (!value.endsWith(extension)) {
|
||||
report.add("ASSET_EXTENSION", location + "." + field, "Expected extension " + extension);
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayNode requireItems(JsonNode document, String name, ValidationReport report) {
|
||||
JsonNode items = document.path("items");
|
||||
if (!items.isArray()) {
|
||||
report.add("ITEMS_SHAPE", name + ".items", "Expected an array");
|
||||
return ContentBundle.json().createArrayNode();
|
||||
}
|
||||
return (ArrayNode) items;
|
||||
}
|
||||
|
||||
private void requireUnofficialItem(JsonNode item, String location, ValidationReport report) {
|
||||
if (!item.path("unofficialFanContent").isBoolean() || !item.path("unofficialFanContent").asBoolean()) {
|
||||
report.add("FAN_CONTENT_MARKER", location + ".unofficialFanContent", "Fan-content entries must be explicitly marked");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireId(JsonNode node, String field, String location, ValidationReport report) {
|
||||
String value = requireText(node, field, location, report);
|
||||
if (!value.isEmpty()) {
|
||||
validateIdValue(value, location, report);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void validateIdValue(String value, String location, ValidationReport report) {
|
||||
if (!STABLE_ID.matcher(value).matches()) {
|
||||
report.add("INVALID_ID", location, "Expected lowercase namespace:value stable ID");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireText(JsonNode node, String field, String location, ValidationReport report) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isTextual() || value.asText().isBlank()) {
|
||||
report.add("REQUIRED_TEXT", location, "Expected non-blank text");
|
||||
return "";
|
||||
}
|
||||
return value.asText();
|
||||
}
|
||||
|
||||
private String requireEnum(JsonNode node, String field, Set<String> allowed, String location, ValidationReport report) {
|
||||
String value = requireText(node, field, location + "." + field, report);
|
||||
if (!value.isEmpty() && !allowed.contains(value)) {
|
||||
report.add("ENUM_VALUE", location + "." + field, "Expected one of " + allowed + ", got " + value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private int requireIntegerRange(
|
||||
JsonNode node,
|
||||
String field,
|
||||
int minimum,
|
||||
int maximum,
|
||||
String location,
|
||||
ValidationReport report
|
||||
) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
report.add("INTEGER_VALUE", location + "." + field, "Expected an integer");
|
||||
return -1;
|
||||
}
|
||||
int number = value.asInt();
|
||||
if (number < minimum || number > maximum) {
|
||||
report.add("INTEGER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
private void requireNumberRange(
|
||||
JsonNode node,
|
||||
String field,
|
||||
double minimum,
|
||||
double maximum,
|
||||
String location,
|
||||
ValidationReport report
|
||||
) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isNumber()) {
|
||||
report.add("NUMBER_VALUE", location + "." + field, "Expected a number");
|
||||
return;
|
||||
}
|
||||
double number = value.asDouble();
|
||||
if (!Double.isFinite(number) || number < minimum || number > maximum) {
|
||||
report.add("NUMBER_RANGE", location + "." + field, "Expected " + minimum + ".." + maximum);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireExactInt(JsonNode node, String field, int expected, String location, ValidationReport report) {
|
||||
JsonNode value = node.path(field);
|
||||
if (!value.isIntegralNumber() || value.asInt() != expected) {
|
||||
report.add("GAMEPLAY_METADATA", location + "." + field, "Expected " + expected);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNonEmptyTextArray(JsonNode node, String location, ValidationReport report) {
|
||||
if (!node.isArray() || node.isEmpty()) {
|
||||
report.add("TEXT_ARRAY", location, "Expected a non-empty text array");
|
||||
return;
|
||||
}
|
||||
Set<String> values = new HashSet<>();
|
||||
for (int index = 0; index < node.size(); index++) {
|
||||
JsonNode value = node.path(index);
|
||||
if (!value.isTextual() || value.asText().isBlank()) {
|
||||
report.add("TEXT_ARRAY", location + "[" + index + "]", "Expected non-blank text");
|
||||
} else if (!values.add(value.asText())) {
|
||||
report.add("TEXT_ARRAY_DUPLICATE", location + "[" + index + "]", "Duplicate value: " + value.asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
return node.path(field).isTextual() ? node.path(field).asText() : "";
|
||||
}
|
||||
|
||||
private record AssetIndex(
|
||||
Set<String> ids,
|
||||
Map<String, String> speciesByAsset,
|
||||
Map<String, Integer> countBySpecies
|
||||
) {}
|
||||
|
||||
private record SpeciesIndex(Set<String> ids, Map<String, String> evolutionTargets) {}
|
||||
|
||||
private record PendingSpeciesReferences(String location, String id, String target, String assetId) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
public record ValidationIssue(String code, String location, String message) {
|
||||
@Override
|
||||
public String toString() {
|
||||
return code + " at " + location + ": " + message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class ValidationReport {
|
||||
private final List<ValidationIssue> issues = new ArrayList<>();
|
||||
|
||||
public void add(String code, String location, String message) {
|
||||
issues.add(new ValidationIssue(code, location, message));
|
||||
}
|
||||
|
||||
public void addAll(ValidationReport other) {
|
||||
issues.addAll(other.issues);
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return issues.isEmpty();
|
||||
}
|
||||
|
||||
public List<ValidationIssue> issues() {
|
||||
return Collections.unmodifiableList(issues);
|
||||
}
|
||||
|
||||
public boolean containsCode(String code) {
|
||||
return issues.stream().anyMatch(issue -> issue.code().equals(code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ContentToolTest {
|
||||
@Test
|
||||
void validateCommandSucceedsForCatalog() {
|
||||
Path root = Path.of(System.getProperty("projectCapture.contentRoot"));
|
||||
ByteArrayOutputStream standardOut = new ByteArrayOutputStream();
|
||||
ByteArrayOutputStream standardError = new ByteArrayOutputStream();
|
||||
|
||||
int exitCode = ContentTool.run(
|
||||
new String[] { "validate", root.toString() },
|
||||
new PrintStream(standardOut, true, StandardCharsets.UTF_8),
|
||||
new PrintStream(standardError, true, StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
assertEquals(0, exitCode, standardError::toString);
|
||||
assertTrue(standardOut.toString(StandardCharsets.UTF_8).contains("Content validation passed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownCommandReturnsUsageError() {
|
||||
ByteArrayOutputStream error = new ByteArrayOutputStream();
|
||||
|
||||
int exitCode = ContentTool.run(
|
||||
new String[] { "publish", "." },
|
||||
new PrintStream(new ByteArrayOutputStream()),
|
||||
new PrintStream(error)
|
||||
);
|
||||
|
||||
assertEquals(64, exitCode);
|
||||
assertTrue(error.toString(StandardCharsets.UTF_8).contains("Unknown command"));
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package dev.projectcapture.contenttools;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ContentValidatorTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
private Path contentRoot;
|
||||
|
||||
@BeforeEach
|
||||
void copyRealCatalog() throws IOException {
|
||||
Path source = Path.of(System.getProperty("projectCapture.contentRoot"));
|
||||
contentRoot = temporaryDirectory.resolve("content");
|
||||
Files.createDirectories(contentRoot);
|
||||
for (String document : ContentBundle.REQUIRED_DOCUMENTS) {
|
||||
Files.copy(source.resolve(document), contentRoot.resolve(document));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void realTwelveFormCatalogPasses() throws IOException {
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.isValid(), () -> report.issues().toString());
|
||||
JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items");
|
||||
Set<String> ids = java.util.stream.StreamSupport.stream(species.spliterator(), false)
|
||||
.map(item -> item.path("id").asText())
|
||||
.collect(Collectors.toSet());
|
||||
assertEquals(12, ids.size());
|
||||
assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongSchemaAndDuplicateIds() throws IOException {
|
||||
ObjectNode moves = object("moves.json");
|
||||
moves.put("schemaVersion", 99);
|
||||
ArrayNode items = (ArrayNode) moves.path("items");
|
||||
items.add(items.path(0).deepCopy());
|
||||
write("moves.json", moves);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("SCHEMA_VERSION"));
|
||||
assertTrue(report.containsCode("DUPLICATE_ID"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownMoveEvolutionAndAssetReferences() throws IOException {
|
||||
ObjectNode species = object("species.json");
|
||||
ObjectNode first = (ObjectNode) species.path("items").path(0);
|
||||
first.put("basicMoveId", "projectcapture:missing-move");
|
||||
first.put("assetId", "projectcapture:missing-assets");
|
||||
((ObjectNode) first.path("evolution")).put("targetSpeciesId", "projectcapture:missing-species");
|
||||
write("species.json", species);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("UNKNOWN_MOVE"));
|
||||
assertTrue(report.containsCode("UNKNOWN_ASSET"));
|
||||
assertTrue(report.containsCode("UNKNOWN_EVOLUTION_TARGET"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEvolutionCycles() throws IOException {
|
||||
ObjectNode species = object("species.json");
|
||||
ObjectNode venusaur = findItem(species, "projectcapture:venusaur");
|
||||
ObjectNode evolution = venusaur.putObject("evolution");
|
||||
evolution.put("method", "LEVEL");
|
||||
evolution.put("targetSpeciesId", "projectcapture:bulbasaur");
|
||||
evolution.put("minimumLevel", 80);
|
||||
write("species.json", species);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("EVOLUTION_CYCLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsafeAssetPathsAndMissingAnimationSlots() throws IOException {
|
||||
ObjectNode assets = object("assets.json");
|
||||
ObjectNode first = (ObjectNode) assets.path("items").path(0);
|
||||
first.put("model", "../../outside.blockymodel");
|
||||
((ObjectNode) first.path("animations")).remove("attack");
|
||||
write("assets.json", assets);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("ASSET_PATH"));
|
||||
assertTrue(report.containsCode("REQUIRED_TEXT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPartyLevelAndRightsMetadataDrift() throws IOException {
|
||||
ObjectNode manifest = object("manifest.json");
|
||||
ObjectNode gameplay = (ObjectNode) manifest.path("gameplay");
|
||||
gameplay.put("minimumLevel", 0);
|
||||
gameplay.put("maximumLevel", 101);
|
||||
gameplay.put("maximumPartySize", 7);
|
||||
gameplay.put("visibleCompanionLimit", 2);
|
||||
gameplay.put("commandedMoveSlots", 4);
|
||||
gameplay.put("starterGrantEnabled", true);
|
||||
manifest.put("nonCommercial", false);
|
||||
manifest.put("rightsReviewRequired", false);
|
||||
write("manifest.json", manifest);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("GAMEPLAY_METADATA"));
|
||||
assertTrue(report.containsCode("STARTER_POLICY"));
|
||||
assertTrue(report.containsCode("NON_COMMERCIAL"));
|
||||
assertTrue(report.containsCode("RIGHTS_REVIEW"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingSpawnCoverage() throws IOException {
|
||||
ObjectNode spawns = object("spawn-rules.json");
|
||||
((ArrayNode) spawns.path("items")).remove(0);
|
||||
write("spawn-rules.json", spawns);
|
||||
|
||||
ValidationReport report = new ContentValidator().validate(contentRoot);
|
||||
|
||||
assertTrue(report.containsCode("SPAWN_COVERAGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void digestIsStableAndChangesWithContent() throws IOException {
|
||||
String first = ContentBundle.load(contentRoot).digest();
|
||||
String second = ContentBundle.load(contentRoot).digest();
|
||||
assertEquals(first, second);
|
||||
|
||||
ObjectNode recipes = object("recipes.json");
|
||||
findItem(recipes, "projectcapture:basic-capture-capsule").put("unlockedByDefault", false);
|
||||
write("recipes.json", recipes);
|
||||
|
||||
String changed = ContentBundle.load(contentRoot).digest();
|
||||
assertFalse(first.equals(changed));
|
||||
}
|
||||
|
||||
private ObjectNode object(String name) throws IOException {
|
||||
return (ObjectNode) ContentBundle.json().readTree(contentRoot.resolve(name).toFile());
|
||||
}
|
||||
|
||||
private void write(String name, JsonNode node) throws IOException {
|
||||
ContentBundle.json().writerWithDefaultPrettyPrinter().writeValue(contentRoot.resolve(name).toFile(), node);
|
||||
}
|
||||
|
||||
private static ObjectNode findItem(ObjectNode document, String id) {
|
||||
for (JsonNode item : document.path("items")) {
|
||||
if (id.equals(item.path("id").asText())) {
|
||||
return (ObjectNode) item;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Missing test fixture item: " + id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user