Compare commits

...
3 Commits
Author SHA1 Message Date
dnorberg 2b2605b277 Add optional authored Ruins structures to Ages (#91)
Build / build (push) Canceled after 0s
2026-10-22 15:17:17 -04:00
dnorberg 2f1e6bd6f2 Clear book receptacle model when removing book
Build / build (push) Canceled after 0s
2026-10-22 03:51:42 -04:00
dnorberg 9d5ff2a2cb Complete authored sky colors and eclipses (#44)
Build / build (push) Canceled after 0s
2026-10-22 03:40:10 -04:00
83 changed files with 1124 additions and 139 deletions

No files matched your search

+15 -6
View File
@@ -124,14 +124,20 @@ the top or underside of one of its blocks. Insert a bound Linking or Descriptive
Book to activate it; the book remains visible on the receptacle. A Linking Book aimed at its
own dimension still opens a visual portal, but travel requires Intra Linking.
Sky writing includes eight color pages and a Static Sky page. Multiple sky-color
pages blend their colors, tinting the Age sky, book preview, and the fallback
surface of portals linked there. Multiple celestial-color pages blend for the
next written sun or moon. An Age may have no suns or moons at all;
Static Sky animates the sky with television-like noise. Sun and Moon pages add
Sky writing has shared Red, Green, Blue, White, and Black pages plus 25 D'ni
number pages (0–24). Put colors before a Sky, Sun, or Moon page to color that
target; trailing colors tint the sky. A number immediately before a color sets
its strength to number × 10. Without a number, a color gets a seeded random
strength from 0–24. RGB contributions add in any order, White brightens all
channels, Black subtracts from all channels, and omitted channels remain zero.
The resulting sky color also tints book previews and fallback portal surfaces.
An Age may have no suns or moons at all; Static Sky animates with fine-grained
television-like noise. Sun and Moon pages add
independent bodies; preceding color, size, angle, period, phase, and direction
pages configure the next body. The third and each subsequent sun add increasing
instability. Newly generated page-free Ages also choose a deterministic sky,
instability. Overlapping moon and sun discs produce an eclipse: the moon darkens,
the sun's color brightens at close alignment, and world and sky lighting dim.
Newly generated page-free Ages also choose a deterministic sky,
color, and celestial arrangement from their Age seed; older saved Ages retain
their previous sky.
@@ -227,6 +233,9 @@ Data-pack runtime profiles are documented in
- NeoForge 21.1.264 or newer in the 21.1 line
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
Ruins is optional. When installed, its configured structures have individual
authorable pages; see [Ruins compatibility](docs/RUINS_COMPATIBILITY.md).
Create is optional. When installed, Rivencraft uses its Ponder renderer for
book previews when Immersive Portals is absent. Immersive Portals is also
optional and takes priority in the default `AUTO` mode, providing a live
+5
View File
@@ -245,6 +245,11 @@ dependencies {
// Keep them on the compile classpath without forcing either mod at runtime.
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
// Ruins is optional; its pre-spawn event is only registered when Ruins is loaded.
compileOnly 'curse.maven:ruins-227873:6391825'
if (providers.gradleProperty('withRuins').map { it.toBoolean() }.orElse(false).get()) {
localRuntime 'curse.maven:ruins-227873:6391825'
}
// TerraBlender's region API is only used by the guarded compat adapter.
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+66
View File
@@ -0,0 +1,66 @@
# Optional Ruins template pages (issue #91)
Rivencraft supports Ruins 1.21.1.2NF as an optional mod. When it is installed,
every `.tml` template in Ruins' active `config/ruins_config/<generic-or-biome>/`
folders receives a separate Ruins page in Rivencraft's creative and JEI page
feeds. New templates added to Ruins' config appear after a restart; removing a
template makes its page unavailable to new books. With Ruins absent, no Ruins
pages or structure integration are active.
An authored Age with Ruins pages is admitted through Ruins' dimension gate.
Rivencraft also admits the authored templates through their individual
`dimensionsToSpawnIn` gates, which usually list only `overworld` and would
otherwise prevent them from loading in an Age. Ruins still owns chunk
inspection, spawn chance, surface validation, biome pools, and spacing.
Rivencraft filters Ruins' weighted
template choice to the exact pages in that Age and vetoes an unauthorized
template in Ruins' pre-spawn event. A page therefore selects *which* template
may appear; it does not guarantee a structure in every chunk. Manual
`/testruin` placements are unaffected.
Repeating a template page multiplies that template's Ruins selection weight by
its page count and adds one spawn attempt per chunk for each duplicate page.
The extra attempts are capped at 32 per chunk (or Ruins' configured count if
higher). Distinct template pages do not add attempts, and Ruins' spacing and
surface checks still apply. As with Rivencraft's standard
structure pages, the first three copies of each template are instability-free;
each additional copy adds 100 instability. Ruins retains its own biome and
surface checks, so the standard structure forced-biome penalty does not apply.
Ruins' event identifies a template only by its filename. If two active folders
contain the same `.tml` basename, Rivencraft cannot distinguish them and
disables all Ruins pages for that configuration. Give each template a unique
filename to restore support. Ruins must also be able to parse the template and
find a suitable surface in the selected biome; a page cannot repair an invalid
or biome-incompatible template.
The adapter and dimension/selection mixin are version-sensitive to Ruins
1.21.1.2NF. The published Rivencraft jar does not include Ruins. To include it
in a development run, use `-PwithRuins=true`.
## Manual check
1. Start a client with Ruins 1.21.1.2NF and Rivencraft 0.6.9.3. Confirm the
Ruins Pages tab contains one page per configured `.tml` file, and search a
distinctive template name in JEI.
2. Bind a new Descriptive Book with a Link Panel, Standard Terrain, a suitable
surface biome, and one Ruins page (for example ZombieHut). Enter the Age and
explore *unvisited* chunks. Ruins marks inspected chunks and will not retry
chunks explored under the earlier broken build. Only the selected Ruins
template should appear. Ruins'
own chance and distance settings still determine how far exploration takes.
3. For duplicate weighting, bind an Age with one copy of one template page and
three copies of a second template page valid in the same biome. Across enough
new candidate chunks, the second should be selected more often relative to
the first, and there should be two additional spawn attempts per inspected
chunk. Four copies of one template should add 100 instability, visible
through the OP Age instability diagnostic, while three copies add none.
4. Make another Age without a Ruins page and confirm Ruins structures do not
appear there. Restart the game and confirm the authored Age keeps its page
selection. Then remove Ruins and confirm Rivencraft starts normally without
a Ruins tab.
The Ruins-enabled GameTest server passed all 90 required tests and loaded the
template-dimension and attempt mixins. Its test world was removed afterward.
Use the client check above to verify actual Ruins generation and the increased
attempt rate.
+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.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.8.7
mod_version=0.6.9.3
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.network.ModNetworking;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
@@ -43,6 +44,7 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
RuinsCompatibility.register();
AgeStatusEffectProviders.register();
AgeDecayProviders.register();
AgeHazardProviders.register();
@@ -82,6 +84,8 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) ->
RuinsCompatibility.onServerStopping());
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
@@ -36,6 +36,7 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
@@ -44,10 +45,11 @@ import net.minecraft.world.phys.shapes.VoxelShape;
public final class BookReceptacleBlock extends BaseEntityBlock {
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.FACING;
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
public BookReceptacleBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@@ -64,7 +66,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
};
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING);
builder.add(FACING, HAS_BOOK);
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookReceptacleBlockEntity(pos, state);
@@ -153,7 +155,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
CrystalPortalNetwork.shutdown(server, pos);
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
ItemStack book = holder.takeBook();
ItemStack book = holder.takeBookOnBreak();
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
pos.getZ()+0.5, book));
}
@@ -2,7 +2,9 @@ package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeEnvironmentCompiler;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.nbt.NbtOps;
import net.minecraft.core.BlockPos;
@@ -52,18 +54,36 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
}
public ItemStack takeBook() {
return removeBook(true);
}
/** The block is already being replaced, so do not write its state back into the world. */
public ItemStack takeBookOnBreak() {
return removeBook(false);
}
private ItemStack removeBook(boolean notify) {
ItemStack result = book;
book = ItemStack.EMPTY;
destination = null;
skyColor = 0xFFFFFF;
sync();
if (notify) sync();
else setChanged();
return result;
}
private void sync() {
setChanged();
if (level != null && !level.isClientSide)
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
if (level instanceof ServerLevel server) {
BlockState state = server.getBlockState(worldPosition);
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return;
boolean hasBook = !book.isEmpty();
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) {
state = state.setValue(BookReceptacleBlock.HAS_BOOK, hasBook);
server.setBlock(worldPosition, state, 2);
}
server.sendBlockUpdated(worldPosition, state, state, 2);
}
}
@Override
@@ -98,6 +118,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
BookReceptacleBlockEntity holder) {
if (!(world instanceof ServerLevel level)) return;
// Reconcile receptacles saved before HAS_BOOK existed, including an
// occupied receptacle restored from an older pre-release world.
boolean hasBook = !holder.book.isEmpty();
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) holder.sync();
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
// Startup maintenance must never turn a saved portal off while its
// chunks and block entities are still arriving. Structural edits are
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
@@ -84,6 +85,7 @@ public final class AgeDimensionService {
if (level != null) {
applyCelestialClock(level, age);
SereneSeasonsCompatibility.registerAge(server, age);
RuinsCompatibility.registerAge(age);
}
}
synchronizeImmersivePortals(server);
@@ -141,6 +143,7 @@ public final class AgeDimensionService {
if (existing != null) {
applyCelestialClock(existing, definition);
SereneSeasonsCompatibility.registerAge(server, definition);
RuinsCompatibility.registerAge(definition);
return existing;
}
@@ -157,6 +160,7 @@ public final class AgeDimensionService {
} else {
applyCelestialClock(loaded, definition);
SereneSeasonsCompatibility.registerAge(server, definition);
RuinsCompatibility.registerAge(definition);
synchronizeImmersivePortals(server);
}
return loaded;
@@ -108,11 +108,7 @@ public final class AgeEnvironmentCompiler {
/** An authored color is fixed; -1 means the biome or selected vanilla sky supplies it. */
public static int skyColor(AgeGenerationPlan plan) {
if (plan == null) return -1;
return AgeSkyProfile.mixColors(plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY_COLOR)
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
.map(AgeSkyProfile::color).toList());
return AgeSkyProfile.authoredSkyColor(plan);
}
public static int selectedSkyColor(AgeGenerationPlan plan, ResourceLocation fallback, int biomeSkyColor) {
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Random;
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
@@ -235,6 +236,9 @@ public final class AgeFeatureSelection {
}
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
if (RuinsTemplatePages.isSymbol(choice))
return RuinsTemplatePages.contains(choice) ? null : "Ruins template " + choice
+ " is unavailable; restore Ruins and its template before loading this Age";
if (AgeStructureSelection.feature(choice))
return AgeStructureSelection.error(choice, profile, null, registries);
if (choice.equals(AgeDenseOres.ID))
@@ -188,7 +188,7 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
String raw = item.getString("Value");
SymbolSemantics.Value value = switch (role) {
case TERRAIN, BIOME, BLOCK, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER ->
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER, COLOR_NUMBER ->
new SymbolSemantics.NumberValue(Double.parseDouble(raw));
default -> new SymbolSemantics.ChoiceValue(raw);
};
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.minecraft.core.RegistryAccess;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
@@ -150,9 +151,12 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|| !(symbol.semantics().value()
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
String name = COUNTED_STRUCTURES.get(resource.id());
if (name == null && RuinsTemplatePages.isSymbol(resource.id()))
name = RuinsTemplatePages.fallbackName(resource.id());
if (name == null) continue;
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
if (COUNTED_STRUCTURES.containsKey(resource.id())
&& AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
FORCED_STRUCTURE_BIOME_COST,
name + " occurrence " + count + " is forced into an unsupported biome"));
@@ -168,7 +172,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
static int featureCost(ResourceLocation feature, int occurrence) {
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
return (COUNTED_STRUCTURES.containsKey(feature) || RuinsTemplatePages.isSymbol(feature)) && occurrence > 3
? EXCESS_LEGACY_STRUCTURE_COST : 0;
}
@@ -125,9 +125,9 @@ public final class AgePersistenceGameTests {
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
helper.assertValueEqual(stableAge.instability().score(), 0,
helper.assertValueEqual(nonCelestialInstability(stableAge), 0,
"Missing seeded branches do not add instability");
helper.assertValueEqual(defectiveAge.instability().score(), 600,
helper.assertValueEqual(nonCelestialInstability(defectiveAge), 600,
"Out-of-order and repeated terrain pages each add their saved penalty");
helper.assertTrue(defectiveAge.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
@@ -184,9 +184,9 @@ public final class AgePersistenceGameTests {
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
helper.assertValueEqual(three.instability().score(), 0,
helper.assertValueEqual(nonCelestialInstability(three), 0,
"The first three " + structure + " pages retain Legacy's zero cost");
helper.assertValueEqual(four.instability().score(), 100,
helper.assertValueEqual(nonCelestialInstability(four), 100,
"The fourth " + structure + " page must add Legacy's 100-point cost");
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
@@ -207,6 +207,13 @@ public final class AgePersistenceGameTests {
"Modern repeated structures must use the same deterministic scoring path");
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
"A Legacy-neutral feature must remain neutral when repeated");
ResourceLocation ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId("ZombieHut.tml");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 1), 0,
"The first Ruins template page is free like a standard structure");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 3), 0,
"The first three copies of a Ruins page are free");
helper.assertValueEqual(AgeInstability.featureCost(ruin, 4), 100,
"Each Ruins page beyond the third adds 100 instability");
OrderedWriting duplicateBright = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
@@ -216,7 +223,7 @@ public final class AgePersistenceGameTests {
));
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
helper.assertValueEqual(bright.instability().score(), 1500,
helper.assertValueEqual(nonCelestialInstability(bright), 1500,
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
helper.assertValueEqual(bright.instability().breakdown().stream()
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
@@ -420,6 +427,13 @@ public final class AgePersistenceGameTests {
helper.succeed();
}
/** Isolate the writing-cost assertion from seed-completed extra-sun instability. */
private static int nonCelestialInstability(AgeDefinition age) {
return age.instability().breakdown().stream()
.filter(part -> !part.reason().equals("extra_sun"))
.mapToInt(AgeInstability.Contribution::points).sum();
}
private static CompoundTag entry(
UUID id,
String name,
@@ -48,7 +48,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
public static AgeSkyProfile compile(AgeGenerationPlan plan, AgeTraits traits) {
if (plan == null) return new AgeSkyProfile(Style.INHERITED, -1, List.of());
Style style = Style.INHERITED;
int color = AgeEnvironmentCompiler.skyColor(plan);
int color = authoredSkyColor(plan);
boolean customBodies = hasCustomCelestials(plan);
List<Body> bodies = new ArrayList<>();
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
@@ -58,18 +58,32 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
float phase = seeded.celestialPhase();
float size = 1.0F;
List<Integer> bodyColors = new ArrayList<>();
ColorAccumulator pendingColors = new ColorAccumulator();
Integer colorMultiplier = 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;
switch (semantics.role()) {
case SKY -> style = switch (((SymbolSemantics.ChoiceValue) semantics.value()).id()) {
case "overworld" -> Style.OVERWORLD;
case "nether" -> Style.NETHER;
case "end" -> Style.END;
case "static" -> Style.STATIC;
default -> throw new IllegalArgumentException("Unknown sky style");
};
case SKY -> {
pendingColors.clear();
style = switch (((SymbolSemantics.ChoiceValue) semantics.value()).id()) {
case "overworld" -> Style.OVERWORLD;
case "nether" -> Style.NETHER;
case "end" -> Style.END;
case "static" -> Style.STATIC;
default -> throw new IllegalArgumentException("Unknown sky style");
};
}
case COLOR_NUMBER -> colorMultiplier = (int) ((SymbolSemantics.NumberValue)
semantics.value()).amount();
case COLOR -> {
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
colorMultiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : colorMultiplier);
colorMultiplier = null;
}
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);
@@ -80,7 +94,8 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
.id().equals("clockwise");
case CELESTIAL -> {
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
int bodyColor = bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
int bodyColor = pendingColors.written() ? pendingColors.rgb()
: bodyColors.isEmpty() ? 0 : mixColors(bodyColors);
if (choice.equals("normal_time")) {
if (customBodies) {
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
@@ -96,6 +111,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
phase = seeded.celestialPhase();
size = 1.0F;
bodyColors.clear();
pendingColors.clear();
clockwise = true;
}
default -> { }
@@ -124,6 +140,74 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
| (int) ((blue + count / 2) / count);
}
/** Shared color pages affect the next Sky or celestial page; trailing colors tint the sky. */
public static int authoredSkyColor(AgeGenerationPlan plan) {
if (plan == null) return -1;
ColorAccumulator pending = new ColorAccumulator();
Integer multiplier = null;
int skyColor = -1;
List<Integer> legacyColors = new ArrayList<>();
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
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) multiplier = null;
switch (semantics.role()) {
case COLOR_NUMBER -> multiplier = (int) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case COLOR -> {
pending.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
multiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : multiplier);
multiplier = null;
}
case SKY -> {
if (pending.written()) skyColor = pending.rgb();
pending.clear();
}
case CELESTIAL -> pending.clear();
case SKY_COLOR -> legacyColors.add(color(
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
default -> { }
}
}
if (pending.written()) skyColor = pending.rgb();
return skyColor != -1 ? skyColor : mixColors(legacyColors);
}
private static int randomColorNumber(long ageSeed, int pageIndex) {
return new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (pageIndex + 1L))).nextInt(25);
}
private static final class ColorAccumulator {
private int red;
private int green;
private int blue;
private boolean written;
void add(String color, int multiplier) {
int amount = multiplier * 10;
written = true;
switch (color) {
case "red" -> red += amount;
case "green" -> green += amount;
case "blue" -> blue += amount;
case "white" -> { red += amount; green += amount; blue += amount; }
case "black" -> { red -= amount; green -= amount; blue -= amount; }
default -> throw new IllegalArgumentException("Unknown shared color " + color);
}
}
boolean written() { return written; }
int rgb() {
return clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
}
void clear() { red = green = blue = 0; written = false; }
private static int clamp(int channel) { return Math.max(0, Math.min(255, channel)); }
}
private static int randomColor(Random random) {
int count = 1 + random.nextInt(3);
List<Integer> colors = new ArrayList<>(count);
@@ -132,12 +216,30 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
}
public static boolean hasCustomCelestials(AgeGenerationPlan plan) {
return plan != null && plan.activeSymbols().stream().anyMatch(symbol ->
return plan != null && (hasColorForCelestial(plan) || plan.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL
&& !((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.DIRECTION_MODIFIER);
|| symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER));
}
private static boolean hasColorForCelestial(AgeGenerationPlan plan) {
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
boolean pending = false;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
if (!active.contains(symbol.index())) continue;
switch (symbol.semantics().role()) {
case COLOR -> pending = true;
case SKY -> pending = false;
case CELESTIAL -> {
if (pending) return true;
pending = false;
}
default -> { }
}
}
return false;
}
public boolean custom() {
@@ -154,12 +256,53 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
/ (24000.0D * body.period()) + body.phase();
float altitude = (float) Math.cos(orbit * Math.PI * 2.0D);
if (altitude <= 0.0F) continue;
if (body.kind() == Kind.SUN) sunlight += altitude;
if (body.kind() == Kind.SUN) sunlight += altitude * (1.0F - 0.85F * eclipseFor(body, dayTime));
else moonlight += altitude * 0.18F;
}
return Math.min(1.0F, sunlight > 0.0F ? sunlight : moonlight);
}
/** Disc overlap in the rendered sky, from first contact (0) to centered alignment (1). */
public float eclipseFor(Body body, double dayTime) {
float strongest = 0.0F;
net.minecraft.world.phys.Vec3 bodyDirection = direction(body, dayTime);
for (Body other : bodies) {
if (other.kind() == body.kind()) continue;
double dot = Math.max(-1.0D, Math.min(1.0D,
bodyDirection.dot(direction(other, dayTime))));
double separation = Math.acos(dot);
// The outer 32x32 quad is only glow. The opaque body occupies its
// central 8x8 pixels, so contact must use one quarter of its size.
double radius = Math.atan((body.kind() == Kind.SUN ? 30.0D : 20.0D)
* body.size() * 0.25D / 100.0D)
+ Math.atan((other.kind() == Kind.SUN ? 30.0D : 20.0D)
* other.size() * 0.25D / 100.0D);
if (radius > 0.0D) strongest = Math.max(strongest,
(float) Math.max(0.0D, 1.0D - separation / radius));
}
return strongest;
}
public float eclipseAt(double dayTime) {
float strongest = 0.0F;
for (Body body : bodies) {
if (body.kind() == Kind.SUN && direction(body, dayTime).y > 0.0D)
strongest = Math.max(strongest, eclipseFor(body, dayTime));
}
return strongest;
}
private static net.minecraft.world.phys.Vec3 direction(Body body, double dayTime) {
double orbit = body.period() == 0.0D ? body.phase()
: (body.clockwise() ? 1.0D : -1.0D) * dayTime
/ (24000.0D * body.period()) + body.phase();
double tilt = orbit * Math.PI * 2.0D;
double bearing = Math.toRadians(body.angle());
double lateral = Math.sin(tilt);
return new net.minecraft.world.phys.Vec3(
Math.sin(bearing) * lateral, Math.cos(tilt), Math.cos(bearing) * lateral);
}
public static int color(String name) {
return switch (name) {
case "red" -> COLORS[0];
@@ -52,10 +52,11 @@ public final class EnvironmentWritingGameTests {
public static void environmentPagesEnterCreativeAndJeiFeed(GameTestHelper helper) {
Set<String> expected = Set.of(
"lighting/normal", "lighting/dark", "lighting/bright",
"sky/overworld", "sky/nether", "sky/end", "sky/static", "sky_color/purple",
"sky/overworld", "sky/nether", "sky/end", "sky/static", "color/red", "color/green",
"color/blue", "color/black", "color/white", "number/0", "number/24",
"weather/normal", "weather/fast", "weather/slow", "weather/off",
"weather/always", "weather/storm", "celestial/normal_time", "celestial/sun",
"celestial/moon", "celestial_color/red", "size/large", "angle/east",
"celestial/moon", "size/large", "angle/east",
"direction/clockwise", "direction/counterclockwise",
"season/no_seasons", "season/seasons", "season/eternal_winter",
"season/eternal_summer", "season/eternal_fall", "season/eternal_spring",
@@ -69,6 +70,9 @@ public final class EnvironmentWritingGameTests {
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(expected)
&& creative.containsAll(expected),
"Every supported environment page must enter the creative index JEI also consumes");
helper.assertValueEqual(PageItem.bundledCreativeSymbols().stream()
.filter(path -> path.startsWith("number/")).count(), 25L,
"Creative and JEI must expose all 25 D'ni number pages");
for (String path : expected) {
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", path);
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id))
@@ -140,8 +144,8 @@ public final class EnvironmentWritingGameTests {
@GameTest(template = "empty")
public static void authoredStaticSkyColorSurvivesWritingAndFeedsPreview(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/static"),
page("sky_color/purple"), page("celestial/normal_time")));
WritingEntry.linkPanel(), page("terrain/standard"), page("number/20"),
page("color/blue"), page("sky/static"), page("celestial/normal_time")));
AgeTraits seeded = AgeTraits.select(44L);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 44L, AgeGenerationProfiles.STANDARD,
seeded, helper.getLevel().registryAccess());
@@ -152,15 +156,15 @@ public final class EnvironmentWritingGameTests {
"The static sky must select Rivencraft's custom renderer");
helper.assertValueEqual(AgeEnvironmentCompiler.selectedSkyColor(plan,
BuiltinDimensionTypes.OVERWORLD_EFFECTS, 0xFF77AAFF),
0x9870B9, "The authored color must tint the sky and preview consistently");
0x0000C8, "The authored color must tint the sky and preview consistently");
AgeGenerationPlan restored = AgeGenerationPlan.load(plan.save());
helper.assertTrue(AgeEnvironmentCompiler.staticSky(restored),
"A saved Age must retain its static sky choice");
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(restored), 0x9870B9,
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(restored), 0x0000C8,
"A saved Age must retain its sky color choice");
AgeGenerationPlan coloredEnd = AgeGenerationPlan.compile(new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/end"),
page("sky_color/red"), page("celestial/normal_time"))),
WritingEntry.linkPanel(), page("terrain/standard"), page("number/15"),
page("color/red"), page("sky/end"), page("celestial/normal_time"))),
45L, AgeGenerationProfiles.STANDARD, AgeTraits.select(45L),
helper.getLevel().registryAccess());
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(coloredEnd,
@@ -169,9 +173,10 @@ public final class EnvironmentWritingGameTests {
"A color page must make non-Overworld skies renderable by the custom sky effect");
helper.assertValueEqual(AgeEnvironmentCompiler.selectedSkyColor(coloredEnd,
BuiltinDimensionTypes.OVERWORLD_EFFECTS, 0xFF77AAFF),
0xB74A50, "A color page must override the selected sky type's default tint");
0x960000, "A color page must override the selected sky type's default tint");
helper.assertTrue(PageItem.bundledCreativeSymbols().contains("sky/static")
&& PageItem.bundledCreativeSymbols().contains("sky_color/purple"),
&& PageItem.bundledCreativeSymbols().contains("color/blue")
&& PageItem.bundledCreativeSymbols().contains("number/24"),
"New sky pages must be available through the Creative and JEI page catalog");
helper.succeed();
}
@@ -179,18 +184,17 @@ public final class EnvironmentWritingGameTests {
@GameTest(template = "empty")
public static void skyAndCelestialColorsBlendWithoutRequiringBodies(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/overworld"),
page("sky_color/red"), page("sky_color/blue"),
page("celestial_color/yellow"), page("celestial_color/blue"), page("celestial/sun")));
WritingEntry.linkPanel(), page("terrain/standard"),
page("number/10"), page("color/red"), page("number/5"), page("color/blue"),
page("sky/overworld"), page("number/12"), page("color/green"),
page("number/7"), page("color/blue"), page("celestial/sun")));
helper.assertTrue(WritingGrammar.parse(writing).canCreate(),
"Two sky colors and two colors for one sun must be valid writing");
AgeTraits traits = AgeTraits.select(46L);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 46L,
AgeGenerationProfiles.STANDARD, traits, helper.getLevel().registryAccess());
int skyColor = AgeSkyProfile.mixColors(List.of(
AgeSkyProfile.color("red"), AgeSkyProfile.color("blue")));
int sunColor = AgeSkyProfile.mixColors(List.of(
AgeSkyProfile.color("yellow"), AgeSkyProfile.color("blue")));
int skyColor = 0x640032;
int sunColor = 0x007846;
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(plan), skyColor,
"Sky color pages must blend without discarding either page");
AgeSkyProfile profile = AgeSkyProfile.compile(plan, traits);
@@ -206,6 +210,58 @@ public final class EnvironmentWritingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void colorNumbersUseDniDigitsAndMissingChannelsRemainZero(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("number/12"), page("color/red"),
page("number/5"), page("color/white"), page("number/3"), page("color/black"),
page("sky/overworld")));
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 49L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(49L), helper.getLevel().registryAccess());
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(plan), 0x8C1414,
"White adds and Black subtracts from every channel; missing green and blue start at zero");
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(AgeGenerationPlan.load(plan.save())), 0x8C1414,
"D'ni color multipliers must remain frozen in a saved Age plan");
OrderedWriting unnumbered = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("color/green"), page("sky/overworld")));
AgeGenerationPlan randomPlan = AgeGenerationPlan.compile(unnumbered, 50L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(50L), helper.getLevel().registryAccess());
int color = AgeEnvironmentCompiler.skyColor(randomPlan);
helper.assertTrue((color & 0xFF00FF) == 0 && ((color >> 8) & 255) <= 240
&& ((color >> 8) & 255) % 10 == 0,
"An unnumbered color gets one seeded base-25 digit and leaves omitted channels at zero");
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(AgeGenerationPlan.load(randomPlan.save())), color,
"A random color digit must not change when an Age is reloaded");
helper.succeed();
}
@GameTest(template = "empty")
public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) {
AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN,
0xCC8844, 1.0F, 0.0F, 1.0D, 0.0F, true);
AgeSkyProfile.Body moon = new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.0F, true);
AgeSkyProfile clear = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA, List.of(sun));
AgeSkyProfile eclipse = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
List.of(sun, moon));
helper.assertValueEqual(eclipse.eclipseAt(0.0D), 1.0F,
"Centered sun and moon must produce a full eclipse");
helper.assertTrue(eclipse.lightAt(0.0D) < clear.lightAt(0.0D),
"An eclipse must dim world skylight, not just the rendered sprites");
AgeSkyProfile separated = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
List.of(sun, new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.5F, true)));
helper.assertValueEqual(separated.eclipseAt(0.0D), 0.0F,
"A moon on the far side of the sky must not eclipse the sun");
AgeSkyProfile glowOnlyOverlap = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
List.of(sun, new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.04F, true)));
helper.assertValueEqual(glowOnlyOverlap.eclipseAt(0.0D), 0.0F,
"Overlapping glows must not count as an eclipse before the opaque discs touch");
helper.succeed();
}
@GameTest(template = "empty")
public static void randomAndAuthoredCelestialsKeepIndependentOrbits(GameTestHelper helper) {
AgeTraits traits = AgeTraits.select(12345L);
@@ -217,7 +273,7 @@ public final class EnvironmentWritingGameTests {
"Random Ages must obey the same celestial limits as authored Ages");
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/end"),
page("angle/east"), page("direction/counterclockwise"), page("celestial_color/red"),
page("angle/east"), page("direction/counterclockwise"), page("number/24"), page("color/red"),
page("size/large"), page("period/half"), page("celestial/sun"),
page("direction/clockwise"), page("celestial/moon")));
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 12345L, AgeGenerationProfiles.STANDARD,
@@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.model.BookModel;
import net.minecraft.client.model.geom.ModelLayers;
@@ -30,11 +31,15 @@ public final class BookReceptacleRenderer implements BlockEntityRenderer<BookRec
@Override
public void render(BookReceptacleBlockEntity receptacle, float partialTick, PoseStack poseStack,
MultiBufferSource buffers, int packedLight, int packedOverlay) {
if (receptacle.getBook().isEmpty()) return;
if (receptacle.getLevel() == null) return;
var state = receptacle.getLevel().getBlockState(receptacle.getBlockPos());
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())
|| !state.getValue(BookReceptacleBlock.HAS_BOOK)
|| receptacle.getBook().isEmpty()) return;
poseStack.pushPose();
poseStack.translate(0.5, 0.5, 0.5);
Direction facing = receptacle.getBlockState().getValue(BookReceptacleBlock.FACING);
Direction facing = state.getValue(BookReceptacleBlock.FACING);
switch (facing) {
case UP -> {
// Rotate the whole assembled top-mounted receptacle/book pair
@@ -1,6 +1,7 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.platform.GlStateManager;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.BufferUploader;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
@@ -22,6 +23,7 @@ import net.minecraft.resources.ResourceLocation;
/** An authored sky can use vanilla celestial rendering or Legacy-style moving TV static. */
public final class RivencraftSkyEffects extends DimensionSpecialEffects {
private static final int GRID = 16;
private static final int STATIC_GRID = 48;
private static final float RADIUS = 100.0F;
private static final ResourceLocation SUN = ResourceLocation.withDefaultNamespace("textures/environment/sun.png");
private static final ResourceLocation MOON = ResourceLocation.withDefaultNamespace("textures/environment/moon_phases.png");
@@ -62,6 +64,8 @@ public final class RivencraftSkyEffects extends DimensionSpecialEffects {
case STATIC -> 0x929292;
default -> level.getBiome(camera.getBlockPosition()).value().getSkyColor();
};
double time = level.getDayTime() + partialTick;
float eclipseShade = 1.0F - 0.65F * sky.eclipseAt(time);
RenderSystem.depthMask(false);
RenderSystem.disableCull();
@@ -70,20 +74,21 @@ public final class RivencraftSkyEffects extends DimensionSpecialEffects {
FogRenderer.setupNoFog();
BufferBuilder vertices = Tesselator.getInstance().begin(
VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
int grid = sky.style() == AgeSkyProfile.Style.STATIC ? STATIC_GRID : GRID;
for (int axis = 0; axis < 3; axis++) {
for (int sign : new int[]{-1, 1}) {
for (int x = 0; x < GRID; x++) {
for (int y = 0; y < GRID; y++) {
for (int x = 0; x < grid; x++) {
for (int y = 0; y < grid; y++) {
int noise = sky.style() == AgeSkyProfile.Style.STATIC
? 65 + random.nextInt(160) : 255;
int red = ((tint >> 16) & 255) * noise / 255;
int green = ((tint >> 8) & 255) * noise / 255;
int blue = (tint & 255) * noise / 255;
int red = (int) (((tint >> 16) & 255) * noise / 255.0F * eclipseShade);
int green = (int) (((tint >> 8) & 255) * noise / 255.0F * eclipseShade);
int blue = (int) ((tint & 255) * noise / 255.0F * eclipseShade);
int color = 0xFF000000 | red << 16 | green << 8 | blue;
float left = -RADIUS + x * (2 * RADIUS / GRID);
float right = -RADIUS + (x + 1) * (2 * RADIUS / GRID);
float bottom = -RADIUS + y * (2 * RADIUS / GRID);
float top = -RADIUS + (y + 1) * (2 * RADIUS / GRID);
float left = -RADIUS + x * (2 * RADIUS / grid);
float right = -RADIUS + (x + 1) * (2 * RADIUS / grid);
float bottom = -RADIUS + y * (2 * RADIUS / grid);
float top = -RADIUS + (y + 1) * (2 * RADIUS / grid);
vertex(vertices, matrix, axis, sign, left, bottom, color);
vertex(vertices, matrix, axis, sign, right, bottom, color);
vertex(vertices, matrix, axis, sign, right, top, color);
@@ -96,8 +101,11 @@ public final class RivencraftSkyEffects extends DimensionSpecialEffects {
if (!sky.bodies().isEmpty()) {
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
double time = level.getGameTime() + partialTick;
for (AgeSkyProfile.Body body : sky.bodies()) drawBody(pose, body, time);
// Draw suns first so an aligned moon can pass visibly in front of them.
for (AgeSkyProfile.Body body : sky.bodies())
if (body.kind() == AgeSkyProfile.Kind.SUN) drawBody(pose, body, time, sky.eclipseFor(body, time));
for (AgeSkyProfile.Body body : sky.bodies())
if (body.kind() == AgeSkyProfile.Kind.MOON) drawBody(pose, body, time, sky.eclipseFor(body, time));
RenderSystem.disableBlend();
}
setupFog.run();
@@ -106,7 +114,7 @@ public final class RivencraftSkyEffects extends DimensionSpecialEffects {
return true;
}
private static void drawBody(PoseStack pose, AgeSkyProfile.Body body, double time) {
private static void drawBody(PoseStack pose, AgeSkyProfile.Body body, double time, float eclipse) {
pose.pushPose();
pose.mulPose(Axis.YP.rotationDegrees(body.angle()));
double orbit = body.period() == 0 ? body.phase()
@@ -115,24 +123,52 @@ public final class RivencraftSkyEffects extends DimensionSpecialEffects {
pose.mulPose(Axis.XP.rotationDegrees((float) (orbit * 360.0D)));
float size = (body.kind() == AgeSkyProfile.Kind.SUN ? 30.0F : 20.0F) * body.size();
int color = body.color();
float red = ((color >> 16) & 255) / 255.0F;
float green = ((color >> 8) & 255) / 255.0F;
float blue = (color & 255) / 255.0F;
if (body.kind() == AgeSkyProfile.Kind.MOON) {
red *= 1.0F - eclipse;
green *= 1.0F - eclipse;
blue *= 1.0F - eclipse;
} else {
float corona = (float) Math.pow(eclipse, 8.0D) * 0.8F;
red += (1.0F - red) * corona;
green += (1.0F - green) * corona;
blue += (1.0F - blue) * corona;
}
RenderSystem.setShader(GameRenderer::getPositionTexShader);
RenderSystem.setShaderTexture(0, body.kind() == AgeSkyProfile.Kind.SUN ? SUN : MOON);
RenderSystem.setShaderColor(((color >> 16) & 255) / 255.0F,
((color >> 8) & 255) / 255.0F, (color & 255) / 255.0F, 1.0F);
// Vanilla's sun/moon atlases have opaque dark pixels around the bright center.
// Additive blending makes those pixels invisible while retaining the halo.
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE);
RenderSystem.setShaderColor(red, green, blue,
body.kind() == AgeSkyProfile.Kind.SUN ? 0.84F : 0.88F + 0.12F * eclipse);
float uMax = body.kind() == AgeSkyProfile.Kind.SUN ? 1.0F : 0.25F;
float vMax = body.kind() == AgeSkyProfile.Kind.SUN ? 1.0F : 0.5F;
BufferBuilder vertices = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS,
DefaultVertexFormat.POSITION_TEX);
Matrix4f matrix = pose.last().pose();
vertices.addVertex(matrix, -size, RADIUS - 1.0F, -size).setUv(0.0F, 0.0F);
vertices.addVertex(matrix, size, RADIUS - 1.0F, -size).setUv(uMax, 0.0F);
vertices.addVertex(matrix, size, RADIUS - 1.0F, size).setUv(uMax, vMax);
vertices.addVertex(matrix, -size, RADIUS - 1.0F, size).setUv(0.0F, vMax);
BufferUploader.drawWithShader(vertices.buildOrThrow());
drawTexturedQuad(pose.last().pose(), -size, size, 0.0F, uMax, 0.0F, vMax);
// The 8x8 center of each 32x32 sprite is the actual body. Draw it last
// with ordinary alpha blending and full opacity so the sky and other
// celestial glows cannot show through it, including during an eclipse.
RenderSystem.defaultBlendFunc();
RenderSystem.setShaderColor(red, green, blue, 1.0F);
drawTexturedQuad(pose.last().pose(), -size * 0.25F, size * 0.25F,
uMax * 0.375F, uMax * 0.625F, vMax * 0.375F, vMax * 0.625F);
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
pose.popPose();
}
private static void drawTexturedQuad(Matrix4f matrix, float min, float max,
float uMin, float uMax, float vMin, float vMax) {
BufferBuilder vertices = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS,
DefaultVertexFormat.POSITION_TEX);
vertices.addVertex(matrix, min, RADIUS - 1.0F, min).setUv(uMin, vMin);
vertices.addVertex(matrix, max, RADIUS - 1.0F, min).setUv(uMax, vMin);
vertices.addVertex(matrix, max, RADIUS - 1.0F, max).setUv(uMax, vMax);
vertices.addVertex(matrix, min, RADIUS - 1.0F, max).setUv(uMin, vMax);
BufferUploader.drawWithShader(vertices.buildOrThrow());
}
private static void vertex(BufferBuilder vertices, Matrix4f matrix, int axis, int sign,
float x, float y, int color) {
float fixed = sign * RADIUS;
@@ -138,6 +138,17 @@ public final class SymbolPageTextures {
y * base.getHeight() / SIZE));
}
}
String numberPath = id.getPath().startsWith("number/")
? id.getPath().substring("number/".length()) : "";
if (id.getNamespace().equals(Rivencraft.MOD_ID)
&& numberPath.matches("(?:[0-9]|1[0-9]|2[0-4])")) {
int digit = Integer.parseInt(numberPath);
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / ICON_SIZE : GLYPH;
int origin = item ? scaleForItem(38) : 38;
for (int component : SymbolNumerals.componentsForDigit(digit))
blendComponent(image, component, origin, origin, glyphSize);
return image;
}
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
int[] offsetsX = {38, 77, 38, 0};
int[] offsetsY = {0, 38, 77, 38};
@@ -146,18 +157,22 @@ public final class SymbolPageTextures {
int originX = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
int originY = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
for (int component : glyphs(words[word])) {
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
(component / 8) * 64 + y * 64 / glyphSize);
int alpha = pixel >>> 24;
if (alpha != 0) image.blendPixel(originX + x, originY + y,
(alpha << 24) | 0x001A212B);
}
blendComponent(image, component, originX, originY, glyphSize);
}
}
return image;
}
private static void blendComponent(NativeImage image, int component, int originX, int originY, int glyphSize) {
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
(component / 8) * 64 + y * 64 / glyphSize);
int alpha = pixel >>> 24;
if (alpha != 0) image.blendPixel(originX + x, originY + y,
(alpha << 24) | 0x001A212B);
}
}
private static int scaleForItem(int coordinate) {
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / ICON_SIZE);
}
@@ -0,0 +1,77 @@
package net.dnorberg.rivencraft.compat;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.fml.ModList;
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
public final class RuinsCompatibility {
private static final Set<ResourceLocation> KNOWN_AGES = ConcurrentHashMap.newKeySet();
private static final Map<ResourceLocation, Map<ResourceLocation, Integer>> AUTHORED_TEMPLATES =
new ConcurrentHashMap<>();
private RuinsCompatibility() {}
public static void register() {
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
}
public static void registerAge(AgeDefinition age) {
if (!ModList.get().isLoaded("ruins")) return;
KNOWN_AGES.add(age.dimensionId());
List<AgeFeatureSelection.Choice> choices = age.plan() == null
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
Map<ResourceLocation, Integer> selected = templateCounts(choices);
if (!selected.isEmpty()) {
AUTHORED_TEMPLATES.put(age.dimensionId(), selected);
} else {
AUTHORED_TEMPLATES.remove(age.dimensionId());
}
}
/** Called only by the optional Ruins FileHandler mixin. */
public static boolean allowsAge(ResourceLocation dimension) {
return AUTHORED_TEMPLATES.containsKey(dimension);
}
public static boolean isKnownAge(ResourceLocation dimension) {
return KNOWN_AGES.contains(dimension);
}
public static boolean permits(ResourceLocation dimension, ResourceLocation template) {
return templateCount(dimension, template) > 0;
}
public static int templateCount(ResourceLocation dimension, ResourceLocation template) {
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
}
/** Extra pages increase attempts, without making distinct template choices denser. */
public static int attemptsPerChunk(ResourceLocation dimension, int configuredAttempts) {
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
}
static int attemptsPerChunk(int configuredAttempts, Map<ResourceLocation, Integer> counts) {
if (counts == null || counts.isEmpty()) return configuredAttempts;
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
}
static Map<ResourceLocation, Integer> templateCounts(List<AgeFeatureSelection.Choice> choices) {
Map<ResourceLocation, Integer> counts = new java.util.HashMap<>();
for (AgeFeatureSelection.Choice choice : choices) {
if (RuinsTemplatePages.contains(choice.id())) counts.merge(choice.id(), 1, Integer::sum);
}
return Map.copyOf(counts);
}
public static void onServerStopping() {
KNOWN_AGES.clear();
AUTHORED_TEMPLATES.clear();
}
}
@@ -0,0 +1,123 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.WritingEntry;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.nio.file.Files;
import net.neoforged.fml.loading.FMLPaths;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Each discoverable Ruins template must have an authorable page, or none do. */
@GameTestHolder("rivencraft")
@PrefixGameTestTemplate(false)
public final class RuinsCompatibilityGameTests {
private RuinsCompatibilityGameTests() {}
@GameTest(template = "empty")
public static void ruinsPagesMatchInstalledTemplates(GameTestHelper helper) {
var templates = RuinsTemplatePages.templates();
if (!ModList.get().isLoaded("ruins")) {
helper.assertTrue(templates.isEmpty(), "Ruins pages must be absent without the optional mod");
} else {
var creative = PageItem.creativeSymbolPages(helper.getLevel().registryAccess(),
RuinsTemplatePages.CATEGORY);
helper.assertValueEqual(creative.size(), templates.size(),
"Creative and JEI must expose exactly one page per Ruins template");
for (String name : templates) {
var id = RuinsTemplatePages.symbolId(name);
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id)),
"Ruins template " + name + " must be authorable");
helper.assertTrue(PageItem.isUsableWriting(PageItem.createCreativeRuinsPage(name)),
"Ruins template " + name + " needs a usable page");
}
}
helper.succeed();
}
@GameTest(template = "empty")
public static void duplicatePagesRetainTheirCount(GameTestHelper helper) {
if (RuinsTemplatePages.templates().isEmpty()) {
helper.succeed();
return;
}
var template = RuinsTemplatePages.symbolId(RuinsTemplatePages.templates().getFirst());
var counts = RuinsCompatibility.templateCounts(List.of(
new AgeFeatureSelection.Choice(template, Optional.empty()),
new AgeFeatureSelection.Choice(template, Optional.empty()),
new AgeFeatureSelection.Choice(template, Optional.empty())));
helper.assertValueEqual(counts.get(template), 3,
"Three authored Ruins pages must triple that template's relative selection weight");
helper.succeed();
}
@GameTest(template = "empty")
public static void duplicatePagesIncreaseAttemptsWithinBudget(GameTestHelper helper) {
var first = RuinsTemplatePages.symbolId("First.tml");
var second = RuinsTemplatePages.symbolId("Second.tml");
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 1)), 6,
"One Ruins page must retain the configured attempt count");
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 3, second, 1)), 8,
"Only duplicate pages must add one attempt each");
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 50)), 32,
"Duplicate pages must not exceed the per-chunk attempt budget");
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(40, Map.of(first, 2)), 40,
"The budget must not lower an intentionally higher Ruins configuration");
helper.succeed();
}
@GameTest(template = "empty")
public static void authoredTemplateBypassesOverworldOnlyGate(GameTestHelper helper) throws Exception {
if (!ModList.get().isLoaded("ruins")) {
helper.succeed();
return;
}
String name = "tikihead1.tml";
var path = FMLPaths.GAMEDIR.get().resolve("config/ruins_config/generic").resolve(name);
if (!Files.exists(path) || !RuinsTemplatePages.contains(RuinsTemplatePages.symbolId(name))) {
helper.succeed(); // Custom Ruins packs need not include the bundled Tiki Head.
return;
}
var writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
"rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
"rivencraft:biome/plains"))),
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
"rivencraft:biome/single"))),
WritingEntry.symbol(new SymbolReference(RuinsTemplatePages.symbolId(name)))));
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Ruins Tiki Gate"), writing);
RuinsCompatibility.registerAge(age);
var ruinsClass = Class.forName("atomicstryker.ruins.common.RuinsMod");
Object ruins = ruinsClass.getMethod("getInstance").invoke(null);
var currentLevel = ruinsClass.getDeclaredField("lastLoadedLevel");
currentLevel.setAccessible(true);
Object previousLevel = currentLevel.get(ruins);
var templateClass = Class.forName("atomicstryker.ruins.common.RuinTemplate");
try {
currentLevel.set(ruins, helper.getLevel());
Object template = templateClass.getConstructor(String.class, String.class)
.newInstance(path.toString(), name);
boolean accepted = (boolean) templateClass.getMethod("acceptsDimension", String.class)
.invoke(template, age.dimensionId().getPath());
helper.assertTrue(accepted, "The authored Tiki Head must load despite dimensionsToSpawnIn=overworld");
} finally {
currentLevel.set(ruins, previousLevel);
}
helper.succeed();
}
}
@@ -0,0 +1,53 @@
package net.dnorberg.rivencraft.compat;
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
import atomicstryker.ruins.common.RuinTemplate;
import java.util.Collection;
import java.util.Comparator;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.neoforged.neoforge.common.NeoForge;
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
public final class RuinsEventAdapter {
private RuinsEventAdapter() {}
static void register() {
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
}
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
if (!event.isPrePhase || event.testingRuin
|| !(event.getLevel() instanceof ServerLevel level)
|| !RuinsCompatibility.isKnownAge(level.dimension().location())) return;
var template = RuinsTemplatePages.featureId(event.template.getName());
if (!RuinsTemplatePages.contains(template)
|| !RuinsCompatibility.permits(level.dimension().location(), template)) event.setCancelled(true);
}
/** Preserve Ruins' weights and multiply each by its authored page count. */
public static Object chooseTemplate(net.minecraft.resources.ResourceLocation dimension,
Collection<?> pool, RandomSource random) {
if (pool == null || pool.isEmpty()) return null;
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
.map(RuinTemplate.class::cast)
.filter(template -> RuinsCompatibility.permits(dimension,
RuinsTemplatePages.featureId(template.getName())))
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
if (total <= 0.0) return null;
double roll = random.nextDouble() * total;
for (RuinTemplate candidate : candidates) {
roll -= authoredWeight(dimension, candidate);
if (roll < 0.0) return candidate;
}
return candidates.getLast();
}
private static double authoredWeight(net.minecraft.resources.ResourceLocation dimension,
RuinTemplate template) {
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
RuinsTemplatePages.featureId(template.getName()));
}
}
@@ -0,0 +1,162 @@
package net.dnorberg.rivencraft.compat;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.SymbolDefinition;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.fml.ModList;
import net.neoforged.fml.loading.FMLPaths;
/** Discovers Ruins' template filenames without making its jar mandatory. */
public final class RuinsTemplatePages {
public static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "ruins");
private static final String PREFIX = "ruins/";
private static volatile Map<ResourceLocation, String> names = Map.of();
private static volatile boolean initialized;
private RuinsTemplatePages() {}
public static void refresh() {
if (!ModList.get().isLoaded("ruins")) {
names = Map.of();
initialized = true;
return;
}
Map<ResourceLocation, String> discovered = new HashMap<>();
Map<String, String> sources = new HashMap<>();
Set<String> duplicateNames = new HashSet<>();
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
// overrideable CONFIGDIR; mirror its active folder exactly.
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
if (Files.isDirectory(configured)) {
try (var paths = Files.walk(configured)) {
paths.filter(Files::isRegularFile)
.filter(path -> configured.relativize(path).getNameCount() == 2)
.filter(path -> isTemplate(path.getFileName().toString()))
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
path.getFileName().toString(), configured.relativize(path).toString()));
} catch (IOException exception) {
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
}
}
// Ruins extracts this archive on first launch. It also supplies the creative
// pages before extraction has completed on a fresh installation.
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
.getResourceAsStream("assets/ruins_config.zip")) {
if (stream != null) {
try (ZipInputStream zip = new ZipInputStream(stream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (!entry.isDirectory()) {
String name = entry.getName();
name = name.substring(name.lastIndexOf('/') + 1);
if (isTemplate(name)) add(discovered, sources, duplicateNames,
name, entry.getName());
}
}
}
}
} catch (IOException exception) {
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
}
}
// Ruins' spawn event exposes only the basename. If two directories use
// that basename, Rivencraft cannot identify which structure was chosen.
// Disable the bridge instead of advertising pages with ambiguous effects.
if (!duplicateNames.isEmpty()) {
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
+ "Give every .tml file a unique basename.", duplicateNames);
names = Map.of();
initialized = true;
return;
}
names = Map.copyOf(discovered);
initialized = true;
}
public static List<String> templates() {
if (!initialized) refresh();
List<String> templates = new ArrayList<>(names.values());
templates.sort(Comparator.comparing(String::toLowerCase));
return List.copyOf(templates);
}
public static boolean isSymbol(ResourceLocation id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
}
public static boolean contains(ResourceLocation id) {
return isSymbol(id) && names.containsKey(id);
}
public static ResourceLocation symbolId(String templateName) {
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
// Hex is reversible and collision-free even for case-sensitive or Unicode
// filenames; normalizing to lowercase would merge distinct templates.
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
}
public static ResourceLocation featureId(String templateName) {
return symbolId(templateName);
}
public static String fallbackName(ResourceLocation symbolId) {
String encoded = symbolId.getPath().substring(PREFIX.length());
if (!encoded.startsWith("t_")) return encoded + " Ruin";
String name;
try {
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
name = encoded;
}
return stripExtension(name).replace('_', ' ') + " Ruin";
}
public static SymbolDefinition definition(String templateName) {
ResourceLocation id = featureId(templateName);
return new SymbolDefinition(SymbolDefinition.CURRENT_SCHEMA_VERSION,
"symbol.rivencraft.ruins.template", "symbol.rivencraft.ruins.description",
CATEGORY, Optional.of(new SymbolSemantics(SymbolSemantics.Role.FEATURE,
new SymbolSemantics.ResourceValue(id))), "");
}
private static boolean isTemplate(String name) {
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
}
private static String stripExtension(String name) {
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
}
private static void add(Map<ResourceLocation, String> templates, Map<String, String> sources,
Set<String> duplicateNames, String name, String source) {
String previousSource = sources.putIfAbsent(name, source);
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
ResourceLocation id = symbolId(name);
String previous = templates.putIfAbsent(id, name);
if (previous != null && !previous.equals(name)) {
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
previous, name, id, previous);
}
}
}
@@ -8,6 +8,7 @@ import net.dnorberg.rivencraft.network.AgeCelestialAnglePayload;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.network.PacketDistributor;
import net.neoforged.neoforge.network.registration.NetworkRegistry;
/** Sends saved celestial angles when a client joins or enters an Age. */
public final class AgeCelestialAngleSync {
@@ -34,6 +35,8 @@ public final class AgeCelestialAngleSync {
}
public static void send(ServerPlayer player, AgeDefinition age) {
// GameTest mock players (and clients without this channel) cannot receive this payload.
if (!NetworkRegistry.hasChannel(player.connection, AgeCelestialAnglePayload.TYPE.id())) return;
PacketDistributor.sendToPlayer(player, new AgeCelestialAnglePayload(
AgeDimensionService.dimensionKey(age), age.traits().celestialAngle(),
AgeSkyProfile.forAge(age)));
@@ -53,6 +53,8 @@ public final class CrystalPortalGameTests {
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
helper.assertTrue(level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
"An occupied receptacle must expose its book state to client renderers");
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
holder.setDestination(destination);
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
@@ -89,6 +91,12 @@ public final class CrystalPortalGameTests {
"A north/south opening must only show its two player-facing sides");
CrystalPortalNetwork.shutdown(level, holderPos);
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
helper.assertTrue(holder.takeBook().is(ModItems.LINKING_BOOK.get()),
"Removing the book must return the stored item");
helper.assertTrue(!level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
"Removing the book must clear the client-visible model state");
helper.assertTrue(!holder.getUpdateTag(level.registryAccess()).contains("Book"),
"The block-entity update must also clear the old client-side book stack");
helper.succeed();
}
@@ -11,6 +11,7 @@ import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
@@ -71,6 +72,7 @@ public final class PageItem extends Item {
categories.add(BlockSymbolPages.CATEGORY);
categories.add(BlockSymbolPages.FLUID_CATEGORY);
categories.add(CAVE_BIOME_CATEGORY);
if (!RuinsTemplatePages.templates().isEmpty()) categories.add(RuinsTemplatePages.CATEGORY);
return List.copyOf(categories);
}
@@ -78,6 +80,11 @@ public final class PageItem extends Item {
if (category.equals(CAVE_BIOME_CATEGORY)) {
return createCreativeBiomePage(ResourceLocation.withDefaultNamespace("lush_caves"));
}
if (category.equals(RuinsTemplatePages.CATEGORY)) {
return RuinsTemplatePages.templates().stream().findFirst()
.map(PageItem::createCreativeRuinsPage)
.orElseGet(() -> createBundledCreativePage("feature/villages"));
}
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
return createCreativeBlockPage(ResourceLocation.withDefaultNamespace(
category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone"));
@@ -93,6 +100,7 @@ public final class PageItem extends Item {
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries) {
List<ItemStack> pages = creativeBundledAndBiomePages(registries);
BlockSymbolPages.availableBlocks().forEach(id -> pages.add(createCreativeBlockPage(id)));
RuinsTemplatePages.templates().forEach(name -> pages.add(createCreativeRuinsPage(name)));
return List.copyOf(pages);
}
@@ -115,6 +123,9 @@ public final class PageItem extends Item {
return BlockSymbolPages.availableBlocks(category).stream()
.map(PageItem::createCreativeBlockPage).toList();
}
if (category.equals(RuinsTemplatePages.CATEGORY)) {
return RuinsTemplatePages.templates().stream().map(PageItem::createCreativeRuinsPage).toList();
}
Set<ResourceLocation> caveBiomes = category.equals(BIOME_CATEGORY) || category.equals(CAVE_BIOME_CATEGORY)
? caveBiomeIds(registries) : Set.of();
return creativeBundledAndBiomePages(registries).stream()
@@ -180,6 +191,14 @@ public final class PageItem extends Item {
return page;
}
public static ItemStack createCreativeRuinsPage(String templateName) {
var symbolId = RuinsTemplatePages.symbolId(templateName);
ItemStack page = create(PageContent.symbol(new SymbolReference(symbolId)));
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
RuinsTemplatePages.definition(templateName).display(symbolId));
return page;
}
private static Map<String, CreativeSymbol> loadCreativeSymbols() {
String resource = "/assets/rivencraft/creative_symbol_pages.json";
try (var stream = PageItem.class.getResourceAsStream(resource)) {
@@ -321,6 +340,10 @@ public final class PageItem extends Item {
return Component.translatable(metadata.nameTranslationKey()).withStyle(ChatFormatting.OBFUSCATED);
}
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
&& RuinsTemplatePages.isSymbol(reference.id())
? Component.translatableWithFallback(metadata.nameTranslationKey(),
RuinsTemplatePages.fallbackName(reference.id()))
: reference.id().getNamespace().equals(Rivencraft.MOD_ID)
&& reference.id().getPath().startsWith("biome/")
? Component.translatableWithFallback(metadata.nameTranslationKey(),
BiomeSymbolPages.fallbackName(reference.id()))
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.mixin;
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
public abstract class RuinsAttemptMixin {
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
private int rivencraft$normalAttempts(int configured, Level level) {
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
}
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
private int rivencraft$netherAttempts(int configured, Level level) {
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
}
}
@@ -0,0 +1,38 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.RandomSource;
import java.util.HashMap;
import java.util.HashSet;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
public abstract class RuinsDimensionMixin {
@Shadow @Final private ResourceLocation dimension;
@Shadow @Final private HashMap<String, HashSet<?>> templates;
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
}
}
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
CallbackInfoReturnable<Object> result) {
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
}
}
}
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.minecraft.resources.ResourceLocation;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
@Pseudo
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
public abstract class RuinsTemplateDimensionMixin {
@Shadow @Final private String name;
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
ResourceLocation dimension = ResourceLocation.fromNamespaceAndPath("rivencraft", dimensionPath);
if (RuinsCompatibility.isKnownAge(dimension)) {
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
}
}
}
@@ -58,7 +58,7 @@ public final class ModCreativeTabs {
.build());
private static final List<String> CATEGORY_ORDER = List.of(
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "celestial", "block", "fluid", "feature", "environment", "corrupted");
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "color", "number", "celestial", "block", "fluid", "feature", "ruins", "environment", "corrupted");
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
registerSymbolPageTabs();
@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
@@ -73,6 +74,9 @@ public final class SymbolCatalog {
BiomeSymbolPages.definition(id)));
BlockSymbolPages.availableBlocks().forEach(id -> loaded.putIfAbsent(
BlockSymbolPages.symbolId(id), BlockSymbolPages.definition(id)));
RuinsTemplatePages.refresh();
RuinsTemplatePages.templates().forEach(name -> loaded.putIfAbsent(
RuinsTemplatePages.symbolId(name), RuinsTemplatePages.definition(name)));
symbols = Map.copyOf(loaded);
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
}
@@ -80,6 +80,8 @@ public record SymbolSemantics(Role role, Value value) {
SKY("sky", Kind.CHOICE, "overworld", "nether", "end", "static"),
SKY_COLOR("sky_color", Kind.CHOICE,
"red", "orange", "yellow", "green", "blue", "purple", "white", "black"),
COLOR("color", Kind.CHOICE, "red", "green", "blue", "black", "white"),
COLOR_NUMBER("color_number", Kind.NUMBER),
WEATHER("weather", Kind.CHOICE,
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
SEASON("season", Kind.CHOICE,
@@ -116,6 +118,8 @@ public record SymbolSemantics(Role role, Value value) {
return switch (this) {
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
case SIZE_MODIFIER -> number.amount() >= 0.25 && number.amount() <= 3.0;
case COLOR_NUMBER -> number.amount() >= 0.0 && number.amount() <= 24.0
&& number.amount() == Math.floor(number.amount());
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
default -> false;
};
@@ -127,6 +131,7 @@ public record SymbolSemantics(Role role, Value value) {
case CHOICE -> "one of " + choices.stream().sorted().toList();
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
: this == SIZE_MODIFIER ? "a finite number in [0.25, 3]"
: this == COLOR_NUMBER ? "an integer D'ni digit in [0, 24]"
: "a finite number in [0, 360) degrees";
};
}
@@ -45,6 +45,7 @@ public final class WritingGrammar {
int lastStage = -1;
int biomeCount = 0;
ParsedSymbol pendingBlock = null;
ParsedSymbol pendingColorNumber = null;
for (int index = 0; index < writing.entries().size(); index++) {
WritingEntry entry = writing.entries().get(index);
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
@@ -73,6 +74,18 @@ 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 (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 (modifier(role)) {
// Legacy modifier pages pop and combine an earlier modifier of the same
// kind. Only the final pending value can be left dangling.
@@ -115,13 +128,15 @@ public final class WritingGrammar {
}
if (role == Role.BIOME) biomeCount++;
int stage = stage(role);
if (stage < lastStage && !modifier(role)) {
if (stage < lastStage && !modifier(role) && role != Role.COLOR && role != Role.COLOR_NUMBER) {
diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER,
index, "Move this " + role.id() + " page before later grammar categories"));
} else {
} else if (role != Role.COLOR && role != Role.COLOR_NUMBER) {
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 (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
"Add a Link Panel as the first page"));
for (ParsedSymbol dangling : pendingModifiers.values()) {
@@ -183,6 +198,7 @@ public final class WritingGrammar {
case SEASON -> 4;
case LIGHTING -> 5;
case SKY, SKY_COLOR -> 6;
case COLOR, COLOR_NUMBER -> 6;
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER,
CELESTIAL_COLOR, DIRECTION_MODIFIER -> 7;
case CELESTIAL -> 8;
@@ -1 +1,10 @@
{"variants":{"facing=north":{"model":"rivencraft:block/book_receptacle","y":180},"facing=east":{"model":"rivencraft:block/book_receptacle","y":270},"facing=south":{"model":"rivencraft:block/book_receptacle"},"facing=west":{"model":"rivencraft:block/book_receptacle","y":90},"facing=up":{"model":"rivencraft:block/book_receptacle","x":90},"facing=down":{"model":"rivencraft:block/book_receptacle","x":270,"y":180}}}
{
"multipart": [
{"when": {"facing": "north"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 180}},
{"when": {"facing": "east"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 270}},
{"when": {"facing": "south"}, "apply": {"model": "rivencraft:block/book_receptacle"}},
{"when": {"facing": "west"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 90}},
{"when": {"facing": "up"}, "apply": {"model": "rivencraft:block/book_receptacle", "x": 90}},
{"when": {"facing": "down"}, "apply": {"model": "rivencraft:block/book_receptacle", "x": 270, "y": 180}}
]
}
@@ -225,22 +225,42 @@
"symbol.rivencraft.sky.end.description": "Use the End sky and dark preview background.",
"symbol.rivencraft.sky.static": "Static Sky",
"symbol.rivencraft.sky.static.description": "Fill the sky with shifting television static.",
"symbol.rivencraft.sky_color.red": "Red Sky Color",
"symbol.rivencraft.sky_color.red.description": "Tint the sky and linked portal red.",
"symbol.rivencraft.sky_color.orange": "Orange Sky Color",
"symbol.rivencraft.sky_color.orange.description": "Tint the sky and linked portal orange.",
"symbol.rivencraft.sky_color.yellow": "Yellow Sky Color",
"symbol.rivencraft.sky_color.yellow.description": "Tint the sky and linked portal yellow.",
"symbol.rivencraft.sky_color.green": "Green Sky Color",
"symbol.rivencraft.sky_color.green.description": "Tint the sky and linked portal green.",
"symbol.rivencraft.sky_color.blue": "Blue Sky Color",
"symbol.rivencraft.sky_color.blue.description": "Tint the sky and linked portal blue.",
"symbol.rivencraft.sky_color.purple": "Purple Sky Color",
"symbol.rivencraft.sky_color.purple.description": "Tint the sky and linked portal purple.",
"symbol.rivencraft.sky_color.white": "White Sky Color",
"symbol.rivencraft.sky_color.white.description": "Tint the sky and linked portal white.",
"symbol.rivencraft.sky_color.black": "Black Sky Color",
"symbol.rivencraft.sky_color.black.description": "Tint the sky and linked portal near-black.",
"symbol.rivencraft.color.red": "Red Color",
"symbol.rivencraft.color.red.description": "Add red to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
"symbol.rivencraft.color.green": "Green Color",
"symbol.rivencraft.color.green.description": "Add green to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
"symbol.rivencraft.color.blue": "Blue Color",
"symbol.rivencraft.color.blue.description": "Add blue to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
"symbol.rivencraft.color.white": "White Color",
"symbol.rivencraft.color.white.description": "Add equal brightness to red, green, and blue in the next Sky, Sun, or Moon.",
"symbol.rivencraft.color.black": "Black Color",
"symbol.rivencraft.color.black.description": "Subtract equal brightness from red, green, and blue in the next Sky, Sun, or Moon.",
"symbol.rivencraft.number.0": "D'ni 0",
"symbol.rivencraft.number.1": "D'ni 1",
"symbol.rivencraft.number.2": "D'ni 2",
"symbol.rivencraft.number.3": "D'ni 3",
"symbol.rivencraft.number.4": "D'ni 4",
"symbol.rivencraft.number.5": "D'ni 5",
"symbol.rivencraft.number.6": "D'ni 6",
"symbol.rivencraft.number.7": "D'ni 7",
"symbol.rivencraft.number.8": "D'ni 8",
"symbol.rivencraft.number.9": "D'ni 9",
"symbol.rivencraft.number.10": "D'ni 10",
"symbol.rivencraft.number.11": "D'ni 11",
"symbol.rivencraft.number.12": "D'ni 12",
"symbol.rivencraft.number.13": "D'ni 13",
"symbol.rivencraft.number.14": "D'ni 14",
"symbol.rivencraft.number.15": "D'ni 15",
"symbol.rivencraft.number.16": "D'ni 16",
"symbol.rivencraft.number.17": "D'ni 17",
"symbol.rivencraft.number.18": "D'ni 18",
"symbol.rivencraft.number.19": "D'ni 19",
"symbol.rivencraft.number.20": "D'ni 20",
"symbol.rivencraft.number.21": "D'ni 21",
"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.weather.normal": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.weather.fast": "Fast Weather",
@@ -271,22 +291,6 @@
"symbol.rivencraft.celestial.sun.description": "Add one independently moving sun to the sky.",
"symbol.rivencraft.celestial.moon": "Moon",
"symbol.rivencraft.celestial.moon.description": "Add one independently moving moon to the sky.",
"symbol.rivencraft.celestial_color.red": "Red Celestial",
"symbol.rivencraft.celestial_color.red.description": "Color the next sun or moon red.",
"symbol.rivencraft.celestial_color.orange": "Orange Celestial",
"symbol.rivencraft.celestial_color.orange.description": "Color the next sun or moon orange.",
"symbol.rivencraft.celestial_color.yellow": "Yellow Celestial",
"symbol.rivencraft.celestial_color.yellow.description": "Color the next sun or moon yellow.",
"symbol.rivencraft.celestial_color.green": "Green Celestial",
"symbol.rivencraft.celestial_color.green.description": "Color the next sun or moon green.",
"symbol.rivencraft.celestial_color.blue": "Blue Celestial",
"symbol.rivencraft.celestial_color.blue.description": "Color the next sun or moon blue.",
"symbol.rivencraft.celestial_color.purple": "Purple Celestial",
"symbol.rivencraft.celestial_color.purple.description": "Color the next sun or moon purple.",
"symbol.rivencraft.celestial_color.white": "White Celestial",
"symbol.rivencraft.celestial_color.white.description": "Color the next sun or moon white.",
"symbol.rivencraft.celestial_color.black": "Black Celestial",
"symbol.rivencraft.celestial_color.black.description": "Blend black into the next sun or moon's color.",
"symbol.rivencraft.size.small": "Small Celestial",
"symbol.rivencraft.size.small.description": "Halve the apparent size of the next sun or moon.",
"symbol.rivencraft.size.normal": "Normal Celestial Size",
@@ -427,6 +431,8 @@
"rivencraft.ponder.crystal_portal.text_3": "Insert a bound Linking or Descriptive Book to activate the portal.",
"rivencraft.ponder.crystal_portal.text_4": "Step through the portal. Remove the book to close it.",
"itemGroup.rivencraft": "Rivencraft",
"itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages",
"symbol.rivencraft.ruins.description": "Allows this Ruins template to appear in the Age.",
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
"itemGroup.rivencraft.pages.biome": "Rivencraft Biome Pages",
@@ -436,6 +442,8 @@
"itemGroup.rivencraft.pages.season": "Rivencraft Season Pages",
"itemGroup.rivencraft.pages.lighting": "Rivencraft Lighting Pages",
"itemGroup.rivencraft.pages.sky": "Rivencraft Sky Pages",
"itemGroup.rivencraft.pages.color": "Rivencraft Color Pages",
"itemGroup.rivencraft.pages.number": "Rivencraft Number Pages",
"itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages",
"itemGroup.rivencraft.pages.block": "Rivencraft Block Pages",
"itemGroup.rivencraft.pages.fluid": "Rivencraft Fluid Pages",
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.black","description":"symbol.rivencraft.celestial_color.black.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"black"},"poem":"Transform Black Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.blue","description":"symbol.rivencraft.celestial_color.blue.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"blue"},"poem":"Transform Blue Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.green","description":"symbol.rivencraft.celestial_color.green.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"green"},"poem":"Transform Green Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.orange","description":"symbol.rivencraft.celestial_color.orange.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"orange"},"poem":"Transform Orange Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.purple","description":"symbol.rivencraft.celestial_color.purple.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"purple"},"poem":"Transform Purple Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.red","description":"symbol.rivencraft.celestial_color.red.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"red"},"poem":"Transform Red Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.white","description":"symbol.rivencraft.celestial_color.white.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"white"},"poem":"Transform White Illuminate Motion"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.celestial_color.yellow","description":"symbol.rivencraft.celestial_color.yellow.description","category":"rivencraft:celestial","semantics":{"role":"celestial_color","value":"yellow"},"poem":"Transform Yellow Illuminate Motion"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.color.black","description":"symbol.rivencraft.color.black.description","category":"rivencraft:color","semantics":{"role":"color","value":"black"},"poem":"Ethereal Black Transform Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.color.blue","description":"symbol.rivencraft.color.blue.description","category":"rivencraft:color","semantics":{"role":"color","value":"blue"},"poem":"Ethereal Blue Transform Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.color.green","description":"symbol.rivencraft.color.green.description","category":"rivencraft:color","semantics":{"role":"color","value":"green"},"poem":"Ethereal Green Transform Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.color.red","description":"symbol.rivencraft.color.red.description","category":"rivencraft:color","semantics":{"role":"color","value":"red"},"poem":"Ethereal Red Transform Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.color.white","description":"symbol.rivencraft.color.white.description","category":"rivencraft:color","semantics":{"role":"color","value":"white"},"poem":"Ethereal White Transform Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.0","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":0},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.1","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":1},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.10","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":10},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.11","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":11},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.12","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":12},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.13","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":13},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.14","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":14},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.15","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":15},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.16","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":16},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.17","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":17},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.18","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":18},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.19","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":19},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.2","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":2},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.20","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":20},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.21","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":21},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.22","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":22},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.23","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":23},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.24","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":24},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.3","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":3},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.4","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":4},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.5","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":5},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.6","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":6},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.7","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":7},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.8","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":8},"poem":"Transform Time System Balance"}
@@ -0,0 +1 @@
{"schema_version":2,"name":"symbol.rivencraft.number.9","description":"symbol.rivencraft.number.description","category":"rivencraft:number","semantics":{"role":"color_number","value":9},"poem":"Transform Time System Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.black","description":"symbol.rivencraft.sky_color.black.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"black"},"poem":"Ethereal Black Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.blue","description":"symbol.rivencraft.sky_color.blue.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"blue"},"poem":"Ethereal Blue Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.green","description":"symbol.rivencraft.sky_color.green.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"green"},"poem":"Ethereal Green Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.orange","description":"symbol.rivencraft.sky_color.orange.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"orange"},"poem":"Ethereal Orange Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.purple","description":"symbol.rivencraft.sky_color.purple.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"purple"},"poem":"Ethereal Purple Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.red","description":"symbol.rivencraft.sky_color.red.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"red"},"poem":"Ethereal Red Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.white","description":"symbol.rivencraft.sky_color.white.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"white"},"poem":"Ethereal White Sky Balance"}
@@ -1 +0,0 @@
{"schema_version":2,"name":"symbol.rivencraft.sky_color.yellow","description":"symbol.rivencraft.sky_color.yellow.description","category":"rivencraft:sky","semantics":{"role":"sky_color","value":"yellow"},"poem":"Ethereal Yellow Sky Balance"}
@@ -9,6 +9,9 @@
"AgeDungeonPlacementMixin",
"AgeStructureBiomeMixin",
"MobDropChanceAccessor",
"RuinsDimensionMixin",
"RuinsAttemptMixin",
"RuinsTemplateDimensionMixin",
"StructurePlacementAccessor"
],
"client": [