Compare commits

..
3 Commits
Author SHA1 Message Date
dnorberg 4f07f675a4 Refresh public links and include development screenshots
Build / build (push) Canceled after 0s
2026-10-26 14:25:18 -04:00
dnorberg b8be99606f Add configurable random Page lists and safe fallbacks (#95) 2026-10-26 14:25:13 -04:00
dnorberg 98595c83ea Complete Milestone 7 acceptance and binder diagnostics (#84)
Build / build (push) Canceled after 0s
2026-10-22 22:32:12 -04:00
49 changed files with 683 additions and 70 deletions

No files matched your search

+10 -3
View File
@@ -87,14 +87,21 @@ season enrollment, and instability controls. The client setting
An operator can inspect Ages and instability with `/rivencraft age` and find
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
can edit link properties and retire an Age; use it carefully and keep backups.
Binder status messages are off by default. An OP can run
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
to control them; only OPs see the diagnostics even when enabled.
## Project and development
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
and [issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues).
Milestone 7 is in progress; its [regression matrix](docs/MILESTONE_7_REGRESSION.md)
distinguishes automated checks from manual checks and known limitations.
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
with guides for Ages, pages, and crafting, or open an issue with documentation
suggestions and corrections.
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
checks from manual evidence and known limitations.
```sh
./gradlew build
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

+8 -6
View File
@@ -1,6 +1,6 @@
# Current implementation (Milestone 7 development)
This is the maintained technical overview of Rivencraft as of the 0.6.11.0
This is the maintained technical overview of Rivencraft as of the 1.0.0.2
slice. It describes implemented behavior, not every behavior in Mystcraft
Legacy. [The README](../README.md) is the player introduction;
[milestone summaries](MILESTONES.md) explain how the implementation arrived
@@ -41,9 +41,11 @@ and [Age administration](AGES.md).
terrain starts from exposed surfaces. If no safe arrival site exists, the
Age-creation path can construct a non-dropping Crystal Platform at Y=63.
- The server catalog accepts typed data-pack symbols. The Binder preserves
written order and diagnoses conflicts. Symbol Pages are composed at runtime
from Legacy glyph assets; number pages 0–24 set color strength and, when
immediately before a Length, Angle, or Phase modifier, their numeric value.
written order and diagnoses conflicts. Its status readout is off by default;
`/rivencraft binder messages enable` makes it visible only to OPs. Symbol
Pages are composed at runtime from Legacy glyph assets. Number pages 0–24
set color strength or, immediately before a Length, Angle, Phase, or Size
modifier, its numeric value.
Celestial pages can create multiple suns/moons with independent color, size,
angle, period, phase, and direction; authored eclipses affect light.
- The Writing Desk stores tabs and copies known Symbol Pages with paper and
@@ -92,5 +94,5 @@ This is not an exact port of every Legacy generator, page, hazard, or client
renderer. Modern safety budgets and NeoForge's dimension/biome APIs require
adaptations. The [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md)
separates tested behavior from unverified multiplayer/restart/optional-mod
combinations. Issue [#84](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/84)
is the final Milestone 7 acceptance and release gate.
combinations. The [Milestone 7 acceptance record](MILESTONE_7_ACCEPTANCE.md)
records release evidence and remaining validation limits.
+6 -5
View File
@@ -54,12 +54,13 @@ adaptations. See [M6 acceptance](MILESTONE_6_ACCEPTANCE.md),
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), and
[runtime policy](INSTABILITY_RUNTIME.md).
## 7 — Structures and polish (in progress)
## 7 — Structures and polish (complete in 1.0.0.2)
Added Legacy-layout Age libraries, page-bearing stands, crystal portals and
receptacles, the handheld dropped-book entity, data-driven recipes,
advancements, asset/audio polish, optional Ruins, compatibility updates, and
expanded sky/glyph pages. Issue #83 covers regression, migration, and document
consolidation; issue #84 is the final milestone acceptance gate. See the
[M7 Legacy audit](LEGACY_M7_AUDIT.md), [M7 regression matrix](MILESTONE_7_REGRESSION.md),
and [M7 compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md).
expanded sky/glyph pages. Issue #83 covered regression, migration, and document
consolidation; issue #84 records the final milestone acceptance. See the
[M7 acceptance](MILESTONE_7_ACCEPTANCE.md), [M7 Legacy audit](LEGACY_M7_AUDIT.md),
[M7 regression matrix](MILESTONE_7_REGRESSION.md), and
[M7 compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md).
+72
View File
@@ -0,0 +1,72 @@
# Milestone 7 acceptance — 1.0.0.2
Milestone 7 is complete for the supported NeoForge 1.21.1 feature set. This is
not a claim of total Mystcraft Legacy parity or exhaustive optional-mod
certification. The [current implementation](CURRENT_IMPLEMENTATION.md) is the
up-to-date behavior summary; the [regression matrix](MILESTONE_7_REGRESSION.md)
separates automated evidence from manual scenarios.
## Scope and issue gate
The Gitea audit for issue #84 found no other open issues. Required existing
#14 (cave terrain), #43 (glyph numbers), #44 (sky), and #60 (old-save policy)
were closed. M7 implementation and review issues #75–#83, #85–#90, and
later additions #91–#94 were closed before this acceptance. No required
issue was silently deferred.
| Legacy concept | Rivencraft implementation or deliberate difference |
| --- | --- |
| Cave terrain and writing | Cave terrain uses its cave/Nether biome set and a modern Y=64-start spawn search. Existing non-cave terrain still supports caves as features. See [Age profiles](AGE_PROFILES.md) and [grammar](WRITING_GRAMMAR.md). |
| Libraries | The recognizable Legacy small-library layout and sparse candidate spacing are ported as a structure template with a chest and up to five page-bearing Book Stands. It generates only in Ages and only on suitable surfaces; player-selectable Legacy or Age-only loot is supported. See [Legacy M7 audit](LEGACY_M7_AUDIT.md). |
| Crystal portal and Receptacle | Connected Crystal Blocks support vertical and horizontal portals. The held book stays in the Receptacle; removing it removes the surface. Rivencraft validates loaded bounds and block protection, saves owner pointers, and uses book-travel rules. Optional Immersive Portals supplies a live destination view, with an animated fallback while unavailable. |
| Handheld Linking/Descriptive Books | The used book remains behind as a rendered book entity at the origin. The exact Legacy entity pickup rule is not copied: modern contact pickup is used. Return Links default to disabled but remain configurable. |
| Recipes and discovery | JSON crafting recipes and recipe-book unlocks cover the supported writing/crystal progression. Optional JEI covers the Binder, Writing Desk, and Ink Mixer; a Lectern conversion has no JEI guide. Blank Pages are not craftable. Optional Create/Ponder teaches portal construction. See [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Models, sounds, and advancements | Legacy-sourced assets are credited in [asset provenance](ASSET_PROVENANCE.md); 1.21.1-compatible models and renderer adaptations are documented in the [visual audit](MILESTONE_7_VISUAL_AUDIT.md). Server-owned advancements cover book, writing, Age, and portal progress; the unimplemented Quinn equivalent is explicitly omitted in [advancement notes](MILESTONE_7_ADVANCEMENTS.md). |
| Numbers, sky, and extra items | Base-25 number glyphs, celestial modifiers and eclipses, Symbol Portfolios, the OP-only Link Modifier, and optional Ruins pages were follow-up additions. Legacy page semantics are adapted to the modern authoring plan rather than a binary copy of 1.12.2 worldgen. |
## Compatibility and migration
NeoForge **21.1.264** is the minimum 21.1 build. A player passed the
singleplayer client with the optional stack, then a local dedicated server
including Immersive Portals, before the minimum was raised. See the
[compatibility decision](MILESTONE_7_COMPATIBILITY_REVIEW.md). JEI
19.69.0.457 and Serene Seasons 10.1.0.9 received their own player checks;
Create/Ponder, Immersive Portals, TerraBlender/biome add-ons, Serene Seasons,
JEI, and Ruins remain optional. Ecliptic Seasons is deliberately not
integrated: its 1.21.1 public API does not support the required live Age
registration and per-Age calendar control. Missing optional mods must not
prevent the core mod from loading.
Known older writing/book, Age-plan, and workstation formats have explicit
migration or stable-default handling. An Age registry and its dimension
folders must be backed up together. Removed mods, missing custom registry
objects, and arbitrary prerelease saves are **not** guaranteed to load.
Retiring an Age through the creative Link Modifier archives its data and
invalidates its UUID; that intentional destructive operation is not a
migration. See the [old-save boundary](MILESTONE_7_REGRESSION.md#old-save-boundary-60).
## Release validation and limits
The 0.6.11.0 regression candidate passed `./gradlew --offline build` and
fresh-world GameTest runs: core **91/91**, generation **129/129**, and
environment **103/103**. The player reported an in-game pass for that
candidate. The 1.0.0.1 release candidate additionally allows Number Pages to
set Celestial Size. The 1.0.0.2 release makes Binder status diagnostics an
OP-only, default-off world setting. Its offline build and core GameTests
passed (93/93), and the player reported an in-game pass. The
preceding 1.0.0.0 candidate passed core and generation GameTests. Its
environment archive stalled in the first 50-test batch, repeatedly printing
its field without completing; it was stopped and is **not** counted as a
1.0.0.0–1.0.0.2 pass. The previous 0.6.11.0 run of that same suite passed 103/103.
The focused sky, season, and number-page in-game checks in the regression
matrix remain useful regression checks, not claimed full-matrix passes. The
Gitea release changelog records the tested JAR and verified SHA-256.
These results do not prove every two-player race, full restart, migration
from every development world, or optional-mod combination. The specific
manual matrix remains in [M7 regression](MILESTONE_7_REGRESSION.md); those
broader combinations are known validation limits, not claimed passes. Modern
chunk and tick budgets, safer portal validation, 1.21.1 world-height and
biome APIs, and the dynamic-dimension backend are deliberate deviations from
Legacy. A complete Legacy worldgen or link-property port is outside this
milestone.
+12 -4
View File
@@ -1,7 +1,7 @@
# Milestone 7 regression and migration matrix (issue #83)
This is a **working** acceptance record for the 0.6.11.0 candidate, not the
Milestone 7 completion sign-off. Issue #84 owns the final gate. A passing
This is the dated regression record for the 0.6.11.0 candidate. The
[Milestone 7 acceptance](MILESTONE_7_ACCEPTANCE.md) owns the final sign-off. A passing
GameTest is server-side evidence; it is not proof of client rendering,
multiplayer timing, or every optional-mod combination.
@@ -11,7 +11,7 @@ multiplayer timing, or every optional-mod combination.
| --- | --- | --- |
| Dedicated server, core linking, book/Age identity, old writing and workstation formats | `./gradlew --offline runGameTestServer`; includes `CoreGameTests`, `AgePersistenceGameTests`, `CrystalPortalGameTests`, M7 crafting/advancement checks | 91/91 required GameTests passed on NeoForge 21.1.264; clean shutdown and fresh-world removal. |
| Deterministic structures and terrain | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=generation`; includes library template/spacing/loot/stand checks, cave profiles, vanilla structures, crystal formations, fissures | 129/129 passed after correcting one village test to assert its own contribution rather than a seed-dependent total; fresh-world removal. |
| Sky, glyph numbers, seasons, creative/JEI page feed | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=environment` | 103/103 passed on the current candidate, including issue #94's number-page check; fresh-world removal. |
| Sky, glyph numbers, seasons, creative/JEI page feed | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=environment` | 103/103 passed on the 0.6.11.0 candidate, including issue #94's number-page check; fresh-world removal. |
| Packaged models and data | `Milestone7ResourceGameTests.coreModelsResolveBundledTextures` in the core suite; parses critical block models, checks their Rivencraft texture references and the library NBT resource | Passed in the 91-test core run. This checks resource presence, not visual orientation or transparency. |
| Recipe behavior | `Milestone7CraftingGameTests` and `LinkingBookRecipeGameTests` through the loaded Recipe Manager | Passed in the core run; confirms crystal and receptacle quantities, ink bucket return, no Blank Page crafting, and Linking Book ingredients. |
| Portal state/save-load | `CrystalPortalGameTests` plus `AgePersistenceGameTests` in the core suite | Passed; covers activation/release, fallback orientation, horizontal/underside placement, uninterrupted surface, cooldown, retired Age and seed/plan identity. Full server restart remains manual. |
@@ -29,7 +29,14 @@ and deletes it after pass or fail. See [GameTest guidance](GAMETESTS.md).
Do not retry a storage-stalled GameTest: stop it, remove that automated
scenario, and give its exact steps to the player. No run in this issue has
stalled so far. The older full-archive storage stall is historical and is not
counted as a current pass.
counted as a current pass. On the subsequent **1.0.0.0 release candidate**,
the environment archive stalled during its first 50-test batch and was
stopped. The 103/103 result above applies to 0.6.11.0 only, not to 1.0.0.0
or the later 1.0.0.1–1.0.0.2 candidates. For the 1.0.0.2 manual handoff, create an Age
with Number Pages immediately before Celestial Angle, Phase, and Size pages;
confirm each affects the intended body rather than becoming a stray page. Also verify a
fixed-season Age remains fixed across a restart while the Overworld remains
independent. Do not rerun the stalled suite merely to obtain a green count.
## Old-save boundary (#60)
@@ -60,6 +67,7 @@ this candidate.
| Optional renderers | With neither Create nor Immersive Portals, confirm Legacy fallback. With Create alone, confirm Ponder snapshots and portal guide. With Immersive Portals, confirm live view, Explore exit, sky/sound isolation, portal travel, and fallback while chunks load. With both, `AUTO` should choose Immersive Portals. |
| Optional world/recipe mods | Test JEI guides/search, TerraBlender with a biome-producing addon (for example Biomes O' Plenty), Serene's six season modes plus Overworld independence/restart, and Ruins template selection/spawn weighting. Repeat startup without optional mods. |
| Migration | Copy a backed-up older development world. Verify books and Notebooks retain their Age/page IDs, a desk and stand keep contents, old Ages load or provide a clear error, and removing a custom symbol retains its unresolved ID. Do not test by modifying the only copy. |
| Binder diagnostics | As a non-OP, verify the Binder still creates books but shows no instability/seeded/status messages. As an OP, run `/rivencraft binder messages enable`, reopen the Binder, and verify those messages appear only for the OP. Disable them and restart the world; the setting should remain disabled. |
Historical evidence: the player approved NeoForge 21.1.264 on singleplayer
and a dedicated server with Immersive Portals; JEI 19.69.0.457 guides, Serene
+1 -1
View File
@@ -13,7 +13,7 @@ former behavior or loader version.
| M4 | [Writing data](WRITING_DATA.md), [book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), [Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md) |
| M5 | [Acceptance](MILESTONE_5_ACCEPTANCE.md), [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [writing grammar](WRITING_GRAMMAR.md), [symbol semantics](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md), [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md) |
| M6 | [Acceptance](MILESTONE_6_ACCEPTANCE.md), [regression](MILESTONE_6_REGRESSION.md), [Legacy audit](LEGACY_INSTABILITY_AUDIT.md), [score](INSTABILITY_SCORE.md), [live blocks](LIVE_BLOCK_INSTABILITY.md), [runtime](INSTABILITY_RUNTIME.md), [diagnostics](INSTABILITY_DIAGNOSTICS.md), [status effects](INSTABILITY_STATUS_EFFECTS.md), [decay](INSTABILITY_DECAY.md), [environmental hazards](INSTABILITY_ENVIRONMENTAL.md), [cloud platform](AGE_CLOUD_PLATFORM.md) |
| M7 | [Regression and manual matrix](MILESTONE_7_REGRESSION.md), [Legacy audit](LEGACY_M7_AUDIT.md), [visual/audio](MILESTONE_7_VISUAL_AUDIT.md), [advancements](MILESTONE_7_ADVANCEMENTS.md), [JEI](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md), [compatibility](MILESTONE_7_COMPATIBILITY_REVIEW.md), [Ruins](RUINS_COMPATIBILITY.md) |
| M7 | [Acceptance](MILESTONE_7_ACCEPTANCE.md), [regression and manual matrix](MILESTONE_7_REGRESSION.md), [Legacy audit](LEGACY_M7_AUDIT.md), [visual/audio](MILESTONE_7_VISUAL_AUDIT.md), [advancements](MILESTONE_7_ADVANCEMENTS.md), [JEI](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md), [compatibility](MILESTONE_7_COMPATIBILITY_REVIEW.md), [Ruins](RUINS_COMPATIBILITY.md) |
[Asset provenance](ASSET_PROVENANCE.md) applies across milestones. Detailed
audits are intentionally retained for source attribution and migration
+1
View File
@@ -35,6 +35,7 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
| `size_modifier` | JSON scale in `[0.25, 3]`. An immediately preceding D'ni number page overrides it with a linear 0.25×–3× scale: digit 0 is 0.25× and digit 24 is 3×. Without a number, Small/Normal/Large remain 0.5×/1×/2×. |
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
+1 -1
View File
@@ -36,7 +36,7 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.11.0
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=pHEgQQUE
@@ -8,6 +8,7 @@ import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.Holder;
@@ -34,6 +35,12 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries) {
return compile(writing, traits, profile, seed, registries, RandomPagePolicy.UNRESTRICTED);
}
public static AgeBiomeSelection compile(WritingGrammar.Result writing, AgeTraits traits,
AgeRuntimeProfile profile, long seed, RegistryAccess registries,
RandomPagePolicy policy) {
List<ResourceLocation> authored = writing.symbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.BIOME)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -67,10 +74,26 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
+ " or a TerraBlender Nether region");
}
}
List<ResourceLocation> availableBiomes = eligible(registries, profile);
if (availableBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> terrainBiomes = eligible(registries, profile);
if (terrainBiomes.isEmpty()) throw new IllegalArgumentException("No eligible biomes for terrain " + profile.noiseSettings());
List<ResourceLocation> availableBiomes = distribution == AgeTraits.BiomeDistribution.NATIVE
? terrainBiomes : new ArrayList<>(policy.eligible(terrainBiomes, BiomeSymbolPages::symbolId));
if (distribution != AgeTraits.BiomeDistribution.NATIVE
&& new LinkedHashSet<>(availableBiomes).size() < minimum(distribution)) {
ResourceLocation preferred = profile.terrain() == AgeRuntimeProfile.Terrain.VOID
? ResourceLocation.withDefaultNamespace("the_void")
: cave ? ResourceLocation.withDefaultNamespace("deep_dark")
: ResourceLocation.withDefaultNamespace("plains");
if (terrainBiomes.contains(preferred) && !availableBiomes.contains(preferred))
availableBiomes.add(preferred);
for (ResourceLocation fallback : terrainBiomes) {
if (new LinkedHashSet<>(availableBiomes).size() >= minimum(distribution)) break;
if (!availableBiomes.contains(fallback)) availableBiomes.add(fallback);
}
}
if (distribution == AgeTraits.BiomeDistribution.NATIVE) {
if (!authored.isEmpty()) throw new IllegalArgumentException("Native biome distribution cannot use biome pages");
// Native noise biomes are supplied by Minecraft's preset, not chosen as Pages.
return new AgeBiomeSelection(distribution, profile.terrain() == AgeRuntimeProfile.Terrain.FLAT
? List.of(ResourceLocation.withDefaultNamespace("plains"))
: isCave(profile) ? availableBiomes : presetBiomes(registries, profile));
@@ -23,7 +23,8 @@ public record AgeDefinition(
@Nullable AgeSeasonMode seasonMode,
List<AgeFeatureSelection.Choice> randomFeatures,
AgeInstability instability,
AgeInstabilityPlan instabilityPlan
AgeInstabilityPlan instabilityPlan,
RandomPagePolicy randomPagePolicy
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -39,6 +40,7 @@ public record AgeDefinition(
Objects.requireNonNull(traits, "traits");
Objects.requireNonNull(instability, "instability");
Objects.requireNonNull(instabilityPlan, "instabilityPlan");
Objects.requireNonNull(randomPagePolicy, "randomPagePolicy");
randomFeatures = List.copyOf(randomFeatures);
if (plan != null && !randomFeatures.isEmpty()) {
throw new IllegalArgumentException("Authored Ages cannot also have random features");
@@ -88,6 +90,16 @@ public record AgeDefinition(
AgeInstabilityPlan.empty());
}
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, AgeInstabilityPlan instabilityPlan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, instability,
instabilityPlan, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation dimensionId(UUID ageId) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + ageId);
}
@@ -117,7 +129,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, instability, instabilityPlan);
randomFeatures, instability, instabilityPlan, randomPagePolicy);
}
/** A frozen authored profile takes precedence over the profile ID on old books. */
@@ -465,7 +465,7 @@ public final class AgeDimensionService {
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
? age.plan().biomeSelection()
: AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
age.traits(), profile, age.seed(), server.registryAccess());
age.traits(), profile, age.seed(), server.registryAccess(), age.randomPagePolicy());
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = new ArrayList<>();
List<ResourceLocation> fallbacks = AgeBiomeSelection.eligible(server.registryAccess(), profile);
@@ -158,12 +158,19 @@ public final class AgeFeatureSelection {
/** Page-free Ages roll up to two material features, plus rare Skylands, once at creation. */
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) {
return randomChoices(ageSeed, profile, registries, RandomPagePolicy.UNRESTRICTED);
}
public static List<Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries,
RandomPagePolicy policy) {
Random random = new Random(ageSeed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
List<LegacyRankChoice.Option<ResourceLocation>> features = new ArrayList<>();
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : BLOCK_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null)
for (ResourceLocation id : FLUID_FEATURES) if (error(id, profile, registries) == null
&& policy.allows(featurePage(id)))
features.add(new LegacyRankChoice.Option<>(id, randomMaterialRank(id)));
List<Choice> choices = new ArrayList<>();
while (choices.size() < count && !features.isEmpty()) {
@@ -172,23 +179,30 @@ public final class AgeFeatureSelection {
var category = solidFeature(feature)
? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY;
List<ResourceLocation> materials = BlockSymbolPages.availableBlocks(category).stream()
.filter(id -> policy.allows(BlockSymbolPages.symbolId(id)))
.filter(id -> materialError(new Choice(feature, Optional.of(id))) == null).toList();
if (!materials.isEmpty()) choices.add(new Choice(feature,
Optional.of(materials.get(random.nextInt(materials.size())))));
}
if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE
&& policy.allows(featurePage(id("skylands")))
&& Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0)
choices.add(new Choice(id("skylands"), Optional.empty()));
if (error(CRYSTAL_FORMATIONS, profile, registries) == null && random.nextInt(10) == 0)
if (error(CRYSTAL_FORMATIONS, profile, registries) == null
&& policy.allows(featurePage(CRYSTAL_FORMATIONS)) && random.nextInt(10) == 0)
choices.add(new Choice(CRYSTAL_FORMATIONS, Optional.empty()));
if (randomStarFissure(ageSeed))
if (policy.allows(featurePage(STAR_FISSURES)) && randomStarFissure(ageSeed))
choices.add(new Choice(STAR_FISSURES, Optional.empty()));
AgeBiomeSelection biomes = AgeBiomeSelection.compile(WritingGrammar.parse(OrderedWriting.EMPTY),
AgeTraits.select(ageSeed), profile, ageSeed, registries);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries));
AgeTraits.select(ageSeed, policy), profile, ageSeed, registries, policy);
choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries, policy));
return List.copyOf(choices);
}
private static ResourceLocation featurePage(ResourceLocation id) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath());
}
static int randomMaterialRank(ResourceLocation feature) {
if (feature.equals(id("surface_lakes")) || feature.equals(id("deep_lakes"))) return 1;
if (feature.equals(id("lava_lakes"))) return 2; // New separate fluid choice.
@@ -56,6 +56,11 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries, long ageSeed) {
return compute(plan, traits, registries, ageSeed, RandomPagePolicy.UNRESTRICTED);
}
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries,
long ageSeed, RandomPagePolicy policy) {
Objects.requireNonNull(traits, "traits");
List<Contribution> contributions = new ArrayList<>();
if (plan == null) {
@@ -87,7 +92,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
"Seeded bright lighting"));
}
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits)
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits, policy)
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
: AgeSkyProfile.compile(plan, traits);
int seenSuns = 0;
@@ -39,6 +39,7 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String TRAITS_TAG = "Traits";
private static final String PLAN_TAG = "GenerationPlan";
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
private static final String RANDOM_PAGE_POLICY_TAG = "RandomPagePolicy";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String INSTABILITY_TAG = "Instability";
private static final String INSTABILITY_PLAN_TAG = "InstabilityPlan";
@@ -77,9 +78,19 @@ public final class AgeRegistrySavedData extends SavedData {
ServerLevel overworld = level.getServer().overworld();
AgeRegistrySavedData data = get(overworld);
long seed = ageSeed(overworld.getSeed(), data.availableDimensionId(normalizedName(name)));
AgeGenerationPlan.compile(writing, seed,
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed,
AgeRuntimeProfiles.selectForWriting(seed, writing, overworld.registryAccess()), AgeTraits.select(seed),
overworld.registryAccess());
if (plan == null) {
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation profileId = AgeRuntimeProfiles.select(seed, policy);
AgeRuntimeProfile profile = AgeRuntimeProfiles.resolve(profileId);
AgeTraits traits = AgeTraits.select(seed, policy);
AgeBiomeSelection.compile(net.dnorberg.rivencraft.writing.WritingGrammar.parse(OrderedWriting.EMPTY),
traits, profile, seed, overworld.registryAccess(), policy);
AgeSkyProfile.random(seed, traits, policy);
AgeSeasonMode.select(null, seed, ModList.get().isLoaded("sereneseasons"), profile, policy);
}
}
public static ResourceLocation previewGenerationProfile(ServerLevel level, String name) {
@@ -155,7 +166,8 @@ public final class AgeRegistrySavedData extends SavedData {
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
previous.randomFeatures(), previous.instability(), previous.instabilityPlan(),
previous.randomPagePolicy());
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
return false;
ages.put(ageId, changed);
@@ -173,13 +185,19 @@ public final class AgeRegistrySavedData extends SavedData {
String name = normalizedName(reference.name());
ResourceLocation dimensionId = availableDimensionId(name);
long seed = ageSeed(worldSeed, dimensionId);
RandomPagePolicy policy = RandomPagePolicy.configured();
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
AgeTraits traits = AgeTraits.select(seed);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
if (plan == null) {
seedProfile = AgeRuntimeProfiles.select(seed, policy);
traits = AgeTraits.select(seed, policy);
}
AgeTraits effectiveTraits = plan == null
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
: AgeEnvironmentCompiler.apply(plan, traits);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed);
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED);
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
AgeDefinition created = new AgeDefinition(
reference.id(),
@@ -193,11 +211,13 @@ public final class AgeRegistrySavedData extends SavedData {
effectiveTraits,
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile(),
plan == null ? policy : RandomPagePolicy.UNRESTRICTED),
plan == null ? AgeFeatureSelection.randomChoices(seed,
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
AgeRuntimeProfiles.resolve(seedProfile), registries, policy) : List.of(),
instability,
instabilityPlan
instabilityPlan,
plan == null ? policy : RandomPagePolicy.UNRESTRICTED
);
ages.put(created.id(), created);
setDirty();
@@ -264,7 +284,10 @@ public final class AgeRegistrySavedData extends SavedData {
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
readRandomFeatures(entry),
instability,
instabilityPlan
instabilityPlan,
entry.contains(RANDOM_PAGE_POLICY_TAG, CompoundTag.TAG_COMPOUND)
? RandomPagePolicy.load(entry.getCompound(RANDOM_PAGE_POLICY_TAG))
: RandomPagePolicy.UNRESTRICTED
));
}
return data;
@@ -309,6 +332,7 @@ public final class AgeRegistrySavedData extends SavedData {
entry.put(RANDOM_FEATURES_TAG, featureTags);
}
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
if (age.plan() == null) entry.put(RANDOM_PAGE_POLICY_TAG, age.randomPagePolicy().save());
entry.put(INSTABILITY_TAG, age.instability().save());
entry.put(INSTABILITY_PLAN_TAG, age.instabilityPlan().save());
serializedAges.add(entry);
@@ -57,20 +57,39 @@ public final class AgeRuntimeProfiles {
/** Selects from all valid loaded profiles in stable resource-ID order. */
public static ResourceLocation select(long seed) {
return select(seed, RandomPagePolicy.UNRESTRICTED);
}
public static ResourceLocation select(long seed, RandomPagePolicy policy) {
if (PROFILES.isEmpty()) {
return AgeGenerationProfiles.select(seed);
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(
AgeGenerationProfiles.all().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id)))
.toList(), option -> terrainPage(option.value()),
new LegacyRankChoice.Option<>(AgeGenerationProfiles.STANDARD, 1));
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
List<LegacyRankChoice.Option<ResourceLocation>> profiles = PROFILES.keySet().stream().sorted()
List<LegacyRankChoice.Option<ResourceLocation>> available = PROFILES.keySet().stream().sorted()
.map(id -> new LegacyRankChoice.Option<>(id, AgeGenerationProfiles.randomRank(id))).toList();
LegacyRankChoice.Option<ResourceLocation> fallback = available.stream()
.filter(option -> option.value().equals(AgeGenerationProfiles.STANDARD))
.findFirst().orElse(available.getFirst());
List<LegacyRankChoice.Option<ResourceLocation>> profiles = policy.required(available,
option -> terrainPage(option.value()), fallback);
return LegacyRankChoice.choose(new Random(seed ^ 0x5445525241494E4CL), profiles);
}
private static ResourceLocation terrainPage(ResourceLocation profile) {
return ResourceLocation.fromNamespaceAndPath("rivencraft", "terrain/" + profile.getPath());
}
/** Keep seed-selected terrain random, but only among profiles able to host authored features. */
public static ResourceLocation selectForWriting(long seed, OrderedWriting writing, RegistryAccess registries) {
ResourceLocation selected = select(seed);
WritingGrammar.Result parsed = WritingGrammar.parse(writing);
if (parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN)) return selected;
boolean authoredTerrain = parsed.activeSymbols().stream().anyMatch(symbol ->
symbol.semantics().role() == SymbolSemantics.Role.TERRAIN);
ResourceLocation selected = select(seed);
if (authoredTerrain) return selected;
List<ResourceLocation> features = parsed.activeSymbols().stream()
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id()).toList();
@@ -24,15 +24,23 @@ public enum AgeSeasonMode {
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile) {
return select(plan, ageSeed, sereneInstalled, profile, RandomPagePolicy.UNRESTRICTED);
}
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
AgeRuntimeProfile profile, RandomPagePolicy policy) {
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), policy.required(List.of(
new LegacyRankChoice.Option<>(SEASONS, 1),
new LegacyRankChoice.Option<>(NO_SEASONS, 2),
new LegacyRankChoice.Option<>(ETERNAL_WINTER, 3),
new LegacyRankChoice.Option<>(ETERNAL_SUMMER, 3),
new LegacyRankChoice.Option<>(ETERNAL_FALL, 3),
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3))) : null;
new LegacyRankChoice.Option<>(ETERNAL_SPRING, 3)),
option -> net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("rivencraft",
"season/" + option.value().name().toLowerCase(Locale.ROOT)),
new LegacyRankChoice.Option<>(NO_SEASONS, 2))) : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.minecraft.resources.ResourceLocation;
/** The ordered, deterministic sky specification for an Age. No client data is trusted here. */
public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
@@ -23,16 +24,25 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
public static AgeSkyProfile forAge(AgeDefinition age) {
if (age.plan() != null) return compile(age.plan(), age.traits());
if (age.generationVersion() >= RANDOM_SKY_GENERATION_VERSION)
return random(age.seed(), age.traits());
return random(age.seed(), age.traits(), age.randomPagePolicy());
return new AgeSkyProfile(Style.INHERITED, -1, List.of());
}
public static AgeSkyProfile random(long seed, AgeTraits traits) {
return random(seed, traits, RandomPagePolicy.UNRESTRICTED);
}
public static AgeSkyProfile random(long seed, AgeTraits traits, RandomPagePolicy policy) {
Random random = new Random(seed ^ 0x534B5950524F464CL);
Style style = Style.values()[random.nextInt(Style.values().length)];
List<Style> styles = policy.required(java.util.Arrays.stream(Style.values())
.filter(style -> style != Style.INHERITED || !policy.restricts("sky")).toList(),
AgeSkyProfile::skyPage, Style.INHERITED);
Style style = styles.get(random.nextInt(styles.size()));
int color = randomColor(random);
int suns = random.nextInt(4);
int moons = random.nextInt(4);
int suns = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/sun"))
? random.nextInt(4) : 0;
int moons = policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "celestial/moon"))
? random.nextInt(4) : 0;
List<Body> bodies = new ArrayList<>();
for (int i = 0; i < suns; i++)
bodies.add(new Body(Kind.SUN, randomColor(random),
@@ -45,6 +55,11 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
return new AgeSkyProfile(style, color, bodies);
}
private static ResourceLocation skyPage(Style style) {
return ResourceLocation.fromNamespaceAndPath("rivencraft",
"sky/" + (style == Style.INHERITED ? "inherited" : style.name().toLowerCase(java.util.Locale.ROOT)));
}
public static AgeSkyProfile compile(AgeGenerationPlan plan, AgeTraits traits) {
if (plan == null) return new AgeSkyProfile(Style.INHERITED, -1, List.of());
Style style = Style.INHERITED;
@@ -86,7 +101,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case SIZE_MODIFIER -> size = (float) CelestialPageValues.value(semantics, number);
case CELESTIAL_COLOR -> bodyColors.add(color(
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
case DIRECTION_MODIFIER -> clockwise = ((SymbolSemantics.ChoiceValue) semantics.value())
@@ -157,7 +157,14 @@ public final class AgeStructureSelection {
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries) {
return randomChoices(ageSeed, profile, biomes, registries, RandomPagePolicy.UNRESTRICTED);
}
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
AgeBiomeSelection biomes, RegistryAccess registries,
RandomPagePolicy policy) {
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
.filter(id -> policy.allows(ResourceLocation.fromNamespaceAndPath("rivencraft", "feature/" + id.getPath())))
.filter(id -> error(id, profile, biomes, registries) == null)
.filter(id -> dungeons(id) || biomeCompatible(id, biomes, registries))
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
@@ -2,6 +2,8 @@ package net.dnorberg.rivencraft.book;
import java.util.Random;
import java.util.List;
import java.util.Locale;
import net.minecraft.resources.ResourceLocation;
/** Immutable random-Age traits selected once from the Age seed. */
public record AgeTraits(
@@ -42,8 +44,12 @@ public record AgeTraits(
}
public static AgeTraits select(long seed) {
return select(seed, RandomPagePolicy.UNRESTRICTED);
}
public static AgeTraits select(long seed, RandomPagePolicy policy) {
Random random = new Random(seed ^ 0x524956454E435241L);
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, List.of(
BiomeDistribution biomeDistribution = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.SINGLE, 1),
new LegacyRankChoice.Option<>(BiomeDistribution.MEDIUM, 1),
@@ -52,12 +58,16 @@ public record AgeTraits(
new LegacyRankChoice.Option<>(BiomeDistribution.TINY, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.SMALL, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.LARGE, 2),
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)));
Lighting lighting = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(BiomeDistribution.HUGE, 2)),
option -> page("biome", option.value()),
new LegacyRankChoice.Option<>(BiomeDistribution.NATIVE, 1)));
Lighting lighting = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1),
new LegacyRankChoice.Option<>(Lighting.DARK, 2),
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)));
Weather weather = LegacyRankChoice.choose(random, List.of(
new LegacyRankChoice.Option<>(Lighting.BRIGHT, 2)),
option -> page("lighting", option.value()),
new LegacyRankChoice.Option<>(Lighting.NORMAL, 1)));
Weather weather = LegacyRankChoice.choose(random, policy.required(List.of(
new LegacyRankChoice.Option<>(Weather.NORMAL, 1),
new LegacyRankChoice.Option<>(Weather.FAST, 2),
new LegacyRankChoice.Option<>(Weather.SLOW, 2),
@@ -66,7 +76,9 @@ public record AgeTraits(
new LegacyRankChoice.Option<>(Weather.CLOUDY, 2),
new LegacyRankChoice.Option<>(Weather.RAIN, 2),
new LegacyRankChoice.Option<>(Weather.SNOW, 2),
new LegacyRankChoice.Option<>(Weather.STORM, 2)));
new LegacyRankChoice.Option<>(Weather.STORM, 2)),
option -> page("weather", option.value()),
new LegacyRankChoice.Option<>(Weather.NORMAL, 1)));
// Legacy's PERIOD_BASIC grammar gives ModZero rank 2 and its other
// three length modifiers rank 1, producing an effective 1-in-7 weight.
boolean stopped = random.nextInt(7) == 0;
@@ -81,6 +93,11 @@ public record AgeTraits(
);
}
private static ResourceLocation page(String category, Enum<?> value) {
return ResourceLocation.fromNamespaceAndPath("rivencraft",
category + "/" + value.name().toLowerCase(Locale.ROOT));
}
public boolean hasStoppedCelestialCycle() {
return celestialPeriod == STOPPED_CELESTIAL_PERIOD;
}
@@ -11,6 +11,7 @@ final class CelestialPageValues {
return switch (semantics.role()) {
case PERIOD_MODIFIER -> number * 0.08D;
case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D;
case SIZE_MODIFIER -> 0.25D + number * (2.75D / 24.0D);
default -> throw new IllegalArgumentException("Not a celestial numeric modifier");
};
}
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
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.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Focused core coverage without launching the storage-heavy environment archive. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CelestialSizeGameTests {
private CelestialSizeGameTests() {}
@GameTest(template = "empty")
public static void adjacentNumberSetsNextCelestialSize(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("number/0"), page("size/small"), page("celestial/sun"),
page("number/24"), page("size/large"), page("celestial/moon"),
page("number/12"), page("size/normal"), page("celestial/sun")));
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
"Adjacent number and size pages must be valid writing");
AgeTraits seeded = AgeTraits.select(94L);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeSkyProfile sky = AgeSkyProfile.compile(plan, AgeEnvironmentCompiler.apply(plan, seeded));
helper.assertValueEqual(sky.bodies().size(), 3, "Three authored bodies must remain distinct");
helper.assertValueEqual(sky.bodies().get(0).size(), 0.25F, "Digit zero selects minimum size");
helper.assertValueEqual(sky.bodies().get(1).size(), 3.0F, "Digit 24 selects maximum size");
helper.assertValueEqual(sky.bodies().get(2).size(), 1.625F, "Digit 12 selects midpoint size");
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()),
AgeEnvironmentCompiler.apply(plan, seeded)), sky,
"Numbered sizes must survive an Age-plan save/load round trip");
OrderedWriting plain = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("size/small"), page("celestial/sun"),
page("size/normal"), page("celestial/moon"),
page("size/large"), page("celestial/sun")));
AgeGenerationPlan plainPlan = AgeGenerationPlan.compile(plain, 95L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(95L), helper.getLevel().registryAccess());
AgeSkyProfile plainSky = AgeSkyProfile.compile(plainPlan,
AgeEnvironmentCompiler.apply(plainPlan, AgeTraits.select(95L)));
helper.assertValueEqual(plainSky.bodies().get(0).size(), 0.5F, "Small retains its fixed value");
helper.assertValueEqual(plainSky.bodies().get(1).size(), 1.0F, "Normal retains its fixed value");
helper.assertValueEqual(plainSky.bodies().get(2).size(), 2.0F, "Large retains its fixed value");
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("number/24"), page("direction/clockwise"),
page("size/large"), page("celestial/sun")));
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
"An intervening page must not allow a number to modify size");
helper.succeed();
}
private static WritingEntry page(String path) {
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
}
}
@@ -43,8 +43,9 @@ public final class HandheldBookTravelGameTests {
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
"An unrelated inventory item must remain with the player");
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
entity -> entity.getItem() == book);
helper.assertValueEqual(drops.size(), 1,
"Exactly one entity for this active book must remain at the departure point");
helper.assertTrue(drops.getFirst().getItem() == book,
"The model-rendered entity must retain the exact active book stack");
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
@@ -0,0 +1,91 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.StringTag;
import net.minecraft.resources.ResourceLocation;
/** A snapshot of Page eligibility, frozen with each random Age at binding time. */
public record RandomPagePolicy(Set<ResourceLocation> allowlist, Set<ResourceLocation> blocklist) {
public static final RandomPagePolicy UNRESTRICTED = new RandomPagePolicy(Set.of(), Set.of());
public RandomPagePolicy {
allowlist = Set.copyOf(allowlist);
blocklist = Set.copyOf(blocklist);
}
public static RandomPagePolicy configured() {
return new RandomPagePolicy(parse(RivencraftConfig.randomPageAllowlist()),
parse(RivencraftConfig.randomPageBlocklist()));
}
private static Set<ResourceLocation> parse(List<? extends String> ids) {
return ids.stream().map(RandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
}
/** Server config IDs may omit the built-in Rivencraft namespace. */
public static ResourceLocation parsePageId(String value) {
ResourceLocation id = ResourceLocation.tryParse(value.contains(":") ? value : "rivencraft:" + value);
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
return id;
}
public boolean allows(ResourceLocation id) {
if (blocklist.contains(id)) return false;
String category = category(id);
return !restricts(category) || allowlist.contains(id);
}
public boolean restricts(String category) {
return allowlist.stream().anyMatch(entry -> category(entry).equals(category));
}
public <T> List<T> eligible(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId) {
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
}
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, ResourceLocation> pageId,
T fallback) {
List<T> eligible = eligible(candidates, pageId);
return eligible.isEmpty() ? List.of(fallback) : eligible;
}
private static String category(ResourceLocation id) {
String path = id.getPath();
if (path.startsWith("biome/") && java.util.Set.of("native", "single", "tiled", "grid",
"tiny", "small", "medium", "large", "huge").contains(path.substring("biome/".length())))
return "biome_distribution";
return path.substring(0, path.indexOf('/') < 0 ? path.length() : path.indexOf('/'));
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.put("Allowlist", strings(allowlist));
tag.put("Blocklist", strings(blocklist));
return tag;
}
public static RandomPagePolicy load(CompoundTag tag) {
return new RandomPagePolicy(read(tag.getList("Allowlist", StringTag.TAG_STRING)),
read(tag.getList("Blocklist", StringTag.TAG_STRING)));
}
private static ListTag strings(Set<ResourceLocation> ids) {
ListTag tags = new ListTag();
ids.stream().sorted().forEach(id -> tags.add(StringTag.valueOf(id.toString())));
return tags;
}
private static Set<ResourceLocation> read(ListTag tags) {
java.util.Set<ResourceLocation> ids = new java.util.HashSet<>();
for (int index = 0; index < tags.size(); index++) {
ResourceLocation id = ResourceLocation.tryParse(tags.getString(index));
if (id != null) ids.add(id);
}
return Set.copyOf(ids);
}
}
@@ -107,7 +107,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
}
graphics.disableScissor();
drawScrollEnds(graphics);
var severity = menu.grammarSeverity().orElse(null);
var severity = menu.diagnosticsVisible() ? menu.grammarSeverity().orElse(null) : null;
Component status = severity == WritingGrammar.Severity.FATAL
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
: severity == WritingGrammar.Severity.INSTABILITY
@@ -117,9 +117,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
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(
Component grammarTooltip = menu.diagnosticsVisible() ? menu.grammarError().map(code -> Component.translatable(
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
errorStripIndex + 1)).orElse(null);
errorStripIndex + 1)).orElse(null) : null;
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
&& mouseY >= topPos + 27 && mouseY < topPos + 43
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.menu.BookBinderDiagnostics;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -29,6 +30,13 @@ public final class RivencraftCommands {
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("binder")
.then(Commands.literal("messages")
.executes(context -> binderMessages(context.getSource(), null))
.then(Commands.literal("enable")
.executes(context -> binderMessages(context.getSource(), true)))
.then(Commands.literal("disable")
.executes(context -> binderMessages(context.getSource(), false)))))
.then(Commands.literal("library")
.then(Commands.literal("nearest")
.executes(context -> LibraryLocateService.start(context.getSource())))
@@ -94,6 +102,14 @@ public final class RivencraftCommands {
.then(environmentCommand())));
}
private static int binderMessages(CommandSourceStack source, Boolean enabled) {
BookBinderDiagnostics diagnostics = BookBinderDiagnostics.get(source.getLevel());
if (enabled != null) diagnostics.setEnabled(enabled);
source.sendSuccess(() -> Component.literal("Book Binder diagnostic messages are "
+ (diagnostics.enabled() ? "enabled for operators" : "disabled") + "."), false);
return diagnostics.enabled() ? 1 : 0;
}
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
return Commands.literal("environment")
.then(Commands.literal("pulse")
@@ -23,6 +23,8 @@ public final class RivencraftConfig {
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_ALLOWLIST;
private static final ModConfigSpec.ConfigValue<List<? extends String>> RANDOM_PAGE_BLOCKLIST;
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
private static final ModConfigSpec.BooleanValue INSTABILITY_ENABLED;
private static final ModConfigSpec.IntValue INSTABILITY_DIFFICULTY;
@@ -89,6 +91,21 @@ public final class RivencraftConfig {
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline link-panel page.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
builder.push("random_pages");
RANDOM_PAGE_ALLOWLIST = builder.comment(
"Optional Page paths allowed in new random Ages and newly generated library loot.",
"The allowlist restricts only categories it mentions; unmentioned categories remain unrestricted.",
"Example: [\"sky/static\"] restricts random sky styles to Static Sky.",
"Full namespaced IDs are also accepted for compatibility.")
.defineList("allowlist", List.<String>of(), () -> "sky/static",
RivencraftConfig::validPageId);
RANDOM_PAGE_BLOCKLIST = builder.comment(
"Page paths excluded from new random Ages and newly generated library loot.",
"The blocklist wins over the allowlist. Example: [\"sky/static\"].",
"Existing Ages and Pages deliberately remain usable in hand-authored books.")
.defineList("blocklist", List.<String>of(), () -> "sky/static",
RivencraftConfig::validPageId);
builder.pop();
builder.push("serene_seasons");
AUTO_WHITELIST_SERENE_AGES = builder.comment(
"Automatically add Rivencraft Ages to Serene Seasons' dimension whitelist when loaded.",
@@ -233,6 +250,14 @@ public final class RivencraftConfig {
return LINK_PANEL_INK_COST.get();
}
public static List<? extends String> randomPageAllowlist() { return RANDOM_PAGE_ALLOWLIST.get(); }
public static List<? extends String> randomPageBlocklist() { return RANDOM_PAGE_BLOCKLIST.get(); }
private static boolean validPageId(Object value) {
return value instanceof String id && !id.isBlank()
&& ResourceLocation.tryParse(id.contains(":") ? id : "rivencraft:" + id) != null;
}
public static boolean autoWhitelistSereneAges() {
return AUTO_WHITELIST_SERENE_AGES.get();
}
@@ -212,8 +212,10 @@ public final class LinkingGameTests {
"A page-free book must select a supported legacy-inspired terrain profile");
helper.assertValueEqual(first.generationVersion(), AgeDefinition.CURRENT_GENERATION_VERSION,
"New Ages must record the current generation algorithm version");
helper.assertValueEqual(first.traits(), AgeTraits.select(first.seed()),
"A page-free book must persist the deterministic traits selected from its seed");
helper.assertValueEqual(first.traits(), net.dnorberg.rivencraft.book.AgeEnvironmentCompiler.forTerrain(
AgeTraits.select(first.seed()),
net.dnorberg.rivencraft.book.AgeRuntimeProfiles.resolve(first.generationProfile())),
"A page-free book must persist deterministic traits, with Cave sky/weather normalization");
helper.assertTrue(first.traits().hasStoppedCelestialCycle()
|| first.traits().celestialPeriod() >= AgeTraits.MIN_CELESTIAL_PERIOD
&& first.traits().celestialPeriod() <= AgeTraits.MAX_CELESTIAL_PERIOD,
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.menu;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** World-persistent OP-only visibility switch for Book Binder writing diagnostics. */
public final class BookBinderDiagnostics extends SavedData {
private static final String DATA_NAME = "rivencraft_binder_diagnostics";
private static final Factory<BookBinderDiagnostics> FACTORY = new Factory<>(
BookBinderDiagnostics::new, BookBinderDiagnostics::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private boolean enabled;
public static BookBinderDiagnostics get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
}
public boolean enabled() { return enabled; }
public boolean visibleTo(ServerPlayer player) {
return visibleTo(player.hasPermissions(2));
}
boolean visibleTo(boolean operator) { return enabled && operator; }
public boolean setEnabled(boolean enabled) {
if (this.enabled == enabled) return false;
this.enabled = enabled;
setDirty();
return true;
}
static BookBinderDiagnostics load(CompoundTag tag, HolderLookup.Provider registries) {
BookBinderDiagnostics data = new BookBinderDiagnostics();
data.enabled = tag.getBoolean("Enabled");
return data;
}
@Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putBoolean("Enabled", enabled);
return tag;
}
}
@@ -0,0 +1,32 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Binder diagnostics are world-persistent but never visible to non-operators. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class BookBinderDiagnosticsGameTests {
private BookBinderDiagnosticsGameTests() {}
@GameTest(template = "empty")
public static void binderMessagesDefaultOffAndPersist(GameTestHelper helper) {
BookBinderDiagnostics data = BookBinderDiagnostics.get(helper.getLevel());
helper.assertFalse(data.enabled(), "Binder diagnostic messages must default to off");
helper.assertTrue(data.setEnabled(true), "An OP toggle must change the world setting");
helper.assertFalse(data.setEnabled(true), "Repeating a toggle must not change the setting");
CompoundTag saved = data.save(new CompoundTag(), helper.getLevel().registryAccess());
BookBinderDiagnostics restored = BookBinderDiagnostics.load(saved, helper.getLevel().registryAccess());
helper.assertTrue(restored.enabled(), "The OP setting must survive world reload");
helper.assertTrue(restored.visibleTo(true), "An OP must see enabled binder diagnostics");
helper.assertFalse(restored.visibleTo(false), "A non-OP must not see enabled binder diagnostics");
helper.assertTrue(restored.setEnabled(false), "An OP toggle must disable messages");
helper.assertFalse(restored.visibleTo(true), "Disabled messages must be invisible even to OPs");
data.setEnabled(false);
helper.succeed();
}
}
@@ -43,6 +43,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private int grammarErrorStripIndex = -1;
private int grammarInstability;
private int grammarIncompleteCount;
private int diagnosticsVisible;
private WritingGrammar.Result serverGrammar;
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
@@ -62,34 +63,38 @@ public final class BookBinderMenu extends AbstractContainerMenu {
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorCode : binderDiagnostic()
return clientSide || binder == null ? grammarErrorCode : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
.map(diagnostic -> diagnostic.code().ordinal()).orElse(-1);
}
@Override public void set(int value) { grammarErrorCode = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorStripIndex : binderDiagnostic()
return clientSide || binder == null ? grammarErrorStripIndex : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
.map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1);
}
@Override public void set(int value) { grammarErrorStripIndex = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarInstability
return clientSide || binder == null ? grammarInstability : !diagnosticsVisibleToAuthor() ? 0
: serverGrammar().writingInstability();
}
@Override public void set(int value) { grammarInstability = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarIncompleteCount
return clientSide || binder == null ? grammarIncompleteCount : !diagnosticsVisibleToAuthor() ? 0
: (int) serverGrammar().diagnostics().stream()
.filter(diagnostic -> diagnostic.severity()
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
}
@Override public void set(int value) { grammarIncompleteCount = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return clientSide ? diagnosticsVisible : diagnosticsVisibleToAuthor() ? 1 : 0; }
@Override public void set(int value) { diagnosticsVisible = 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));
@@ -130,7 +135,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
}
public int grammarInstability() { return grammarInstability; }
public int grammarIncompleteCount() { return grammarIncompleteCount; }
public boolean diagnosticsVisible() { return diagnosticsVisible != 0; }
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
private boolean diagnosticsVisibleToAuthor() {
return bookAuthor instanceof ServerPlayer serverPlayer
&& BookBinderDiagnostics.get(serverPlayer.serverLevel()).visibleTo(serverPlayer);
}
public String title() { return binder == null ? "" : binder.title(); }
public boolean setTitle(Player player, String title) {
if (clientSide || !stillValid(player) || title == null
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.RandomPagePolicy;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
@@ -336,9 +337,17 @@ public final class MystcraftLibraryFeature {
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
RivencraftConfig.LibraryLootMode mode) {
return symbolsForLoot(ageSymbols, mode, RandomPagePolicy.configured());
}
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
RivencraftConfig.LibraryLootMode mode,
RandomPagePolicy policy) {
return mode == RivencraftConfig.LibraryLootMode.AGE_ONLY ? ageSymbols.stream()
.filter(policy::allows)
.filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).distinct().toList()
: SymbolCatalog.symbols().entrySet().stream()
.filter(entry -> policy.allows(entry.getKey()))
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
&& entry.getValue().semantics().isPresent()
&& !entry.getKey().getPath().startsWith("block/")
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.world;
import java.util.List;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.book.RandomPagePolicy;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
@@ -107,6 +108,85 @@ public final class MystcraftLibraryGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void pageListsFilterRandomChoicesAndLibraryLoot(GameTestHelper helper) {
ResourceLocation staticSky = ResourceLocation.parse("rivencraft:sky/static");
ResourceLocation netherSky = ResourceLocation.parse("rivencraft:sky/nether");
ResourceLocation rocks = ResourceLocation.parse("rivencraft:feature/rocks");
helper.assertValueEqual(RandomPagePolicy.parsePageId("sky/static"), staticSky,
"A shorthand Page path must resolve to Rivencraft's namespace");
helper.assertValueEqual(RandomPagePolicy.parsePageId("rivencraft:sky/static"), staticSky,
"An existing fully qualified Page ID must still work");
RandomPagePolicy policy = new RandomPagePolicy(java.util.Set.of(staticSky, netherSky),
java.util.Set.of(netherSky, rocks));
helper.assertTrue(policy.allows(staticSky), "Allowlisted sky Page must remain available");
helper.assertTrue(!policy.allows(netherSky), "Blocklist must override allowlist");
helper.assertTrue(!policy.allows(rocks), "Blocked feature Page must be excluded");
helper.assertTrue(policy.allows(ResourceLocation.parse("rivencraft:terrain/standard")),
"Unmentioned categories must remain unrestricted");
RandomPagePolicy biomeController = new RandomPagePolicy(
java.util.Set.of(ResourceLocation.parse("rivencraft:biome/tiny")), java.util.Set.of());
helper.assertTrue(biomeController.allows(ResourceLocation.parse("rivencraft:biome/plains")),
"A biome-controller allowlist must not exclude actual biome Pages");
helper.assertTrue(!biomeController.allows(ResourceLocation.parse("rivencraft:biome/huge")),
"A biome-controller allowlist must constrain other distributions");
helper.assertValueEqual(RandomPagePolicy.load(policy.save()), policy,
"Random Age Page policy must survive save and reload");
helper.assertValueEqual(MystcraftLibraryFeature.symbolsForLoot(
List.of(staticSky, netherSky, rocks), RivencraftConfig.LibraryLootMode.AGE_ONLY, policy),
List.of(staticSky), "Age-only library loot must exclude blocked Pages");
for (long seed = 0; seed < 100; seed++) {
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSkyProfile.random(seed,
net.dnorberg.rivencraft.book.AgeTraits.select(seed), policy).style(),
net.dnorberg.rivencraft.book.AgeSkyProfile.Style.STATIC,
"Sky allowlist must constrain new random Ages");
}
RandomPagePolicy singleTerrain = new RandomPagePolicy(
java.util.Set.of(ResourceLocation.parse("rivencraft:terrain/standard"),
ResourceLocation.parse("rivencraft:lighting/normal")), java.util.Set.of());
for (long seed = 0; seed < 100; seed++) {
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeRuntimeProfiles.select(seed, singleTerrain),
ResourceLocation.parse("rivencraft:standard"),
"Random terrain must honor a category allowlist");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeTraits.select(seed, singleTerrain).lighting(),
net.dnorberg.rivencraft.book.AgeTraits.Lighting.NORMAL,
"Random lighting must honor a category allowlist");
}
RandomPagePolicy noRequiredPages = new RandomPagePolicy(java.util.Set.of(
ResourceLocation.parse("rivencraft:terrain/not_present"),
ResourceLocation.parse("rivencraft:lighting/not_present"),
ResourceLocation.parse("rivencraft:weather/not_present"),
ResourceLocation.parse("rivencraft:sky/not_present"),
ResourceLocation.parse("rivencraft:season/not_present")), java.util.Set.of());
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeRuntimeProfiles.select(42L, noRequiredPages),
ResourceLocation.parse("rivencraft:standard"), "Empty terrain family must use Standard");
var fallbackTraits = net.dnorberg.rivencraft.book.AgeTraits.select(42L, noRequiredPages);
helper.assertValueEqual(fallbackTraits.lighting(),
net.dnorberg.rivencraft.book.AgeTraits.Lighting.NORMAL,
"Empty lighting family must use Normal");
helper.assertValueEqual(fallbackTraits.weather(),
net.dnorberg.rivencraft.book.AgeTraits.Weather.NORMAL,
"Empty weather family must use Normal");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSkyProfile.random(42L,
fallbackTraits, noRequiredPages).style(),
net.dnorberg.rivencraft.book.AgeSkyProfile.Style.INHERITED,
"Empty sky family must use inherited sky");
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeSeasonMode.select(null, 42L,
true, null, noRequiredPages),
net.dnorberg.rivencraft.book.AgeSeasonMode.NO_SEASONS,
"Empty season family must use No Seasons");
var blockedControllers = java.util.Arrays.stream(
net.dnorberg.rivencraft.book.AgeTraits.BiomeDistribution.values())
.map(value -> ResourceLocation.parse("rivencraft:biome/"
+ value.name().toLowerCase(java.util.Locale.ROOT)))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
helper.assertValueEqual(net.dnorberg.rivencraft.book.AgeTraits.select(42L,
new RandomPagePolicy(java.util.Set.of(), blockedControllers)).biomeDistribution(),
net.dnorberg.rivencraft.book.AgeTraits.BiomeDistribution.NATIVE,
"Empty biome-distribution family must use Native");
helper.succeed();
}
@GameTest(template = "empty")
public static void testSpacingIsSessionOnlyAndKeepsLegacySites(GameTestHelper helper) {
long seed = 881L;
@@ -191,7 +191,8 @@ public final class WritingGrammar {
private static boolean numberTarget(Role role) {
return role == Role.COLOR || role == Role.PERIOD_MODIFIER
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER
|| role == Role.SIZE_MODIFIER;
}
private static int stage(Role role) {
@@ -260,7 +260,7 @@
"symbol.rivencraft.number.22": "D'ni 22",
"symbol.rivencraft.number.23": "D'ni 23",
"symbol.rivencraft.number.24": "D'ni 24",
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, or Length to × 0.08. A number page affects only the immediately following page.",
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, Length to × 0.08, or Celestial Size from 0.25× to 3×. A number page affects only the immediately following page.",
"symbol.rivencraft.weather.normal": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.weather.fast": "Fast Weather",