Use number pages for celestial modifiers (#94)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-22 15:33:37 -04:00
1 parent 2b2605b277
commit 77f15f8599
9 files changed
+108 -31

No files matched your search

+3 -3
View File
@@ -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`. | | `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. | | `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). | | `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. | | `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. | | `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 will convert to turns. | | `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. | | `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. | | `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
+1 -1
View File
@@ -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. # 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 mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. # 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 immersive_portals_version=6.0.7
terrablender_version=4.1.0.8 terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE serene_seasons_version_id=pHEgQQUE
@@ -155,15 +155,19 @@ public final class AgeEnvironmentCompiler {
Double pendingPeriod = null; Double pendingPeriod = null;
Float pendingPhase = null; Float pendingPhase = null;
boolean writtenPhase = false; boolean writtenPhase = false;
Double pendingNumber = null;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
SymbolSemantics semantics = symbol.semantics(); SymbolSemantics semantics = symbol.semantics();
Double number = pendingNumber;
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
switch (semantics.role()) { switch (semantics.role()) {
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount();
case PERIOD_MODIFIER -> { case PERIOD_MODIFIER -> {
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount(); double next = CelestialPageValues.value(semantics, number);
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D; pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
} }
case PHASE_MODIFIER -> { 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); pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
} }
case CELESTIAL -> { case CELESTIAL -> {
@@ -59,13 +59,13 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
float size = 1.0F; float size = 1.0F;
List<Integer> bodyColors = new ArrayList<>(); List<Integer> bodyColors = new ArrayList<>();
ColorAccumulator pendingColors = new ColorAccumulator(); ColorAccumulator pendingColors = new ColorAccumulator();
Integer colorMultiplier = null; Double pendingNumber = null;
boolean clockwise = true; boolean clockwise = true;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) { for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
if (!active.contains(symbol.index())) continue; if (!active.contains(symbol.index())) continue;
SymbolSemantics semantics = symbol.semantics(); SymbolSemantics semantics = symbol.semantics();
if (semantics.role() != SymbolSemantics.Role.COLOR Double number = pendingNumber;
&& semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) colorMultiplier = null; if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
switch (semantics.role()) { switch (semantics.role()) {
case SKY -> { case SKY -> {
pendingColors.clear(); pendingColors.clear();
@@ -77,16 +77,15 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
default -> throw new IllegalArgumentException("Unknown sky style"); default -> throw new IllegalArgumentException("Unknown sky style");
}; };
} }
case COLOR_NUMBER -> colorMultiplier = (int) ((SymbolSemantics.NumberValue) case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue)
semantics.value()).amount(); semantics.value()).amount();
case COLOR -> { case COLOR -> {
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(), pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
colorMultiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : colorMultiplier); number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue());
colorMultiplier = null;
} }
case PERIOD_MODIFIER -> period = ((SymbolSemantics.NumberValue) semantics.value()).amount(); case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
case ANGLE_MODIFIER -> angle = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount(); case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
case PHASE_MODIFIER -> phase = (float) (((SymbolSemantics.NumberValue) semantics.value()).amount() / 360.0D); case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount(); case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case CELESTIAL_COLOR -> bodyColors.add(color( case CELESTIAL_COLOR -> bodyColors.add(color(
((SymbolSemantics.ChoiceValue) semantics.value()).id())); ((SymbolSemantics.ChoiceValue) semantics.value()).id()));
@@ -95,7 +94,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
case CELESTIAL -> { case CELESTIAL -> {
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id(); String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
int bodyColor = pendingColors.written() ? pendingColors.rgb() int bodyColor = pendingColors.written() ? pendingColors.rgb()
: bodyColors.isEmpty() ? 0 : mixColors(bodyColors); : bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
if (choice.equals("normal_time")) { if (choice.equals("normal_time")) {
if (customBodies) { if (customBodies) {
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise)); bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
@@ -221,6 +220,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
&& !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time") && !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time")
|| symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR || symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR
|| symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER || symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER
|| symbol.semantics().role() == SymbolSemantics.Role.ANGLE_MODIFIER
|| symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER)); || symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER));
} }
@@ -29,8 +29,8 @@ public record AgeTraits(
if (biomeDistribution == null || lighting == null || weather == null) { if (biomeDistribution == null || lighting == null || weather == null) {
throw new IllegalArgumentException("Age trait choices cannot be null"); throw new IllegalArgumentException("Age trait choices cannot be null");
} }
if (celestialPeriod != STOPPED_CELESTIAL_PERIOD if (!Double.isFinite(celestialPeriod)
&& (celestialPeriod < MIN_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD)) { || celestialPeriod < STOPPED_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD) {
throw new IllegalArgumentException("Celestial period is outside the legacy range"); throw new IllegalArgumentException("Celestial period is outside the legacy range");
} }
if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) { if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) {
@@ -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");
};
}
}
@@ -236,6 +236,57 @@ public final class EnvironmentWritingGameTests {
helper.succeed(); 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") @GameTest(template = "empty")
public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) { public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) {
AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN, AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN,
@@ -45,7 +45,7 @@ public final class WritingGrammar {
int lastStage = -1; int lastStage = -1;
int biomeCount = 0; int biomeCount = 0;
ParsedSymbol pendingBlock = null; ParsedSymbol pendingBlock = null;
ParsedSymbol pendingColorNumber = null; ParsedSymbol pendingNumber = null;
for (int index = 0; index < writing.entries().size(); index++) { for (int index = 0; index < writing.entries().size(); index++) {
WritingEntry entry = writing.entries().get(index); WritingEntry entry = writing.entries().get(index);
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) { if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
@@ -74,17 +74,17 @@ public final class WritingGrammar {
Role role = semantics.orElseThrow().role(); Role role = semantics.orElseThrow().role();
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow()); ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
symbols.add(parsed); symbols.add(parsed);
if (pendingColorNumber != null && role != Role.COLOR && role != Role.COLOR_NUMBER) { if (pendingNumber != null && !numberTarget(role) && role != Role.COLOR_NUMBER) {
diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingColorNumber.index(), diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingNumber.index(),
"A number page must be immediately followed by a color page")); "A number page must be immediately followed by a color or celestial modifier page"));
pendingColorNumber = null; pendingNumber = null;
} }
if (role == Role.COLOR_NUMBER) { if (role == Role.COLOR_NUMBER) {
if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER,
pendingColorNumber.index(), "A number page must be immediately followed by a color page")); pendingNumber.index(), "A number page must be immediately followed by a color or celestial modifier page"));
pendingColorNumber = parsed; pendingNumber = parsed;
} else if (role == Role.COLOR) { } else if (numberTarget(role)) {
pendingColorNumber = null; pendingNumber = null;
} }
if (modifier(role)) { if (modifier(role)) {
// Legacy modifier pages pop and combine an earlier modifier of the same // Legacy modifier pages pop and combine an earlier modifier of the same
@@ -135,8 +135,8 @@ public final class WritingGrammar {
lastStage = stage; lastStage = stage;
} }
} }
if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER, if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER,
pendingColorNumber.index(), "A number page must be followed by a color page")); 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, if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
"Add a Link Panel as the first page")); "Add a Link Panel as the first page"));
for (ParsedSymbol dangling : pendingModifiers.values()) { for (ParsedSymbol dangling : pendingModifiers.values()) {
@@ -189,6 +189,11 @@ public final class WritingGrammar {
|| role == Role.CELESTIAL_COLOR || role == Role.DIRECTION_MODIFIER; || 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) { private static int stage(Role role) {
return switch (role) { return switch (role) {
case TERRAIN -> 0; case TERRAIN -> 0;
@@ -260,7 +260,7 @@
"symbol.rivencraft.number.22": "D'ni 22", "symbol.rivencraft.number.22": "D'ni 22",
"symbol.rivencraft.number.23": "D'ni 23", "symbol.rivencraft.number.23": "D'ni 23",
"symbol.rivencraft.number.24": "D'ni 24", "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": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.", "symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.weather.fast": "Fast Weather", "symbol.rivencraft.weather.fast": "Fast Weather",