Public Access
This commit is contained in:
1 parent
8cce41fd71
commit
3c900634c9
15 files changed
+354
-46
No files matched your search
+4
-4
@@ -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
|
||||
|
||||
@@ -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/<namespace>/rivencraft/symbols/<path>.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.
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<ResourceLocation, SymbolDefinition> 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());
|
||||
}
|
||||
|
||||
@@ -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<SymbolSemantics> 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<String> 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<SymbolDefinition> CODEC = RecordCodecBuilder.<SymbolDefinition>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<String> 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<SymbolSemantics> 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<SymbolDefinition> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
Reference in new issue
Block a user