Port seeded sky colors and Normal Time celestial controls to 26.3
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-11-03 16:56:07 -05:00
1 parent a7a73d3c17
commit 38e40ddba3
27 files changed
+1516 -35

No files matched your search

@@ -1,6 +1,8 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
@@ -9,17 +11,23 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions) implements CustomPacketPayload {
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
AgeCatalogPayload::new
);
public AgeCatalogPayload {
dimensions = List.copyOf(dimensions);
colors = Map.copyOf(colors);
dayCycles = Map.copyOf(dayCycles);
}
@Override
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
var registrar = event.registrar("6");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
@@ -77,7 +77,8 @@ public final class AgeCatalogSync {
State state = state(server);
state.acknowledged.remove(player.getUUID());
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions));
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
}
private static void acknowledge(ServerPlayer player, int version) {
@@ -38,7 +38,11 @@ final class AgeLevelIndex extends SavedData {
Codec.unboundedMap(Identifier.CODEC, PortLighting.CODEC)
.optionalFieldOf("lighting", Map.of()).forGetter(index -> index.lighting),
Codec.unboundedMap(Identifier.CODEC, PortSkyStyle.CODEC)
.optionalFieldOf("sky", Map.of()).forGetter(index -> index.sky)
.optionalFieldOf("sky", Map.of()).forGetter(index -> index.sky),
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
@@ -59,6 +63,8 @@ final class AgeLevelIndex extends SavedData {
private final Map<Identifier, PortTerrainFeaturePlan> terrainFeatures;
private final Map<Identifier, PortLighting> lighting;
private final Map<Identifier, PortSkyStyle> sky;
private final Map<Identifier, PortAgeColors> colors;
private final Map<Identifier, PortDayCycle> dayCycles;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
@@ -73,6 +79,8 @@ final class AgeLevelIndex extends SavedData {
this.terrainFeatures = new LinkedHashMap<>();
this.lighting = new LinkedHashMap<>();
this.sky = new LinkedHashMap<>();
this.colors = new LinkedHashMap<>();
this.dayCycles = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
@@ -85,7 +93,8 @@ final class AgeLevelIndex extends SavedData {
Map<Identifier, PortLakePlan> lakes,
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
Map<Identifier, PortLighting> lighting,
Map<Identifier, PortSkyStyle> sky) {
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
@@ -98,6 +107,8 @@ final class AgeLevelIndex extends SavedData {
this.terrainFeatures = new LinkedHashMap<>(terrainFeatures);
this.lighting = new LinkedHashMap<>(lighting);
this.sky = new LinkedHashMap<>(sky);
this.colors = new LinkedHashMap<>(colors);
this.dayCycles = new LinkedHashMap<>(dayCycles);
}
Map<Identifier, Long> entries() {
@@ -130,10 +141,15 @@ final class AgeLevelIndex extends SavedData {
PortTerrainFeaturePlan terrainFeatures(Identifier id) { return terrainFeatures.get(id); }
PortLighting lighting(Identifier id) { return lighting.getOrDefault(id, PortLighting.NORMAL); }
PortSkyStyle sky(Identifier id) { return sky.getOrDefault(id, PortSkyStyle.OVERWORLD); }
PortAgeColors colors(Identifier id) { return colors.getOrDefault(id, PortAgeColors.NATIVE); }
Map<Identifier, PortAgeColors> colors() { return Map.copyOf(colors); }
PortDayCycle dayCycle(Identifier id) { return dayCycles.getOrDefault(id, PortDayCycle.NATIVE); }
Map<Identifier, PortDayCycle> dayCycles() { return Map.copyOf(dayCycles); }
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain,
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle) {
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle,
PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (contains(id)) {
throw new IllegalArgumentException("Age already exists: " + id);
}
@@ -147,6 +163,8 @@ final class AgeLevelIndex extends SavedData {
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
setDirty();
}
@@ -162,6 +180,8 @@ final class AgeLevelIndex extends SavedData {
terrainFeatures.remove(id);
lighting.remove(id);
sky.remove(id);
colors.remove(id);
dayCycles.remove(id);
retired.add(id);
setDirty();
}
@@ -170,7 +190,7 @@ final class AgeLevelIndex extends SavedData {
PortAgeTerrain ageTerrain, PortRockPlan rockPlan,
PortVegetationPlan vegetationPlan, int dungeonCount,
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light,
PortSkyStyle skyStyle) {
PortSkyStyle skyStyle, PortAgeColors colorProfile, PortDayCycle dayCycle) {
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
ages.put(id, seed);
if (biomePlan != null) biomePlans.put(id, biomePlan);
@@ -182,6 +202,8 @@ final class AgeLevelIndex extends SavedData {
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
if (light != PortLighting.NORMAL) lighting.put(id, light);
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
setDirty();
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.Level;
import org.joml.Vector3f;
import org.joml.Vector3fc;
/** Frozen shared-color writing, adapted from 1.21.1 AgeSkyProfile. -1 means native. */
public record PortAgeColors(int sky, int ambient) {
public static final PortAgeColors NATIVE = new PortAgeColors(-1, -1);
public static final Codec<PortAgeColors> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(-1, 0xffffff).fieldOf("sky").forGetter(PortAgeColors::sky),
Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient)
).apply(instance, PortAgeColors::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeColors> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient, PortAgeColors::new);
public static final Identifier AMBIENT = Identifier.fromNamespaceAndPath("rivencraft", "lighting/ambient_color");
private static volatile Map<Identifier, PortAgeColors> clientColors = Map.of();
public PortAgeColors {
if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff)
throw new IllegalArgumentException("Invalid Age color");
}
public static void updateClient(Map<Identifier, PortAgeColors> colors) { clientColors = Map.copyOf(colors); }
public static PortAgeColors forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.colors();
return level.isClientSide() ? clientColors.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
public static boolean isColor(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "color/red", "color/green", "color/blue", "color/white", "color/black" -> true;
default -> false;
};
}
public static int number(Identifier page) {
if (!page.getNamespace().equals("rivencraft") || !page.getPath().startsWith("number/")) return -1;
try {
int value = Integer.parseInt(page.getPath().substring(7));
return value >= 0 && value <= 24 ? value : -1;
} catch (NumberFormatException exception) { return -1; }
}
public static boolean isModifier(Identifier page) { return isColor(page) || number(page) >= 0 || page.equals(AMBIENT); }
public static boolean validNumbers(List<Identifier> pages) {
for (int i = 0; i < pages.size(); i++)
if (number(pages.get(i)) >= 0 && (i + 1 == pages.size()
|| !isColor(pages.get(i + 1)) && !PortDayCycle.isPeriod(pages.get(i + 1))
&& !PortDayCycle.isPhase(pages.get(i + 1)) && !PortDayCycle.isAngle(pages.get(i + 1))
&& !PortDayCycle.isSize(pages.get(i + 1)))) return false;
return true;
}
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
if (isColor(page)) {
int amount = i > 0 ? number(pages.get(i - 1)) : -1;
if (amount < 0) amount = new Random(seed ^ (0x9E3779B97F4A7C15L * (i + 1L))).nextInt(25);
amount *= 10;
written = true;
switch (page.getPath()) {
case "color/red" -> red += amount;
case "color/green" -> green += amount;
case "color/blue" -> blue += amount;
case "color/white" -> { red += amount; green += amount; blue += amount; }
case "color/black" -> { red -= amount; green -= amount; blue -= amount; }
}
} else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)) {
if (written) {
int rgb = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
if (page.equals(AMBIENT)) ambient = rgb; else sky = rgb;
}
red = green = blue = 0;
written = false;
}
}
if (written) sky = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
return new PortAgeColors(sky, ambient);
}
private static int clamp(int value) { return Math.max(0, Math.min(255, value)); }
public static Vector3fc vector(int rgb) {
return new Vector3f((rgb >> 16 & 255) / 255f, (rgb >> 8 & 255) / 255f, (rgb & 255) / 255f);
}
}
@@ -68,6 +68,51 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create_size")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("size", StringArgumentType.word())
.executes(context -> createSizeAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createSizeAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("create_angle")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("bearing", StringArgumentType.word())
.executes(context -> createAngleAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createAngleAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("create_phase")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("length", StringArgumentType.word())
.then(Commands.argument("phase", StringArgumentType.word())
.then(Commands.argument("direction", StringArgumentType.word())
.executes(context -> createPhaseAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createPhaseAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))))
.then(Commands.literal("create_cycle")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("length", StringArgumentType.word())
.executes(context -> createDayCycleAge(context, -1))
.then(Commands.argument("number", IntegerArgumentType.integer(0, 24))
.executes(context -> createDayCycleAge(context,
IntegerArgumentType.getInteger(context, "number"))))))))
.then(Commands.literal("cycle_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeDayCycle)))
.then(Commands.literal("create_colors")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("sky", StringArgumentType.word())
.executes(PortAgeCommands::createColorAge)))))
.then(Commands.literal("color_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeColors)))
.then(Commands.literal("create_sky")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -347,6 +392,277 @@ public final class PortAgeCommands {
.executes(PortAgeCommands::sampleBiome)))))));
}
private static int createSizeAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/zero"));
pages.add(symbol.apply("phase/zenith"));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("size/" + StringArgumentType.getString(context, "size")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("lighting/normal", "weather/off", "terrain/flat").map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported size fixture writing");
for (var size : java.util.Map.of("small", 0.5F, "normal", 1F, "large", 2F).entrySet()) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("size/" + size.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.sunSize() != size.getValue() || selected.ticks(origin) != origin)
throw new IllegalStateException("Size mapping changed cycle timing");
}
for (int n = 0; n <= 24; n++) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("number/" + n),
symbol.apply("size/large"), PortDayCycle.NORMAL_TIME), seed, origin);
float expected = (float) (0.25D + n * (2.75D / 24D));
if (selected.sunSize() != expected || n == 0 && expected != 0.25F
|| n == 12 && expected != 1.625F || n == 24 && expected != 3F)
throw new IllegalStateException("Numeric size override failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":1,\"stopped_time\":0,"
+ "\"origin_tick\":100,\"phase_tick\":6000,\"clockwise\":false,\"bearing\":90}")).getOrThrow();
if (!older.equals(new PortDayCycle(true, 1, 0, 100, 6000, false, 90)))
throw new IllegalStateException("Old cycle size fallback failed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT);
if (environment.sky() != PortSkyStyle.OVERWORLD)
throw new IllegalStateException("Sized celestial selected an incompatible sky");
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(),
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createAngleAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/zero"));
pages.add(symbol.apply("phase/rising"));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("angle/" + StringArgumentType.getString(context, "bearing")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("sky/overworld", "lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported angle fixture writing");
for (var bearing : java.util.Map.of("north", 0, "east", 90, "south", 180, "west", 270).entrySet()) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("angle/" + bearing.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.bearing() != bearing.getValue() || selected.ticks(origin) != origin)
throw new IllegalStateException("Angle mapping changed cycle timing");
}
for (int n = 0; n <= 24; n++) {
var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("number/" + n),
symbol.apply("angle/west"), PortDayCycle.NORMAL_TIME), seed, origin);
if (selected.bearing() != n * 15 % 360)
throw new IllegalStateException("Numeric angle override failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":1,\"stopped_time\":0,"
+ "\"origin_tick\":100,\"phase_tick\":6000,\"clockwise\":false}")).getOrThrow();
if (!older.equals(new PortDayCycle(true, 1, 0, 100, 6000, false)))
throw new IllegalStateException("Old cycle bearing fallback failed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.NORMAL, PortSkyStyle.OVERWORLD,
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createPhaseAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("period/" + StringArgumentType.getString(context, "length")));
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("phase/" + StringArgumentType.getString(context, "phase")));
pages.add(symbol.apply("direction/" + StringArgumentType.getString(context, "direction")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("sky/overworld", "lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
long origin = PortDayCycle.nativeTicks(server.overworld());
var cycle = PortDayCycle.compile(pages, seed, origin);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages))
throw new IllegalArgumentException("Unsupported phase fixture writing");
for (var phase : java.util.Map.of("rising", 0, "zenith", 6000, "setting", 12000, "nadir", 18000).entrySet()) {
var forward = PortDayCycle.compile(java.util.List.of(symbol.apply("phase/" + phase.getKey()),
PortDayCycle.NORMAL_TIME), seed, origin);
var reverse = PortDayCycle.compile(java.util.List.of(symbol.apply("phase/" + phase.getKey()),
symbol.apply("direction/counterclockwise"), PortDayCycle.NORMAL_TIME), seed, origin);
var stopped = PortDayCycle.compile(java.util.List.of(symbol.apply("period/zero"),
symbol.apply("phase/" + phase.getKey()), PortDayCycle.NORMAL_TIME), seed, origin);
if (forward.ticks(origin) != phase.getValue() || reverse.ticks(origin) != phase.getValue()
|| forward.ticks(origin + 1000) != phase.getValue() + 1000
|| reverse.ticks(origin + 1000) != phase.getValue() - 1000
|| stopped.ticks(origin + 123456) != phase.getValue())
throw new IllegalStateException("Phase/direction fixture failed");
}
var legacy = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":0,\"stopped_time\":321}"))
.getOrThrow();
if (!legacy.equals(new PortDayCycle(true, 0, 321)))
throw new IllegalStateException("Old day-cycle defaults changed");
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.NORMAL, PortSkyStyle.OVERWORLD,
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int createDayCycleAge(CommandContext<CommandSourceStack> context, int number) {
try {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = new java.util.ArrayList<Identifier>();
if (number >= 0) pages.add(symbol.apply("number/" + number));
pages.add(symbol.apply("period/" + StringArgumentType.getString(context, "length")));
pages.add(PortDayCycle.NORMAL_TIME);
pages.addAll(java.util.stream.Stream.of("lighting/normal", "weather/off", "terrain/flat")
.map(symbol).toList());
long seed = LongArgumentType.getLong(context, "seed");
var cycle = PortDayCycle.compile(pages, seed);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| !cycle.equals(PortDayCycle.compile(pages, seed))
|| new PortDayCycle(true, 0.5, 0).ticks(12000) != 24000
|| new PortDayCycle(true, 2, 0).ticks(12000) != 6000
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|| new PortDayCycle(true, 0, 321).ticks(123456) != 321
|| PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
throw new IllegalStateException("Day cycle fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT);
if (environment.sky() != PortSkyStyle.OVERWORLD)
throw new IllegalStateException("Celestial-only writing selected an incompatible sky");
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(),
PortAgeColors.compile(pages, seed), cycle);
return probeDayCycle(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeDayCycle(CommandContext<CommandSourceStack> context) {
var server = context.getSource().getServer();
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded"));
return 0;
}
verifyCatalogRoundTrip(server);
var definition = server.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK)
.getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD);
var source = level.clockManager().getInstance(definition);
var sample = PortDayCycle.timelines(level, level.clockManager()).getInstance(definition);
var cycle = PortDayCycle.forLevel(level);
if (cycle.ticks(source.totalTicks()) != sample.totalTicks()
|| cycle.authored() && cycle.period() == 0 && (sample.partialTick() != 0 || sample.rate() != 0))
throw new IllegalStateException("Day cycle clock invariant failed");
var pos = new net.minecraft.core.BlockPos(0, 80, 0);
var sun = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SUN_ANGLE, pos);
var light = level.environmentAttributes().getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_LIGHT_LEVEL);
context.getSource().sendSuccess(() -> Component.literal("Cycle probe " + ageId(context)
+ " saved=" + cycle + " nativeTicks=" + source.totalTicks() + " ageTicks=" + sample.totalTicks()
+ " sunAngle=" + sun + " skyLight=" + light), false);
return 1;
}
private static int createColorAge(CommandContext<CommandSourceStack> context) {
try {
String sky = StringArgumentType.getString(context, "sky").toLowerCase(Locale.ROOT);
PortSkyStyle style = PortSkyStyle.valueOf(sky.toUpperCase(Locale.ROOT));
var pages = java.util.stream.Stream.of("number/10", "color/red", "number/5", "color/green",
"number/15", "color/blue", "sky/" + sky, "number/20", "color/white",
"number/10", "color/black", "number/5", "color/green", "number/10", "color/blue",
"lighting/ambient_color", "lighting/bright", "weather/off", "terrain/flat")
.map(path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path)).toList();
long seed = LongArgumentType.getLong(context, "seed");
var colors = PortAgeColors.compile(pages, seed);
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var trailing = PortAgeColors.compile(java.util.List.of(symbol.apply("number/24"), symbol.apply("color/red")), seed);
var zero = PortAgeColors.compile(java.util.List.of(symbol.apply("number/0"), symbol.apply("color/blue")), seed);
var clamped = PortAgeColors.compile(java.util.List.of(symbol.apply("number/24"), symbol.apply("color/white"),
symbol.apply("number/24"), symbol.apply("color/red")), seed);
var unnumbered = java.util.List.of(symbol.apply("color/blue"));
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| colors.sky() != 0x643296 || colors.ambient() != 0x6496c8
|| !colors.equals(PortAgeColors.compile(pages, seed))
|| trailing.sky() != 0xf00000 || zero.sky() != 0 || clamped.sky() != 0xfff0f0
|| !PortAgeColors.compile(unnumbered, seed).equals(PortAgeColors.compile(unnumbered, seed))
|| PortAgeColors.validNumbers(java.util.List.of(symbol.apply("number/5")))
|| !PortAgeColors.compile(java.util.List.of(), seed).equals(PortAgeColors.NATIVE))
throw new IllegalStateException("Authored color fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT,
null, null, 0, null, null, PortWeatherMode.OFF, PortLighting.BRIGHT, style, colors);
return probeColors(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeColors(CommandContext<CommandSourceStack> context) {
var server = context.getSource().getServer();
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded"));
return 0;
}
var colors = PortAgeColors.forLevel(level);
verifyCatalogRoundTrip(server);
var pos = new net.minecraft.core.BlockPos(0, 80, 0);
var sky = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos);
var fog = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.FOG_COLOR, pos);
var ambient = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.AMBIENT_LIGHT_COLOR, pos);
if (colors.sky() >= 0 && (!sky.equals(PortAgeColors.vector(colors.sky())) || !fog.equals(sky))
|| colors.ambient() >= 0 && !ambient.equals(PortAgeColors.vector(colors.ambient()))) {
context.getSource().sendFailure(Component.literal("Color attribute mismatch"));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Color probe " + ageId(context)
+ " saved=" + colors + " sky=" + sky + " fog=" + fog + " ambient=" + ambient), false);
return 1;
}
private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) {
var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(),
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server));
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
server.registryAccess(), net.neoforged.neoforge.network.connection.ConnectionType.NEOFORGE);
try {
AgeCatalogPayload.STREAM_CODEC.encode(buffer, payload);
if (!payload.equals(AgeCatalogPayload.STREAM_CODEC.decode(buffer)))
throw new IllegalStateException("Age catalog round-trip failed");
} finally { buffer.release(); }
}
private static int createSkyAge(CommandContext<CommandSourceStack> context) {
try {
PortSkyStyle sky = PortSkyStyle.valueOf(StringArgumentType.getString(context, "sky").toUpperCase(Locale.ROOT));
@@ -402,11 +718,38 @@ public final class PortAgeCommands {
if (!selected.equals(PortEnvironmentSelection.resolve(java.util.List.of(), seed, terrain))
|| explicit.lighting() != PortLighting.BRIGHT
|| explicit.weather() != (terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF : PortWeatherMode.STORM)
|| weatherOnly.lighting() != selected.lighting() || lightingOnly.weather() != selected.weather())
|| weatherOnly.lighting() != selected.lighting() || lightingOnly.weather() != selected.weather()
|| explicit.sky() != selected.sky() || weatherOnly.sky() != selected.sky()
|| lightingOnly.sky() != selected.sky())
throw new IllegalStateException("Environment selection invariant failed");
for (PortSkyStyle sky : PortSkyStyle.values()) {
var page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"sky/" + sky.name().toLowerCase(Locale.ROOT));
var skyOnly = PortEnvironmentSelection.resolve(java.util.List.of(page), seed, terrain);
var allExplicit = PortEnvironmentSelection.resolve(java.util.List.of(
page, explicitPages.get(0), explicitPages.get(1)), seed, terrain);
if (skyOnly.sky() != sky || allExplicit.sky() != sky
|| skyOnly.weather() != selected.weather() || skyOnly.lighting() != selected.lighting()
|| allExplicit.weather() != explicit.weather() || allExplicit.lighting() != explicit.lighting())
throw new IllegalStateException("Sky override/independence invariant failed: " + sky);
}
for (String celestial : java.util.List.of("normal_time", "sun", "moon")) {
var page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "celestial/" + celestial);
var automatic = PortEnvironmentSelection.resolve(java.util.List.of(page), seed, terrain);
if (automatic.sky() != PortSkyStyle.OVERWORLD || automatic.weather() != selected.weather()
|| automatic.lighting() != selected.lighting())
throw new IllegalStateException("Celestial-compatible sky fallback failed: " + celestial);
for (PortSkyStyle sky : PortSkyStyle.values()) {
var skyPage = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"sky/" + sky.name().toLowerCase(Locale.ROOT));
if (PortSkyStyle.resolve(java.util.List.of(page, skyPage), seed) != sky
|| PortSkyStyle.resolve(java.util.List.of(skyPage, page), seed) != sky)
throw new IllegalStateException("Explicit sky lost precedence for " + celestial);
}
}
context.getSource().sendSuccess(() -> Component.literal("Environment probe seed=" + seed
+ " terrain=" + terrain + " selected=" + selected + " explicit=" + explicit
+ " deterministic=true"), false);
+ " deterministic=true skyOverrides=true celestialFallback=true"), false);
return 1;
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
@@ -741,7 +1084,7 @@ public final class PortAgeCommands {
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan,
int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures) {
return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures,
new PortEnvironmentSelection(PortWeatherMode.NORMAL, PortLighting.NORMAL));
new PortEnvironmentSelection(PortWeatherMode.NORMAL, PortLighting.NORMAL, PortSkyStyle.OVERWORLD));
}
private static int createAge(CommandContext<CommandSourceStack> context,
@@ -756,7 +1099,7 @@ public final class PortAgeCommands {
try {
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan,
terrain, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures,
environment.weather(), environment.lighting());
environment.weather(), environment.lighting(), environment.sky());
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
@@ -14,6 +14,21 @@ final class PortAgeLevel extends ServerLevel {
private final WeatherData ageWeather;
private final PortWeatherSettings weatherSettings;
private final boolean cave;
private PortAgeColors colors = PortAgeColors.NATIVE;
private PortDayCycle dayCycle = PortDayCycle.NATIVE;
PortDayCycle dayCycle() { return dayCycle == null ? PortDayCycle.NATIVE : dayCycle; }
void configureDayCycle(PortDayCycle cycle) {
this.dayCycle = cycle;
environmentAttributes().invalidateTickCache();
updateSkyBrightness();
}
PortAgeColors colors() { return colors == null ? PortAgeColors.NATIVE : colors; }
void configureColors(PortAgeColors colors) {
this.colors = colors;
environmentAttributes().invalidateTickCache();
}
PortAgeLevel(MinecraftServer server, ResourceKey<Level> key, LevelStem stem,
long biomeZoomSeed, PortAgeTerrain terrain) {
@@ -0,0 +1,212 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.minecraft.core.Holder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.clock.ClockInstance;
import net.minecraft.world.clock.ClockManager;
import net.minecraft.world.clock.WorldClock;
import net.minecraft.world.clock.WorldClocks;
import net.minecraft.world.level.Level;
/** Authored native celestial controls. Never mutates the shared clock. */
public record PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) {
public static final PortDayCycle NATIVE = new PortDayCycle(false, 1, 0);
public static final Identifier NORMAL_TIME = Identifier.fromNamespaceAndPath("rivencraft", "celestial/normal_time");
public static final Codec<PortDayCycle> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.BOOL.fieldOf("authored").forGetter(PortDayCycle::authored),
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortDayCycle::period),
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortDayCycle::stoppedTime),
Codec.LONG.optionalFieldOf("origin_tick", 0L).forGetter(PortDayCycle::originTick),
Codec.intRange(0, 23999).optionalFieldOf("phase_tick", 0).forGetter(PortDayCycle::phaseTick),
Codec.BOOL.optionalFieldOf("clockwise", true).forGetter(PortDayCycle::clockwise),
Codec.intRange(-1, 359).optionalFieldOf("bearing", -1).forGetter(PortDayCycle::bearing),
Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize)
).apply(instance, PortDayCycle::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortDayCycle> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, PortDayCycle::authored, ByteBufCodecs.DOUBLE, PortDayCycle::period,
ByteBufCodecs.VAR_INT, PortDayCycle::stoppedTime, ByteBufCodecs.VAR_LONG, PortDayCycle::originTick,
ByteBufCodecs.VAR_INT, PortDayCycle::phaseTick, ByteBufCodecs.BOOL, PortDayCycle::clockwise,
ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize, PortDayCycle::new);
private static volatile Map<Identifier, PortDayCycle> clientCycles = Map.of();
public PortDayCycle {
if (!Double.isFinite(period) || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing >= 360
|| !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F)
throw new IllegalArgumentException("Invalid day cycle");
}
public PortDayCycle(boolean authored, double period, int stoppedTime) {
this(authored, period, stoppedTime, 0, 0, true);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, -1);
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, 1F);
}
public static boolean isPeriod(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "period/full", "period/half", "period/double", "period/zero" -> true;
default -> false;
};
}
public static boolean isPhase(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "phase/rising", "phase/zenith", "phase/setting", "phase/nadir" -> true;
default -> false;
};
}
public static boolean isDirection(Identifier page) {
return page.getNamespace().equals("rivencraft") && (page.getPath().equals("direction/clockwise")
|| page.getPath().equals("direction/counterclockwise"));
}
public static boolean isAngle(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "angle/north", "angle/east", "angle/south", "angle/west" -> true;
default -> false;
};
}
public static boolean supports(Identifier page) {
return page.equals(NORMAL_TIME) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page);
}
public static boolean isSize(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "size/small", "size/normal", "size/large" -> true;
default -> false;
};
}
public static boolean validWriting(List<Identifier> pages) {
int target = pages.indexOf(NORMAL_TIME), periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0;
if (target != pages.lastIndexOf(NORMAL_TIME)) return false;
for (int i = 0; i < pages.size(); i++) {
if (isPeriod(pages.get(i)) && (++periods > 1 || i > target)) return false;
if (isPhase(pages.get(i)) && (++phases > 1 || i > target)) return false;
if (isDirection(pages.get(i)) && (++directions > 1 || i > target)) return false;
if (isAngle(pages.get(i)) && (++angles > 1 || i > target)) return false;
if (isSize(pages.get(i)) && (++sizes > 1 || i > target)) return false;
}
return true;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
return compile(pages, seed, 0);
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one Length, Phase, Direction, Angle, and Size before Normal Time");
if (!pages.contains(NORMAL_TIME)) return NATIVE;
double period = 1;
Integer phase = null;
boolean clockwise = true;
int bearing = -1;
float size = 1F;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
int number = i > 0 ? PortAgeColors.number(pages.get(i - 1)) : -1;
if (isPhase(page)) {
int degrees = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "phase/rising" -> 90;
case "phase/zenith" -> 180;
case "phase/setting" -> 270;
default -> 0;
};
// Same conversion as 1.21.1 AgeDimensionService.applyInitialCelestialPhase:
// the written angle starts at Nadir, while native time zero is sunrise.
phase = (degrees * 24000 / 360 + 18000) % 24000;
} else if (isAngle(page)) {
bearing = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
case "angle/east" -> 90;
case "angle/south" -> 180;
case "angle/west" -> 270;
default -> 0;
};
} else if (isSize(page)) {
size = number >= 0 ? (float) (0.25D + number * (2.75D / 24D)) : switch (page.getPath()) {
case "size/small" -> 0.5F;
case "size/large" -> 2F;
default -> 1F;
};
} else if (isDirection(page)) {
clockwise = page.getPath().equals("direction/clockwise");
} else if (isPeriod(page)) period = number >= 0 ? number * 0.08 : switch (page.getPath()) {
case "period/half" -> 0.5;
case "period/double" -> 2;
case "period/zero" -> 0;
default -> 1;
};
}
int stopped = period == 0 ? phase != null ? phase
: new Random(seed ^ 0x4441594359434C45L).nextInt(24000) : 0;
return new PortDayCycle(true, period, stopped, phase != null ? nativeTicks : 0,
phase == null ? 0 : phase, clockwise, bearing, size);
}
public long ticks(long nativeTicks) {
if (!authored) return nativeTicks;
return (long) Math.floor(time(nativeTicks));
}
private double time(long nativeTicks) {
return !authored ? nativeTicks : period == 0 ? stoppedTime
: (clockwise ? 1 : -1) * ((double) nativeTicks - originTick) / period + phaseTick;
}
public static long nativeTicks(Level level) {
return level.clockManager().getInstance(level.registryAccess().lookupOrThrow(
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
}
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
public static PortDayCycle forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.dayCycle();
return level.isClientSide() ? clientCycles.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
}
/** Timelines keep this manager; profiles are read lazily after level construction/catalog sync. */
public static ClockManager timelines(Level level, ClockManager nativeManager) {
Map<Holder<WorldClock>, ClockInstance> wrappers = new HashMap<>();
return definition -> {
if (!definition.is(WorldClocks.OVERWORLD)) return nativeManager.getInstance(definition);
return wrappers.computeIfAbsent(definition, ignored -> new ClockInstance() {
private ClockInstance source() { return nativeManager.getInstance(definition); }
public long totalTicks() { return forLevel(level).ticks(source().totalTicks()); }
public float partialTick() {
PortDayCycle cycle = forLevel(level);
if (!cycle.authored) return source().partialTick();
if (cycle.period == 0) return 0;
double time = cycle.time(source().totalTicks());
return (float) (time - Math.floor(time)
+ (cycle.clockwise ? 1 : -1) * source().partialTick() / cycle.period);
}
public float rate() {
PortDayCycle cycle = forLevel(level);
return !cycle.authored ? source().rate() : cycle.period == 0 ? 0
: (float) ((cycle.clockwise ? 1 : -1) * source().rate() / cycle.period);
}
public boolean isPaused() { return source().isPaused() || forLevel(level).authored && forLevel(level).period == 0; }
});
};
}
}
@@ -6,14 +6,14 @@ import java.util.Random;
import net.minecraft.resources.Identifier;
/** Seed-completes missing environment controllers once, before an Age is saved. */
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting) {
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
return new PortEnvironmentSelection(weather, lighting);
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
}
private static <T> T rankedDefault(Random random, T[] choices) {
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.List;
import java.util.Locale;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
@@ -24,8 +25,33 @@ public enum PortSkyStyle {
};
}
public static PortSkyStyle fromPages(List<Identifier> pages) {
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElse(OVERWORLD);
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
? OVERWORLD : random(seed));
}
private static boolean isCelestial(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
// Only Overworld currently draws bodies in this port. Recognize the
// Sun/Moon intentions too, without marking those pages implemented.
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
}
private static PortSkyStyle random(long seed) {
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
// likely INHERITED and OVERWORLD slots currently share the native
// Overworld presentation here. Do not add unported colors/bodies.
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
return switch (choice) {
case 0, 1 -> OVERWORLD;
case 2 -> NETHER;
case 3 -> END;
case 4 -> STATIC;
default -> throw new IllegalStateException("Invalid random sky choice");
};
}
}
@@ -131,14 +131,32 @@ public final class RuntimeAgeBackend {
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, PortAgeColors.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, colors, PortDayCycle.NATIVE);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky);
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
@@ -215,6 +233,8 @@ public final class RuntimeAgeBackend {
ArchivePaths paths = archivePaths(server, name);
PortLighting lighting = index.lighting(id);
PortSkyStyle sky = index.sky(id);
PortAgeColors colors = index.colors(id);
PortDayCycle dayCycle = index.dayCycle(id);
if (loaded) unloadLoaded(server, id);
// Persist the tombstone before moving files: a crash mid-archive must
// not let an old book silently regenerate this identity.
@@ -232,7 +252,7 @@ public final class RuntimeAgeBackend {
} catch (IOException | RuntimeException exception) {
try {
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky);
terrainFeatures, lighting, sky, colors, dayCycle);
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
@@ -255,6 +275,16 @@ public final class RuntimeAgeBackend {
return index(server).entries();
}
public static Map<Identifier, PortAgeColors> savedColors(MinecraftServer server) {
checkServerThread(server);
return index(server).colors();
}
public static Map<Identifier, PortDayCycle> savedDayCycles(MinecraftServer server) {
checkServerThread(server);
return index(server).dayCycles();
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
@@ -359,13 +389,14 @@ public final class RuntimeAgeBackend {
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, index(server).lighting(id), index(server).sky(id));
terrainFeatures, index(server).lighting(id), index(server).sky(id), index(server).colors(id), index(server).dayCycle(id));
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky) {
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
PortAgeColors colors, PortDayCycle dayCycle) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
@@ -442,6 +473,8 @@ public final class RuntimeAgeBackend {
generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new PortAgeLevel(server, key, stem, biomeZoomSeed, terrain);
((PortAgeLevel) level).configureColors(colors);
((PortAgeLevel) level).configureDayCycle(dayCycle);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
@@ -18,10 +18,22 @@ public final class AgeCatalogClient {
private AgeCatalogClient() {
}
@SubscribeEvent
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
if (listener.getLevel() != null) {
listener.getLevel().environmentAttributes().invalidateTickCache();
listener.getLevel().updateSkyBrightness();
}
listener.levels = payload.dimensions().stream()
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
.collect(Collectors.toUnmodifiableSet());
@@ -22,21 +22,27 @@ public final class PortStaticSky implements AutoCloseable {
private final DynamicTexture texture = new DynamicTexture("Rivencraft static sky", GRID * 6, GRID, false);
private final GpuBuffer cube = buildCube();
private long uploadedFrame = Long.MIN_VALUE;
private int uploadedTint = -1;
public void render(RenderTarget target, GpuBufferSlice fog, long frame) {
if (uploadedFrame != frame) {
public void render(RenderTarget target, GpuBufferSlice fog, long frame, int tint) {
if (tint < 0) tint = 0x929292;
if (uploadedFrame != frame || uploadedTint != tint) {
RandomSource random = RandomSource.create(frame);
var image = texture.getPixels();
for (int face = 0; face < 6; face++) {
for (int y = 0; y < GRID; y++) {
for (int x = 0; x < GRID; x++) {
int gray = 146 * (65 + random.nextInt(160)) / 255;
image.setPixel(face * GRID + x, y, 0xff000000 | gray << 16 | gray << 8 | gray);
int noise = 65 + random.nextInt(160);
int red = (tint >> 16 & 255) * noise / 255;
int green = (tint >> 8 & 255) * noise / 255;
int blue = (tint & 255) * noise / 255;
image.setPixel(face * GRID + x, y, 0xff000000 | red << 16 | green << 8 | blue);
}
}
}
texture.upload();
uploadedFrame = frame;
uploadedTint = tint;
}
RenderSystem.setShaderFog(fog);
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
@@ -1,7 +1,13 @@
package net.dnorberg.rivencraft.client;
/** Carries the extracted animation frame without consulting the live world during drawing. */
/** Carries extracted sky presentation values without live-world reads during drawing. */
public interface StaticSkyState {
long rivencraft$staticFrame();
void rivencraft$staticFrame(long frame);
int rivencraft$skyTint();
void rivencraft$skyTint(int color);
int rivencraft$orbitBearing();
void rivencraft$orbitBearing(int bearing);
float rivencraft$sunSize();
void rivencraft$sunSize(float size);
}
@@ -0,0 +1,32 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.age.PortAgeColors;
import net.minecraft.world.attribute.EnvironmentAttributeSystem;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnvironmentAttributeSystem.Builder.class)
public class AgeColorAttributesMixin {
@Inject(method = "addDefaultLayers", at = @At("RETURN"))
private void rivencraft$colors(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return;
var builder = cir.getReturnValue();
builder.addTimeBasedLayer(EnvironmentAttributes.SKY_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).sky();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
builder.addTimeBasedLayer(EnvironmentAttributes.FOG_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).sky();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
builder.addTimeBasedLayer(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).ambient();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
});
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.age.PortDayCycle;
import net.minecraft.world.attribute.EnvironmentAttributeSystem;
import net.minecraft.world.attribute.WeatherAttributes;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnvironmentAttributeSystem.Builder.class)
public class AgeDayTimelineMixin {
@Inject(method = "addDynamicLayers", at = @At("HEAD"), cancellable = true)
private void rivencraft$dayCycle(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return;
var builder = (EnvironmentAttributeSystem.Builder) (Object) this;
var clocks = PortDayCycle.timelines(level, level.clockManager());
level.dimensionType().timelines().forEach(timeline -> builder.addTimelineLayer(timeline, clocks));
if (level.canHaveWeather()) WeatherAttributes.addBuiltinLayers(builder, WeatherAttributes.WeatherAccess.from(level));
cir.setReturnValue(builder);
}
}
@@ -15,12 +15,17 @@ import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.ModifyArg;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(SkyRenderer.class)
public class StaticSkyRendererMixin {
@Shadow @Final private RenderTarget renderTarget;
@Unique private PortStaticSky rivencraft$staticSky;
@Unique private int rivencraft$endTint = -1;
@Unique private int rivencraft$orbitBearing = -1;
@Unique private float rivencraft$sunSize = 1F;
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void rivencraft$extract(ClientLevel level, float partialTicks, Camera camera,
@@ -29,6 +34,10 @@ public class StaticSkyRendererMixin {
key.identifier().getNamespace().equals("rivencraft")
&& key.identifier().getPath().endsWith("_sky_static")).orElse(false);
((StaticSkyState) state).rivencraft$staticFrame(enabled ? level.getGameTime() / 2 : Long.MIN_VALUE);
((StaticSkyState) state).rivencraft$skyTint(net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).sky());
var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forLevel(level);
((StaticSkyState) state).rivencraft$orbitBearing(cycle.authored() ? cycle.bearing() : -1);
((StaticSkyState) state).rivencraft$sunSize(cycle.authored() ? cycle.sunSize() : 1F);
// Only the extracted presentation uses END to schedule the sky pass. The actual
// dimension has NONE, so it never acquires End flashes or End gameplay behavior.
if (enabled) state.skybox = DimensionType.Skybox.END;
@@ -37,12 +46,52 @@ public class StaticSkyRendererMixin {
@Inject(method = "render", at = @At("HEAD"), cancellable = true)
private void rivencraft$render(GpuBufferSlice fog, SkyRenderState state, CallbackInfo ci) {
long frame = ((StaticSkyState) state).rivencraft$staticFrame();
rivencraft$endTint = ((StaticSkyState) state).rivencraft$skyTint();
rivencraft$orbitBearing = ((StaticSkyState) state).rivencraft$orbitBearing();
rivencraft$sunSize = ((StaticSkyState) state).rivencraft$sunSize();
if (frame == Long.MIN_VALUE) return;
if (rivencraft$staticSky == null) rivencraft$staticSky = new PortStaticSky();
rivencraft$staticSky.render(renderTarget, fog, frame);
rivencraft$staticSky.render(renderTarget, fog, frame, rivencraft$endTint);
ci.cancel();
}
@ModifyArg(method = "renderSunMoonAndStars", at = @At(value = "INVOKE", target =
"Lcom/mojang/blaze3d/vertex/PoseStack;rotateDegrees(Lcom/mojang/math/Axis;F)V", ordinal = 0), index = 1)
private float rivencraft$orbitAngle(float nativeBearing) {
return rivencraft$orbitBearing < 0 ? nativeBearing : rivencraft$orbitBearing;
}
@ModifyArg(method = "renderSun", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/SkyRenderer;applyCelestialBodyTransform(Lcom/mojang/blaze3d/vertex/PoseStack;FF)Lorg/joml/Matrix4f;"), index = 2)
private float rivencraft$scaleSun(float nativeSize) {
// 1.21.1 Normal Time applies Size to its sun; its paired moon remains 1x.
return nativeSize * rivencraft$sunSize;
}
@Inject(method = "renderSunriseAndSunset", at = @At("HEAD"))
private void rivencraft$rotateHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$orbitBearing < 0) return;
pose.pushPose();
pose.rotateDegrees(com.mojang.math.Axis.YP, rivencraft$orbitBearing + 90);
}
@Inject(method = "renderSunriseAndSunset", at = @At("TAIL"))
private void rivencraft$restoreHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$orbitBearing >= 0) pose.popPose();
}
@Redirect(method = "renderEndSky", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/DynamicGpuData;writeTransform(Lorg/joml/Matrix4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"))
private GpuBufferSlice rivencraft$endColor(net.minecraft.client.renderer.DynamicGpuData uniforms,
org.joml.Matrix4f matrix) {
return rivencraft$endTint < 0 ? uniforms.writeTransform(matrix) : uniforms.writeTransform(matrix,
new org.joml.Vector4f(net.dnorberg.rivencraft.age.PortAgeColors.vector(rivencraft$endTint), 1));
}
@Inject(method = "close", at = @At("HEAD"))
private void rivencraft$close(CallbackInfo ci) {
if (rivencraft$staticSky != null) {
@@ -11,9 +11,23 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(SkyRenderState.class)
public class StaticSkyStateMixin implements StaticSkyState {
@Unique private long rivencraft$frame = Long.MIN_VALUE;
@Unique private int rivencraft$tint = -1;
@Unique private int rivencraft$bearing = -1;
@Unique private float rivencraft$sunSize = 1F;
public long rivencraft$staticFrame() { return rivencraft$frame; }
public void rivencraft$staticFrame(long frame) { rivencraft$frame = frame; }
public int rivencraft$skyTint() { return rivencraft$tint; }
public void rivencraft$skyTint(int color) { rivencraft$tint = color; }
public int rivencraft$orbitBearing() { return rivencraft$bearing; }
public void rivencraft$orbitBearing(int bearing) { rivencraft$bearing = bearing; }
public float rivencraft$sunSize() { return rivencraft$sunSize; }
public void rivencraft$sunSize(float size) { rivencraft$sunSize = size; }
@Inject(method = "reset", at = @At("TAIL"))
private void rivencraft$reset(CallbackInfo ci) { rivencraft$frame = Long.MIN_VALUE; }
private void rivencraft$reset(CallbackInfo ci) {
rivencraft$frame = Long.MIN_VALUE;
rivencraft$tint = -1;
rivencraft$bearing = -1;
rivencraft$sunSize = 1F;
}
}
@@ -141,8 +141,10 @@ public final class PortBookNetworking {
PortAuthoredWriting.dungeonCount(pages),
PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed),
environment.weather(), environment.lighting(),
net.dnorberg.rivencraft.age.PortSkyStyle.fromPages(pages));
environment.weather(), environment.lighting(), environment.sky(),
net.dnorberg.rivencraft.age.PortAgeColors.compile(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.compile(pages, seed,
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
@@ -55,6 +55,8 @@ public final class PortAuthoredWriting {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
var entry = PortSymbolPages.find(page);
if (entry == null) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null
|| PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true;
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
@@ -70,6 +72,8 @@ public final class PortAuthoredWriting {
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false;
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
boolean terrain = false;
int biomes = 0;
int controllers = 0;
@@ -91,7 +95,10 @@ public final class PortAuthoredWriting {
if (pendingMaterial != null && !(pendingMaterial.equals("rivencraft:block")
&& (page.equals(ROCKS) || PortTerrainFeaturePlan.acceptsMaterial(page))
|| pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false;
if (isTerrain(page) && !terrain) {
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) {
// Shared color groups can precede a sky/ambient target or tint the sky at the end.
} else if (isTerrain(page) && !terrain) {
terrain = true;
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
// Sky presentation, independent of terrain and illumination.
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.lighting.ambient_color","description":"symbol.rivencraft.lighting.ambient_color.description","category":"rivencraft:lighting","poem":"Ethereal Red Sustain Balance"}
@@ -3,7 +3,7 @@
"minVersion": "0.8",
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin"],
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"],
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"],
"injectors": {"defaultRequire": 1}
}