Compare commits

..
2 Commits
Author SHA1 Message Date
dnorberg 77f15f8599 Use number pages for celestial modifiers (#94)
Build / build (push) Canceled after 0s
2026-10-22 15:33:37 -04:00
dnorberg 2b2605b277 Add optional authored Ruins structures to Ages (#91)
Build / build (push) Canceled after 0s
2026-10-22 15:17:17 -04:00
28 changed files with 745 additions and 34 deletions

No files matched your search

+3
View File
@@ -233,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()) {
+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.
+3 -3
View File
@@ -32,9 +32,9 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
+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.11
mod_version=0.6.10.0
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);
@@ -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;
@@ -155,15 +155,19 @@ public final class AgeEnvironmentCompiler {
Double pendingPeriod = null;
Float pendingPhase = null;
boolean writtenPhase = false;
Double pendingNumber = null;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
SymbolSemantics semantics = symbol.semantics();
Double number = pendingNumber;
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
switch (semantics.role()) {
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount();
case PERIOD_MODIFIER -> {
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount();
double next = CelestialPageValues.value(semantics, number);
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
}
case PHASE_MODIFIER -> {
float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
float next = (float) CelestialPageValues.value(semantics, number);
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
}
case CELESTIAL -> {
@@ -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))
@@ -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;
}
@@ -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(),
@@ -59,13 +59,13 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
float size = 1.0F;
List<Integer> bodyColors = new ArrayList<>();
ColorAccumulator pendingColors = new ColorAccumulator();
Integer colorMultiplier = null;
Double pendingNumber = null;
boolean clockwise = true;
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
if (!active.contains(symbol.index())) continue;
SymbolSemantics semantics = symbol.semantics();
if (semantics.role() != SymbolSemantics.Role.COLOR
&& semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) colorMultiplier = null;
Double number = pendingNumber;
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
switch (semantics.role()) {
case SKY -> {
pendingColors.clear();
@@ -77,16 +77,15 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
default -> throw new IllegalArgumentException("Unknown sky style");
};
}
case COLOR_NUMBER -> colorMultiplier = (int) ((SymbolSemantics.NumberValue)
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue)
semantics.value()).amount();
case COLOR -> {
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
colorMultiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : colorMultiplier);
colorMultiplier = null;
number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue());
}
case PERIOD_MODIFIER -> period = ((SymbolSemantics.NumberValue) semantics.value()).amount();
case ANGLE_MODIFIER -> angle = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case PHASE_MODIFIER -> phase = (float) (((SymbolSemantics.NumberValue) semantics.value()).amount() / 360.0D);
case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case CELESTIAL_COLOR -> bodyColors.add(color(
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
@@ -95,7 +94,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
case CELESTIAL -> {
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
int bodyColor = pendingColors.written() ? pendingColors.rgb()
: bodyColors.isEmpty() ? 0 : mixColors(bodyColors);
: bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
if (choice.equals("normal_time")) {
if (customBodies) {
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
@@ -221,6 +220,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
&& !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time")
|| symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR
|| symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER
|| symbol.semantics().role() == SymbolSemantics.Role.ANGLE_MODIFIER
|| symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER));
}
@@ -29,8 +29,8 @@ public record AgeTraits(
if (biomeDistribution == null || lighting == null || weather == null) {
throw new IllegalArgumentException("Age trait choices cannot be null");
}
if (celestialPeriod != STOPPED_CELESTIAL_PERIOD
&& (celestialPeriod < MIN_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD)) {
if (!Double.isFinite(celestialPeriod)
|| celestialPeriod < STOPPED_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD) {
throw new IllegalArgumentException("Celestial period is outside the legacy range");
}
if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) {
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
/** A preceding D'ni number overrides the next celestial modifier's fixed page value. */
final class CelestialPageValues {
private CelestialPageValues() {}
static double value(SymbolSemantics semantics, Double number) {
if (number == null) return ((SymbolSemantics.NumberValue) semantics.value()).amount();
return switch (semantics.role()) {
case PERIOD_MODIFIER -> number * 0.08D;
case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D;
default -> throw new IllegalArgumentException("Not a celestial numeric modifier");
};
}
}
@@ -236,6 +236,57 @@ public final class EnvironmentWritingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void numberPagesSetAdjacentCelestialModifiers(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/overworld"),
page("number/1"), page("period/double"),
page("number/7"), page("angle/east"),
page("number/12"), page("phase/rising"), page("celestial/sun"),
page("number/24"), page("angle/west"),
page("number/0"), page("phase/setting"),
page("number/0"), page("period/full"), page("celestial/moon")));
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
"Adjacent number and celestial modifier pages must not count as misplaced writing");
AgeTraits seeded = AgeTraits.select(94L);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeTraits effective = AgeEnvironmentCompiler.apply(plan, seeded);
AgeSkyProfile sky = AgeSkyProfile.compile(plan, effective);
helper.assertValueEqual(sky.bodies().getFirst().period(), 0.08D,
"A number overrides a fixed Length page at 0.08 per digit, even below the random minimum");
helper.assertValueEqual(sky.bodies().getFirst().angle(), 105.0F,
"A number overrides a fixed Angle page at 15 degrees per digit");
helper.assertValueEqual(sky.bodies().getFirst().phase(), 0.5F,
"A number overrides a fixed Phase page at 15 degrees per digit");
helper.assertValueEqual(sky.bodies().getLast().angle(), 0.0F,
"Digit 24 wraps a full 360-degree rotation to zero");
helper.assertValueEqual(sky.bodies().getLast().phase(), 0.0F,
"Digit zero is a valid phase override");
helper.assertValueEqual(effective.celestialPeriod(), 0.0D,
"The final authored celestial controls the world clock");
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), effective), sky,
"Numbered celestial values must survive a plan save/load round trip");
AgeGenerationPlan normalTime = AgeGenerationPlan.compile(new OrderedWriting(List.of(
WritingEntry.linkPanel(), page("number/6"), page("angle/north"),
page("celestial/normal_time"))), 95L, AgeGenerationProfiles.STANDARD,
AgeTraits.select(95L), helper.getLevel().registryAccess());
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(normalTime,
BuiltinDimensionTypes.OVERWORLD_EFFECTS), AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS,
"An authored angle on Normal Time must select the custom celestial renderer");
AgeSkyProfile normalSky = AgeSkyProfile.compile(normalTime,
AgeEnvironmentCompiler.apply(normalTime, AgeTraits.select(95L)));
helper.assertValueEqual(normalSky.bodies().getFirst().angle(), 90.0F,
"The custom Normal Time sun must honor its numbered angle");
helper.assertValueEqual(normalSky.bodies().getFirst().color(), 0xFFFFFF,
"Uncolored custom celestial bodies must remain visible");
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("number/7"), page("direction/clockwise"), page("angle/east"), page("celestial/sun")));
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
"A number does not reach past an intervening page");
helper.succeed();
}
@GameTest(template = "empty")
public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) {
AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN,
@@ -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);
}
}
}
@@ -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", "color", "number", "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());
}
@@ -45,7 +45,7 @@ public final class WritingGrammar {
int lastStage = -1;
int biomeCount = 0;
ParsedSymbol pendingBlock = null;
ParsedSymbol pendingColorNumber = null;
ParsedSymbol pendingNumber = null;
for (int index = 0; index < writing.entries().size(); index++) {
WritingEntry entry = writing.entries().get(index);
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
@@ -74,17 +74,17 @@ public final class WritingGrammar {
Role role = semantics.orElseThrow().role();
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
symbols.add(parsed);
if (pendingColorNumber != null && role != Role.COLOR && role != Role.COLOR_NUMBER) {
diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingColorNumber.index(),
"A number page must be immediately followed by a color page"));
pendingColorNumber = null;
if (pendingNumber != null && !numberTarget(role) && role != Role.COLOR_NUMBER) {
diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingNumber.index(),
"A number page must be immediately followed by a color or celestial modifier page"));
pendingNumber = null;
}
if (role == Role.COLOR_NUMBER) {
if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER,
pendingColorNumber.index(), "A number page must be immediately followed by a color page"));
pendingColorNumber = parsed;
} else if (role == Role.COLOR) {
pendingColorNumber = null;
if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER,
pendingNumber.index(), "A number page must be immediately followed by a color or celestial modifier page"));
pendingNumber = parsed;
} else if (numberTarget(role)) {
pendingNumber = null;
}
if (modifier(role)) {
// Legacy modifier pages pop and combine an earlier modifier of the same
@@ -135,8 +135,8 @@ public final class WritingGrammar {
lastStage = stage;
}
}
if (pendingColorNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER,
pendingColorNumber.index(), "A number page must be followed by a color page"));
if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER,
pendingNumber.index(), "A number page must be followed by a color or celestial modifier page"));
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
"Add a Link Panel as the first page"));
for (ParsedSymbol dangling : pendingModifiers.values()) {
@@ -189,6 +189,11 @@ public final class WritingGrammar {
|| role == Role.CELESTIAL_COLOR || role == Role.DIRECTION_MODIFIER;
}
private static boolean numberTarget(Role role) {
return role == Role.COLOR || role == Role.PERIOD_MODIFIER
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
}
private static int stage(Role role) {
return switch (role) {
case TERRAIN -> 0;
@@ -260,7 +260,7 @@
"symbol.rivencraft.number.22": "D'ni 22",
"symbol.rivencraft.number.23": "D'ni 23",
"symbol.rivencraft.number.24": "D'ni 24",
"symbol.rivencraft.number.description": "Set the next color page to this D'ni number times 10. Without a number page, the Age chooses a random number from 0 to 24.",
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, or Length to × 0.08. A number page affects only the immediately following page.",
"symbol.rivencraft.weather.normal": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.weather.fast": "Fast Weather",
@@ -431,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",
@@ -9,6 +9,9 @@
"AgeDungeonPlacementMixin",
"AgeStructureBiomeMixin",
"MobDropChanceAccessor",
"RuinsDimensionMixin",
"RuinsAttemptMixin",
"RuinsTemplateDimensionMixin",
"StructurePlacementAccessor"
],
"client": [