diff --git a/README.md b/README.md index 6e9b5b3..7b59153 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.gradle b/build.gradle index f0334ed..f055df1 100644 --- a/build.gradle +++ b/build.gradle @@ -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()) { diff --git a/docs/RUINS_COMPATIBILITY.md b/docs/RUINS_COMPATIBILITY.md new file mode 100644 index 0000000..41f4c03 --- /dev/null +++ b/docs/RUINS_COMPATIBILITY.md @@ -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//` +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. diff --git a/gradle.properties b/gradle.properties index 30550c5..bfae686 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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.9.3 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=pHEgQQUE diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 6e8f1a4..41f5dbe 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index 6aeae8d..77ddc66 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -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; diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index 419898a..3cf892b 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -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)) diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java index 0db5d4d..c4874f4 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeInstability.java @@ -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 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 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; } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java index 8dff0a2..3afb2e9 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java @@ -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(), diff --git a/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibility.java b/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibility.java new file mode 100644 index 0000000..342efa1 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibility.java @@ -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 KNOWN_AGES = ConcurrentHashMap.newKeySet(); + private static final Map> 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 choices = age.plan() == null + ? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan()); + Map 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 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 templateCounts(List choices) { + Map 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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibilityGameTests.java b/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibilityGameTests.java new file mode 100644 index 0000000..d164d41 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/RuinsCompatibilityGameTests.java @@ -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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java b/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java new file mode 100644 index 0000000..09af589 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/RuinsEventAdapter.java @@ -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())); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java b/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java new file mode 100644 index 0000000..f26d618 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/RuinsTemplatePages.java @@ -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 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 discovered = new HashMap<>(); + Map sources = new HashMap<>(); + Set duplicateNames = new HashSet<>(); + // Ruins itself reads /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 templates() { + if (!initialized) refresh(); + List 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 templates, Map sources, + Set 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); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 1d56ade..b09740e 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -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 creativeSymbolPages(HolderLookup.Provider registries) { List 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 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 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())) diff --git a/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java new file mode 100644 index 0000000..64d7c18 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsAttemptMixin.java @@ -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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java new file mode 100644 index 0000000..c693cc2 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsDimensionMixin.java @@ -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> templates; + + @Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false) + private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable 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 result) { + if (RuinsCompatibility.isKnownAge(dimension)) { + result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random)); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java new file mode 100644 index 0000000..9cad5fe --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/mixin/RuinsTemplateDimensionMixin.java @@ -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 result) { + ResourceLocation dimension = ResourceLocation.fromNamespaceAndPath("rivencraft", dimensionPath); + if (RuinsCompatibility.isKnownAge(dimension)) { + result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name))); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index fecdef8..d3a1e13 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -58,7 +58,7 @@ public final class ModCreativeTabs { .build()); private static final List 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> SYMBOL_PAGES = registerSymbolPageTabs(); diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java index a9d4ee3..0e88edd 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java @@ -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()); } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 20bc7ec..b6bad1e 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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", diff --git a/src/main/resources/rivencraft.mixins.json b/src/main/resources/rivencraft.mixins.json index 761a490..e48cfcf 100644 --- a/src/main/resources/rivencraft.mixins.json +++ b/src/main/resources/rivencraft.mixins.json @@ -9,6 +9,9 @@ "AgeDungeonPlacementMixin", "AgeStructureBiomeMixin", "MobDropChanceAccessor", + "RuinsDimensionMixin", + "RuinsAttemptMixin", + "RuinsTemplateDimensionMixin", "StructurePlacementAccessor" ], "client": [