Compare commits

..
3 Commits
Author SHA1 Message Date
dnorberg 066ef2b758 Implement Legacy-inspired Age status instability effects (#67)
Build / build (push) Canceled after 0s
2026-10-09 04:13:55 -04:00
dnorberg 8b32735c2e Add bounded per-Age instability provider runtime (#66)
Build / build (push) Canceled after 0s
2026-10-09 01:56:39 -04:00
dnorberg 7a47e40e2d Freeze Age instability scores and classify authored writing (#64 #65)
Build / build (push) Canceled after 0s
2026-10-09 00:12:31 -04:00
24 changed files with 1331 additions and 52 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Instability provider runtime (Milestone 6, issue #66)
Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score. The version-1 plan contains provider IDs and levels. Selection uses the saved score rounded down to a 500-point step and the Age seed. It independently walks Legacy's basic, harsh, destructive, eating, and death decks with their audited entry costs (0, 2,500, 10,000, 15,000, 20,000), shuffling each deck deterministically before paying its weighted card costs. The source card weights and default provider costs are copied from the user's local Mystcraft 0.13.7.06 `InstabilityData` class. This preserves Legacy's multiple-deck behavior, but does not claim byte-for-byte reproduction of its historical Java RNG state, difficulty multiplier, config overrides, or 400-chunk block profiling delay.
The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert.
The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are disabled by default. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs.
Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). World hazards remain for #68–#70. The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps.
+9
View File
@@ -0,0 +1,9 @@
# Frozen Age instability score (Milestone 6, issue #64)
Every newly created Age now gets a version-1 `Instability` compound inside its server-owned `rivencraft_ages` saved definition. It contains the total `Score` and an ordered `Breakdown` of reason, original page index (zero-based, or -1 for an Age-wide cause), points, and explanation. `AgeRegistrySavedData.instability(UUID)` is the read-only server query. No client tooltip or live data-pack lookup is needed to read a saved score.
The baseline is explicitly zero for page-free and Link-Panel-only random Ages, and for authored Ages. Incomplete writing receives seed-based completion and a zero-point explanatory entry. Supported writing defects use the provisional Milestone 6 grammar costs: duplicate/conflicting single-choice pages +500, out-of-order or misplaced pages +100. Legacy's audited bright-lighting symbol adds +500 whether authored or seed-selected. Other balance costs are not guessed for symbols that Rivencraft has not implemented. The score is not yet an active hazard or effect selection.
The score is calculated after the Age plan and effective traits are chosen, then saved with the Age identity before its dimension is generated. The same UUID returns the existing definition, including the same score, on repeated use. Spawn updates preserve it. Reload only deserializes the frozen breakdown; it never reruns the current grammar or catalog. An older Age definition without `Instability` is migrated to a persisted version-1 neutral score of zero, rather than retroactively scoring its writing. A future scoring-rule change must introduce an explicit new schema/migration policy, not silently reinterpret existing Ages. Issue #66 additionally freezes a provider selection from this score; see [runtime policy](INSTABILITY_RUNTIME.md).
Unknown or unsupported pages remain invalid for *new* Ages. Their absence from a later registry does not change an already saved score. Focused GameTests cover stable/defective writing, random baseline and bright lighting, repeated identity, save round-trip, and missing-symbol/old-save behavior.
+9
View File
@@ -0,0 +1,9 @@
# Age status effects (Milestone 6, issue #67)
This slice activates the Legacy potion cards selected and saved by issue #66. The user-provided Mystcraft 0.13.7.06 `EffectPotion` chooses a random living entity in a chunk section. A local card requires sky visibility; a global card does not. `EffectPotionEnemy` excludes players. Its amplifier is selected level minus one. The defaults in `InstabilityData` give blindness and nausea 60 ticks, mining fatigue/hunger/poison/slowness/weakness 80 ticks, wither 30 ticks, and enemy regeneration/resistance 200 ticks. Rivencraft preserves those durations, sky rules, and level-to-amplifier mapping, subject to the server's lower duration/level caps.
General debuffs can affect survival/adventure players and other living entities, as Legacy did. The two enemy buffs affect hostile mobs only; Legacy affected any non-player living entity, so excluding passive animals is a deliberate modern adaptation. Creative and spectator players are exempt per issue #67, although the inspected Legacy potion class did not have that exemption. Existing effects from vanilla or another mod are never overwritten.
The issue #66 scheduler chooses an already-loaded chunk near a player, at most the configured number of provider calls and action claims per Age run. This provider queries at most `max_candidates_per_attempt` living entities in that chunk, picks one deterministically from the bounded candidates, and claims an action before applying. It holds at most `max_active_per_age` short-lived, owned-effect leases. It neither force-loads chunks nor scans all entities or chunks. Exact target RNG therefore intentionally differs from Legacy's random chunk-section walk; the target types, local/global distinction, duration, and strength are retained.
The server config can disable all status effects, player targets, general non-player targets, or hostile buffs, and can lower active leases, candidates, and duration. Leaving an Age, logging out, disabling effects, or stopping the server removes only an effect instance this provider actually installed; it will not remove a replacement from another mod. Leases also expire automatically after their short duration. The runtime never runs in the Overworld or a preview dimension. Automated tests cover mappings, target eligibility, creative/spectator exemption, and Overworld isolation. In-game multiplayer, Age-to-Age travel, and restart checks remain useful because GameTests do not create a second dynamic Age for this slice.
+5 -3
View File
@@ -47,11 +47,13 @@ Assets in the supplied local tree: `assets/mystcraft/textures/blocks/decay_{blac
## Rivencraft boundary and proposed safety contract
[`WritingGrammar`](../src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java) treats `INCOMPLETE` and supported writing defects as warnings. `AgeGenerationPlan.compile()` freezes the original writing, repaired active-page indexes, and a provisional writing-instability score before world generation. #64 still needs a full versioned score breakdown and saved provider/deck choice so reloads do not reroll effects. The plan must not mutate an already-created Age when a data pack changes. Server-side settings may disable execution, but should not rewrite the saved authored score.
[`WritingGrammar`](../src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java) classifies `INCOMPLETE` as seeded completion and supported writing defects as accepted instability. `AgeGenerationPlan.compile()` freezes the original writing, repaired active-page indexes, and a provisional writing-instability score before world generation. #64 now persists a versioned score breakdown; saved provider/deck choice remains future work. The plan must not mutate an already-created Age when a data pack changes. Server-side settings may disable execution, but should not rewrite the saved authored score.
For #65, **eligible-to-become-unstable** means a known, supported page whose semantics and resulting world are still deterministic: repeated/conflicting single-choice pages, an otherwise valid misplaced modifier, and excess supported symbols within the Binder capacity. The initial 0.5.1.0 implementation retains every original page, uses the last single-choice page, sorts active categories, and saves 500 per extra/conflicting choice or 100 per misplaced page. These are provisional Rivencraft values, not a full port of every `IAgeSymbol.instabilityModifier`. Missing branches remain completion warnings; legal repeated feature/material pages are not automatically errors. **Remain fatal**: missing/duplicate/misordered Link Panel, over-capacity book, unresolved or schema-v1 display-only symbol, unsupported weather/angle/runtime profile/biome/feature, unavailable required optional mod, and any combination the compiler cannot represent safely. This intentionally differs from Legacy's silent unmatched-symbol skip. #65 still needs the final symbol-specific penalty table and tests.
For #65, **eligible-to-become-unstable** means a known, supported page whose semantics and resulting world are still deterministic: repeated/conflicting single-choice pages and otherwise valid out-of-order or misplaced pages. The implementation retains every original page, uses the last single-choice page, sorts active categories, and saves 500 per extra/conflicting choice or 100 per misplaced page. These are provisional Rivencraft values, not a full port of every `IAgeSymbol.instabilityModifier`. Missing branches are explicitly classified as zero-cost seeded completion; legal repeated feature/material pages are not automatically errors. **Remain fatal**: missing/duplicate/misordered Link Panel, over-capacity book, unresolved or schema-v1 display-only symbol, unsupported weather/angle/runtime profile/biome/feature, unavailable required optional mod, and any combination the compiler cannot represent safely. Binder and unbound-book previews distinguish blocked writing, seeded completion, and accepted instability before binding; the server revalidates on output. This intentionally differs from Legacy's silent unmatched-symbol skip, universal controller cost assumptions, and modifier-specific dangling costs. A full symbol-specific Legacy penalty port remains a separate balance task, not a claim of parity in this slice.
Proposed server defaults for #66–#72 (to confirm during implementation): instability scoring on for newly created Ages; passive entity effects on; world-mutating hazards **off by default** until an operator opts in; explicit per-Age toggle and separate per-family switches. Enforce a central work budget rather than letting every loaded chunk and provider roll independently: default at most one hazard attempt per Age per 20 ticks, one block mutation per successful attempt, and no forced chunk loads. Budgets should be configurable with hard upper bounds, including active Ages, target radius, tick interval, block changes/explosions/meteor impacts, entity effect attempts, and decay spread. Never target another dimension, unloaded chunk, book preview/snapshot, protected spawn/link platform, or portal. Stop safely when the Age unloads or settings change. These are **Rivencraft proposals**, not Legacy defaults or code that exists today; implementation issues must test/adjust them, especially multiple concurrent Ages. Expose score breakdown, selected effects, enablement, and budget suppression in server diagnostics (#72).
Automated #65 checks cover classification, preserved input slots on rejection, and an accepted unstable preview. The live output/one-time Age creation check is assigned to in-game testing because the GameTest storage phase stalled when that transaction ran; the test world is not retained.
Issue #66 establishes a server-only scheduler with bounded per-Age actions, provider calls, levels, cooldowns, loaded-chunk candidates, and rotating active-Age admission. World-mutating handlers are **off by default**. No provider effects are registered yet; #67–#70 must enforce protected spawn/link platforms, portals, and provider-specific damage/spread limits before activation. These are **Rivencraft safety adaptations**, not Legacy defaults. Expose score breakdown, selected effects, enablement, and budget suppression in server diagnostics (#72). See [runtime policy](INSTABILITY_RUNTIME.md).
M6 owns scoring, selection, effects, controls, and regression. M7 still owns portal/transport and broader polish; M6 must not depend on new M7 structures. An illegal biome/terrain combination that is presently unrepresentable remains a generation failure until a separate, safe compiler path exists; instability must not be used to disguise absent worldgen support. Existing pre-release saves need explicit migration behavior in #73 rather than a silent rescore.
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.5.1.1
mod_version=0.5.5.0
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
@@ -21,6 +21,8 @@ import net.dnorberg.rivencraft.config.RivencraftClientConfig;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.book.AgeStatusEffectProviders;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
@@ -37,6 +39,7 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
AgeStatusEffectProviders.register();
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC);
modEventBus.addListener(ModNetworking::register);
@@ -52,11 +55,18 @@ public final class Rivencraft {
ModCreativeTabs.register(modEventBus);
NeoForge.EVENT_BUS.addListener(BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeWeatherService::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerTickPost);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onEntityTravel);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(SereneSeasonsCompatibility::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
@@ -5,7 +5,6 @@ import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeGenerationPlan;
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.item.PageItem;
@@ -117,19 +116,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
}
OrderedWriting writing = currentWriting();
if (!writingGrammar().canCreate()) return false;
try {
if (level instanceof ServerLevel serverLevel) {
AgeRegistrySavedData.validateNewWriting(serverLevel, title, writing);
} else {
AgeGenerationPlan.compile(writing, 0L, AgeGenerationProfiles.STANDARD,
net.dnorberg.rivencraft.book.AgeTraits.select(0L), level.registryAccess());
}
return true;
} catch (IllegalArgumentException exception) {
return false;
}
return writingGrammar().canCreate();
}
public WritingGrammar.Result writingGrammar() {
@@ -181,18 +168,21 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
// check. This also pinpoints biome-pool conflicts that cannot be inferred from
// an individual feature page alone (for example Trees with only Nether biomes).
if (level instanceof ServerLevel serverLevel && diagnostics.stream().noneMatch(
diagnostic -> diagnostic.code() == WritingGrammar.Code.UNSUPPORTED_FEATURE)) {
WritingGrammar.Diagnostic::error)) {
try {
AgeRegistrySavedData.validateNewWriting(serverLevel, title, currentWriting());
} catch (IllegalArgumentException exception) {
if (exception.getMessage() != null && (exception.getMessage().startsWith("Feature ")
|| exception.getMessage().startsWith("Caves "))) {
parsed.symbols().stream().filter(symbol -> symbol.semantics().role()
== SymbolSemantics.Role.FEATURE).findFirst().ifPresent(symbol ->
diagnostics.add(new WritingGrammar.Diagnostic(
WritingGrammar.Code.UNSUPPORTED_FEATURE, symbol.index(),
exception.getMessage())));
}
boolean feature = exception.getMessage() != null
&& (exception.getMessage().startsWith("Feature ")
|| exception.getMessage().startsWith("Caves "));
int index = feature ? parsed.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.mapToInt(WritingGrammar.ParsedSymbol::index).findFirst().orElse(-1) : -1;
diagnostics.add(new WritingGrammar.Diagnostic(
feature ? WritingGrammar.Code.UNSUPPORTED_FEATURE
: WritingGrammar.Code.UNSUPPORTED_COMBINATION,
index, exception.getMessage() == null ? "The Age compiler rejected this writing"
: exception.getMessage()));
}
}
}
@@ -21,7 +21,9 @@ public record AgeDefinition(
AgeTraits traits,
AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -34,6 +36,8 @@ public record AgeDefinition(
Objects.requireNonNull(generationProfile, "generationProfile");
Objects.requireNonNull(dimensionId, "dimensionId");
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -47,14 +51,14 @@ public record AgeDefinition(
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, null, null, List.of());
dimensionId, spawn, traits, null, null, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, null, List.of());
dimensionId, spawn, traits, plan, null, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
@@ -62,7 +66,25 @@ public record AgeDefinition(
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, List.of());
dimensionId, spawn, traits, plan, seasonMode, List.of(), AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, AgeInstability.legacyNeutral());
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
AgeInstabilityPlan.empty());
}
public static ResourceLocation dimensionId(UUID ageId) {
@@ -93,6 +115,7 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures);
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
randomFeatures, instability, instabilityPlan);
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
/** Frozen, server-owned score. Only new Age creation consults the current symbol catalog. */
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
public static final int CURRENT_VERSION = 1;
public AgeInstability {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported instability version " + version);
breakdown = List.copyOf(breakdown);
if (score < 0 || score != breakdown.stream().mapToInt(Contribution::points).sum()) {
throw new IllegalArgumentException("Instability score does not match breakdown");
}
}
/** Pre-scoring saves are explicitly neutral, never rescored from current data packs. */
public static AgeInstability legacyNeutral() {
return new AgeInstability(CURRENT_VERSION, 0,
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits) {
Objects.requireNonNull(traits, "traits");
List<Contribution> contributions = new ArrayList<>();
if (plan == null) {
contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age"));
} else {
contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age"));
WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing());
if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) {
throw new IllegalArgumentException("Writing changed between Age compilation and scoring");
}
for (WritingGrammar.Diagnostic diagnostic : parsed.diagnostics()) {
contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT),
diagnostic.index(), diagnostic.instability(), diagnostic.message()));
}
}
// Legacy's bright-lighting symbol adds +500. Other balance costs are deferred
// until their corresponding authored symbols and audited values exist.
if (traits.lighting() == AgeTraits.Lighting.BRIGHT) {
int pageIndex = plan == null ? -1 : plan.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role()
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING)
.mapToInt(AgeGenerationPlan.ResolvedSymbol::index).findFirst().orElse(-1);
contributions.add(new Contribution("bright_lighting", pageIndex, 500,
pageIndex < 0 ? "Seeded bright lighting" : "Authored bright lighting"));
}
return new AgeInstability(CURRENT_VERSION,
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
tag.putInt("Score", score);
ListTag entries = new ListTag();
for (Contribution contribution : breakdown) {
CompoundTag entry = new CompoundTag();
entry.putString("Reason", contribution.reason());
entry.putInt("PageIndex", contribution.pageIndex());
entry.putInt("Points", contribution.points());
entry.putString("Detail", contribution.detail());
entries.add(entry);
}
tag.put("Breakdown", entries);
return tag;
}
public static AgeInstability load(CompoundTag tag) {
ListTag entries = tag.getList("Breakdown", CompoundTag.TAG_COMPOUND);
List<Contribution> contributions = new ArrayList<>();
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
contributions.add(new Contribution(entry.getString("Reason"), entry.getInt("PageIndex"),
entry.getInt("Points"), entry.getString("Detail")));
}
return new AgeInstability(tag.getInt("Version"), tag.getInt("Score"), contributions);
}
public record Contribution(String reason, int pageIndex, int points, String detail) {
public Contribution {
Objects.requireNonNull(reason, "reason");
Objects.requireNonNull(detail, "detail");
if (reason.isBlank() || pageIndex < -1 || points < 0) {
throw new IllegalArgumentException("Invalid instability contribution");
}
}
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
public record AgeInstabilityPlan(int version, List<ProviderLevel> providers) {
public static final int CURRENT_VERSION = 1;
public AgeInstabilityPlan {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
providers = List.copyOf(providers);
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
}
}
public static AgeInstabilityPlan empty() { return new AgeInstabilityPlan(CURRENT_VERSION, List.of()); }
public static AgeInstabilityPlan select(AgeInstability instability, long ageSeed) {
int roundedScore = instability.score() / 500 * 500;
Map<ResourceLocation, Integer> levels = new LinkedHashMap<>();
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
int remaining = roundedScore - deck.entryCost();
if (remaining <= 0) continue;
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
for (InstabilityProviderCatalog.Card card : cards) {
if (remaining < card.cost()) break;
remaining -= card.cost();
levels.merge(card.id(), 1, Integer::sum);
}
}
return new AgeInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putInt("Version", version);
ListTag entries = new ListTag();
for (ProviderLevel provider : providers) {
CompoundTag entry = new CompoundTag();
entry.putString("Id", provider.id().toString());
entry.putInt("Level", provider.level());
entries.add(entry);
}
tag.put("Providers", entries);
return tag;
}
public static AgeInstabilityPlan load(CompoundTag tag) {
ListTag entries = tag.getList("Providers", CompoundTag.TAG_COMPOUND);
List<ProviderLevel> providers = new ArrayList<>();
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
ResourceLocation id = ResourceLocation.tryParse(entry.getString("Id"));
if (id == null) throw new IllegalArgumentException("Invalid saved instability provider ID at " + index);
providers.add(new ProviderLevel(id, entry.getInt("Level")));
}
return new AgeInstabilityPlan(tag.getInt("Version"), providers);
}
public record ProviderLevel(ResourceLocation id, int level) {
public ProviderLevel {
Objects.requireNonNull(id, "id");
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
}
}
}
@@ -0,0 +1,171 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** Server-only scheduler. Effect handlers are registered by later provider slices. */
public final class AgeInstabilityRuntime {
private static final Map<ResourceLocation, Handler> HANDLERS = new HashMap<>();
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
private static final Map<ServerLevel, Map<ResourceLocation, Long>> FAILURES = new WeakHashMap<>();
private static final Set<ResourceLocation> UNKNOWN_LOGGED = new HashSet<>();
private AgeInstabilityRuntime() {}
/** Register once during mod setup. Missing optional handlers never alter saved provider IDs. */
public static void register(ResourceLocation id, boolean worldMutating, ProviderEffect effect) {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(effect, "effect");
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, effect)) != null) {
throw new IllegalArgumentException("Duplicate instability handler " + id);
}
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level) || !RivencraftConfig.instabilityEnabled()
|| !isAgeDimension(level.dimension().location()) || level.players().isEmpty()) return;
MinecraftServer server = level.getServer();
long tick = server.getTickCount();
int cooldown = RivencraftConfig.instabilityCooldown();
if (tick % cooldown != 0) return;
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
if (state.tick != tick) {
state.tick = tick;
state.eligible = loadedPlayerAges(server);
}
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;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, RivencraftConfig.instabilityMaxChunks());
if (chunks.isEmpty()) return;
Budget budget = new Budget(RivencraftConfig.instabilityMaxActions());
Context context = new Context(level, age, chunks, tick, budget);
List<AgeInstabilityPlan.ProviderLevel> providers = age.instabilityPlan().providers();
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < RivencraftConfig.instabilityMaxProviders()
&& budget.remaining() > 0; offset++) {
AgeInstabilityPlan.ProviderLevel provider = providers.get((first + offset) % providers.size());
Handler handler = HANDLERS.get(provider.id());
if (handler == null) {
if (!InstabilityProviderCatalog.contains(provider.id()) && UNKNOWN_LOGGED.add(provider.id())) {
Rivencraft.LOGGER.warn("Saved Age references unknown instability provider {}; skipping it",
provider.id());
}
continue;
}
if (handler.worldMutating() && !RivencraftConfig.instabilityWorldMutation()) continue;
long retryAfter = FAILURES.computeIfAbsent(level, ignored -> new HashMap<>())
.getOrDefault(provider.id(), 0L);
if (tick < retryAfter) continue;
ran++;
try {
handler.effect().apply(context, provider.id(),
Math.min(provider.level(), RivencraftConfig.instabilityMaxLevel()));
} catch (RuntimeException exception) {
FAILURES.get(level).put(provider.id(), tick + Math.max(200L, cooldown * 10L));
Rivencraft.LOGGER.error("Instability provider {} failed in Age {}; temporarily quarantined",
provider.id(), age.id(), exception);
}
}
}
public static void onServerStopping(ServerStoppingEvent event) {
TICKS.remove(event.getServer());
FAILURES.clear();
UNKNOWN_LOGGED.clear();
}
public static boolean isAgeDimension(ResourceLocation id) {
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith("age/");
}
static boolean admitted(List<ResourceLocation> eligible, ResourceLocation id, long run,
int maximum) {
int index = eligible.indexOf(id);
if (index < 0 || maximum < 1) return false;
if (eligible.size() <= maximum) return true;
return Math.floorMod(index - (int) Math.floorMod(run, eligible.size()), eligible.size()) < maximum;
}
private static List<ResourceLocation> loadedPlayerAges(MinecraftServer server) {
List<ResourceLocation> eligible = new ArrayList<>();
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
for (ServerLevel level : server.getAllLevels()) {
ResourceLocation id = level.dimension().location();
if (isAgeDimension(id) && !level.players().isEmpty()
&& registry.findByDimension(id).filter(age -> !age.instabilityPlan().providers().isEmpty())
.isPresent()) eligible.add(id);
}
eligible.sort(Comparator.comparing(ResourceLocation::toString));
return List.copyOf(eligible);
}
private static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
int x = center.x + dx;
int z = center.z + dz;
if (level.getChunkSource().hasChunk(x, z)) chunks.add(new ChunkPos(x, z));
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
return List.copyOf(chunks);
}
@FunctionalInterface
public interface ProviderEffect {
/** Claim budget before each effect action; only use chunks supplied in the context. */
void apply(Context context, ResourceLocation id, int effectiveLevel);
}
public record Context(ServerLevel level, AgeDefinition age, List<ChunkPos> loadedChunks,
long serverTick, Budget budget) {
public Context { loadedChunks = List.copyOf(loadedChunks); }
}
public static final class Budget {
private int remaining;
public Budget(int maximum) {
if (maximum < 0) throw new IllegalArgumentException("Negative work budget");
remaining = maximum;
}
public boolean claim() {
if (remaining == 0) return false;
remaining--;
return true;
}
public int remaining() { return remaining; }
}
private record Handler(boolean worldMutating, ProviderEffect effect) {}
private static final class TickState {
private long tick = Long.MIN_VALUE;
private List<ResourceLocation> eligible = List.of();
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Pure server-side provider-plan and scheduler-boundary checks for issue #66. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeInstabilityRuntimeGameTests {
private AgeInstabilityRuntimeGameTests() {}
@GameTest(template = "empty")
public static void selectionIsDeterministicAndFrozen(GameTestHelper helper) {
AgeInstability score = new AgeInstability(AgeInstability.CURRENT_VERSION, 30_000,
List.of(new AgeInstability.Contribution("fixture", -1, 30_000, "provider selection")));
AgeInstabilityPlan first = AgeInstabilityPlan.select(score, 44L);
helper.assertTrue(!first.providers().isEmpty(), "An eligible score must select providers");
helper.assertValueEqual(AgeInstabilityPlan.select(score, 44L), first,
"The same Age seed and frozen score must select the same providers");
helper.assertValueEqual(AgeInstabilityPlan.load(first.save()), first,
"Saved provider IDs and levels must survive a round trip");
helper.assertTrue(first.providers().stream().allMatch(provider -> provider.level() > 0
&& InstabilityProviderCatalog.contains(provider.id())),
"Only audited providers with positive levels may be selected");
helper.assertTrue(AgeInstabilityPlan.select(AgeInstability.legacyNeutral(), 44L)
.providers().isEmpty(), "Neutral old Ages must stay inert");
helper.succeed();
}
@GameTest(template = "empty")
public static void savedPlanSurvivesAgeUpdatesAndMissingHandlers(GameTestHelper helper) {
ResourceLocation optional = ResourceLocation.parse("example:optional_provider");
AgeInstabilityPlan plan = new AgeInstabilityPlan(1,
List.of(new AgeInstabilityPlan.ProviderLevel(optional, 2)));
helper.assertValueEqual(AgeInstabilityPlan.load(plan.save()), plan,
"A syntactically valid provider ID must persist even if no handler is installed");
UUID id = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(id, "Runtime", 91L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(id), null,
AgeTraits.LEGACY_DEFAULTS, null, null, List.of(), AgeInstability.legacyNeutral(), plan);
helper.assertValueEqual(age.withSpawn(new BlockPos(1, 64, 2)).instabilityPlan(), plan,
"Setting an Age spawn must not reroll selected providers");
CompoundTag corrupt = plan.save();
corrupt.getList("Providers", CompoundTag.TAG_COMPOUND).getCompound(0)
.putString("Id", "bad provider id");
boolean rejected = false;
try { AgeInstabilityPlan.load(corrupt); }
catch (IllegalArgumentException expected) { rejected = true; }
helper.assertTrue(rejected, "Malformed provider IDs must fail clearly");
helper.succeed();
}
@GameTest(template = "empty")
public static void ageScopeRotationAndActionBudgetAreBounded(GameTestHelper helper) {
ResourceLocation first = ResourceLocation.parse("rivencraft:age/first");
ResourceLocation second = ResourceLocation.parse("rivencraft:age/second");
ResourceLocation third = ResourceLocation.parse("rivencraft:age/third");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(helper.getLevel().dimension().location()),
"The GameTest Overworld must never be treated as an Age");
helper.assertTrue(!AgeInstabilityRuntime.isAgeDimension(ResourceLocation.parse("rivencraft:preview")),
"Preview worlds must not receive Age effects");
List<ResourceLocation> loaded = List.of(first, second, third);
helper.assertTrue(AgeInstabilityRuntime.admitted(loaded, first, 0, 1)
&& AgeInstabilityRuntime.admitted(loaded, second, 1, 1)
&& AgeInstabilityRuntime.admitted(loaded, third, 2, 1),
"A global cap must rotate fairly among loaded Ages");
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(2);
helper.assertTrue(budget.claim() && budget.claim() && !budget.claim(),
"A handler cannot claim more actions than the Age budget");
helper.assertValueEqual(budget.remaining(), 0, "The budget must not go negative");
helper.succeed();
}
}
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
@@ -55,6 +56,124 @@ public final class AgePersistenceGameTests {
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(id).orElseThrow();
helper.assertValueEqual(restored, created, "The complete plan must survive a saved-data round trip");
helper.assertValueEqual(restored.instability(), created.instability(),
"The complete instability breakdown must survive reload");
helper.assertValueEqual(restored.instabilityPlan(), created.instabilityPlan(),
"The selected provider levels must not reroll on reload");
helper.assertValueEqual(registry.instability(id).orElseThrow(), created.instability(),
"The server diagnostic query must return the saved score");
helper.succeed();
}
@GameTest(template = "empty")
public static void instabilitySeparatesStableAndDefectiveWriting(GameTestHelper helper) {
OrderedWriting stable = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal")))
));
OrderedWriting defective = new OrderedWriting(List.of(
WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))
));
AgeDefinition stableAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
helper.assertValueEqual(stableAge.instability().score(), 0,
"Missing seeded branches do not add instability");
helper.assertValueEqual(defectiveAge.instability().score(), 600,
"Out-of-order and repeated terrain pages each add their saved penalty");
helper.assertTrue(defectiveAge.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
"The score must explain the offending page");
AgeDefinition reused = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(defectiveAge.id(), "Changed Name"), stable);
helper.assertValueEqual(reused, defectiveAge, "Repeated book use must not rescore an Age");
CompoundTag scoreOnlySave = AgeRegistrySavedData.get(helper.getLevel())
.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag savedAges = scoreOnlySave.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < savedAges.size(); index++) {
CompoundTag entry = savedAges.getCompound(index);
if (entry.getUUID("Id").equals(defectiveAge.id())) entry.remove("InstabilityPlan");
}
AgeRegistrySavedData migrated = AgeRegistrySavedData.load(scoreOnlySave,
helper.getLevel().registryAccess());
AgeDefinition migratedAge = migrated.find(defectiveAge.id()).orElseThrow();
helper.assertValueEqual(migratedAge.instabilityPlan(),
AgeInstabilityPlan.select(defectiveAge.instability(), defectiveAge.seed()),
"A score-only save must receive one deterministic provider migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
migrated.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).find(defectiveAge.id()).orElseThrow().instabilityPlan(),
migratedAge.instabilityPlan(), "Migrated provider selection must then remain frozen");
helper.succeed();
}
@GameTest(template = "empty")
public static void randomBaselineAndBrightBalanceAreFrozen(GameTestHelper helper) {
AgeDefinition random = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Random Score"), OrderedWriting.EMPTY);
helper.assertTrue(random.instability().breakdown().stream()
.anyMatch(part -> part.reason().equals("random_baseline") && part.points() == 0),
"Random Ages require an explicit baseline entry");
helper.assertValueEqual(random.instability().score(),
random.traits().lighting() == AgeTraits.Lighting.BRIGHT ? 500 : 0,
"Seeded bright lighting must use the audited balance cost");
AgeInstability bright = AgeInstability.compute(null, new AgeTraits(
AgeTraits.LEGACY_DEFAULTS.biomeDistribution(), AgeTraits.Lighting.BRIGHT,
AgeTraits.LEGACY_DEFAULTS.weather(), AgeTraits.LEGACY_DEFAULTS.celestialPeriod(),
AgeTraits.LEGACY_DEFAULTS.celestialAngle(), AgeTraits.LEGACY_DEFAULTS.celestialPhase()));
helper.assertValueEqual(bright.score(), 500, "Bright lighting has the audited Legacy cost");
helper.assertValueEqual(AgeInstability.load(bright.save()), bright,
"A score must survive its own versioned round trip");
helper.succeed();
}
@GameTest(template = "empty")
public static void missingRegistryEntryDoesNotReinterpretAnOldAge(GameTestHelper helper) {
UUID id = UUID.randomUUID();
CompoundTag root = new CompoundTag();
ListTag entries = new ListTag();
entries.add(entry(id, "Old Age", 17L, AgeGenerationProfiles.STANDARD, 15, 1L,
AgeDefinition.dimensionId(id), new BlockPos(0, 64, 0), AgeTraits.LEGACY_DEFAULTS));
root.put("Ages", entries);
AgeRegistrySavedData old = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
helper.assertValueEqual(old.instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
"Old Ages without a score must migrate to a neutral saved value");
helper.assertTrue(old.instabilityPlan(id).orElseThrow().providers().isEmpty(),
"Pre-score Ages must not acquire effects during migration");
helper.assertValueEqual(AgeRegistrySavedData.load(
old.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess()).instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
"Neutral migration must persist after restart");
OrderedWriting missing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:missing_symbol")))));
helper.assertTrue(!WritingGrammar.parse(missing, symbol -> java.util.Optional.empty()).canCreate(),
"Missing registry symbols remain fatal for new Ages");
OrderedWriting authored = new OrderedWriting(List.of(WritingEntry.linkPanel(),
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))));
AgeDefinition created = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
new AgeReference(UUID.randomUUID(), "Catalog Snapshot"), authored);
CompoundTag saved = AgeRegistrySavedData.get(helper.getLevel())
.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag savedAges = saved.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < savedAges.size(); index++) {
CompoundTag savedAge = savedAges.getCompound(index);
if (!savedAge.getUUID("Id").equals(created.id())) continue;
CompoundTag plan = savedAge.getCompound("GenerationPlan");
plan.getList("Resolved", CompoundTag.TAG_COMPOUND).getCompound(0)
.putString("Symbol", "rivencraft:removed_from_catalog");
plan.getList("Writing", CompoundTag.TAG_COMPOUND).getCompound(1)
.putString("Symbol", "rivencraft:removed_from_catalog");
}
AgeDefinition restored = AgeRegistrySavedData.load(saved, helper.getLevel().registryAccess())
.find(created.id()).orElseThrow();
helper.assertValueEqual(restored.instability(), created.instability(),
"A removed symbol must not reroll or reinterpret the frozen instability score");
helper.succeed();
}
@@ -170,6 +289,8 @@ public final class AgePersistenceGameTests {
"Definitions predating traits must receive stable legacy defaults");
helper.assertTrue(definition.plan() == null,
"Existing Ages must not be reinterpreted as authored plans during migration");
helper.assertValueEqual(definition.instability(), AgeInstability.legacyNeutral(),
"Old Ages must not be rescored from modern page rules");
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
@@ -35,6 +35,8 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String PLAN_TAG = "GenerationPlan";
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution";
private static final String LIGHTING_TAG = "Lighting";
private static final String WEATHER_TAG = "Weather";
@@ -89,6 +91,15 @@ public final class AgeRegistrySavedData extends SavedData {
return Optional.ofNullable(ages.get(ageId));
}
/** Read-only server diagnostic; never compiles writing or mutates a saved Age. */
public Optional<AgeInstability> instability(UUID ageId) {
return find(ageId).map(AgeDefinition::instability);
}
public Optional<AgeInstabilityPlan> instabilityPlan(UUID ageId) {
return find(ageId).map(AgeDefinition::instabilityPlan);
}
public Optional<AgeDefinition> findByDimension(ResourceLocation dimensionId) {
return ages.values().stream()
.filter(age -> age.dimensionId().equals(dimensionId))
@@ -123,6 +134,9 @@ public final class AgeRegistrySavedData extends SavedData {
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
name,
@@ -132,11 +146,13 @@ public final class AgeRegistrySavedData extends SavedData {
createdGameTime,
dimensionId,
null,
AgeEnvironmentCompiler.apply(plan, traits),
effectiveTraits,
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of()
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
instability,
instabilityPlan
);
ages.put(created.id(), created);
setDirty();
@@ -169,6 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
BlockPos spawn = entry.contains(SPAWN_Y_TAG)
? new BlockPos(entry.getInt(SPAWN_X_TAG), entry.getInt(SPAWN_Y_TAG), entry.getInt(SPAWN_Z_TAG))
: null;
AgeInstability instability = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstability.load(entry.getCompound(INSTABILITY_TAG)) : AgeInstability.legacyNeutral();
AgeInstabilityPlan instabilityPlan;
if (entry.contains(INSTABILITY_PLAN_TAG, CompoundTag.TAG_COMPOUND)) {
instabilityPlan = AgeInstabilityPlan.load(entry.getCompound(INSTABILITY_PLAN_TAG));
} else {
// The previous release froze scores but did not yet choose providers.
// Migrate once from that frozen score, then persist the result.
instabilityPlan = entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
? AgeInstabilityPlan.select(instability, entry.getLong(SEED_TAG))
: AgeInstabilityPlan.empty();
data.setDirty();
}
data.ages.put(id, new AgeDefinition(
id,
normalizedName(entry.getString(NAME_TAG)),
@@ -183,7 +212,9 @@ public final class AgeRegistrySavedData extends SavedData {
? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null,
entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING)
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry)
readRandomFeatures(entry),
instability,
instabilityPlan
));
}
return data;
@@ -228,6 +259,8 @@ public final class AgeRegistrySavedData extends SavedData {
entry.put(RANDOM_FEATURES_TAG, featureTags);
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.level.GameType;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused mapping, targeting, and isolation checks for issue #67. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeStatusEffectGameTests {
private AgeStatusEffectGameTests() {}
@GameTest(template = "empty")
public static void legacyPotionMappingRetainsShortDurationsAndSkyRule(GameTestHelper helper) {
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("wither")), 30,
"Legacy Wither lasts only 30 ticks per application");
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("poison_global")), 80,
"Global Poison retains Legacy's 80-tick duration");
helper.assertValueEqual(AgeStatusEffectProviders.legacyDuration(id("enemyregen_global")), 200,
"The enemy regeneration card retains its 200-tick duration");
helper.assertTrue(AgeStatusEffectProviders.localRequiresSky(id("poison"))
&& !AgeStatusEffectProviders.localRequiresSky(id("poison_global")),
"Only local potion cards require open sky");
helper.succeed();
}
@GameTest(template = "empty")
public static void generalDebuffsIncludeLivingEntitiesButCreativePlayersAreExempt(GameTestHelper helper) {
var player = helper.makeMockPlayer(GameType.SURVIVAL);
var cow = helper.spawnWithNoFreeWill(EntityType.COW, new BlockPos(1, 1, 1));
var hostile = helper.spawnWithNoFreeWill(EntityType.SPIDER, new BlockPos(2, 1, 1));
helper.assertTrue(player.isAlive(), "Mock survival player must be alive");
helper.assertTrue(player.level() == helper.getLevel(), "Mock survival player must be in the test level");
helper.assertTrue(!player.isCreative() && !player.isSpectator(),
"Mock survival player must not be creative or spectator");
helper.assertTrue(RivencraftConfig.statusEffectsPlayers(), "Player status effects must be enabled");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), player, id("poison_global")),
"A survival player is eligible for a general Legacy debuff");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), cow, id("poison_global")),
"General Legacy debuffs are not limited to players");
helper.assertTrue(hostile.isAlive() && hostile.level() == helper.getLevel(),
"Mock hostile mob must be alive in the test level");
helper.assertTrue(RivencraftConfig.statusEffectsHostiles(), "Hostile status effects must be enabled");
helper.assertTrue(hostile.getEffect(MobEffects.REGENERATION) == null,
"Mock hostile mob must not already have regeneration");
helper.assertTrue(hostile.canBeAffected(new MobEffectInstance(MobEffects.REGENERATION, 200, 0)),
"Mock hostile mob must accept regeneration");
helper.assertTrue(AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), hostile,
id("enemyregen_global")), "Hostile mobs can receive the enemy buff");
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), cow,
id("enemyregen_global")), "Passive mobs do not receive the adapted enemy buff");
var creative = helper.makeMockPlayer(GameType.CREATIVE);
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), creative,
id("poison_global")), "Creative players must be exempt");
var spectator = helper.makeMockPlayer(GameType.SPECTATOR);
helper.assertTrue(!AgeStatusEffectProviders.eligibleTarget(helper.getLevel(), spectator,
id("poison_global")), "Spectators must be exempt");
helper.succeed();
}
@GameTest(template = "empty")
public static void overworldCannotRunAnAgeStatusProvider(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
player.setGameMode(GameType.SURVIVAL);
UUID ageId = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(ageId, "Isolated", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), null,
AgeTraits.LEGACY_DEFAULTS);
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
var context = new AgeInstabilityRuntime.Context(helper.getLevel(), age,
List.of(player.chunkPosition()), helper.getLevel().getServer().getTickCount(), budget);
AgeStatusEffectProviders.apply(context, id("poison_global"), 1);
helper.assertTrue(player.getEffect(MobEffects.POISON) == null,
"An Age provider must not apply in the Overworld");
helper.assertValueEqual(budget.remaining(), 1,
"A rejected cross-world attempt must not consume the Age budget");
helper.succeed();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,218 @@
package net.dnorberg.rivencraft.book;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy potion cards adapted to bounded, Age-local server effects. */
public final class AgeStatusEffectProviders {
private static final int MAX_TRACKED_AGES = 64;
private static final Map<ResourceLocation, Spec> SPECS = createSpecs();
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
private AgeStatusEffectProviders() {}
public static void register() {
SPECS.keySet().forEach(id -> AgeInstabilityRuntime.register(id, false, AgeStatusEffectProviders::apply));
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.statusEffectsEnabled()
|| !AgeInstabilityRuntime.isAgeDimension(context.level().dimension().location())
|| !context.age().dimensionId().equals(context.level().dimension().location())) return;
Spec spec = SPECS.get(id);
if (spec == null || spec.target() == Target.HOSTILE && !RivencraftConfig.statusEffectsHostiles()
|| spec.target() == Target.GENERAL && !RivencraftConfig.statusEffectsPlayers()
&& !RivencraftConfig.statusEffectsNonplayers()) return;
List<Lease> leases = LEASES.get(context.level());
if (leases == null) {
if (LEASES.size() >= MAX_TRACKED_AGES) return;
leases = new ArrayList<>();
LEASES.put(context.level(), leases);
}
prune(context.level(), leases, context.serverTick(), false);
if (leases.size() >= RivencraftConfig.statusEffectsMaxActive()) return;
Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L)
^ id.hashCode());
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinBuildHeight(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, context.level().getMaxBuildHeight(), chunk.getMaxBlockZ() + 1);
int duration = Math.min(spec.legacyDuration(), RivencraftConfig.statusEffectsMaxDuration());
int amplifier = Math.max(0, level - 1);
MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier);
List<LivingEntity> candidates = new ArrayList<>();
context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligible(context.level(), entity, spec, instance), candidates,
RivencraftConfig.statusEffectsMaxCandidates());
if (candidates.isEmpty()) return;
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,
context.serverTick() + duration, spec.target()));
}
private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec,
MobEffectInstance instance) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != level
|| entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance)
|| !spec.global() && !level.canSeeSky(entity.blockPosition())) return false;
return allowedByPolicy(spec.target(), entity);
}
private static boolean allowedByPolicy(Target target, LivingEntity entity) {
if (entity instanceof Player player) {
return target == Target.GENERAL && RivencraftConfig.statusEffectsPlayers()
&& !player.isCreative() && !player.isSpectator();
}
return target == Target.GENERAL ? RivencraftConfig.statusEffectsNonplayers()
: entity instanceof Monster && RivencraftConfig.statusEffectsHostiles();
}
public static void onServerTickPost(ServerTickEvent.Post event) {
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);
LEASES.values().removeIf(List::isEmpty);
}
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
Entity entity = event.getEntity();
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
clearEntity(living);
}
}
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
public static void onServerStopping(ServerStoppingEvent event) {
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true);
LEASES.clear();
}
private static void clearEntity(LivingEntity entity) {
for (List<Lease> leases : LEASES.values()) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
if (lease.entity().get() != entity) continue;
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void prune(ServerLevel level, List<Lease> leases, long tick, boolean removeAll) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
LivingEntity entity = lease.entity().get();
if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) {
iterator.remove();
continue;
}
boolean revoke = removeAll || entity.level() != level
|| !allowedByPolicy(lease.target(), entity);
if (revoke || tick >= lease.expiresAt()) {
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void removeOwnedEffect(LivingEntity entity, Lease lease) {
// addEffect stores the supplied instance when no effect exists. Do not remove
// a replacement from another mod (or a stronger/extended effect).
if (entity.getEffect(lease.instance().getEffect()) == lease.instance()
&& lease.instance().getAmplifier() == lease.amplifier()
&& lease.instance().getDuration() <= lease.originalDuration()) {
entity.removeEffect(lease.instance().getEffect());
}
}
static int legacyDuration(ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec == null ? -1 : spec.legacyDuration();
}
static boolean localRequiresSky(ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec != null && !spec.global();
}
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, ResourceLocation id) {
Spec spec = SPECS.get(id);
return spec != null && eligible(level, entity, spec,
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
}
private static Map<ResourceLocation, Spec> createSpecs() {
Map<ResourceLocation, Spec> specs = new HashMap<>();
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
addPair(specs, "fatigue", MobEffects.DIG_SLOWDOWN, 80);
addPair(specs, "hunger", MobEffects.HUNGER, 80);
addPair(specs, "nausea", MobEffects.CONFUSION, 60);
addPair(specs, "poison", MobEffects.POISON, 80);
addPair(specs, "slow", MobEffects.MOVEMENT_SLOWDOWN, 80);
addPair(specs, "weakness", MobEffects.WEAKNESS, 80);
addPair(specs, "wither", MobEffects.WITHER, 30);
specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE));
specs.put(id("enemyresist_global"), new Spec(MobEffects.DAMAGE_RESISTANCE, 200, true, Target.HOSTILE));
return Map.copyOf(specs);
}
private static void addPair(Map<ResourceLocation, Spec> specs, String name,
Holder<MobEffect> effect, int duration) {
specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL));
specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL));
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
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,
Target target, int amplifier, int originalDuration) {
private Lease(WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target) {
this(entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
}
}
}
@@ -0,0 +1,74 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.ResourceLocation;
/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */
public final class InstabilityProviderCatalog {
public enum Deck {
BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000);
private final int entryCost;
Deck(int entryCost) { this.entryCost = entryCost; }
public int entryCost() { return entryCost; }
}
public record Card(ResourceLocation id, int cost) {
public Card {
if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive");
}
}
private static final List<List<Card>> DECKS = createDecks();
private InstabilityProviderCatalog() {}
public static List<Card> cards(Deck deck) { return DECKS.get(deck.ordinal()); }
public static boolean contains(ResourceLocation id) {
return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id));
}
private static List<List<Card>> createDecks() {
List<List<Card>> decks = new ArrayList<>();
for (Deck ignored : Deck.values()) decks.add(new ArrayList<>());
add(decks, "blindness", 1000, 0, 0, 0, 1, 0);
add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1);
add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0);
add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0);
add(decks, "fatigue", 500, 5, 0, 0, 0, 0);
add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0);
add(decks, "hunger", 500, 8, 2, 0, 0, 0);
add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0);
add(decks, "nausea", 1000, 0, 0, 0, 1, 0);
add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1);
add(decks, "poison", 500, 9, 3, 0, 0, 0);
add(decks, "poison_global", 1000, 0, 5, 0, 0, 0);
add(decks, "slow", 500, 6, 1, 0, 0, 0);
add(decks, "slow_global", 1000, 0, 5, 0, 0, 0);
add(decks, "weakness", 500, 8, 2, 0, 0, 0);
add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0);
add(decks, "wither", 1000, 0, 1, 1, 2, 0);
add(decks, "wither_global", 2000, 0, 0, 1, 1, 1);
add(decks, "burning", 500, 0, 1, 0, 0, 0);
add(decks, "crumble", 2000, 0, 0, 6, 0, 0);
add(decks, "decayblue", 2000, 0, 0, 0, 2, 1);
add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1);
add(decks, "decayred", 2000, 0, 0, 0, 2, 1);
add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3);
add(decks, "explosions", 1000, 0, 0, 8, 0, 0);
add(decks, "lightning", 1000, 0, 4, 4, 0, 0);
add(decks, "meteors", 1000, 0, 0, 4, 0, 0);
return decks.stream().map(List::copyOf).toList();
}
private static void add(List<List<Card>> decks, String path, int cost, int... copies) {
Card card = new Card(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost);
for (Deck deck : Deck.values()) {
for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card);
}
}
}
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.network.SetBookBinderTitlePayload;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
@@ -106,14 +107,33 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
}
graphics.disableScissor();
drawScrollEnds(graphics);
var severity = menu.grammarSeverity().orElse(null);
Component status = severity == WritingGrammar.Severity.FATAL
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
: severity == WritingGrammar.Severity.INSTABILITY
? Component.translatable("screen.rivencraft.book_binder.status.unstable", menu.grammarInstability())
: severity == WritingGrammar.Severity.SEEDED_COMPLETION
? Component.translatable("screen.rivencraft.book_binder.status.seeded") : null;
if (status != null) graphics.drawString(font, status, leftPos + 77, topPos + 30,
severity == WritingGrammar.Severity.FATAL ? 0xFFAA3030
: severity == WritingGrammar.Severity.INSTABILITY ? 0xFF924F18 : 0xFF50624B, false);
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
errorStripIndex + 1)).orElse(null);
if ((menu.grammarWarning() || menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty())
&& mouseX >= leftPos + 152 && mouseX < leftPos + 168
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
&& mouseY >= topPos + 27 && mouseY < topPos + 43
&& grammarTooltip != null) {
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
&& (status != null || menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty())) {
java.util.List<Component> details = new java.util.ArrayList<>();
if (status != null) details.add(status);
details.add(grammarTooltip);
if (menu.grammarInstability() > 0 && severity != WritingGrammar.Severity.INSTABILITY)
details.add(Component.translatable("screen.rivencraft.book_binder.status.unstable",
menu.grammarInstability()));
if (menu.grammarIncompleteCount() > 0 && severity != WritingGrammar.Severity.SEEDED_COMPLETION)
details.add(Component.translatable("screen.rivencraft.book_binder.status.seeded_count",
menu.grammarIncompleteCount()));
graphics.renderComponentTooltip(font, details, mouseX, mouseY);
return;
}
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
import net.minecraft.core.BlockPos;
@@ -226,6 +227,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
x, top + 40, 110);
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
x, top + 52, 110);
WritingGrammar.Result grammar = WritingGrammar.parse(writing);
boolean unresolved = writingSymbols.stream().anyMatch(display -> !display.resolved());
if (!grammar.canCreate() || unresolved) {
drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.writing.fatal"),
x, top + 132, 110, UNRESOLVED_TEXT_COLOR);
} else if (grammar.writingInstability() > 0) {
drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.writing.unstable",
grammar.writingInstability()), x, top + 132, 110);
}
if (grammar.firstIncomplete().isPresent()) {
drawWrappedWithoutShadow(graphics,
Component.translatable("screen.rivencraft.age.writing.seeded"),
x, top + 145, 110);
}
return;
}
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
@@ -18,6 +18,21 @@ public final class RivencraftConfig {
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
private static final ModConfigSpec.BooleanValue INSTABILITY_WORLD_MUTATION;
private static final ModConfigSpec.IntValue INSTABILITY_COOLDOWN;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_AGES;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_PROVIDERS;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_ACTIONS;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_LEVEL;
private static final ModConfigSpec.IntValue INSTABILITY_MAX_CHUNKS;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_ENABLED;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_PLAYERS;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_NONPLAYERS;
private static final ModConfigSpec.BooleanValue STATUS_EFFECTS_HOSTILES;
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_ACTIVE;
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_CANDIDATES;
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_DURATION;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -40,6 +55,40 @@ public final class RivencraftConfig {
"Existing entries are preserved; disabling this does not remove previously added Ages."
).define("auto_whitelist_ages", true);
builder.pop();
builder.push("instability");
INSTABILITY_ENABLED = builder.comment("Run selected instability providers in loaded Ages. Disabling does not alter saved plans.")
.define("enabled", true);
INSTABILITY_WORLD_MUTATION = builder.comment("Allow providers to change blocks or create explosions. Off until world hazards are reviewed.")
.define("world_mutation", false);
INSTABILITY_COOLDOWN = builder.comment("Minimum ticks between instability runs for each loaded Age.")
.defineInRange("cooldown_ticks", 20, 20, 1200);
INSTABILITY_MAX_AGES = builder.comment("Maximum loaded Ages processed in one server tick.")
.defineInRange("max_ages_per_tick", 4, 1, 16);
INSTABILITY_MAX_PROVIDERS = builder.comment("Maximum provider handlers called in one Age run.")
.defineInRange("max_providers_per_age", 2, 1, 16);
INSTABILITY_MAX_ACTIONS = builder.comment("Shared effect-action budget for one Age run.")
.defineInRange("max_actions_per_age", 1, 1, 16);
INSTABILITY_MAX_LEVEL = builder.comment("Maximum effective level passed to an effect handler; saved levels are unchanged.")
.defineInRange("max_effect_level", 3, 1, 16);
INSTABILITY_MAX_CHUNKS = builder.comment("Maximum already-loaded candidate chunks inspected per Age run.")
.defineInRange("max_loaded_chunks", 16, 1, 64);
builder.pop();
builder.push("instability_status_effects");
STATUS_EFFECTS_ENABLED = builder.comment("Allow bounded instability status effects in loaded Ages.")
.define("enabled", true);
STATUS_EFFECTS_PLAYERS = builder.comment("Allow instability status effects on survival/adventure players.")
.define("affect_players", true);
STATUS_EFFECTS_NONPLAYERS = builder.comment("Allow general instability status effects on non-player living entities, as in Legacy.")
.define("affect_nonplayers", true);
STATUS_EFFECTS_HOSTILES = builder.comment("Allow regeneration and resistance on hostile mobs.")
.define("buff_hostiles", true);
STATUS_EFFECTS_MAX_ACTIVE = builder.comment("Maximum Rivencraft-owned active effect leases per Age.")
.defineInRange("max_active_per_age", 32, 1, 128);
STATUS_EFFECTS_MAX_CANDIDATES = builder.comment("Maximum living entities examined in one chosen loaded chunk.")
.defineInRange("max_candidates_per_attempt", 8, 1, 16);
STATUS_EFFECTS_MAX_DURATION = builder.comment("Maximum duration in ticks for one instability status effect.")
.defineInRange("max_duration_ticks", 200, 20, 200);
builder.pop();
SPEC = builder.build();
}
@@ -61,4 +110,20 @@ public final class RivencraftConfig {
public static boolean autoWhitelistSereneAges() {
return AUTO_WHITELIST_SERENE_AGES.get();
}
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.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(); }
public static int instabilityMaxProviders() { return INSTABILITY_MAX_PROVIDERS.get(); }
public static int instabilityMaxActions() { return INSTABILITY_MAX_ACTIONS.get(); }
public static int instabilityMaxLevel() { return INSTABILITY_MAX_LEVEL.get(); }
public static int instabilityMaxChunks() { return INSTABILITY_MAX_CHUNKS.get(); }
public static boolean statusEffectsEnabled() { return STATUS_EFFECTS_ENABLED.get(); }
public static boolean statusEffectsPlayers() { return STATUS_EFFECTS_PLAYERS.get(); }
public static boolean statusEffectsNonplayers() { return STATUS_EFFECTS_NONPLAYERS.get(); }
public static boolean statusEffectsHostiles() { return STATUS_EFFECTS_HOSTILES.get(); }
public static int statusEffectsMaxActive() { return STATUS_EFFECTS_MAX_ACTIVE.get(); }
public static int statusEffectsMaxCandidates() { return STATUS_EFFECTS_MAX_CANDIDATES.get(); }
public static int statusEffectsMaxDuration() { return STATUS_EFFECTS_MAX_DURATION.get(); }
}
@@ -0,0 +1,106 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused classification and transaction checks for Milestone 6 issue #65. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class Milestone6GrammarGameTests {
private Milestone6GrammarGameTests() {}
@GameTest(template = "empty")
public static void writingOutcomesAreDistinct(GameTestHelper helper) {
var terrain = symbol("rivencraft:terrain/standard");
var weather = symbol("rivencraft:weather/normal");
var missing = symbol("example:missing_definition");
var fatal = WritingGrammar.parse(new OrderedWriting(List.of(terrain)));
helper.assertValueEqual(fatal.firstError().orElseThrow().severity(), WritingGrammar.Severity.FATAL,
"A missing Link Panel is fatal");
helper.assertTrue(!WritingGrammar.parse(new OrderedWriting(List.of(WritingEntry.linkPanel(), missing)))
.canCreate(), "An unresolved symbol must remain fatal");
var incomplete = WritingGrammar.parse(new OrderedWriting(List.of(WritingEntry.linkPanel(), terrain)));
helper.assertTrue(incomplete.canCreate() && incomplete.firstIncomplete().isPresent(),
"Missing branches should be seeded rather than rejected");
helper.assertValueEqual(incomplete.firstIncomplete().orElseThrow().severity(),
WritingGrammar.Severity.SEEDED_COMPLETION, "Seeded completion needs its own classification");
helper.assertValueEqual(incomplete.writingInstability(), 0,
"Missing branches alone must not make an Age unstable");
OrderedWriting unstableWriting = new OrderedWriting(List.of(WritingEntry.linkPanel(), weather,
terrain, terrain));
var unstable = WritingGrammar.parse(unstableWriting);
helper.assertTrue(unstable.canCreate() && unstable.firstInstability().isPresent(),
"Supported order and duplicate defects should remain authorable");
helper.assertValueEqual(unstable.writingInstability(), 600,
"A misplaced terrain and duplicate choice should score once each");
helper.assertValueEqual(unstable.symbols().size(), 3,
"The diagnostic must preserve all authored page IDs and order");
helper.assertValueEqual(unstable.symbols().get(2).reference().id(),
ResourceLocation.parse("rivencraft:terrain/standard"),
"The duplicate page must remain in the frozen writing");
helper.succeed();
}
@GameTest(template = "empty")
public static void rejectedBinderKeepsInputsAndUnstablePreview(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3);
var binder = (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos);
helper.assertTrue(binder != null, "Binder must exist");
binder.setTitle("Grammar Transaction " + UUID.randomUUID());
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, page("rivencraft:terrain/standard"));
helper.assertTrue(!binder.canAssemble() && !binder.consumeAssembly(),
"Missing panel must reject before consuming inputs");
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER)
&& binder.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
"A structural failure must preserve cover and page");
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1,
PageItem.createRecoveredPage(new SymbolReference(ResourceLocation.parse("example:missing"))));
helper.assertTrue(!binder.canAssemble() && !binder.consumeAssembly(),
"Missing registry entry must reject without consuming ink or pages");
helper.assertTrue(!binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty()
&& !binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1).isEmpty(),
"Rejected writing must keep the Link Panel and unresolved Page");
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1, page("rivencraft:terrain/standard"));
helper.assertTrue(binder.canAssemble(), "Known duplicate terrain should be accepted as unstable");
helper.assertValueEqual(binder.writingGrammar().writingInstability(), 500,
"Binder must expose the instability cost before assembly");
ItemStack preview = binder.previewBook();
helper.assertTrue(preview.get(ModDataComponents.AGE_REFERENCE.get()) instanceof AgeReference,
"Accepted writing must preview a stable Age identity without consuming inputs");
helper.succeed();
}
private static WritingEntry symbol(String id) {
return WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(id)));
}
private static ItemStack page(String id) {
return PageItem.createSymbolPage(new SymbolReference(ResourceLocation.parse(id)));
}
}
@@ -38,6 +38,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private int selectedPage = -1;
private int grammarErrorCode = -1;
private int grammarErrorStripIndex = -1;
private int grammarInstability;
private int grammarIncompleteCount;
private WritingGrammar.Result serverGrammar;
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
@@ -68,6 +71,22 @@ public final class BookBinderMenu extends AbstractContainerMenu {
}
@Override public void set(int value) { grammarErrorStripIndex = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarInstability
: serverGrammar().writingInstability();
}
@Override public void set(int value) { grammarInstability = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarIncompleteCount
: (int) serverGrammar().diagnostics().stream()
.filter(diagnostic -> diagnostic.severity()
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
}
@Override public void set(int value) { grammarIncompleteCount = value; }
});
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
@@ -87,9 +106,13 @@ public final class BookBinderMenu extends AbstractContainerMenu {
}
public int selectedPage() { return selectedPage; }
private WritingGrammar.Result serverGrammar() {
if (serverGrammar == null) serverGrammar = binder.writingGrammar();
return serverGrammar;
}
private Optional<WritingGrammar.Diagnostic> binderDiagnostic() {
WritingGrammar.Result grammar = binder.writingGrammar();
return grammar.firstError().or(grammar::firstWarning);
WritingGrammar.Result grammar = serverGrammar();
return grammar.firstError().or(grammar::firstInstability).or(grammar::firstIncomplete);
}
public Optional<WritingGrammar.Code> grammarError() {
WritingGrammar.Code[] codes = WritingGrammar.Code.values();
@@ -97,11 +120,13 @@ public final class BookBinderMenu extends AbstractContainerMenu {
? Optional.of(codes[grammarErrorCode]) : Optional.empty();
}
public boolean grammarWarning() {
return grammarError().map(code -> switch (code) {
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> true;
default -> false;
}).orElse(false);
return grammarSeverity().orElse(null) == WritingGrammar.Severity.INSTABILITY;
}
public Optional<WritingGrammar.Severity> grammarSeverity() {
return grammarError().map(WritingGrammar.Code::severity);
}
public int grammarInstability() { return grammarInstability; }
public int grammarIncompleteCount() { return grammarIncompleteCount; }
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
public String title() { return binder == null ? "" : binder.title(); }
public boolean setTitle(Player player, String title) {
@@ -228,6 +253,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
}
@Override public void broadcastChanges() {
serverGrammar = null;
refreshResult();
super.broadcastChanges();
}
@@ -175,22 +175,32 @@ public final class WritingGrammar {
public enum Code {
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE,
UNSUPPORTED_COMBINATION;
public Severity severity() {
return switch (this) {
case INCOMPLETE -> Severity.SEEDED_COMPLETION;
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> Severity.INSTABILITY;
default -> Severity.FATAL;
};
}
}
public enum Severity { FATAL, SEEDED_COMPLETION, INSTABILITY }
public record Diagnostic(Code code, int index, String message) {
public Diagnostic {
Objects.requireNonNull(code, "code");
Objects.requireNonNull(message, "message");
}
public boolean error() {
return switch (code) {
case INCOMPLETE, DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> false;
default -> true;
};
public Severity severity() {
return code.severity();
}
public boolean error() { return severity() == Severity.FATAL; }
/** The known writing defects have an explicit, provisional M6 score. */
public int instability() {
return switch (code) {
@@ -218,7 +228,13 @@ public final class WritingGrammar {
public boolean canCreate() { return diagnostics.stream().noneMatch(Diagnostic::error); }
public Optional<Diagnostic> firstError() { return diagnostics.stream().filter(Diagnostic::error).findFirst(); }
public Optional<Diagnostic> firstWarning() {
return diagnostics.stream().filter(diagnostic -> diagnostic.instability() > 0).findFirst();
return firstInstability();
}
public Optional<Diagnostic> firstInstability() {
return diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Severity.INSTABILITY).findFirst();
}
public Optional<Diagnostic> firstIncomplete() {
return diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Severity.SEEDED_COMPLETION).findFirst();
}
public int writingInstability() {
return diagnostics.stream().mapToInt(Diagnostic::instability).sum();
@@ -51,6 +51,18 @@
"screen.rivencraft.book_binder.grammar.conflict": "Page %s: conflicting page adds instability; the supported choice is used",
"screen.rivencraft.book_binder.grammar.out_of_order": "Page %s: incorrect order adds instability; the Age repairs the order",
"screen.rivencraft.book_binder.grammar.misplaced_modifier": "Page %s: misplaced modifier adds instability; the Age repairs its order",
"screen.rivencraft.book_binder.grammar.incomplete": "Missing branches will be completed from the Age seed",
"screen.rivencraft.book_binder.grammar.unknown_biome": "Page %s: biome is unavailable; restore its data pack",
"screen.rivencraft.book_binder.grammar.unsupported_biome": "Page %s: biome is incompatible with this terrain",
"screen.rivencraft.book_binder.grammar.unsupported_feature": "Page %s: feature is incompatible with this Age",
"screen.rivencraft.book_binder.grammar.unsupported_combination": "These pages cannot generate together; no inputs will be consumed",
"screen.rivencraft.book_binder.status.fatal": "Blocked",
"screen.rivencraft.book_binder.status.unstable": "Unstable +%s",
"screen.rivencraft.book_binder.status.seeded": "Seeded",
"screen.rivencraft.book_binder.status.seeded_count": "%s missing branches will be seeded",
"screen.rivencraft.age.writing.fatal": "Writing blocked",
"screen.rivencraft.age.writing.unstable": "Unstable +%s",
"screen.rivencraft.age.writing.seeded": "Missing parts seeded",
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
"screen.rivencraft.writing_desk.search": "Search",
"screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",