From 3c900634c96f8906020157353a7e9a5666d057ff Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Wed, 30 Sep 2026 07:17:14 -0400 Subject: [PATCH] Define server-authoritative symbol semantics (#29) --- docs/GAMETESTS.md | 8 +- docs/SYMBOL_SEMANTICS.md | 39 ++++++ docs/WRITING_DATA.md | 8 +- gradle.properties | 2 +- .../rivencraft/gametest/CoreGameTests.java | 5 + .../gametest/WritingDataGameTests.java | 59 +++++++++ .../rivencraft/writing/SymbolCatalog.java | 12 +- .../rivencraft/writing/SymbolDefinition.java | 117 +++++++++++++---- .../rivencraft/writing/SymbolSemantics.java | 120 ++++++++++++++++++ .../rivencraft/symbols/biome/native.json | 5 +- .../symbols/celestial/normal_time.json | 5 +- .../rivencraft/symbols/feature/caves.json | 5 +- .../rivencraft/symbols/lighting/normal.json | 5 +- .../rivencraft/symbols/terrain/standard.json | 5 +- .../rivencraft/symbols/weather/normal.json | 5 +- 15 files changed, 354 insertions(+), 46 deletions(-) create mode 100644 docs/SYMBOL_SEMANTICS.md create mode 100644 src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java diff --git a/docs/GAMETESTS.md b/docs/GAMETESTS.md index e2845d6..553f043 100644 --- a/docs/GAMETESTS.md +++ b/docs/GAMETESTS.md @@ -1,15 +1,15 @@ # GameTest suites -The default dedicated-server run is a small smoke suite: 16 Rivencraft tests +The default dedicated-server run is a small smoke suite: 17 Rivencraft tests cover core linking, deterministic Age definition, writing persistence, legacy data migration, workstation transactions, and forged/replayed menu actions. -NeoForge currently adds two of its own tests, so the runner reports 18. +NeoForge currently adds two of its own tests, so the runner reports 19. ```shell ./gradlew runGameTestServer ``` -The remaining 46 Rivencraft tests are archived, not deleted. They cover +The remaining 47 Rivencraft tests are archived, not deleted. They cover completed milestone details such as every Age terrain profile, Notebook and Desk surface interactions, refill edge cases, book assembly controls, and workstation break/drop behavior. Run only the relevant archive alongside the @@ -21,7 +21,7 @@ core tests when investigating an issue: ``` Available suite names are `linking`, `writing`, `desk`, `binder`, `mixer`, and -`recipe`. Run all 62 Rivencraft tests (64 including NeoForge's two) before a +`recipe`. Run all 64 Rivencraft tests (66 including NeoForge's two) before a release or after broad cross-system changes: ```shell diff --git a/docs/SYMBOL_SEMANTICS.md b/docs/SYMBOL_SEMANTICS.md new file mode 100644 index 0000000..6b4ffd8 --- /dev/null +++ b/docs/SYMBOL_SEMANTICS.md @@ -0,0 +1,39 @@ +# Server-authoritative symbol semantics (schema v2) + +Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalog. Put definitions at `data//rivencraft/symbols/.json`; the file ID becomes the stable symbol ID. For example, `data/example/rivencraft/symbols/terrain/highlands.json` defines `example:terrain/highlands`: + +```json +{ + "schema_version": 2, + "name": "symbol.example.terrain.highlands", + "description": "symbol.example.terrain.highlands.description", + "category": "example:terrain", + "semantics": { + "role": "terrain", + "value": "example:highlands" + } +} +``` + +`name` and `description` are translation keys. `category` is for display and sorting; it does not determine grammar. `semantics.role` and its typed `value` are server-owned. The terrain value above names an Age runtime profile that a later compiler must resolve and validate. A syntactically valid resource ID alone does not guarantee the profile or feature exists. + +| Role | Value type and allowed values | +| --- | --- | +| `terrain` | Canonical namespaced Age profile ID. | +| `biome_controller` | `native`, `single`, `tiled`, `grid`, `tiny`, `small`, `medium`, `large`, or `huge`. | +| `biome` | Canonical namespaced biome ID. | +| `lighting` | `normal`, `dark`, or `bright`. | +| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. | +| `celestial` | `normal_time` (the first representable single-cycle behavior). | +| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. | +| `angle_modifier` | JSON number in `[0, 360)` degrees. | +| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. | +| `feature` | Canonical namespaced feature-effect ID; future generation mapping must recognize it. | + +These are schema bounds, not claims that Legacy shipped numeric config ranges. See the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md) for the source values and biome-count rules. Roles outside this table—including multiple suns, gradients, special structures, and hazards—are intentionally unsupported by schema v2. Their meaning requires a later versioned extension, not an unvalidated free-form field. + +The server rejects a v2 definition with an unknown top-level or semantics field, missing/incorrectly typed field, unknown role or choice, malformed/non-canonical resource ID, or out-of-range/non-finite number. An invalid definition is logged with its file ID and excluded from the active catalog. Saved pages and books still retain that ID, allowing the player to recover them if the data pack is corrected. A reload atomically replaces the catalog rather than mutating saved writing. + +Schema v1 definitions remain readable as **display-only** symbols; existing packs need not be rewritten merely to show or copy pages. A v1 definition cannot add a `semantics` field. To make one eligible for future Age compilation, publish a v2 definition with the **same file path and ID**, adding a valid role and value. Do not rename the page ID or reorder a book's `OrderedWriting`. The client receives only `SymbolDisplayMetadata` (ID, translation keys, category, resolved flag); semantic definitions are not serialized to it. This slice defines and validates meaning but does **not yet apply** written symbols to Age generation; issues #30 and #31 implement parsing and compilation. + +The six built-in definitions demonstrate terrain, biome-controller, lighting, weather, celestial, and feature roles. A third-party pack may add its own definitions under its namespace. Use an explicit semantic role even when the display category has the same name; the two fields serve different purposes. diff --git a/docs/WRITING_DATA.md b/docs/WRITING_DATA.md index e21ab93..6f3aa5b 100644 --- a/docs/WRITING_DATA.md +++ b/docs/WRITING_DATA.md @@ -27,13 +27,17 @@ Schema version 1 contains display metadata only: `name` and `description` are translation keys, not arbitrary formatted text or executable behavior. `category` is a namespaced organizational identifier. +Milestone 5 adds [server-authoritative schema v2 semantics](SYMBOL_SEMANTICS.md) +with a typed role and bounded value. Existing v1 definitions remain readable +but display-only; updating an ID to v2 never rewrites saved pages or books. Invalid definitions are logged and excluded by the server. Menus and future page synchronization must obtain resolved display metadata from the server; clients never make a symbol valid by naming it. Rivencraft ships a small catalog covering terrain, biomes, lighting, weather, -celestial behavior, and features. It exists to exercise the complete writing -workflow before Milestone 5 implements the full legacy grammar. +celestial behavior, and features. Its v2 semantics prepare for the full +Legacy-inspired grammar; writing still uses random Age fallback until the +parser and compiler are implemented. ## Persistent writing diff --git a/gradle.properties b/gradle.properties index 15c40fe..91ea795 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.4.0.2 +mod_version=0.4.1.0 immersive_portals_version=6.0.7 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java index e0dd2e5..e260ea2 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java @@ -42,6 +42,11 @@ public final class CoreGameTests { WritingDataGameTests.pageItemPreservesContentAndRejectsForgedUnknownSymbols(helper); } + @GameTest(template = "empty") + public static void semanticSymbolSchema(GameTestHelper helper) { + WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper); + } + @GameTest(template = "empty") public static void notebookPersistence(GameTestHelper helper) { WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java index f487b66..d83dc84 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java @@ -1,5 +1,6 @@ package net.dnorberg.rivencraft.gametest; +import com.google.gson.JsonParser; import com.mojang.serialization.JsonOps; import io.netty.buffer.Unpooled; import java.util.List; @@ -8,6 +9,8 @@ import net.dnorberg.rivencraft.book.AgeReference; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.SymbolCatalog; +import net.dnorberg.rivencraft.writing.SymbolDefinition; +import net.dnorberg.rivencraft.writing.SymbolSemantics; import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.WritingEntry; import net.dnorberg.rivencraft.writing.NotebookContents; @@ -100,6 +103,62 @@ public final class WritingDataGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void semanticSchemaIsServerOnlyBoundedAndV1Compatible(GameTestHelper helper) { + var builtIn = SymbolCatalog.find(reference("rivencraft:terrain/standard")).orElseThrow(); + helper.assertValueEqual(builtIn.schemaVersion(), 2, "Built-in symbol must use semantic schema v2"); + helper.assertValueEqual(builtIn.semantics().orElseThrow().role(), SymbolSemantics.Role.TERRAIN, + "Built-in terrain symbol must declare its grammar role"); + + String display = "\"name\":\"symbol.example.test\"," + + "\"description\":\"symbol.example.test.description\"," + + "\"category\":\"example:terrain\""; + SymbolDefinition legacy = SymbolDefinition.parse(JsonParser.parseString( + "{\"schema_version\":1," + display + "}")); + helper.assertTrue(legacy.semantics().isEmpty(), "Existing v1 definitions must remain display-only"); + SymbolDefinition oldCategory = SymbolDefinition.parse(JsonParser.parseString( + "{\"schema_version\":1,\"name\":\"symbol.example.test\"," + + "\"description\":\"symbol.example.test.description\"," + + "\"category\":\"terrain\"}")); + helper.assertValueEqual(oldCategory.category().toString(), "minecraft:terrain", + "A v1 category without a namespace must retain the old ResourceLocation behavior"); + helper.assertValueEqual(legacy.display(reference("example:test").id()).id(), + reference("example:test").id(), "Display metadata must preserve the symbol ID"); + + SymbolDefinition period = SymbolDefinition.parse(JsonParser.parseString( + "{\"schema_version\":2," + display + + ",\"semantics\":{\"role\":\"period_modifier\",\"value\":0}}")); + helper.assertValueEqual(period.semantics().orElseThrow().role(), SymbolSemantics.Role.PERIOD_MODIFIER, + "Zero-length modifier must be accepted"); + rejectDefinition(helper, "{\"schema_version\":2," + display + + ",\"semantics\":{\"role\":\"unknown\",\"value\":\"normal\"}}", "role"); + rejectDefinition(helper, "{\"schema_version\":2," + display + + ",\"semantics\":{\"role\":\"weather\",\"value\":\"blizzard\"}}", "value"); + rejectDefinition(helper, "{\"schema_version\":2," + display + + ",\"semantics\":{\"role\":\"period_modifier\",\"value\":2.01}}", "value"); + rejectDefinition(helper, "{\"schema_version\":2," + display + + ",\"semantics\":{\"role\":\"terrain\",\"value\":\"minecraft:overworld\",\"extra\":1}}", "extra"); + rejectDefinition(helper, "{\"schema_version\":1," + display + + ",\"semantics\":{\"role\":\"weather\",\"value\":\"normal\"}}", "v1"); + + var clientJson = builtIn.display(reference("rivencraft:terrain/standard").id()); + var encoded = net.dnorberg.rivencraft.writing.SymbolDisplayMetadata.CODEC + .encodeStart(JsonOps.INSTANCE, clientJson).result().orElseThrow(); + helper.assertFalse(encoded.getAsJsonObject().has("semantics"), + "Client display payload must never contain generation semantics"); + helper.succeed(); + } + + private static void rejectDefinition(GameTestHelper helper, String json, String diagnostic) { + boolean rejected = false; + try { + SymbolDefinition.parse(JsonParser.parseString(json)); + } catch (IllegalArgumentException exception) { + rejected = exception.getMessage().contains(diagnostic); + } + helper.assertTrue(rejected, "Invalid symbol must be rejected with diagnostic containing '" + diagnostic + "'"); + } + @GameTest(template = "empty") public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) { SymbolReference known = reference("rivencraft:terrain/standard"); diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java index 780b92d..b240c63 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java @@ -3,7 +3,6 @@ package net.dnorberg.rivencraft.writing; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; -import com.mojang.serialization.JsonOps; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -56,10 +55,13 @@ public final class SymbolCatalog { ResourceManager resourceManager, ProfilerFiller profiler) { Map loaded = new HashMap<>(); - resources.forEach((id, json) -> SymbolDefinition.CODEC.parse(JsonOps.INSTANCE, json) - .resultOrPartial(error -> Rivencraft.LOGGER.error( - "Ignoring invalid writing symbol {}: {}", id, error)) - .ifPresent(definition -> loaded.put(id, definition))); + resources.forEach((id, json) -> { + try { + loaded.put(id, SymbolDefinition.parse(json)); + } catch (IllegalArgumentException exception) { + Rivencraft.LOGGER.error("Ignoring invalid writing symbol {}: {}", id, exception.getMessage()); + } + }); symbols = Map.copyOf(loaded); Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size()); } diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolDefinition.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolDefinition.java index 8f25d87..d7ec70a 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/SymbolDefinition.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolDefinition.java @@ -1,47 +1,120 @@ package net.dnorberg.rivencraft.writing; -import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.codecs.RecordCodecBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.regex.Pattern; import net.minecraft.resources.ResourceLocation; -/** Server-loaded, non-executable display metadata for a writing symbol. */ +/** Server-only symbol definition. Only display metadata crosses to clients. */ public record SymbolDefinition( int schemaVersion, String nameTranslationKey, String descriptionTranslationKey, - ResourceLocation category + ResourceLocation category, + Optional semantics ) { - public static final int CURRENT_SCHEMA_VERSION = 1; + public static final int CURRENT_SCHEMA_VERSION = 2; private static final Pattern TRANSLATION_KEY = Pattern.compile("[a-z0-9_.-]+"); - private static final Codec TRANSLATION_KEY_CODEC = Codec.STRING.comapFlatMap( - value -> TRANSLATION_KEY.matcher(value).matches() - ? DataResult.success(value) - : DataResult.error(() -> "Invalid translation key: " + value), - value -> value - ); - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.INT.fieldOf("schema_version").forGetter(SymbolDefinition::schemaVersion), - TRANSLATION_KEY_CODEC.fieldOf("name").forGetter(SymbolDefinition::nameTranslationKey), - TRANSLATION_KEY_CODEC.fieldOf("description").forGetter(SymbolDefinition::descriptionTranslationKey), - ResourceLocation.CODEC.fieldOf("category").forGetter(SymbolDefinition::category) - ).apply(instance, SymbolDefinition::new)).flatXmap(SymbolDefinition::validate, SymbolDefinition::validate); + private static final Set V2_FIELDS = Set.of( + "schema_version", "name", "description", "category", "semantics"); public SymbolDefinition { Objects.requireNonNull(nameTranslationKey, "nameTranslationKey"); Objects.requireNonNull(descriptionTranslationKey, "descriptionTranslationKey"); Objects.requireNonNull(category, "category"); + semantics = Objects.requireNonNull(semantics, "semantics"); + if (schemaVersion != 1 && schemaVersion != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported schema_version " + schemaVersion); + } + if (schemaVersion == 1 && semantics.isPresent()) { + throw new IllegalArgumentException("Schema v1 symbols cannot have semantics"); + } + if (schemaVersion == CURRENT_SCHEMA_VERSION && semantics.isEmpty()) { + throw new IllegalArgumentException("Schema v2 symbols require semantics"); + } + validateTranslationKey(nameTranslationKey, "name"); + validateTranslationKey(descriptionTranslationKey, "description"); + } + + /** v1 remains display-only; v2 has an exact field set and typed semantics. */ + public static SymbolDefinition parse(JsonElement json) { + if (json == null || !json.isJsonObject()) { + throw new IllegalArgumentException("Symbol definition must be a JSON object"); + } + JsonObject object = json.getAsJsonObject(); + int version = requiredInteger(object, "schema_version"); + if (version != 1 && version != CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported schema_version " + version); + } + if (version == CURRENT_SCHEMA_VERSION) { + for (String field : object.keySet()) { + if (!V2_FIELDS.contains(field)) { + throw new IllegalArgumentException("Unknown symbol field '" + field + "'"); + } + } + } else if (object.has("semantics")) { + throw new IllegalArgumentException("Schema v1 cannot declare semantics; use schema_version 2"); + } + String name = requiredString(object, "name"); + String description = requiredString(object, "description"); + ResourceLocation category = requiredResourceLocation(object, "category", + version == CURRENT_SCHEMA_VERSION); + Optional semantics = version == CURRENT_SCHEMA_VERSION + ? Optional.of(SymbolSemantics.parse(object.get("semantics"))) + : Optional.empty(); + return new SymbolDefinition(version, name, description, category, semantics); } public SymbolDisplayMetadata display(ResourceLocation id) { return new SymbolDisplayMetadata(id, nameTranslationKey, descriptionTranslationKey, category, true); } - private static DataResult validate(SymbolDefinition definition) { - return definition.schemaVersion == CURRENT_SCHEMA_VERSION - ? DataResult.success(definition) - : DataResult.error(() -> "Unsupported symbol schema_version " + definition.schemaVersion); + static String requiredString(JsonObject object, String field) { + JsonElement element = object.get(field); + if (element == null || !element.isJsonPrimitive() + || !element.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("'" + field + "' must be a string"); + } + return element.getAsString(); + } + + static ResourceLocation requiredResourceLocation(JsonObject object, String field) { + return requiredResourceLocation(object, field, true); + } + + private static ResourceLocation requiredResourceLocation(JsonObject object, String field, boolean canonical) { + String value = requiredString(object, field); + ResourceLocation id = ResourceLocation.tryParse(value); + if (id == null || (canonical && !id.toString().equals(value))) { + throw new IllegalArgumentException("'" + field + "' must be " + + (canonical ? "a canonical namespaced ID" : "a resource location") + ": " + value); + } + return id; + } + + private static int requiredInteger(JsonObject object, String field) { + JsonElement element = object.get(field); + if (element == null || !element.isJsonPrimitive()) { + throw new IllegalArgumentException("'" + field + "' must be an integer"); + } + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (!primitive.isNumber() || !primitive.getAsString().matches("-?\\d+")) { + throw new IllegalArgumentException("'" + field + "' must be an integer"); + } + try { + return Integer.parseInt(primitive.getAsString()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("'" + field + "' is outside the integer range", exception); + } + } + + private static void validateTranslationKey(String value, String field) { + if (!TRANSLATION_KEY.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid '" + field + "' translation key: " + value); + } } } diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java new file mode 100644 index 0000000..46d4717 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolSemantics.java @@ -0,0 +1,120 @@ +package net.dnorberg.rivencraft.writing; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; +import net.minecraft.resources.ResourceLocation; + +/** Bounded grammar role and value, retained by the server catalog only. */ +public record SymbolSemantics(Role role, Value value) { + public SymbolSemantics { + Objects.requireNonNull(role, "role"); + Objects.requireNonNull(value, "value"); + if (!role.accepts(value)) { + throw new IllegalArgumentException("Invalid semantics.value for role '" + role.id + + "': " + value + "; expected " + role.expected()); + } + } + + public static SymbolSemantics parse(JsonElement json) { + if (json == null || !json.isJsonObject()) { + throw new IllegalArgumentException("'semantics' must be an object"); + } + JsonObject object = json.getAsJsonObject(); + for (String field : object.keySet()) { + if (!field.equals("role") && !field.equals("value")) { + throw new IllegalArgumentException("Unknown semantics field '" + field + "'"); + } + } + String roleId = SymbolDefinition.requiredString(object, "role"); + Role role = Arrays.stream(Role.values()).filter(candidate -> candidate.id.equals(roleId)) + .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown semantic role '" + roleId + "'")); + JsonElement raw = object.get("value"); + if (raw == null || !raw.isJsonPrimitive()) { + throw new IllegalArgumentException("'semantics.value' is required"); + } + JsonPrimitive primitive = raw.getAsJsonPrimitive(); + Value value; + if (role.kind == Kind.RESOURCE) { + value = new ResourceValue(SymbolDefinition.requiredResourceLocation(object, "value")); + } else if (role.kind == Kind.NUMBER) { + if (!primitive.isNumber()) { + throw new IllegalArgumentException("'semantics.value' for " + role.id + " must be a number"); + } + value = new NumberValue(primitive.getAsDouble()); + } else { + value = new ChoiceValue(SymbolDefinition.requiredString(object, "value")); + } + return new SymbolSemantics(role, value); + } + + public sealed interface Value permits ResourceValue, ChoiceValue, NumberValue {} + + public record ResourceValue(ResourceLocation id) implements Value { + public ResourceValue { Objects.requireNonNull(id, "id"); } + } + + public record ChoiceValue(String id) implements Value { + public ChoiceValue { Objects.requireNonNull(id, "id"); } + } + + public record NumberValue(double amount) implements Value { + public NumberValue { + if (!Double.isFinite(amount)) { + throw new IllegalArgumentException("Semantic numeric value must be finite"); + } + } + } + + private enum Kind { RESOURCE, CHOICE, NUMBER } + + public enum Role { + TERRAIN("terrain", Kind.RESOURCE), + BIOME_CONTROLLER("biome_controller", Kind.CHOICE, + "native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"), + BIOME("biome", Kind.RESOURCE), + LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"), + WEATHER("weather", Kind.CHOICE, + "normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"), + CELESTIAL("celestial", Kind.CHOICE, "normal_time"), + PERIOD_MODIFIER("period_modifier", Kind.NUMBER), + ANGLE_MODIFIER("angle_modifier", Kind.NUMBER), + PHASE_MODIFIER("phase_modifier", Kind.NUMBER), + FEATURE("feature", Kind.RESOURCE); + + private final String id; + private final Kind kind; + private final Set choices; + + Role(String id, Kind kind, String... choices) { + this.id = id; + this.kind = kind; + this.choices = Set.of(choices); + } + + public String id() { return id; } + + private boolean accepts(Value value) { + if (kind == Kind.RESOURCE) return value instanceof ResourceValue; + if (kind == Kind.CHOICE) return value instanceof ChoiceValue choice && choices.contains(choice.id()); + if (!(value instanceof NumberValue number)) return false; + return switch (this) { + case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0; + case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0; + default -> false; + }; + } + + private String expected() { + return switch (kind) { + case RESOURCE -> "a canonical namespaced ID"; + case CHOICE -> "one of " + choices.stream().sorted().toList(); + case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]" + : "a finite number in [0, 360) degrees"; + }; + } + } +} diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/biome/native.json b/src/main/resources/data/rivencraft/rivencraft/symbols/biome/native.json index 6c24b36..ba42ebd 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/biome/native.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/biome/native.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.biome.native", "description": "symbol.rivencraft.biome.native.description", - "category": "rivencraft:biome" + "category": "rivencraft:biome", + "semantics": { "role": "biome_controller", "value": "native" } } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/celestial/normal_time.json b/src/main/resources/data/rivencraft/rivencraft/symbols/celestial/normal_time.json index afbaa29..a441947 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/celestial/normal_time.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/celestial/normal_time.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.celestial.normal_time", "description": "symbol.rivencraft.celestial.normal_time.description", - "category": "rivencraft:celestial" + "category": "rivencraft:celestial", + "semantics": { "role": "celestial", "value": "normal_time" } } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/feature/caves.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/caves.json index 92f5750..b9f3822 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/feature/caves.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/caves.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.feature.caves", "description": "symbol.rivencraft.feature.caves.description", - "category": "rivencraft:feature" + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:caves" } } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/lighting/normal.json b/src/main/resources/data/rivencraft/rivencraft/symbols/lighting/normal.json index 0dd7d83..6ce9239 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/lighting/normal.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/lighting/normal.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.lighting.normal", "description": "symbol.rivencraft.lighting.normal.description", - "category": "rivencraft:lighting" + "category": "rivencraft:lighting", + "semantics": { "role": "lighting", "value": "normal" } } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/terrain/standard.json b/src/main/resources/data/rivencraft/rivencraft/symbols/terrain/standard.json index 6af3144..ccb289d 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/terrain/standard.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/terrain/standard.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.terrain.standard", "description": "symbol.rivencraft.terrain.standard.description", - "category": "rivencraft:terrain" + "category": "rivencraft:terrain", + "semantics": { "role": "terrain", "value": "rivencraft:standard" } } diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/weather/normal.json b/src/main/resources/data/rivencraft/rivencraft/symbols/weather/normal.json index 7e7a503..4c60c14 100644 --- a/src/main/resources/data/rivencraft/rivencraft/symbols/weather/normal.json +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/weather/normal.json @@ -1,6 +1,7 @@ { - "schema_version": 1, + "schema_version": 2, "name": "symbol.rivencraft.weather.normal", "description": "symbol.rivencraft.weather.normal.description", - "category": "rivencraft:weather" + "category": "rivencraft:weather", + "semantics": { "role": "weather", "value": "normal" } }