feat: add multiplayer capture vertical slice

This commit is contained in:
2026-08-15 09:09:17 +02:00
parent 9229040382
commit c418e2f36c
24 changed files with 3989 additions and 47 deletions
+1
View File
@@ -16,6 +16,7 @@ java {
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0")
testImplementation(project(":engine"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
@@ -20,6 +20,12 @@ import java.util.regex.Pattern;
public final class ContentValidator {
private static final Pattern STABLE_ID = Pattern.compile("^[a-z][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$");
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$");
static final Set<String> ENGINE_ELEMENT_TYPES = Set.of(
"NORMAL", "GRASS", "POISON", "FIRE", "FLYING", "WATER", "ELECTRIC", "PSYCHIC",
"GROUND", "ROCK", "ICE", "BUG", "FIGHTING", "GHOST", "DRAGON"
);
static final Set<String> ENGINE_RARITIES = Set.of("COMMON", "UNCOMMON", "RARE", "EPIC", "LEGENDARY");
static final Set<String> ENGINE_MOVE_CATEGORIES = Set.of("PHYSICAL", "SPECIAL", "STATUS");
static final Set<String> UNOFFICIAL_SLICE_SPECIES = Set.of(
"projectcapture:bulbasaur",
@@ -165,11 +171,24 @@ public final class ContentValidator {
if (!id.isEmpty() && roles.containsKey(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate move ID: " + id);
}
requireText(move, "name", location + ".name", report);
requireEnum(move, "element", Set.of("NORMAL", "GRASS", "TOXIN", "FIRE", "AIR", "WATER", "ELECTRIC"), location, report);
requireCurrentSchema(move, location, report);
rejectLegacyField(move, "name", location, report);
rejectLegacyField(move, "element", location, report);
requireText(move, "displayName", location + ".displayName", report);
requireEnum(move, "type", ENGINE_ELEMENT_TYPES, location, report);
String category = requireEnum(move, "category", ENGINE_MOVE_CATEGORIES, location, report);
int power = requireIntegerRange(move, "power", 0, 250, location, report);
requireNumberRange(move, "accuracy", 0.0, 1.0, location, report);
requireIntegerRange(move, "priority", -7, 7, location, report);
String role = requireEnum(move, "role", Set.of("BASIC", "COMMAND"), location, report);
requireNumberRange(move, "power", 0, 250, location, report);
requireNumberRange(move, "cooldownSeconds", 0.1, 120, location, report);
if (!category.isEmpty() && !"STATUS".equals(category) && power == 0) {
report.add(
"MOVE_POWER_CATEGORY",
location + ".power",
"Engine damaging moves must have positive power"
);
}
if (!id.isEmpty()) {
roles.putIfAbsent(id, role);
}
@@ -298,14 +317,18 @@ public final class ContentValidator {
if (!id.isEmpty() && !ids.add(id)) {
report.add("DUPLICATE_ID", location + ".id", "Duplicate species ID: " + id);
}
requireCurrentSchema(species, location, report);
requireText(species, "displayName", location + ".displayName", report);
int catalogNumber = requireIntegerRange(species, "catalogNumber", 1, 9999, location, report);
if (catalogNumber >= 0 && !catalogNumbers.add(catalogNumber)) {
report.add("DUPLICATE_CATALOG_NUMBER", location + ".catalogNumber", "Duplicate catalog number: " + catalogNumber);
}
requireEnum(species, "form", Set.of("BASE", "STAGE_1", "STAGE_2", "SINGLE"), location, report);
requireNonEmptyTextArray(species.path("elements"), location + ".elements", report);
requireNumberRange(species, "captureDifficulty", 0.01, 0.99, location, report);
rejectLegacyField(species, "elements", location, report);
rejectLegacyField(species, "captureDifficulty", location, report);
requireEnumArray(species.path("types"), ENGINE_ELEMENT_TYPES, 1, 2, location + ".types", report);
requireEnum(species, "rarity", ENGINE_RARITIES, location, report);
requireIntegerRange(species, "captureRate", 1, 255, location, report);
JsonNode stats = species.path("baseStats");
for (String stat : List.of("vitality", "power", "guard", "agility")) {
requireIntegerRange(stats, stat, 1, 255, location + ".baseStats", report);
@@ -594,6 +617,58 @@ public final class ContentValidator {
}
}
private void requireCurrentSchema(JsonNode node, String location, ValidationReport report) {
JsonNode version = node.path("schemaVersion");
if (!version.isIntegralNumber() || version.asInt() != ContentBundle.CURRENT_SCHEMA_VERSION) {
report.add(
"SCHEMA_VERSION",
location + ".schemaVersion",
"Expected engine-compatible schemaVersion " + ContentBundle.CURRENT_SCHEMA_VERSION
);
}
}
private void rejectLegacyField(JsonNode node, String field, String location, ValidationReport report) {
if (node.has(field)) {
report.add(
"LEGACY_FIELD",
location + "." + field,
"Legacy field is not allowed in the engine-aligned schema"
);
}
}
private void requireEnumArray(
JsonNode node,
Set<String> allowed,
int minimumSize,
int maximumSize,
String location,
ValidationReport report
) {
requireNonEmptyTextArray(node, location, report);
if (!node.isArray()) {
return;
}
if (node.size() < minimumSize || node.size() > maximumSize) {
report.add(
"ARRAY_SIZE",
location,
"Expected " + minimumSize + ".." + maximumSize + " values"
);
}
for (int index = 0; index < node.size(); index++) {
JsonNode value = node.path(index);
if (value.isTextual() && !allowed.contains(value.asText())) {
report.add(
"ENUM_VALUE",
location + "[" + index + "]",
"Expected one of " + allowed + ", got " + value.asText()
);
}
}
}
private void requireNonEmptyTextArray(JsonNode node, String location, ValidationReport report) {
if (!node.isArray() || node.isEmpty()) {
report.add("TEXT_ARRAY", location, "Expected a non-empty text array");
@@ -3,6 +3,9 @@ package dev.projectcapture.contenttools;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.projectcapture.engine.model.ElementType;
import dev.projectcapture.engine.model.MoveDefinition;
import dev.projectcapture.engine.model.SpeciesDefinition;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -46,6 +49,45 @@ class ContentValidatorTest {
assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids);
}
@Test
void allTwelveSpeciesUseEngineElementRarityAndCaptureSchema() throws IOException {
JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items");
assertEquals(12, species.size());
for (JsonNode definition : species) {
assertEquals(SpeciesDefinition.CURRENT_SCHEMA_VERSION, definition.path("schemaVersion").asInt());
assertTrue(definition.path("types").size() >= 1 && definition.path("types").size() <= 2);
for (JsonNode type : definition.path("types")) {
ElementType.valueOf(type.asText());
}
SpeciesDefinition.Rarity.valueOf(definition.path("rarity").asText());
assertTrue(definition.path("captureRate").asInt() >= 1);
assertTrue(definition.path("captureRate").asInt() <= 255);
assertFalse(definition.has("elements"));
assertFalse(definition.has("captureDifficulty"));
}
}
@Test
void allMovesConstructAsEngineMoveDefinitions() throws IOException {
JsonNode moves = ContentBundle.load(contentRoot).document("moves.json").path("items");
for (JsonNode move : moves) {
new MoveDefinition(
move.path("schemaVersion").asInt(),
move.path("id").asText(),
move.path("displayName").asText(),
ElementType.valueOf(move.path("type").asText()),
MoveDefinition.Category.valueOf(move.path("category").asText()),
move.path("power").asInt(),
move.path("accuracy").asDouble(),
move.path("priority").asInt()
);
assertFalse(move.has("name"));
assertFalse(move.has("element"));
}
}
@Test
void rejectsWrongSchemaAndDuplicateIds() throws IOException {
ObjectNode moves = object("moves.json");
@@ -60,6 +102,40 @@ class ContentValidatorTest {
assertTrue(report.containsCode("DUPLICATE_ID"));
}
@Test
void rejectsNonEngineSpeciesEnumsAndCaptureRate() throws IOException {
ObjectNode species = object("species.json");
ObjectNode first = (ObjectNode) species.path("items").path(0);
((ArrayNode) first.path("types")).set(0, ContentBundle.json().getNodeFactory().textNode("TOXIN"));
first.put("rarity", "MYTHIC");
first.put("captureRate", 0);
write("species.json", species);
ValidationReport report = new ContentValidator().validate(contentRoot);
assertTrue(report.containsCode("ENUM_VALUE"));
assertTrue(report.containsCode("INTEGER_RANGE"));
}
@Test
void rejectsNonEngineMoveCombatFields() throws IOException {
ObjectNode moves = object("moves.json");
ObjectNode first = (ObjectNode) moves.path("items").path(0);
first.put("type", "AIR");
first.put("category", "SPECIAL");
first.put("power", 0);
first.put("accuracy", 1.1);
first.put("priority", 8);
write("moves.json", moves);
ValidationReport report = new ContentValidator().validate(contentRoot);
assertTrue(report.containsCode("ENUM_VALUE"));
assertTrue(report.containsCode("MOVE_POWER_CATEGORY"));
assertTrue(report.containsCode("NUMBER_RANGE"));
assertTrue(report.containsCode("INTEGER_RANGE"));
}
@Test
void rejectsUnknownMoveEvolutionAndAssetReferences() throws IOException {
ObjectNode species = object("species.json");