Expose instability controls and diagnostics (#72)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-10 14:39:38 -04:00
1 parent 78a0f7ee51
commit 7b5597dd47
19 files changed
+387 -43

No files matched your search

@@ -51,6 +51,8 @@ public final class AgeDecayProviders {
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
ResourceLocation id = id("decay" + color.getSerializedName());
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(id)) return -1;
int changed = 0;
for (int i = 0; i < Math.min(count, 32); i++) {
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
@@ -60,6 +60,9 @@ public final class AgeEnvironmentalProviders {
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, effect.equals("meteor") ? "meteors" : effect))) return 0;
List<ChunkPos> chunks = AgeHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
var context = new AgeInstabilityRuntime.Context(level, age, chunks,
@@ -165,7 +168,8 @@ public final class AgeEnvironmentalProviders {
return;
}
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) {
if (age == null || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(METEORS)) {
ACTIVE.remove(level);
return;
}
@@ -96,6 +96,8 @@ public final class AgeHazardProviders {
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
if (!RivencraftConfig.instabilityEnabled() || !AgeInstabilityControls.get(level).enabled(age.id())
|| !RivencraftConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
AgeHazardSavedData data = AgeHazardSavedData.get(level);
if (!data.ready(lightning, level.getGameTime(), RivencraftConfig.hazardsMinInterval())) return 0;
ChunkPos center = player.chunkPosition();
@@ -0,0 +1,55 @@
package net.dnorberg.rivencraft.book;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** Per-world operator overrides. Absence means enabled; scores and plans stay immutable. */
public final class AgeInstabilityControls extends SavedData {
private static final String DATA_NAME = "rivencraft_instability_controls";
private static final Factory<AgeInstabilityControls> FACTORY = new Factory<>(
AgeInstabilityControls::new, AgeInstabilityControls::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private final Set<UUID> disabled = new HashSet<>();
public static AgeInstabilityControls get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
}
public boolean enabled(UUID ageId) {
return !disabled.contains(ageId);
}
public boolean setEnabled(UUID ageId, boolean enabled) {
boolean changed = enabled ? disabled.remove(ageId) : disabled.add(ageId);
if (changed) setDirty();
return changed;
}
static AgeInstabilityControls load(CompoundTag tag, HolderLookup.Provider registries) {
AgeInstabilityControls controls = new AgeInstabilityControls();
ListTag entries = tag.getList("DisabledAges", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
if (entry.hasUUID("Id")) controls.disabled.add(entry.getUUID("Id"));
}
return controls;
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
ListTag entries = new ListTag();
disabled.stream().sorted().forEach(id -> {
CompoundTag entry = new CompoundTag();
entry.putUUID("Id", id);
entries.add(entry);
});
tag.put("DisabledAges", entries);
return tag;
}
}
@@ -52,7 +52,8 @@ public final class AgeInstabilityRuntime {
if (!admitted(state.eligible, level.dimension().location(), tick / cooldown,
RivencraftConfig.instabilityMaxAges())) return;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null || age.instabilityPlan().providers().isEmpty()) return;
if (age == null || age.instabilityPlan().providers().isEmpty()
|| !AgeInstabilityControls.get(level).enabled(age.id())) return;
// Legacy's per-chunk hazards must not wait for the rotating two-provider sample.
AgeHazardProviders.tickPlanned(level, age, tick);
AgeEnvironmentalProviders.tickPlanned(level, age, tick);
@@ -74,6 +75,8 @@ public final class AgeInstabilityRuntime {
/** Shared handler guard, including quarantine, for rotating and per-chunk provider lanes. */
static boolean invoke(Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.instabilityEnabled()
|| !AgeInstabilityControls.get(context.level()).enabled(context.age().id())) return false;
Handler handler = HANDLERS.get(id);
if (handler == null) {
if (!InstabilityProviderCatalog.contains(id) && UNKNOWN_LOGGED.add(id)) {
@@ -81,6 +84,7 @@ public final class AgeInstabilityRuntime {
}
return false;
}
if (!RivencraftConfig.instabilityProviderEnabled(id)) return false;
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return false;
long retryAfter = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>())
.getOrDefault(id, 0L);
@@ -107,6 +111,40 @@ public final class AgeInstabilityRuntime {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
}
/** Current runtime state, distinct from the Age's immutable score and provider selection. */
public static String providerStatus(ServerLevel level, AgeDefinition age, ResourceLocation id) {
if (!RivencraftConfig.instabilityEnabled()) return "global config off";
if (!AgeInstabilityControls.get(level).enabled(age.id())) return "Age disabled";
Handler handler = HANDLERS.get(id);
if (handler == null) return "handler unavailable";
if (!RivencraftConfig.instabilityProviderEnabled(id)) return "provider config off";
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) return "world mutation off";
String path = id.getPath();
if (path.startsWith("decay") && !RivencraftConfig.decayEnabled()) return "decay config off";
if ((path.equals("lightning") || path.equals("explosions")) && !RivencraftConfig.hazardsEnabled())
return "hazards config off";
if (path.equals("meteors") && !RivencraftConfig.meteorEnabled()) return "meteor config off";
if (path.equals("burning") && !RivencraftConfig.scorchEnabled()) return "burning config off";
if (path.equals("erosion") && !RivencraftConfig.erosionEnabled()) return "erosion config off";
if (!path.startsWith("decay") && !path.equals("lightning") && !path.equals("explosions")
&& !path.equals("meteors") && !path.equals("burning") && !path.equals("erosion")
&& !path.equals("crumble") && !RivencraftConfig.statusEffectsEnabled())
return "status effects config off";
if (path.startsWith("enemy") && !RivencraftConfig.statusEffectsHostiles())
return "hostile status effects config off";
if (!path.startsWith("enemy") && !path.startsWith("decay") && !path.equals("lightning")
&& !path.equals("explosions") && !path.equals("meteors") && !path.equals("burning")
&& !path.equals("erosion") && !RivencraftConfig.statusEffectsPlayers()
&& !RivencraftConfig.statusEffectsNonplayers())
return "status effect targets disabled";
ServerLevel ageLevel = level.getServer().getLevel(AgeDimensionService.dimensionKey(age));
if (ageLevel == null) return "Age unloaded";
if (ageLevel.players().isEmpty()) return "no players in Age";
long retryAfter = FAILURES.getOrDefault(ageLevel, Map.of()).getOrDefault(id, 0L);
if (ageLevel.getServer().getTickCount() < retryAfter) return "quarantined until tick " + retryAfter;
return "eligible; chance, cooldown, and protection still apply";
}
static boolean admitted(List<ResourceLocation> eligible, ResourceLocation id, long run,
int maximum) {
int index = eligible.indexOf(id);
@@ -122,6 +160,7 @@ public final class AgeInstabilityRuntime {
ResourceLocation id = level.dimension().location();
if (isAgeDimension(id) && !level.players().isEmpty()
&& registry.findByDimension(id).filter(age -> !age.instabilityPlan().providers().isEmpty())
.filter(age -> AgeInstabilityControls.get(level).enabled(age.id()))
.isPresent()) eligible.add(id);
}
eligible.sort(Comparator.comparing(ResourceLocation::toString));
@@ -59,6 +59,53 @@ public final class AgeInstabilityRuntimeGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void perAgeControlsPersistWithoutChangingScoresOrOtherAges(GameTestHelper helper) {
UUID first = UUID.randomUUID();
UUID second = UUID.randomUUID();
AgeInstabilityControls controls = new AgeInstabilityControls();
helper.assertTrue(controls.enabled(first) && controls.enabled(second),
"Ages must be enabled by default");
helper.assertTrue(controls.setEnabled(first, false) && !controls.enabled(first)
&& controls.enabled(second), "Disabling one Age must not change another");
AgeInstabilityControls restored = AgeInstabilityControls.load(
controls.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(!restored.enabled(first) && restored.enabled(second),
"The per-Age override must survive a save round trip");
helper.assertTrue(restored.setEnabled(first, true) && restored.enabled(first),
"Re-enabling must remove the override");
var oldAge = new AgeDefinition(first, "Old Age", 1L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(first), null,
AgeTraits.LEGACY_DEFAULTS);
helper.assertValueEqual(oldAge.instability().score(), 0,
"A pre-scoring Age must remain neutral regardless of runtime controls");
helper.assertTrue(oldAge.instabilityPlan().providers().isEmpty(),
"Runtime controls must not generate providers for old Ages");
AgeInstabilityControls live = AgeInstabilityControls.get(helper.getLevel());
live.setEnabled(first, false);
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
ResourceLocation.parse("rivencraft:poison")), "Age disabled",
"Diagnostics must distinguish an Age override from the saved plan");
live.setEnabled(first, true);
helper.assertValueEqual(AgeInstabilityRuntime.providerStatus(helper.getLevel(), oldAge,
ResourceLocation.parse("example:missing_handler")), "handler unavailable",
"Unknown saved providers must not be reported as active");
helper.succeed();
}
@GameTest(template = "empty")
public static void instabilityControlCommandsRequireOperatorPermission(GameTestHelper helper) {
var server = helper.getLevel().getServer();
var command = server.getCommands().getDispatcher().getRoot().getChild("rivencraft");
helper.assertTrue(command != null, "Rivencraft commands must be registered on a dedicated server");
helper.assertTrue(!command.canUse(server.createCommandSourceStack().withPermission(0))
&& command.canUse(server.createCommandSourceStack().withPermission(2)),
"Non-operators cannot reach Age instability enable/disable commands");
helper.succeed();
}
@GameTest(template = "empty")
public static void ageScopeRotationAndActionBudgetAreBounded(GameTestHelper helper) {
ResourceLocation first = ResourceLocation.parse("rivencraft:age/first");
@@ -75,7 +75,7 @@ public final class AgeStatusEffectProviders {
candidates.sort(Comparator.comparing(Entity::getUUID));
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
if (!context.budget().claim() || !target.addEffect(instance)) return;
leases.add(new Lease(new WeakReference<>(target), instance,
leases.add(new Lease(id, new WeakReference<>(target), instance,
context.serverTick() + duration, spec.target()));
}
@@ -100,7 +100,12 @@ public final class AgeStatusEffectProviders {
if (event.getServer().getTickCount() % 20 != 0) return;
boolean disabled = !RivencraftConfig.instabilityEnabled() || !RivencraftConfig.statusEffectsEnabled();
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, disabled);
for (var entry : LEASES.entrySet()) {
AgeDefinition age = AgeRegistrySavedData.get(entry.getKey())
.findByDimension(entry.getKey().dimension().location()).orElse(null);
prune(entry.getKey(), entry.getValue(), tick, disabled || age == null
|| !AgeInstabilityControls.get(entry.getKey()).enabled(age.id()));
}
LEASES.values().removeIf(List::isEmpty);
}
@@ -146,7 +151,8 @@ public final class AgeStatusEffectProviders {
iterator.remove();
continue;
}
boolean revoke = removeAll || entity.level() != level
boolean revoke = removeAll || !RivencraftConfig.instabilityProviderEnabled(lease.provider())
|| entity.level() != level
|| !allowedByPolicy(lease.target(), entity);
if (revoke || tick >= lease.expiresAt()) {
removeOwnedEffect(entity, lease);
@@ -208,11 +214,11 @@ public final class AgeStatusEffectProviders {
private enum Target { GENERAL, HOSTILE }
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
private record Lease(WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
private record Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target, int amplifier, int originalDuration) {
private Lease(WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
private Lease(ResourceLocation provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target) {
this(entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
}
}
}
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.client.screen.DescriptiveBookScreen;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.world.InteractionHand;
@@ -87,8 +88,12 @@ public final class ClientBookInteractionHandler {
currentBook = minecraft.player.getItemInHand(hand);
}
// Ignore delayed replies if the player has moved or replaced the book.
ItemStack currentWithoutSummary = currentBook.copy();
ItemStack replyWithoutSummary = payload.book().copy();
currentWithoutSummary.remove(ModDataComponents.AGE_INSTABILITY_SCORE.get());
replyWithoutSummary.remove(ModDataComponents.AGE_INSTABILITY_SCORE.get());
if (!currentBook.is(ModItems.DESCRIPTIVE_BOOK.get())
|| !ItemStack.isSameItemSameComponents(currentBook, payload.book())) {
|| !ItemStack.isSameItemSameComponents(currentWithoutSummary, replyWithoutSummary)) {
return;
}
minecraft.setScreen(payload.fromStand()
@@ -54,6 +54,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private final List<SymbolDisplayMetadata> writingSymbols;
private final boolean pageFree;
private final String author;
private final Integer instabilityScore;
private final InteractionHand hand;
private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview;
@@ -74,6 +75,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
instabilityScore = book.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
this.hand = hand;
bookStandPos = null;
}
@@ -88,6 +90,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
this.writingSymbols = List.copyOf(writingSymbols);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
instabilityScore = book.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
hand = InteractionHand.MAIN_HAND;
this.bookStandPos = bookStandPos.immutable();
}
@@ -250,7 +253,12 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
int detailsY = drawWrappedWithoutShadow(graphics,
author == null || author.isBlank()
? Component.translatable("screen.rivencraft.age.unknown_author")
: Component.literal(author), x + 10, nameEnd + 2, 100) + 10;
: Component.literal(author), x + 10, nameEnd + 2, 100) + 4;
if (instabilityScore != null) {
detailsY = drawWrappedWithoutShadow(graphics,
Component.translatable("item.rivencraft.descriptive_book.instability", instabilityScore),
x, detailsY, 110, instabilityScore > 0 ? UNRESOLVED_TEXT_COLOR : BOOK_TEXT_COLOR) + 6;
}
if (!pageFree) return;
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
@@ -9,12 +9,17 @@ import net.dnorberg.rivencraft.book.AgeHazardProviders;
import net.dnorberg.rivencraft.book.AgeEnvironmentalProviders;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import java.util.List;
import java.util.Locale;
/** Administrative commands for inspecting and safely unloading persistent Ages. */
public final class RivencraftCommands {
@@ -30,21 +35,23 @@ public final class RivencraftCommands {
.then(Commands.literal("instability")
.executes(context -> inspectInstability(context.getSource(),
context.getSource().getLevel().dimension().location().toString()))
.then(Commands.literal("enable")
.then(Commands.argument("dimension", StringArgumentType.string())
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
.executes(context -> setInstability(context.getSource(),
StringArgumentType.getString(context, "dimension"), true))))
.then(Commands.literal("disable")
.then(Commands.argument("dimension", StringArgumentType.string())
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
.executes(context -> setInstability(context.getSource(),
StringArgumentType.getString(context, "dimension"), false))))
.then(Commands.argument("dimension", StringArgumentType.string())
.suggests((context, builder) -> {
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
.forEach(age -> builder.suggest(age.dimensionId().toString()));
return builder.buildFuture();
})
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
.executes(context -> inspectInstability(context.getSource(),
StringArgumentType.getString(context, "dimension")))))
.then(Commands.literal("unload")
.then(Commands.argument("dimension", StringArgumentType.string())
.suggests((context, builder) -> {
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
.forEach(age -> builder.suggest(age.dimensionId().toString()));
return builder.buildFuture();
})
.suggests((context, builder) -> suggestAges(context.getSource(), builder))
.executes(context -> unloadAge(
context.getSource(),
StringArgumentType.getString(context, "dimension")
@@ -100,16 +107,8 @@ public final class RivencraftCommands {
}
private static int unloadAge(net.minecraft.commands.CommandSourceStack source, String requestedDimension) {
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
if (dimension == null) {
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
return 0;
}
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
return 0;
}
AgeDefinition age = resolveAge(source, requestedDimension);
if (age == null) return 0;
AgeDimensionService.UnloadResult result = AgeDimensionService.unload(source.getServer(), age);
return switch (result) {
@@ -131,27 +130,96 @@ public final class RivencraftCommands {
}
private static int inspectInstability(CommandSourceStack source, String requestedDimension) {
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
if (dimension == null) {
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
return 0;
}
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
if (age == null) {
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
return 0;
}
AgeDefinition age = resolveAge(source, requestedDimension);
if (age == null) return 0;
var totals = age.instability().totals();
source.sendSuccess(() -> Component.literal(age.name() + " — " + age.dimensionId()), false);
source.sendSuccess(() -> Component.literal("Instability: base " + totals.baseScore()
+ ", stability bonus " + totals.stabilityBonus()
+ ", penalty " + totals.instabilityPenalty()
+ ", effective " + totals.effectiveScore()), false);
boolean enabled = AgeInstabilityControls.get(source.getLevel()).enabled(age.id());
source.sendSuccess(() -> Component.literal("Runtime: " + (enabled ? "Age enabled" : "Age disabled")
+ ", global " + (RivencraftConfig.instabilityEnabled() ? "enabled" : "disabled")
+ "; saved score and plan are unchanged by controls"), false);
source.sendSuccess(() -> Component.literal("Score breakdown (saved schema v"
+ age.instability().version() + "):"), false);
age.instability().breakdown().forEach(part -> source.sendSuccess(() -> Component.literal(
" " + (part.pageIndex() < 0 ? "Age" : "page " + (part.pageIndex() + 1))
+ " +" + part.points() + " " + part.reason() + ": " + part.detail()), false));
source.sendSuccess(() -> Component.literal("Selected providers (saved plan v"
+ age.instabilityPlan().version() + "): " + age.instabilityPlan().providers().size()), false);
age.instabilityPlan().providers().forEach(provider -> source.sendSuccess(() -> Component.literal(
" " + provider.id() + " level " + provider.level() + " — "
+ AgeInstabilityRuntime.providerStatus(source.getLevel(), age, provider.id())), false));
if (age.instabilityPlan().providers().isEmpty()) source.sendSuccess(() -> Component.literal(
" No providers selected; an old neutral Age stays neutral."), false);
source.sendSuccess(() -> Component.literal("Limits: cooldown " + RivencraftConfig.instabilityCooldown()
+ " ticks, " + RivencraftConfig.instabilityMaxAges() + " Ages/tick, "
+ RivencraftConfig.instabilityMaxProviders() + " providers/Age, "
+ RivencraftConfig.instabilityMaxActions() + " actions/Age, level cap "
+ RivencraftConfig.instabilityMaxLevel()), false);
source.sendSuccess(() -> Component.literal("Mutable player bonuses: disabled (no active Legacy registration)"),
false);
return totals.effectiveScore();
}
private static java.util.concurrent.CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestAges(
CommandSourceStack source, com.mojang.brigadier.suggestion.SuggestionsBuilder builder) {
AgeRegistrySavedData.get(source.getLevel()).definitions()
.forEach(age -> {
builder.suggest(age.dimensionId().toString());
if (!age.name().contains(" ")) builder.suggest(age.name());
});
return builder.buildFuture();
}
/** Bare input is an Age name or short path, never a minecraft-namespace resource ID. */
static List<AgeDefinition> matchingAges(List<AgeDefinition> ages, String requested) {
String input = requested.strip();
if (input.isEmpty()) return List.of();
if (input.contains(":")) {
ResourceLocation id = ResourceLocation.tryParse(input);
return id == null ? List.of() : ages.stream()
.filter(age -> age.dimensionId().equals(id)).toList();
}
List<AgeDefinition> names = ages.stream()
.filter(age -> age.name().equalsIgnoreCase(input)).toList();
if (!names.isEmpty()) return names;
String path = input.toLowerCase(Locale.ROOT);
if (!path.startsWith("age/")) path = "age/" + path;
String expectedPath = path;
return ages.stream().filter(age -> age.dimensionId().getPath().equals(expectedPath)).toList();
}
private static AgeDefinition resolveAge(CommandSourceStack source, String requested) {
List<AgeDefinition> matches = matchingAges(
AgeRegistrySavedData.get(source.getLevel()).definitions(), requested);
if (matches.size() == 1) return matches.getFirst();
if (matches.size() > 1) {
source.sendFailure(Component.literal("Ambiguous Age name '" + requested
+ "'. Use its full dimension ID from /rivencraft age list."));
} else {
source.sendFailure(Component.literal("Unknown Rivencraft Age '" + requested
+ "'. Use its name or dimension ID from /rivencraft age list."));
}
return null;
}
private static int setInstability(CommandSourceStack source, String requestedDimension, boolean enabled) {
if (!source.hasPermission(2)) {
source.sendFailure(Component.literal("Operator permission is required to change Age instability."));
return 0;
}
AgeDefinition age = resolveAge(source, requestedDimension);
if (age == null) return 0;
boolean changed = AgeInstabilityControls.get(source.getLevel()).setEnabled(age.id(), enabled);
source.sendSuccess(() -> Component.literal(age.name() + " instability "
+ (enabled ? "enabled" : "disabled") + (changed ? "" : " (already set)")
+ ". The saved score and provider plan are unchanged."), true);
return 1;
}
private static int pulseDecay(net.minecraft.commands.CommandSourceStack source, String colorName, int attempts) {
DecayBlock.Color color;
try {
@@ -0,0 +1,53 @@
package net.dnorberg.rivencraft.command;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Command-facing Age lookup regression checks. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class RivencraftCommandsGameTests {
private RivencraftCommandsGameTests() {}
@GameTest(template = "empty")
public static void bareAgeNameDoesNotBecomeMinecraftResourceId(GameTestHelper helper) {
AgeDefinition fort = age("Fort", "fort");
AgeDefinition tower = age("Tower", "tower");
List<AgeDefinition> ages = List.of(fort, tower);
for (String query : List.of("fort", "Fort", "age/fort", "rivencraft:age/fort")) {
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, query), List.of(fort),
"Age lookup must accept its name, short path, and full dimension ID");
}
helper.assertTrue(RivencraftCommands.matchingAges(ages, "minecraft:fort").isEmpty(),
"An explicit wrong namespace must not resolve to a Rivencraft Age");
helper.succeed();
}
@GameTest(template = "empty")
public static void duplicateNamesRequireFullDimensionId(GameTestHelper helper) {
AgeDefinition first = age("Fort", "fort");
AgeDefinition second = age("Fort", "fort_2");
List<AgeDefinition> ages = List.of(first, second);
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, "fort").size(), 2,
"Duplicate display names must remain ambiguous, not silently pick an Age");
helper.assertValueEqual(RivencraftCommands.matchingAges(ages, "rivencraft:age/fort_2"),
List.of(second), "A full dimension ID must select the intended duplicate");
helper.succeed();
}
private static AgeDefinition age(String name, String path) {
UUID id = UUID.randomUUID();
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L,
net.minecraft.resources.ResourceLocation.parse("rivencraft:age/" + path), null,
AgeTraits.LEGACY_DEFAULTS);
}
}
@@ -1,5 +1,8 @@
package net.dnorberg.rivencraft.config;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-configurable gameplay policies. */
@@ -56,6 +59,7 @@ public final class RivencraftConfig {
private static final ModConfigSpec.BooleanValue SCORCH_PLAYERS;
private static final ModConfigSpec.IntValue SCORCH_MAX_TARGETS;
private static final ModConfigSpec.BooleanValue EROSION_ENABLED;
private static final Map<String, ModConfigSpec.BooleanValue> INSTABILITY_PROVIDERS = new HashMap<>();
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -170,6 +174,19 @@ public final class RivencraftConfig {
"Ages with a destructive instability score may spread adjacent fluid into one safe block per pass.")
.define("enabled", true);
builder.pop();
builder.push("instability_providers");
for (String provider : new String[] {
"blindness", "blindness_global", "enemyregen_global", "enemyresist_global",
"fatigue", "fatigue_global", "hunger", "hunger_global", "nausea", "nausea_global",
"poison", "poison_global", "slow", "slow_global", "weakness", "weakness_global",
"wither", "wither_global", "burning", "crumble", "decayblue", "decaypurple",
"decayred", "decaywhite", "explosions", "lightning", "meteors", "erosion"
}) {
INSTABILITY_PROVIDERS.put(provider, builder.comment(
"Allow this saved provider to run. Disabling it does not change an Age's frozen plan.")
.define(provider, true));
}
builder.pop();
SPEC = builder.build();
}
@@ -193,6 +210,11 @@ public final class RivencraftConfig {
}
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
public static boolean instabilityProviderEnabled(ResourceLocation id) {
ModConfigSpec.BooleanValue value = id.getNamespace().equals("rivencraft")
? INSTABILITY_PROVIDERS.get(id.getPath()) : null;
return value != null && value.get();
}
public static boolean instabilityWorldMutation() { return INSTABILITY_WORLD_MUTATION.get(); }
public static int instabilityCooldown() { return INSTABILITY_COOLDOWN.get(); }
public static int instabilityMaxAges() { return INSTABILITY_MAX_AGES.get(); }
@@ -112,6 +112,11 @@ public final class DescriptiveBookItem extends Item {
? Component.translatable("item.rivencraft.descriptive_book.unwritten")
: Component.translatable("item.rivencraft.descriptive_book.age", age.name());
tooltipComponents.add(status.withStyle(ChatFormatting.GRAY));
Integer instability = stack.get(ModDataComponents.AGE_INSTABILITY_SCORE.get());
if (age != null && instability != null) {
tooltipComponents.add(Component.translatable("item.rivencraft.descriptive_book.instability", instability)
.withStyle(instability > 0 ? ChatFormatting.DARK_RED : ChatFormatting.DARK_GREEN));
}
stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
.forDescriptiveBook().appendTooltip(tooltipComponents);
}
@@ -111,6 +111,11 @@ public final class ModNetworking {
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile());
access.sync().run();
}
if (definition != null && !java.util.Objects.equals(
access.stack().get(ModDataComponents.AGE_INSTABILITY_SCORE.get()), definition.instability().score())) {
access.stack().set(ModDataComponents.AGE_INSTABILITY_SCORE.get(), definition.instability().score());
access.sync().run();
}
// Requests run on the same server thread as Age creation. A request queued
// during generation can only open the finished book, never a stale client copy.
player.inventoryMenu.broadcastChanges();
@@ -170,8 +175,10 @@ public final class ModNetworking {
access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference);
access.stack().set(ModDataComponents.BOOK_AUTHOR.get(), player.getGameProfile().getName());
AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition ->
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile()));
AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition -> {
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile());
access.stack().set(ModDataComponents.AGE_INSTABILITY_SCORE.get(), definition.instability().score());
});
access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title));
access.sync().run();
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN, SoundSource.PLAYERS, 1.0F, 1.0F);
@@ -81,6 +81,13 @@ public final class ModDataComponents {
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
.cacheEncoding()
.build());
/** Server-written display summary only; detailed scoring and provider plans remain server-owned. */
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> AGE_INSTABILITY_SCORE =
DATA_COMPONENTS.register("age_instability_score", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, Integer.MAX_VALUE))
.networkSynchronized(ByteBufCodecs.VAR_INT)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16))
@@ -2,6 +2,7 @@
"item.rivencraft.descriptive_book": "Descriptive Book",
"item.rivencraft.descriptive_book.unwritten": "Unwritten",
"item.rivencraft.descriptive_book.age": "%s",
"item.rivencraft.descriptive_book.instability": "Instability: %s (potential)",
"item.rivencraft.linking_book": "Linking Book",
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)",