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

@@ -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 -> {
@@ -59,13 +59,13 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
float size = 1.0F;
List<Integer> 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<Body> 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<Body> 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<Body> 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));
}
@@ -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) {
@@ -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();
}
@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,
@@ -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;
@@ -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",