Add optional authored Ruins structures to Ages (#91)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-22 15:17:17 -04:00
1 parent 2f1e6bd6f2
commit 2b2605b277
21 files changed
+638 -4

No files matched your search

@@ -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;
@@ -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(),
@@ -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());
}
@@ -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": [