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",