From 77f15f8599823d151622b43e417e9dc69d2ce220 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Thu, 22 Oct 2026 15:33:37 -0400 Subject: [PATCH] Use number pages for celestial modifiers (#94) --- docs/SYMBOL_SEMANTICS.md | 6 +-- gradle.properties | 2 +- .../book/AgeEnvironmentCompiler.java | 8 ++- .../rivencraft/book/AgeSkyProfile.java | 20 ++++---- .../dnorberg/rivencraft/book/AgeTraits.java | 4 +- .../rivencraft/book/CelestialPageValues.java | 17 +++++++ .../book/EnvironmentWritingGameTests.java | 51 +++++++++++++++++++ .../rivencraft/writing/WritingGrammar.java | 29 ++++++----- .../assets/rivencraft/lang/en_us.json | 2 +- 9 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/book/CelestialPageValues.java diff --git a/docs/SYMBOL_SEMANTICS.md b/docs/SYMBOL_SEMANTICS.md index fadd4e5..e889019 100644 --- a/docs/SYMBOL_SEMANTICS.md +++ b/docs/SYMBOL_SEMANTICS.md @@ -32,9 +32,9 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le | `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. | | `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. | | `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. | +| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. | +| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. | +| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. | | `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. | | `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. | diff --git a/gradle.properties b/gradle.properties index bfae686..4283207 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.6.9.3 +mod_version=0.6.10.0 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=pHEgQQUE diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java index 8f116a5..152ae61 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeEnvironmentCompiler.java @@ -155,15 +155,19 @@ public final class AgeEnvironmentCompiler { Double pendingPeriod = null; Float pendingPhase = null; boolean writtenPhase = false; + Double pendingNumber = null; for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { SymbolSemantics semantics = symbol.semantics(); + Double number = pendingNumber; + if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null; switch (semantics.role()) { + case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount(); case PERIOD_MODIFIER -> { - double next = ((SymbolSemantics.NumberValue) semantics.value()).amount(); + double next = CelestialPageValues.value(semantics, number); pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D; } case PHASE_MODIFIER -> { - float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount(); + float next = (float) CelestialPageValues.value(semantics, number); pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next); } case CELESTIAL -> { diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeSkyProfile.java b/src/main/java/net/dnorberg/rivencraft/book/AgeSkyProfile.java index 52a56d6..db9bc09 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeSkyProfile.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeSkyProfile.java @@ -59,13 +59,13 @@ public record AgeSkyProfile(Style style, int color, List bodies) { float size = 1.0F; List bodyColors = new ArrayList<>(); ColorAccumulator pendingColors = new ColorAccumulator(); - Integer colorMultiplier = null; + Double pendingNumber = null; boolean clockwise = true; for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { if (!active.contains(symbol.index())) continue; SymbolSemantics semantics = symbol.semantics(); - if (semantics.role() != SymbolSemantics.Role.COLOR - && semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) colorMultiplier = null; + Double number = pendingNumber; + if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null; switch (semantics.role()) { case SKY -> { pendingColors.clear(); @@ -77,16 +77,15 @@ public record AgeSkyProfile(Style style, int color, List bodies) { default -> throw new IllegalArgumentException("Unknown sky style"); }; } - case COLOR_NUMBER -> colorMultiplier = (int) ((SymbolSemantics.NumberValue) + case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount(); case COLOR -> { pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(), - colorMultiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : colorMultiplier); - colorMultiplier = null; + number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue()); } - case PERIOD_MODIFIER -> period = ((SymbolSemantics.NumberValue) semantics.value()).amount(); - case ANGLE_MODIFIER -> angle = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount(); - case PHASE_MODIFIER -> phase = (float) (((SymbolSemantics.NumberValue) semantics.value()).amount() / 360.0D); + case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number); + case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number); + case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D); case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount(); case CELESTIAL_COLOR -> bodyColors.add(color( ((SymbolSemantics.ChoiceValue) semantics.value()).id())); @@ -95,7 +94,7 @@ public record AgeSkyProfile(Style style, int color, List bodies) { case CELESTIAL -> { String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id(); int bodyColor = pendingColors.written() ? pendingColors.rgb() - : bodyColors.isEmpty() ? 0 : mixColors(bodyColors); + : bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors); if (choice.equals("normal_time")) { if (customBodies) { bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise)); @@ -221,6 +220,7 @@ public record AgeSkyProfile(Style style, int color, List bodies) { && !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time") || symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR || symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER + || symbol.semantics().role() == SymbolSemantics.Role.ANGLE_MODIFIER || symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER)); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java b/src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java index 8db73ac..503b45a 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java @@ -29,8 +29,8 @@ public record AgeTraits( if (biomeDistribution == null || lighting == null || weather == null) { throw new IllegalArgumentException("Age trait choices cannot be null"); } - if (celestialPeriod != STOPPED_CELESTIAL_PERIOD - && (celestialPeriod < MIN_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD)) { + if (!Double.isFinite(celestialPeriod) + || celestialPeriod < STOPPED_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD) { throw new IllegalArgumentException("Celestial period is outside the legacy range"); } if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/CelestialPageValues.java b/src/main/java/net/dnorberg/rivencraft/book/CelestialPageValues.java new file mode 100644 index 0000000..9f12f0a --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/CelestialPageValues.java @@ -0,0 +1,17 @@ +package net.dnorberg.rivencraft.book; + +import net.dnorberg.rivencraft.writing.SymbolSemantics; + +/** A preceding D'ni number overrides the next celestial modifier's fixed page value. */ +final class CelestialPageValues { + private CelestialPageValues() {} + + static double value(SymbolSemantics semantics, Double number) { + if (number == null) return ((SymbolSemantics.NumberValue) semantics.value()).amount(); + return switch (semantics.role()) { + case PERIOD_MODIFIER -> number * 0.08D; + case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D; + default -> throw new IllegalArgumentException("Not a celestial numeric modifier"); + }; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java index ec2b893..aedaada 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java @@ -236,6 +236,57 @@ public final class EnvironmentWritingGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void numberPagesSetAdjacentCelestialModifiers(GameTestHelper helper) { + OrderedWriting writing = new OrderedWriting(List.of( + WritingEntry.linkPanel(), page("terrain/standard"), page("sky/overworld"), + page("number/1"), page("period/double"), + page("number/7"), page("angle/east"), + page("number/12"), page("phase/rising"), page("celestial/sun"), + page("number/24"), page("angle/west"), + page("number/0"), page("phase/setting"), + page("number/0"), page("period/full"), page("celestial/moon"))); + helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0, + "Adjacent number and celestial modifier pages must not count as misplaced writing"); + AgeTraits seeded = AgeTraits.select(94L); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L, + AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess()); + AgeTraits effective = AgeEnvironmentCompiler.apply(plan, seeded); + AgeSkyProfile sky = AgeSkyProfile.compile(plan, effective); + helper.assertValueEqual(sky.bodies().getFirst().period(), 0.08D, + "A number overrides a fixed Length page at 0.08 per digit, even below the random minimum"); + helper.assertValueEqual(sky.bodies().getFirst().angle(), 105.0F, + "A number overrides a fixed Angle page at 15 degrees per digit"); + helper.assertValueEqual(sky.bodies().getFirst().phase(), 0.5F, + "A number overrides a fixed Phase page at 15 degrees per digit"); + helper.assertValueEqual(sky.bodies().getLast().angle(), 0.0F, + "Digit 24 wraps a full 360-degree rotation to zero"); + helper.assertValueEqual(sky.bodies().getLast().phase(), 0.0F, + "Digit zero is a valid phase override"); + helper.assertValueEqual(effective.celestialPeriod(), 0.0D, + "The final authored celestial controls the world clock"); + helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), effective), sky, + "Numbered celestial values must survive a plan save/load round trip"); + AgeGenerationPlan normalTime = AgeGenerationPlan.compile(new OrderedWriting(List.of( + WritingEntry.linkPanel(), page("number/6"), page("angle/north"), + page("celestial/normal_time"))), 95L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(95L), helper.getLevel().registryAccess()); + helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(normalTime, + BuiltinDimensionTypes.OVERWORLD_EFFECTS), AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS, + "An authored angle on Normal Time must select the custom celestial renderer"); + AgeSkyProfile normalSky = AgeSkyProfile.compile(normalTime, + AgeEnvironmentCompiler.apply(normalTime, AgeTraits.select(95L))); + helper.assertValueEqual(normalSky.bodies().getFirst().angle(), 90.0F, + "The custom Normal Time sun must honor its numbered angle"); + helper.assertValueEqual(normalSky.bodies().getFirst().color(), 0xFFFFFF, + "Uncolored custom celestial bodies must remain visible"); + OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(), + page("number/7"), page("direction/clockwise"), page("angle/east"), page("celestial/sun"))); + helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100, + "A number does not reach past an intervening page"); + helper.succeed(); + } + @GameTest(template = "empty") public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) { AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN, diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index f7b3aed..ead5b92 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -45,7 +45,7 @@ public final class WritingGrammar { int lastStage = -1; int biomeCount = 0; ParsedSymbol pendingBlock = null; - ParsedSymbol pendingColorNumber = null; + ParsedSymbol pendingNumber = null; for (int index = 0; index < writing.entries().size(); index++) { WritingEntry entry = writing.entries().get(index); if (entry.kind() == WritingEntry.Kind.LINK_PANEL) { @@ -74,17 +74,17 @@ public final class WritingGrammar { Role role = semantics.orElseThrow().role(); ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow()); symbols.add(parsed); - if (pendingColorNumber != null && role != Role.COLOR && role != Role.COLOR_NUMBER) { - diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingColorNumber.index(), - "A number page must be immediately followed by a color page")); - pendingColorNumber = null; + if (pendingNumber != null && !numberTarget(role) && role != Role.COLOR_NUMBER) { + diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingNumber.index(), + "A number page must be immediately followed by a color or celestial modifier page")); + pendingNumber = null; } if (role == Role.COLOR_NUMBER) { - if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, - pendingColorNumber.index(), "A number page must be immediately followed by a color page")); - pendingColorNumber = parsed; - } else if (role == Role.COLOR) { - pendingColorNumber = null; + if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, + pendingNumber.index(), "A number page must be immediately followed by a color or celestial modifier page")); + pendingNumber = parsed; + } else if (numberTarget(role)) { + pendingNumber = null; } if (modifier(role)) { // Legacy modifier pages pop and combine an earlier modifier of the same @@ -135,8 +135,8 @@ public final class WritingGrammar { lastStage = stage; } } - if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER, - pendingColorNumber.index(), "A number page must be followed by a color page")); + if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER, + pendingNumber.index(), "A number page must be followed by a color or celestial modifier page")); if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1, "Add a Link Panel as the first page")); for (ParsedSymbol dangling : pendingModifiers.values()) { @@ -189,6 +189,11 @@ public final class WritingGrammar { || role == Role.CELESTIAL_COLOR || role == Role.DIRECTION_MODIFIER; } + private static boolean numberTarget(Role role) { + return role == Role.COLOR || role == Role.PERIOD_MODIFIER + || role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER; + } + private static int stage(Role role) { return switch (role) { case TERRAIN -> 0; diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index b6bad1e..da2e2c1 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -260,7 +260,7 @@ "symbol.rivencraft.number.22": "D'ni 22", "symbol.rivencraft.number.23": "D'ni 23", "symbol.rivencraft.number.24": "D'ni 24", - "symbol.rivencraft.number.description": "Set the next color page to this D'ni number times 10. Without a number page, the Age chooses a random number from 0 to 24.", + "symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, or Length to × 0.08. A number page affects only the immediately following page.", "symbol.rivencraft.weather.normal": "Normal Weather", "symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.", "symbol.rivencraft.weather.fast": "Fast Weather",