Compare commits

...
Author SHA1 Message Date
dnorberg 41daff1e61 Release 26.3.0.1 with live Reborn views and random page policies
Build / build (push) Waiting to run
2026-11-11 17:15:46 -05:00
dnorberg f9097967b9 Port optional integrations and finish Age creation and modifier lifecycle
Build / build (push) Waiting to run
2026-11-11 04:17:41 -05:00
dnorberg 32c52e635f Port live instability, writing recovery and Link Modifier controls to 26.3
Build / build (push) Waiting to run
2026-11-10 17:40:52 -05:00
dnorberg d2904eaa8d Port libraries, crystal portals and instability decay to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-10 04:14:59 -05:00
dnorberg 35980338e1 Port lectern stands, book GUIs and progression to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-09 18:20:40 -05:00
dnorberg be8440ddb5 Port collections, dropped books, return links and Writing Desk to 26.3
Build / build (push) Canceled after 0s
2026-11-05 22:04:24 -05:00
dnorberg f13bedd26f Port seeded Age completion and Accelerated pages to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-05 15:19:11 -05:00
200 changed files with 16632 additions and 379 deletions

No files matched your search

+17 -13
View File
@@ -10,17 +10,19 @@ also lets you author modern terrain, biomes, structures, seasons, and celestial
bodies. It is an independently developed project, not a byte-for-byte copy of
the 1.12.2 mod.
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
supports authored terrain/biomes, structure and vegetation pages, independent
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
feature completion, and random structure selection. Nether/End noise-biome
materials now follow their native rules. The beta does not yet provide full
1.21.1 gameplay or optional-integration parity.
See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.93-beta.1.md).
The gameplay guide below describes the **Minecraft 1.21.1 release**. The separate
**Minecraft 26.3 release, 26.3.0.1**, uses Java 25, NeoForge 26.3.0.99 and a
first-party Age backend; it does not need Dynamic Dimensions or Bad Packets.
It includes authored/seeded Ages, writing stations and collections, libraries,
crystal portals, celestials/eclipses, instability and Link Modifier controls.
JEI, TerraBlender/Biomes O' Plenty, Serene Seasons and Ruins remain optional.
Immersive Portals Reborn 7.0.12 adds live book previews and crystal portal views;
animated fallbacks work without it. Random-page eligibility and random-Age glyph
descriptions are included. Full 1.21.1 parity and the final expanded-catalog
two-client regression remain ongoing. Reborn's Static Sky rendering bug is tracked
in [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116).
See the [26.3 guide](ports/mc263/README.md) and
[release notes](ports/mc263/RELEASE_NOTES_26.3.0.1.md).
Keep the builds and their saves separate, and back up worlds before updating.
## Getting started
@@ -123,8 +125,10 @@ checks from manual evidence and known limitations.
The JAR is written to `build/libs/`. Development runs need the required mods;
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
Versions use `x.y.z.b`: major release, completed milestone, release slice,
then fixes/additional work within that slice.
New versions prefix the Minecraft version, followed by the milestone/release
slice and fixes/additional-work iteration. For example, `26.3.0.1` produces
`rivencraft-26.3.0.1.jar`. Existing 1.21.1 artifacts keep their published legacy
numbers until that line's next code release. See the [release workflow](docs/DUAL_VERSION_RELEASE.md).
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+53
View File
@@ -0,0 +1,53 @@
# Dual-version release workflow
Maintain Minecraft 1.21.1 and 26.3 independently. The 1.21.1 line retains Dynamic
Dimensions AR Edition and Bad Packets. The 26.3 line uses its first-party backend.
Do not widen either artifact's Minecraft dependency range or import saves between
the tracks.
## Version identity
From 26.3.0.1 onward, prefix the Minecraft version and append the milestone/release
slice and iteration: Minecraft.release-slice.iteration. Example:
`26.3.0.1` -> `rivencraft-26.3.0.1.jar`, Gitea tag `V26.3.0.1`.
An explicitly selected prerelease uses a suffix such as `-beta.1`.
The existing 1.21.1 release keeps its published version until its next code update;
then use the corresponding `1.21.1.<slice>.<iteration>` convention.
The user authorized regular release status for 26.3.0.1. Release-channel status
does not close unfinished parity or multiplayer issues automatically.
## Build and publish
For 26.3, update `ports/mc263/gradle.properties`, then run
`./gradlew -p ports/mc263 build --console=plain` with Java 25 available.
Use the exact built/player-validated artifact; naming/metadata-only changes do
not change generation or save behavior.
Write release notes, audit the guide/wiki/project description, commit and push
the changes, then create and push the matching annotated tag. Preview the
`scripts/publish_mod_sites.py release` command with the JAR, changelog, version,
display name, Minecraft version, Java version and channel; add `--publish` to
upload to Gitea, Modrinth and CurseForge. Tokens stay in the system keyring.
Verify published metadata and downloaded artifact hashes. CurseForge approval
may remain pending after a successful upload.
The publisher accepts legacy Minecraft-prefixed archive filenames and beta tags
for already numbered historical uploads; new releases use the identity above.
## Validation gates and issue audit
Run appropriate builds and targeted server checks for changed behavior. The user
performs visual/gameplay validation. Keep temporary test fixtures out of shipped
JARs; use new worlds and remove them regardless of pass/fail. Stop test processes.
If generation stalls, hand the test to the user.
Before closing a port umbrella issue, compare its acceptance criteria and comments
to actual evidence. Current expanded-catalog two-client create/travel/reconnect/
restart/stale-ack regression remains in #100. #103 retains dual-line release
automation/regression completion and #104 retains the full parity gate.
Shared issues need both versions resolved before closure.
For each release, document tested optional versions and no-optional fallbacks,
match client/server JARs, preserve the independent 1.21.1 support line, update wiki
and platform descriptions, and keep known bugs linked in the release notes.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -35,7 +35,7 @@ mod_id=rivencraft
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.
# Existing 1.21.1 release number. Adopt the Minecraft-prefixed policy in README.md on its next code release.
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
+1733 -12
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.106-beta.1
Collects the player-validated port slices .99–.106 since 1.0.1.98-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Books and travel
- Fallback Age platforms now use the dedicated non-collectible platform block
from 1.21.1: opaque crystal texture, glass-like breaking strength, no drops
or pick-block item. Existing ordinary Crystal Block platforms are unchanged.
- Ported automatic Return Links for successful handheld Descriptive Book
travel. `descriptive_books.return_link_mode` in `config/rivencraft-local.toml`
defaults to **DISABLED**. `ON_CREATION` issues one on the first arrival per
Age/world with that mode enabled; `ALWAYS` issues one on each arrival.
- Return Links preserve departure dimension, position and facing. They are
issued after Disarm and drop at the destination if inventory is full.
First-arrival records persist across reload, reseeding and restart.
- Successful handheld Linking and Descriptive Book travel now leaves the
active book at departure as a recoverable open-book entity. Both hands are
supported. Failed travel or canceled entity spawning restores the book.
- Dropped books reuse the existing textures and skyward-facing model. They
have a one-second pickup delay, no ordinary five-minute expiry, slow decay
and water/rain damage, matching 1.21.1. Their components and decay counter
persist through restart.
## Page collections and binding
- Ported Notebooks and Collation Folders with their existing editor and assets,
capacities of 252 and 27 positions, sparse page storage, search, scrolling,
page movement and inventory transfers. Link Panels are rejected by the editor.
- Notebook crafting uses the existing leather/string pattern. The held
collection is locked against ordinary menu movement. Escape closes the menu
with search focused; search still consumes inventory/hotbar shortcuts.
- Corrected the port's keyboard and mouse-button mappings, including arrow
movement, right-click handling and scrollbar dragging.
- The Book Binder accepts collection imports: shift-click to append pages or
click a carried collection on the strip to insert them. Empty positions are
skipped and occupied page order is retained. Imports validate all inputs and
capacity before committing; successful imports return the empty collection.
- Added category Symbol Portfolios to Creative, including registry-derived
biome, material and fluid pages. Large categories split into volumes of up
to 252 pages. Portfolios use the editor and Binder import flow.
- Sealed Notebooks open into Folders containing 16 random pages using the
1.21.1 pool/exclusions. Survival consumes one; Creative retains the item.
Full inventories receive a dropped Folder. No new recipes are invented for
Folders, Portfolios or Sealed Notebooks; discovery/library loot is pending.
## Writing Desk
- Ported the two-block Writing Desk, its existing model/textures, selected-page
and white paper-stack display, and Legacy GUI atlases.
- Ported 88 tabs cycled four at a time, category/search/sort/ALL views, collection
and loose-page storage, page movement/extraction, and server-owned catalog
synchronization. Link Panels cannot be stored or copied in the desk.
- Copy previews are virtual. Taking a copy consumes one paper and one ink by
default while retaining the original page. `writing.symbol_ink_cost` is
configurable in `rivencraft-local.toml`.
- Vials fill a 16-unit meter and return vanilla glass bottles. Refills are
event-driven and require bottle space. Full inventory prevents shift-copy
consumption. Unknown pages are retained but cannot be copied unresolved.
- Ported crafting and adapted its recipe-unlock advancement to 26.3.
Removing either desk half removes its partner and drops contents once.
- Further corrections to the desk model, focused on page/paper placement,
remain tracked for both versions in issue #113.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. The 1.21.1 implementation is
unchanged. Use matching client/server builds; Age catalog protocol remains 10.
- Player in-game checks passed. Both build targets passed. Targeted disposable
server fixtures covered ownership recovery, collection/copy transactions,
save/network codecs, block removal, reload/reseed and restart persistence.
Servers were stopped and test worlds removed. This was not a full GameTest run.
- Full writing recovery, remaining discovery/stations, instability, crystal
portals, progression awards and optional integration parity remain unfinished.
JEI guides and discovery loot for the newly ported interactions are pending.
Unsupported effects remain marked red; Creative availability is not parity.
- Back up saves. New Native Cave profiles from .97 onward cannot be read by
earlier betas, and newly ported items/entities also require the newer build.
Keep 1.21.1 and 26.3 saves separate. Machine acceleration (#112) and Native
Cave default biome-pool policy (#111) remain separate planned work.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,88 @@
# Rivencraft 26.3 beta 1.0.1.115-beta.1
Collects the player-validated port slices .107–.115 since 1.0.1.106-beta.1.
Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. This remains a beta with
incomplete Minecraft 1.21.1 gameplay and optional-integration parity.
## Book stands and dropped books
- Place a Linking Book, Descriptive Book or Page on an empty vanilla lectern
to convert it into a Rivencraft book stand, preserving facing. Occupied
vanilla lecterns are left alone.
- Books use the existing open-book model and textures and show their name
above the stand. Pages use their item artwork without a hover label,
retaining the 1.21.1 orientation corrections.
- Open a bound book with an empty hand, then click its destination panel to
travel while leaving the book on its stand. Existing link modifiers,
same-dimension rules, Return Link configuration and Age-entry awards apply.
- Shift-right-click with an empty hand retrieves the stored item and restores
a vanilla lectern. Breaking returns one lectern and its contents. Unwritten
books can be displayed; retrieve them to bind in hand in this beta.
- Ordinary dropped Linking/Descriptive Books now become recoverable open-book
entities, including unwritten books, inventory/player drops, container and
stand drops, death/Disarm drops and ordinary book entities loaded from disk.
- Conversion preserves the stack and components, position, motion, facing,
pickup delay and ownership/thrower metadata. It uses the existing open-book
lifetime, slow decay and water/rain behavior. Already-custom books and
non-book items are skipped.
- Conversion runs after registration without world scans or force-loading.
The original is removed only after replacement succeeds; a canceled
replacement leaves the ordinary item intact and eligible on a later reload.
- Stand travel validates source dimension, distance, loaded chunk and the exact
currently stored book, rejecting stale screens after removal/replacement.
## Book GUIs and names
- Descriptive Books reuse the Legacy gilded cover, name/author front spread,
and one authored symbol at a time on following right-hand pages. Saved page
order and repetitions are retained, with runtime glyphs and no extra item outline.
- New Descriptive Books record their author server-side. Older books without
that metadata use the unknown-author label.
- Linking Books use the plain Legacy cover, wrapped title, destination name
and block coordinates, plus the blank final spread. Return Links suppress
the redundant title. Bottom corners or arrow keys turn pages with sound.
- Both GUIs share the Legacy artwork/text/counter/corner helpers. Naming
controls hide on later pages and remain correct after resizing. Escape closes
even with naming focused; normal text input does not trigger inventory keys.
- Ported the existing 1.21.1 fallback's animated teal spiral panel to both GUIs,
including lecterns. Removed placeholder labels. The Legacy frame draws over
the animation; travel is active only on the front panel.
- The animated fallback is not a live destination view. Destination tint,
snapshot renderer integrations and random-Age glyph descriptions remain pending.
- New Ages save their entered display name independently of their ID, folder
and seed. Newly bound Linking Books and automatic Return Links capture their
destination/departure label for both GUI and tooltip. Older Ages/books retain
vanilla-name or dimension-ID fallbacks; existing labels are not rewritten.
- Corrected negative tooltip coordinates to floor consistently with the GUI.
## Progression and recipe discovery
- Ported the Rivencraft advancement tree: root, obtaining a Page/Linking Book,
taking a Link Panel from the Ink Mixer, copying a Page, binding an Age and
successfully travelling into an Age. Action awards occur after committed
server actions, including normal and shift pickup; previews earn nothing.
- The advancement background uses the existing blue Crystal Block texture,
with the corrected 26.3 resource-path format. The portal node waits for the
Receptacle/portal port.
- Added recipe-book discovery for Ink Vials (Black Dye), Ink Mixers (Glass
Bottle), Book Binders (Iron Ingot) and Crystal Blocks (Crystal Shard), alongside
the Notebook and Writing Desk unlocks. Three-by-three recipes appear in a
Crafting Table's recipe book, not the inventory's two-by-two book.
- Ingredients and costs are unchanged. Blank Pages remain uncraftable.
## Validation and remaining work
- Player in-game checks passed each slice and the final dropped-book changes.
- The 26.3 and independent 1.21.1 builds passed. Gradle test tasks have no
standalone test sources; targeted disposable-server fixtures covered real
station actions, advancements/recipe unlocks, stand ownership/drops and
persistence, book author/page/name persistence, and drop conversion/state.
- Temporary test servers were stopped and their worlds deleted. A no-player
entity fixture required one loaded fixture chunk for inspection; further
visual/restart validation was handed to the player.
- 1.21.1 gameplay code is unchanged. Shared issue #114 remains open for its
matching universal dropped-book fix; Writing Desk model placement stays in #113.
- Instability, full writing recovery, discovery/library loot, crystal portals,
remaining stations/progression and optional integrations still need port work.
Keep 26.3 and 1.21.1 instances/saves separate and back up beta worlds before updating.
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.120-beta.1
Collects the player-validated .116–.120 port slices since 1.0.1.115-beta.1.
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent 1.21.1
release is unchanged; full gameplay and optional-integration parity remain incomplete.
## Libraries and writing
- Ported Legacy library generation, shared schematic, chest loot and five correctly
oriented book/page stands. Libraries generate after decoration in eligible new
non-Cave Age chunks, using the 1.21.1 spacing, terrain and clearance checks.
- Library loot defaults to `LEGACY`; optional `AGE_ONLY` limits new library Pages
to known symbols originally written into that Age. Original writing is saved
separately and restored on reload. Older Ages without it have an empty Age-only
pool. Ordinary Books replace stand Pages when that pool is empty.
- Added operator library placement, candidate/probe/loot diagnostics, session-only
test spacing and a locator limited to nearby already-loaded chunks.
- Hand-prepared Link Panel ink cost is server-configurable (default two). The
Ink Mixer still consumes one ink unit and one paper per panel. Writing Desk
ink handling now uses the shared service.
## Crystal portals
- Ported craftable Book Receptacles with all six mounting orientations and the
existing 1.21.1 model/book transforms. A bound Linking or Descriptive Book
activates a valid connected crystal frame, including supported irregular frames.
- Reused the bounded crystal flood/tension algorithm, placement protection events,
rollback, owner persistence, restart repair, frame cleanup and traversal cooldown.
- Empty-hand removal closes the portal and returns the book. Breaking the holder
or its crystal support closes it and drops a recoverable open-book entity.
- Player and non-player travel reuse book destinations, safe arrival, Intra-Linking,
Disarm and momentum rules. Descriptive Books share the same landing resolver
with portals; no separate portal exit offset is added. Player Return Link policy
and progression remain in force.
- The animated fallback surface renders its facing sides and uses destination sky
tint. The Open Portal advancement and receptacle recipe unlock are restored.
Live destination views are still pending optional integration work.
## Instability and colored decay
- New Ages save operator-only creation-time score contributions and deterministic
provider selections using all five Legacy decks. Existing Ages remain neutral;
reloads/config changes do not reroll stored plans.
- Initial scoring covers Bright Lighting, Accelerated, Corrupted Pages, ineffective
Cave sky/weather modifiers, excess and biome-forced structures, and extra suns.
Corrupted Pages now bind successfully and retain their animated redacted text.
- Ported 18 potion providers: local/global Blindness, Fatigue, Hunger, Nausea,
Poison, Slowness, Weakness and Wither, plus hostile Regeneration/Resistance.
General effects affect eligible Survival/Adventure players and living entities;
Creative/Spectator remain exempt. Local variants require sky exposure.
- Added a bounded occupied-Age scheduler, owned-effect cleanup, provider controls
and operator score/status, enable/disable, session test-score and pulse commands.
Effects supplied or extended by another source are preserved.
- Ported Blue, Purple, Red and White decay with the existing 16x16 textures,
hardness/air conversion odds, saved per-color frontiers and attempt sequence.
White retains stepping-contact magic damage. Decay has no item form or drops.
- Spread uses only already-loaded candidate chunks, bounded attempts and action
budgets. Fluids, block entities, critical/unbreakable blocks, infrastructure,
protected tags and claim events prevent conversion.
- The first resolved book/portal arrival is saved for spawn protection. Re-enter
an older Age once to record it. Global/Age/provider and world-mutation controls
gate spread; existing decay blocks remain when spreading is disabled.
- Test a color with `/rivencraft_port instability decay blue 32` (also `purple`,
`red`, `white`); output includes actual changes and nearby tracked coordinates.
Scores are not added to ordinary book or Binder displays.
## Validation and remaining work
Player in-game checks passed .116–.120. Both the 26.3 and 1.21.1 builds passed;
Gradle has no standalone test sources. Targeted disposable dedicated-server checks
covered library placement, portal entity travel and restart ownership/cleanup,
potion application/cleanup and persistence, all four decay colors, saved spread
continuation, and bedrock/fluid protection. Test servers were stopped and their
worlds removed.
Full writing recovery/penalties, live material-abundance profiling, other instability
hazards, remaining discovery/stations and optional integrations still need port work.
Unported hazard cards remain saved and are reported as unavailable, rather than
being replaced with different cards. Book previews remain the animated fallback.
Machine acceleration (#112), Writing Desk model placement (#113), and the matching
1.21.1 universal dropped-book fix (#114) remain tracked separately.
Use matching client/server beta builds (catalog protocol 10). Keep Minecraft tracks
and saves separate, back up beta worlds, and do not downgrade saves containing new
blocks or data to earlier betas.
@@ -0,0 +1,68 @@
# Rivencraft 26.3 beta 1.0.1.129-beta.1
Collects the player-validated .121–.129 port slices since 1.0.1.120-beta.1.
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent Minecraft
1.21.1 release is unchanged. Full parity and optional integrations remain in progress.
## Instability hazards and live profiling
- Ported lightning, explosions, falling meteor effects, burning and erosion with
bounded loaded-chunk scans, spawn protection, claim events and configurable
world/entity damage. Meteor flight uses the existing sound and particle effects.
- Natural hazards choose randomized locations; operator test commands provide
targeted checks with independent cooldowns. Erosion retains its requested testing
default of enabled above 10,000 effective instability, outside the Legacy deck.
- Ported persistent live material profiling, including native ores, valuable blocks,
recognized installed-mod materials and configurable modded block weight overrides.
No content mod is required. Profiling starts scoring after 401 Age and 500
Overworld baseline samples, with bounded gradual background sampling while an
eligible Age is occupied. A chunk snapshot is counted once.
- Effective instability and deterministic provider selection now update as material
abundance is measured; the original writing contribution remains frozen. Added
difficulty scaling and operator-only sampling/score diagnostics. Older neutral
Ages remain neutral. Normal book/Binder screens do not reveal scores.
## Ordered writing recovery
- Repeated singular terrain, distribution, weather, lighting and sky controllers
use the last choice, adding 500 instability per duplicate/conflict. Category-order
mistakes add 100. Native distribution ignores supplied biomes; Single retains the
first biome when too many are supplied, with conflict penalties.
- Repeated celestial modifiers use the final pending value of each kind. Dangling
modifiers and misplaced/trailing numbers allow binding and add 100 instability.
Numbers only attach to the immediately following supported target.
- Preserved original book/library pages, ordered color mixing and independent
celestial chains. A trailing modifier cannot change an earlier sun or moon.
Unported pages, unsupported combinations and body limits remain enforced.
## Link Modifier station
- Added the creative-only Link Modifier with its existing model, textures and
Legacy single-book GUI. There is no crafting recipe.
- Books can be renamed and edited through all six controls: Disarm, Intra-Linking,
Intra-Linking Only, Maintain Momentum, Relative Link and Generate Platform.
- Intra-Linking Only enables Intra-Linking and prevents cross-dimension Linking
Book travel. Both intra controls are unavailable for Descriptive Books.
- Relative Link preserves the traveller's offset from the source dimension spawn
at the destination spawn. Linking Book platform generation defaults off;
Descriptive Book fallback platforms default on and can be disabled.
- Player and non-player portal paths share the new properties. Book contents and
flags persist in the station. Removal releases its stored book.
- Fixed the controls ignoring real clicks: 26.3 left-click is button 1, not 0.
Registered native button widgets now handle input, focus, sound and state tooltips.
## Validation and remaining work
The player passed every included slice, including the six controls and click fix.
Both Minecraft builds pass (no standalone Gradle test sources). Targeted temporary
checks covered grammar/celestial recovery, component serialization, station actions,
platform rules and dedicated-server save/restart behavior. Live profiling was checked
with real snapshots plus synthetic threshold records; natural gameplay was validated
by the player. Temporary servers were stopped and their test worlds removed.
Link Modifier seed editing/retirement, remaining discovery/config policy and optional
integrations are not complete. Live previews remain unavailable; books and portals
use the animated fallback. Crumble has no active 1.21.1 handler and is not invented
in this port. The dual-version follow-up issues and full multiplayer/parity gate
remain open. Use matching client/server beta builds and keep Minecraft version
tracks and their saves separate.
@@ -0,0 +1,60 @@
# Rivencraft 26.3 beta 1.0.1.136-beta.1
Collects the player-validated .130–.136 port slices since 1.0.1.129-beta.1.
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The Minecraft 1.21.1
release and its Dynamic Dimensions backend are unchanged. This remains an
incomplete beta port; use matching client/server builds and separate saves.
## Link Modifier and Age creation
- Edit a Descriptive Book Age's full signed 64-bit seed. Existing chunks remain;
newly generated terrain uses the new seed. Occupied Ages and stale/remote menu
requests are refused. Seed input supports Enter or focus loss without game keys
taking over while typing.
- Operators can retire an unoccupied Age through a separate confirmation action.
Its exact dimension directory is archived under `world-retired-ages`; the Age
is tombstoned and cannot reload after restart. Other Age data remains intact.
- Failed Age creation now rolls back the new level, index, initial name, library
writing, instability and season policy before publishing the catalog. Cleanup
removes only the newly created directory; pre-existing storage is protected.
Persistent filesystem failures or process crashes are not transactional guarantees.
## Optional integrations
All integrations remain optional and are never bundled.
- **JEI:** searchable symbol pages, Link Panel variants and portfolios, with Ink
Mixer/Writing Desk/Book Binder guidance. Internal Blank Pages and empty portfolios
remain hidden. Tested with JEI 31.17.0.109 and MezzConfig 0.7.1.
- **TerraBlender:** recognizes registered Overworld and Nether region biomes from
installed biome mods, including Biomes O' Plenty. Nether-region biomes join the
Cave-compatible authoring pool and use Nether material rules. Tested with
TerraBlender 26.3.0.0.9 and Biomes O' Plenty 26.3.0.0.16. The requested Native Cave
pool changes and Native Nether/End pages remain separate work in #111.
- **Serene Seasons:** No Seasons, Seasons and all four Eternal modes operate per
Age. Missing policies are seed-selected only when Serene is installed; Cave Ages
disable seasons. Dynamic whitelist updates synchronize without restarting, while
eternal policies leave the Overworld calendar unchanged. Tested with Serene
Seasons 26.1.2.0.7 and GlitchCore 26.3.0.0.5.
- **Ruins:** exact-template pages appear in creative and JEI. Authored Ages permit
only their selected templates; Ruins' surface, biome and distance checks remain.
Duplicate pages increase selection weight and attempts (32-attempt cap, without
lowering higher Ruins settings). The first three copies are free; each later
copy adds 100 instability, even beyond the attempt cap. Tested with Ruins
26.3.2NF. Duplicate template basenames disable the bridge to prevent ambiguity.
## Validation and remaining work
Both Minecraft builds pass. Every included slice passed player in-game checks;
the latest client log confirms JEI, TerraBlender/BOP, Serene/GlitchCore and Ruins
loaded together. Disposable dedicated-server checks covered seed changes with
unchanged old chunks, retirement/archive isolation, injected creation failures,
optional-mod absence, region discovery, season policies, Ruins template placement
and restart/unload/reload persistence. Test fixtures/worlds were removed and test
servers stopped. No permanent game tests were added.
Books and crystal portals still use animated fallback previews. Live destination
renderers, remaining discovery/configuration parity and random-Age glyph descriptions
remain unfinished. The current expanded-catalog dedicated two-client regression
and full parity/release gates remain open. Shared 1.21.1/26.3 follow-up issues are
not complete merely because one version's implementation works.
@@ -0,0 +1,63 @@
# Rivencraft 26.3 beta 1.0.1.98-beta.1
Collects the player-validated port slices .94–.98 since 1.0.1.93-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Seeded terrain and biome completion
- Books without a Terrain page now choose terrain using the 1.21.1 weighted
selector. Incompatible choices are replaced with a seeded compatible terrain;
explicit Terrain pages retain their authored behavior.
- Omitted biome controllers now use the seeded weighted distribution selector.
Supplied biome pages are retained, with missing minimum slots completed.
Void without biome pages still uses The Void. Link-Panel-only random Ages
retain their existing selection path.
- Missing nonnative biome pools account for authored structures. Single chooses
a terrain-compatible biome satisfying all structure requirements where one
exists. Multiple-biome distributions add compatible biomes for unmet structure
requirements before completing their ordinary minimum. Explicit Single
choices are never replaced.
- New Native Cave Ages freeze the full recognized Cave/Nether biome pool and
distribute it using seeded Tiny regions, matching the 1.21.1 implementation.
This includes recognized modded cave biomes. Existing empty Native Cave
profiles retain their previous Lush Caves fallback; explicit Single is unchanged.
- Removing Nether-style biomes from the default Native Cave pool is separate
planned work for both versions in issue #111, not part of this release.
## Accelerated pages
- Ported the Accelerated environment page. Repeated pages stack, each adding
three extra random positions per eligible block section per tick near players.
- Defaults match 1.21.1: at most eight already-loaded candidate chunks and
16,384 extra random-tick attempts per Age tick, shared fairly across sections.
No extra chunks are force-loaded.
- Limits are configurable in `config/rivencraft-local.toml`, under
`accelerated_age`. A zero attempt budget disables the extra ticks.
- Page counts persist across unload/reload, reseeding and restart. Existing
Ages default to zero. Pageless random Ages do not gain Accelerated automatically.
- This currently affects random-ticking blocks such as crops and saplings.
Machine/block-entity acceleration is deferred for both versions under issue
#112; entities and scheduled block ticks are also unaffected. Instability
scoring remains part of the unfinished instability port.
- Operator command `/rivencraft_port age acceleration <name>` reports the
saved page count and current budgets.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
- Catalog protocol remains **10**. Use matching client/server builds.
- Player in-game checks passed. Builds and targeted disposable-server checks
covered seeded completion, Native Cave sampling, tick-budget arithmetic,
unload/reload, reseeding and restart. Test servers were stopped and their
worlds removed. This was not a full automated GameTest run.
- Back up worlds before updating. New populated Native Cave profiles cannot
be read by earlier beta builds; do not downgrade those saves. Keep Minecraft
1.21.1 and 26.3 saves separate.
- Full writing recovery, discovery/stations, instability, crystal portals and
optional integration parity remain unfinished. Random-page eligibility config
and modded biome surface-rule integration also remain pending. Creative page
availability does not imply full support.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
+81
View File
@@ -0,0 +1,81 @@
# Rivencraft 26.3.0.1
For **Minecraft 26.3**, **NeoForge 26.3.0.99** and **Java 25**.
This regular release collects the player-validated .137–.142 development slices
since 1.0.1.136-beta.1. Versions now use the Minecraft version followed by the
release slice and its iteration: `26.3.0.1`. The JAR is
`rivencraft-26.3.0.1.jar`. The existing Minecraft 1.21.1 release is unchanged.
## Live destination views with Immersive Portals Reborn
- Optional live rotating destination views in Linking and Descriptive Books,
including books on stands. Reborn streams the actual destination chunks.
- Crystal portals show a continuous live view across each coplanar opening,
masked to its shape, with the crystal frame remaining in front.
- Linking Book views use the written destination/facing; Descriptive Book
portals preserve the traveler's facing. Views anchor standing eye height to
the opening's floor, so taller frames do not shift arrival height.
- Rivencraft retains control of portal travel, Disarm, momentum and arrival
safety. The native view cannot independently teleport or interact remotely.
- Preview sessions release their chunk streams on close, logout, invalidation
or expiry. Remote book-preview audio is suppressed and the player camera
restored after rendering.
- Portal maintenance restores views after restart and removes them when the
book is removed or the frame is shut down.
- Tested with **[Immersive Portals Reborn 7.0.12 for NeoForge 26.3](https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354)**.
Use one portal fork per instance. No optional mod is bundled or required.
- The renamed Seamless fork also receives runtime Age dimension/metadata
synchronization; this release's live rendering is enabled for Reborn.
## Random Ages, pages and book descriptions
- New random-Age and library-page allowlists/blocklists accept short paths such
as `sky/static`, as well as full namespaced IDs.
- Blocklists take precedence. An allowlist restricts only the categories it
mentions. Existing Ages and manually authored books remain usable.
- Required empty categories use safe defaults; optional empty categories can be
omitted. Native preset biome pools and necessary biome minimum fallbacks can
bypass individual biome-page exclusions.
- Random-Age Descriptive Books show Legacy glyph descriptions of their public
traits, beneath the readable Age name/author. Seed and instability scores
remain absent from these descriptions.
- Optional portal-engine dimension IDs and native metadata synchronize before
the Rivencraft catalog acknowledgement permits travel.
## Configuration
In `config/rivencraft-local.toml`:
- `random_pages.allowlist` and `random_pages.blocklist`: default empty.
- `book_previews.reborn_live_views`: default `true`; disable locally for
animated book panels.
- `book_previews.reborn_crystal_live_views`: default `true`; the server
controls live crystal surfaces. Disabling retains animated surfaces and travel.
The built-in Legacy animation remains available without optional renderers.
Dynamic Dimensions and Bad Packets are not required by the 26.3 line.
## Validation and known limits
The user passed the included slices in game. Disposable checks covered random
policy resolution, description/catalog serialization and persistence, native
dimension IDs and packet formats for both portal forks, preview rendering and
chunk-lease cleanup, all three crystal portal plane axes, both sides, destination
eye/facing transforms, fallback toggling and full restart restoration. A real
dedicated server/client displayed the live crystal view and crossed into its Age.
The no-optional-mod server also started successfully. Temporary fixtures/worlds
were removed and the production JAR contains no test or optional-engine classes.
**Known issue [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116):**
with Reborn active, a Static Sky Age can render its sky outside the book GUI.
Use the animated book panel option if affected.
26.3 is now published on the regular release channel at the user's request.
This is not a claim that every 1.21.1 behavior or the final expanded-catalog
two-client regression is complete. The remaining multiplayer, parity and shared
follow-up issues stay open. Preview exploration controls and Create/Ponder scenes
are not included in this release.
Use matching client/server JARs. Back up worlds, keep the Minecraft tracks and
saves separate, and do not downgrade worlds containing newer data.
+35
View File
@@ -0,0 +1,35 @@
# Random Page policy test — 1.0.1.137-beta.1
Install the new JAR and launch once so the new entries appear in
Minecraft's config/rivencraft-local.toml. Close the instance before editing.
On a dedicated server edit its own config, not the client's.
For a visible check, replace only the existing random_pages section with:
```toml
[random_pages]
allowlist = ["terrain/flat", "biome/single", "biome/plains", "sky/static", "lighting/normal", "weather/off", "feature/not_present"]
blocklist = ["sky/nether", "celestial/sun", "celestial/moon"]
```
The deliberately nonexistent feature entry restricts only optional features,
leaving an empty feature pool for this test.
1. Bind a Descriptive Book with only a Link Panel. Enter it: expect Flat terrain,
Plains, Static Sky, normal lighting, no rain and no suns/moons. Optional Page
features/structures should be absent; native biome decoration is independent.
2. Bind an authored Flat/Plains/Single/Nether Sky book. Nether Sky remains usable
despite the blocklist; the setting controls random generation and direct
library loot, not manual authorship. The Page remains visible in Creative/JEI.
3. Change sky/static to sky/end, restart and revisit the first Age. It must retain
Static Sky. A newly bound Link-Panel-only book should get End Sky.
4. Optional library check: allowlist = ["sky/static"], blocklist = ["sky/nether"].
New library direct Pages may include Static Sky or other unrestricted
categories, but never another Sky Page. Test both library loot modes where
desired. Existing chests/stands remain untouched; AGE_ONLY may have no Pages
when the book's written Page set has no eligible entries.
Restore both lists to [] afterward unless you want the restrictions to persist.
No existing Age should change. Keep the full world backup customary for beta
updates. Native biome presets and required minimum-biome fallbacks intentionally
bypass biome-Page filters; Sealed Notebooks keep their separate 1.21.1 draw pool.
+21
View File
@@ -0,0 +1,21 @@
# Random-Age glyph description test — 1.0.1.138-beta.1
Use the new build on both client and server (Age catalog protocol 11).
1. Open an existing or new Link-Panel-only Descriptive Book. Its front spread
should retain the plain-text Age name and author on the left, with the generated
description below rendered as Legacy glyphs. The destination animation remains
on the right and clicking it still travels.
2. Put the same book on a lectern and open it. The description and panel should
match handheld use.
3. Open an authored book containing Symbol Pages. Its front spread should retain
just the name/author, and page turning should show one authored symbol at a time.
4. Reconnect or restart, then reopen the random book. Glyph traits should remain
consistent with its saved Age. Test a longer name and GUI scale for clipping.
5. Check Writing Desk tab numbers around 24/25 and 49/50. Their existing glyph
size/position and base-25 behavior should be unchanged.
Descriptions are available only for loaded Ages; viewing a book does not force-load
an unloaded/retired Age. No instability score or seed appears in the description.
The desktop glyph display still needs your visual check; server/codec/layout
validation does not substitute for a real client render check.
+8
View File
@@ -0,0 +1,8 @@
# Superseded by the 1.0.1.140-beta.1 portal-fork test
Use TESTING_1.0.1.140-beta.1.md. It covers both supported fork candidates.
Correction: the user's installed Seamless file exactly matches current published
CurseForge file 9218525 (displayed 10.0.0, internal version/filename 1.0.0).
The startup failure applies only to older file 8937394. The earlier recommendation
to avoid the current download was incorrect.
+29
View File
@@ -0,0 +1,29 @@
# Portal fork runtime compatibility — 1.0.1.140-beta.1
Candidate downloads for Minecraft 26.3 / NeoForge:
- Immersive Portals Reborn 7.0.12:
https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354
Version 7.0.18 is Fabric, so use the NeoForge 7.0.12 file for this test.
- Seamless latest file 9218525:
https://www.curseforge.com/minecraft/mc-mods/immersive-portal/files/9218525
Display name is 10.0.0, but its downloaded filename/internal version remain 1.0.0.
This exactly matches the user's installed working JAR.
Use ONE portal fork per instance: they share engine classes and mixins.
Prism files were left unchanged. Install matching Rivencraft builds on both sides
for dedicated testing. Current tested loader is NeoForge 26.3.0.99.
1. Enable/install the chosen fork and Rivencraft .140.
2. Create an authored Descriptive Book after joining, using a distinctive sky and
lighting. Travel into its newly created Age without reconnecting.
3. Return using a bound Linking Book and test a second freshly created Age.
4. Restart and revisit both Ages. Check for unknown/unloaded-dimension rejection,
disconnects or incorrect sky/lighting.
5. Test existing crystal portals through the usual Rivencraft fallback path.
This slice synchronizes runtime Ages and native metadata. Live book panels and
live crystal portal surfaces are NOT ported yet; animated fallbacks are expected.
Actual client travel/packet handling needs your check. Server lifecycle, packet
serialization and restart checks passed with both Reborn and latest Seamless
(32 lifecycle/packet plus 15 restart checks each). NeoForge .102 is a candidate for a later isolated upgrade
check; the project target has not been changed yet.
+26
View File
@@ -0,0 +1,26 @@
# 1.0.1.141-beta.1 — Reborn live book previews
Use NeoForge 26.3.0.99 and Immersive Portals Reborn 7.0.12, CurseForge file 9435354.
Install one portal fork per instance and matching Rivencraft builds on a dedicated
server/client. Reborn remains optional; nothing is bundled.
The user reports live views working in both book types. Follow-up coverage:
1. View a Linking Book and Descriptive Book pointing to different Ages. Check
orbiting terrain, upright orientation, destination lighting/sky and the panel
cutout. Click the live panel to travel using the existing book controls.
2. Repeat from a book stand, including a destination whose chunks are not loaded.
3. Close/reopen, switch books quickly and restart/revisit the Age. A stale view
must not replace the newly selected destination.
4. During rain, confirm the source world's weather audio remains audible and the
remote world's sounds do not leak into the GUI.
5. Set [book_previews] reborn_live_views = false in rivencraft-local.toml to
verify the animated fallback; restore true for live views.
The live image streams actual destination chunks. It is not restricted to the
old fixed block snapshot volume. Exploration controls and live crystal portal
surfaces are not included in this slice.
Build and isolated dedicated/client rendering check passed, with a live Linking
Book screenshot inspected and zero remaining preview leases on logout. Temporary
test worlds were removed regardless of result. Reborn's four deprecated logoFile
startup warnings concern its metadata, not Rivencraft rendering.
+30
View File
@@ -0,0 +1,30 @@
# 1.0.1.142-beta.1 — Reborn live crystal portal surfaces
Use Reborn 7.0.12 for NeoForge 26.3, CurseForge file 9435354, with NeoForge
26.3.0.99. Use one portal fork and matching Rivencraft builds on a server/client.
Implemented and validated:
- One continuous view per opening on each side, masked to the portal cells.
- Correct destination eye-height reference; Linking Book facing and Descriptive
Book inherited facing.
- Native surfaces render only; Rivencraft's blocks retain transport and effects.
- Fallback model hidden only for established live surfaces.
- Maintenance restores views after restart and removes them on shutdown/removal.
- Optional server setting [book_previews] reborn_crystal_live_views = false retains
animated surfaces and ordinary portal travel.
68 server checks and five restart checks passed, plus a real dedicated/client
live-view and walking-through check. Screenshots were inspected and the user
reported success. Temporary worlds were deleted and fixture sources removed.
Useful follow-up manual coverage:
1. Try both book types, compare portal arrival to using the same book, and inspect
both sides of the frame.
2. Try taller, horizontal and irregular frames; check that frame edges occlude the
scene and the view does not repeat per cell.
3. Restart, revisit, remove/reinsert the book and break a frame block.
4. Check a destination with different sky/lighting and an initially unloaded
destination area.
The initial synthetic render fixture used a blocked Y=1 target; that was corrected
to the resolved Y=4 surface. No global landing-height behavior was changed.
+98 -1
View File
@@ -3,11 +3,61 @@ plugins {
id 'net.neoforged.moddev' version '2.0.148'
}
repositories {
maven { url = 'https://www.cursemaven.com'; content { includeGroup 'curse.maven' } }
maven { url = 'https://api.modrinth.com/maven'; content { includeGroup 'maven.modrinth' } }
maven {
name = 'TerraBlenderMaven'
url = 'https://maven.minecraftforge.net/releases/'
content { includeGroup 'com.github.glitchfiend' }
}
maven {
name = 'JaredsMaven'
url = 'https://maven.blamejared.com'
content { includeGroup 'mezz.jei'; includeGroup 'net.mezzdev.config' }
}
}
// Optional APIs stay out of published dependency metadata.
configurations {
localRuntime
runtimeClasspath.extendsFrom localRuntime
}
dependencies {
// Both optional forks retain qouteall APIs, but must never be loaded together.
if (project.hasProperty('withSeamlessPortals') && project.hasProperty('withImmersivePortalsReborn'))
throw new GradleException('Choose one Immersive Portals fork for the development run')
def portalDependency = project.hasProperty('withImmersivePortalsReborn')
? 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
: project.hasProperty('seamlessPortalsJar') ? files(project.property('seamlessPortalsJar'))
: project.hasProperty('withSeamlessPortals') ? 'curse.maven:immersive-portal-1511174:9218525'
: 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
compileOnly portalDependency
if (project.hasProperty('withSeamlessPortals') || project.hasProperty('withImmersivePortalsReborn'))
localRuntime portalDependency
// Compile against the 26.3 baseline API; a local current Ruins JAR can override it.
def ruinsDependency = project.hasProperty('ruinsJar') ? files(project.property('ruinsJar'))
: 'curse.maven:ruins-227873:8962119'
compileOnly ruinsDependency
if (project.hasProperty('withRuins')) localRuntime ruinsDependency
compileOnly "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
compileOnly "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
if (project.hasProperty('withSereneSeasons')) {
localRuntime "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
localRuntime "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
}
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
if (project.hasProperty('withTerraBlender')) localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
// Plugin-only API dependency: JEI is never bundled or required at runtime.
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
if (project.hasProperty('withJei')) localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
}
group = 'net.dnorberg.rivencraft'
version = mod_version
base {
archivesName = "rivencraft-${minecraft_version}"
archivesName = "rivencraft"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
@@ -193,6 +243,10 @@ tasks.named('processResources', ProcessResources).configure {
include 'data/rivencraft/worldgen/structure_set/bastions.json'
include 'assets/rivencraft/models/block/star_fissure.json'
include 'assets/rivencraft/blockstates/star_fissure.json'
include 'assets/rivencraft/blockstates/decay.json'
include 'assets/rivencraft/models/block/decay_*.json'
include 'assets/rivencraft/textures/block/decay_*.png'
include 'data/rivencraft/tags/block/decay_protected.json'
include 'assets/rivencraft/sounds.json'
include 'assets/rivencraft/sounds/**/*.ogg'
include 'assets/rivencraft/textures/item/crystal_shard.png'
@@ -203,6 +257,25 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/notebook.png'
include 'assets/rivencraft/textures/item/collation_folder.png'
include 'assets/rivencraft/models/item/notebook.json'
include 'assets/rivencraft/models/item/collation_folder.json'
include 'assets/rivencraft/textures/item/sealed_notebook.png'
include 'assets/rivencraft/textures/item/symbol_portfolio.png'
include 'assets/rivencraft/models/item/sealed_notebook.json'
include 'assets/rivencraft/models/item/symbol_portfolio.json'
include 'assets/rivencraft/textures/item/writing_desk.png'
include 'assets/rivencraft/textures/block/writing_desk.png'
include 'assets/rivencraft/textures/gui/writing_desk_legacy.png'
include 'assets/rivencraft/textures/gui/scrollbar.png'
include 'assets/rivencraft/models/item/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk_extension.json'
include 'assets/rivencraft/blockstates/writing_desk.json'
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
include 'data/rivencraft/advancement/progression/*.json'
exclude 'data/rivencraft/advancement/progression/root.json'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
@@ -214,10 +287,19 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/bookui_cover.png'
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.png'
include 'assets/rivencraft/textures/gui/bookui_rpage_full.png'
include 'assets/rivencraft/textures/block/crystal_block.png'
include 'assets/rivencraft/textures/entity/linking_book.png'
include 'assets/rivencraft/textures/entity/descriptive_book.png'
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
include 'assets/rivencraft/textures/gui/link_modifier_legacy.png'
include 'assets/rivencraft/textures/block/link_modifier_*.png'
include 'assets/rivencraft/models/block/link_modifier.json'
include 'assets/rivencraft/models/item/link_modifier.json'
include 'assets/rivencraft/blockstates/link_modifier.json'
include 'data/rivencraft/loot_table/blocks/link_modifier.json'
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
include 'assets/rivencraft/textures/block/inkwell_base.png'
include 'assets/rivencraft/textures/block/inkwell_cup.png'
@@ -230,9 +312,24 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/book_binder.json'
include 'assets/rivencraft/blockstates/book_receptacle.json'
include 'assets/rivencraft/blockstates/crystal_portal.json'
include 'assets/rivencraft/models/block/book_receptacle.json'
include 'assets/rivencraft/models/block/crystal_portal.json'
include 'assets/rivencraft/models/item/book_receptacle.json'
include 'assets/rivencraft/textures/block/book_receptacle.png'
include 'assets/rivencraft/textures/block/crystal_portal.png'
include 'assets/rivencraft/textures/block/crystal_portal.png.mcmeta'
include 'data/rivencraft/loot_table/blocks/book_receptacle.json'
include 'assets/rivencraft/blockstates/book_stand.json'
include 'data/rivencraft/loot_table/blocks/book_stand.json'
include 'data/rivencraft/loot_table/chests/mystcraft_library.json'
include 'data/rivencraft/structure/mystcraft_library.nbt'
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/models/block/cloud_platform.json'
include 'assets/rivencraft/blockstates/cloud_platform.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
+8 -1
View File
@@ -7,4 +7,11 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.93-beta.1
mod_version=26.3.0.1
jei_version=31.17.0.109
terrablender_version=26.3.0.0.9
serene_seasons_version_id=QnvW5HPm
glitchcore_version_id=pQgy3bfh
+7 -2
View File
@@ -85,10 +85,11 @@
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Allow abandoned camps on surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.block.generated.description": "Modify the next compatible feature. Without a following feature, choose a compatible feature from the Age seed.",
"symbol.rivencraft.biome.native.description": "Use the terrain's native biome source; do not include Biome pages.",
"symbol.rivencraft.biome.native.description": "Use the terrain's default biome source. Cave uses Tiny regions from recognized Cave/Nether biomes. Do not include Biome pages.",
"symbol.rivencraft.feature.dungeons.description": "Add monster-room dungeon attempts throughout surface noise or Cave terrain, including Nether cave biomes. Not Flat or Void.",
"symbol.rivencraft.biome.single.description": "Use one biome across the Age. If omitted, choose an eligible biome from the Age seed.",
"symbol.rivencraft.terrain.flat.description": "A level, biome-decorated landscape with its foundation at Y=0.",
"symbol.rivencraft.environment.accelerated.description": "Adds extra random block ticks near players in this Age. Repeated pages stack within server tick budgets; does not speed up entities or machines.",
"symbol.rivencraft.feature.floating_islands.description": "Add high floating islands alongside noise, Flat, or Void terrain; not Cave terrain.",
"symbol.rivencraft.feature.caves.description": "Add extra cave carving to noise terrain, including Cave; not Flat or Void.",
"symbol.rivencraft.feature.ravines.description": "Add canyon carving to noise terrain; not Flat or Void. Skips Nether biomes.",
@@ -105,5 +106,9 @@
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes.",
"jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. Each panel uses one paper and one ink. A vial supplies 16 ink and returns a glass bottle; additives reset when a panel is taken.",
"jei.rivencraft.link_modifier": "Creative-only tool; no crafting recipe. Insert a book to rename it and edit link properties. Bound Descriptive Books also allow seed editing while their Age is empty. Age retirement requires OP permission and a separate confirmation; its data is archived.",
"symbol.rivencraft.ruins.description": "Generates this Ruins template. Repeated pages increase its selection weight and spawn attempts.",
"itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages"
}
@@ -0,0 +1,80 @@
package net.dnorberg.rivencraft;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-owned gameplay policies adapted from 1.21.1. */
public final class PortConfig {
public enum LibraryLootMode { LEGACY, AGE_ONLY }
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_ALLOWLIST;
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_BLOCKLIST;
public static final ModConfigSpec.BooleanValue LIVE_BOOK_PREVIEWS;
public static final ModConfigSpec.BooleanValue CRYSTAL_LIVE_VIEWS;
public static final ModConfigSpec SPEC;
public static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
static {
var builder = new ModConfigSpec.Builder();
builder.push("book_previews");
LIVE_BOOK_PREVIEWS = builder.comment("Show live rotating destination views when Immersive Portals Reborn is installed on client and server.",
"Disable locally to retain the animated Legacy link panels.")
.define("reborn_live_views", true);
CRYSTAL_LIVE_VIEWS = builder.comment("Server option: create native live crystal portal surfaces when Reborn is installed.",
"Disabling keeps crystal portal travel with the animated surface.")
.define("reborn_crystal_live_views", true);
builder.pop();
builder.push("random_pages");
RANDOM_PAGE_ALLOWLIST = builder.comment(
"Page paths allowed in new random Ages and newly generated library pages.",
"Restricts only categories it mentions; other categories remain unrestricted.",
"Example: [\"sky/static\"]; full namespaced IDs are accepted too.")
.defineList("allowlist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
RANDOM_PAGE_BLOCKLIST = builder.comment(
"Page paths excluded from new random Ages and newly generated library pages.",
"Blocklist wins. Existing Ages, Creative/JEI pages and manually authored books remain usable.",
"Required empty categories use safe defaults; Native biomes come from the preset, not biome pages.")
.defineList("blocklist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
builder.pop();
builder.push("serene_seasons");
AUTO_WHITELIST_SERENE_AGES = builder.comment("Automatically whitelist Ages with seed-selected/legacy cycling seasons. Explicit pages still apply; existing entries are preserved when disabled.")
.define("auto_whitelist_ages", true);
builder.pop();
builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop();
builder.push("libraries");
LIBRARY_LOOT_MODE = builder.comment("LEGACY uses writing supplies/Sealed Notebooks and random lectern pages; AGE_ONLY uses only pages written into this Age.")
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline Link Panel by hand with paper.",
"The Ink Mixer uses one ink unit per panel independently of this setting.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
builder.push("accelerated_age");
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
.defineInRange("max_loaded_chunks", 8, 1, 64);
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop();
net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder);
SPEC = builder.build();
}
private static boolean validPageId(Object value) {
if (!(value instanceof String id)) return false;
try { net.dnorberg.rivencraft.age.PortRandomPagePolicy.parsePageId(id); return true; }
catch (IllegalArgumentException exception) { return false; }
}
private PortConfig() {}
}
@@ -57,6 +57,15 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<net.minecraft.world.entity.EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID);
public static final Supplier<net.minecraft.world.entity.EntityType<net.dnorberg.rivencraft.entity.DroppedBookEntity>>
DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder
.<net.dnorberg.rivencraft.entity.DroppedBookEntity>of(
net.dnorberg.rivencraft.entity.DroppedBookEntity::new, net.minecraft.world.entity.MobCategory.MISC)
.sized(0.65F, 0.4F).clientTrackingRange(10).updateInterval(20)
.build(net.minecraft.resources.ResourceKey.create(Registries.ENTITY_TYPE,
Identifier.fromNamespaceAndPath(MOD_ID, "dropped_book"))));
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK = sound("linking.link");
@@ -82,6 +91,12 @@ public final class Rivencraft {
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<net.dnorberg.rivencraft.writing.NotebookContents>> NOTEBOOK_CONTENTS =
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
public static final Supplier<DataComponentType<String>> LINK_DESTINATION_NAME = COMPONENTS.registerComponentType(
"link_destination_name", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
@@ -93,6 +108,8 @@ public final class Rivencraft {
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
/** Ordered authored page IDs remain on the finished book for later port slices. */
public static final Supplier<DataComponentType<String>> BOOK_AUTHOR = COMPONENTS.registerComponentType(
"book_author", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
@@ -110,16 +127,52 @@ public final class Rivencraft {
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<net.dnorberg.rivencraft.block.CloudPlatformBlock> CLOUD_PLATFORM =
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookReceptacleBlock> BOOK_RECEPTACLE =
BLOCKS.registerBlock("book_receptacle", net.dnorberg.rivencraft.block.BookReceptacleBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).noOcclusion().mapColor(MapColor.COLOR_BLUE));
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE_ITEM = ITEMS.registerSimpleBlockItem(BOOK_RECEPTACLE);
public static final DeferredBlock<net.dnorberg.rivencraft.block.CrystalPortalBlock> CRYSTAL_PORTAL =
BLOCKS.registerBlock("crystal_portal", net.dnorberg.rivencraft.block.CrystalPortalBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
.noCollision().noOcclusion().lightLevel(state -> 11).noLootTable());
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity>> BOOK_RECEPTACLE_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_receptacle", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity::new, BOOK_RECEPTACLE.get()));
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity>> CRYSTAL_PORTAL_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("crystal_portal", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity::new, CRYSTAL_PORTAL.get()));
public static final DeferredBlock<net.dnorberg.rivencraft.block.DecayBlock> DECAY =
BLOCKS.registerBlock("decay", net.dnorberg.rivencraft.block.DecayBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).strength(5.0F, 10.0F).noLootTable());
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
StarFissureBlockEntity::new, STAR_FISSURE.get()));
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookStandBlock> BOOK_STAND =
BLOCKS.registerBlock("book_stand", net.dnorberg.rivencraft.block.BookStandBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN));
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookStandBlockEntity>> BOOK_STAND_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_stand", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.BookStandBlockEntity::new, BOOK_STAND.get()));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
public static final DeferredBlock<net.dnorberg.rivencraft.block.LinkModifierBlock> LINK_MODIFIER = BLOCKS.registerBlock(
"link_modifier", net.dnorberg.rivencraft.block.LinkModifierBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).noOcclusion());
public static final DeferredItem<BlockItem> LINK_MODIFIER_ITEM = ITEMS.registerSimpleBlockItem(LINK_MODIFIER);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity>> LINK_MODIFIER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("link_modifier", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity::new, LINK_MODIFIER.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.LinkModifierMenu>> LINK_MODIFIER_MENU =
MENUS.register("link_modifier", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.LinkModifierMenu::new));
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
@@ -134,6 +187,25 @@ public final class Rivencraft {
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredBlock<net.dnorberg.rivencraft.block.WritingDeskBlock> WRITING_DESK =
BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity>> WRITING_DESK_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("writing_desk", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity::new, WRITING_DESK.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.WritingDeskMenu>> WRITING_DESK_MENU =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.NotebookMenu>> NOTEBOOK_MENU =
MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new));
public static final DeferredItem<net.dnorberg.rivencraft.item.NotebookItem> NOTEBOOK =
ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.CollationFolderItem> COLLATION_FOLDER =
ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SymbolPortfolioItem> SYMBOL_PORTFOLIO =
ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SealedNotebookItem> SEALED_NOTEBOOK =
ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
@@ -166,25 +238,44 @@ public final class Rivencraft {
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
public Rivencraft(IEventBus modEventBus) {
public Rivencraft(IEventBus modEventBus, net.neoforged.fml.ModContainer container) {
container.registerConfig(net.neoforged.fml.config.ModConfig.Type.LOCAL, PortConfig.SPEC);
BLOCKS.register(modEventBus);
SOUNDS.register(modEventBus);
ITEMS.register(modEventBus);
ENTITIES.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
net.dnorberg.rivencraft.compat.RuinsCompatibility.register();
net.dnorberg.rivencraft.compat.RebornBookPreviews.register();
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility::onServerStarted);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onTagsUpdated);
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onServerAboutToStart);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLogin);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads);
modEventBus.addListener(PortBookNetworking::registerPayloads);
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
net.dnorberg.rivencraft.age.PortDecayProviders.register();
net.dnorberg.rivencraft.age.PortHazardProviders.register();
net.dnorberg.rivencraft.age.PortMeteorProvider.register();
net.dnorberg.rivencraft.age.PortEnvironmentalProviders.register();
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.advancement;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements {
public static final String OPEN_PORTAL = "open_portal";
public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age";
public static final String ENTER_AGE = "enter_age";
private ProgressionAdvancements() {}
public static void award(ServerPlayer player, String path) {
var advancement = player.level().getServer().getAdvancements().get(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement != null) player.getAdvancements().award(advancement, "completed");
}
public static void awardAgeTravel(ServerPlayer player) {
if (RuntimeAgeBackend.savedAges(player.level().getServer()).containsKey(player.level().dimension().identifier()))
award(player, ENTER_AGE);
}
}
@@ -13,7 +13,8 @@ import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
Map<Identifier, PortDayCycle> dayCycles,
Map<Identifier, PortAgeDescription> descriptions) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
@@ -21,6 +22,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeDescription.STREAM_CODEC), AgeCatalogPayload::descriptions,
AgeCatalogPayload::new
);
@@ -28,6 +30,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
dimensions = List.copyOf(dimensions);
colors = Map.copyOf(colors);
dayCycles = Map.copyOf(dayCycles);
descriptions = Map.copyOf(descriptions);
}
@Override
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("10");
var registrar = event.registrar("11");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
@@ -55,6 +55,9 @@ public final class AgeCatalogSync {
State state = state(server);
state.version++;
state.acknowledged.clear();
// Optional synchronization failures must not turn a committed Age into a
// reported binding failure. Withhold acknowledgements, so travel stays safe.
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.synchronize(server)) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
try {
send(player);
@@ -76,9 +79,11 @@ public final class AgeCatalogSync {
MinecraftServer server = player.level().getServer();
State state = state(server);
state.acknowledged.remove(player.getUUID());
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.beforeCatalog(player))
throw new IllegalStateException("Optional portal dimension catalog is unavailable");
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server)));
}
private static void acknowledge(ServerPlayer player, int version) {
@@ -42,7 +42,9 @@ final class AgeLevelIndex extends SavedData {
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles),
Codec.unboundedMap(Identifier.CODEC, Codec.STRING)
.optionalFieldOf("display_names", Map.of()).forGetter(index -> index.displayNames)
).apply(instance, AgeLevelIndex::new));
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
@@ -65,6 +67,7 @@ final class AgeLevelIndex extends SavedData {
private final Map<Identifier, PortSkyStyle> sky;
private final Map<Identifier, PortAgeColors> colors;
private final Map<Identifier, PortDayCycle> dayCycles;
private final Map<Identifier, String> displayNames;
private AgeLevelIndex() {
this.ages = new LinkedHashMap<>();
@@ -81,6 +84,7 @@ final class AgeLevelIndex extends SavedData {
this.sky = new LinkedHashMap<>();
this.colors = new LinkedHashMap<>();
this.dayCycles = new LinkedHashMap<>();
this.displayNames = new LinkedHashMap<>();
}
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
@@ -94,7 +98,7 @@ final class AgeLevelIndex extends SavedData {
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
Map<Identifier, PortLighting> lighting,
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) {
Map<Identifier, PortDayCycle> dayCycles, Map<Identifier, String> displayNames) {
this.ages = new LinkedHashMap<>(ages);
this.retired = new LinkedHashSet<>(retired);
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
@@ -109,6 +113,16 @@ final class AgeLevelIndex extends SavedData {
this.sky = new LinkedHashMap<>(sky);
this.colors = new LinkedHashMap<>(colors);
this.dayCycles = new LinkedHashMap<>(dayCycles);
this.displayNames = new LinkedHashMap<>(displayNames);
}
String displayName(Identifier id) { return ages.containsKey(id) ? displayNames.get(id) : null; }
void setDisplayName(Identifier id, String name) {
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
if (name == null || name.isBlank() || name.length() > 32) throw new IllegalArgumentException("Invalid Age display name");
displayNames.put(id, name.strip());
setDirty();
}
Map<Identifier, Long> entries() {
@@ -168,8 +182,15 @@ final class AgeLevelIndex extends SavedData {
setDirty();
}
void retire(Identifier id) {
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
/** Failed creation is not retirement: release the new identity for a clean retry. */
void discardCreation(Identifier id) {
if (retired.contains(id)) throw new IllegalArgumentException("Cannot discard a retired Age: " + id);
removeActive(id);
displayNames.remove(id);
setDirty();
}
private void removeActive(Identifier id) {
ages.remove(id);
biomeOverrides.remove(id);
biomePlans.remove(id);
terrain.remove(id);
@@ -182,6 +203,10 @@ final class AgeLevelIndex extends SavedData {
sky.remove(id);
colors.remove(id);
dayCycles.remove(id);
}
void retire(Identifier id) {
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
removeActive(id);
retired.add(id);
setDirty();
}
@@ -0,0 +1,74 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** 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(Identifier 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(Identifier 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(Identifier.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);
}
}
}
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.dnorberg.rivencraft.PortConfig;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.util.RandomSource;
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
public final class PortAcceleratedTicks {
public static final Identifier PAGE = Identifier.fromNamespaceAndPath("rivencraft", "environment/accelerated");
private PortAcceleratedTicks() {}
public static int pageCount(List<Identifier> pages) {
return (int) pages.stream().filter(PAGE::equals).count();
}
public static void configure(ServerLevel level, int pages) {
if (!(level instanceof PortAgeLevel)) throw new IllegalArgumentException("Not a Rivencraft Age");
level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).setPages(pages);
level.getDataStorage().saveAndJoin();
}
public static int pages(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).pages();
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (event.getLevel() instanceof PortAgeLevel level) tick(level);
}
static void tick(ServerLevel level) {
int maximumAttempts = PortConfig.ACCELERATED_MAX_ATTEMPTS.get();
if (maximumAttempts == 0) return;
int pages = pages(level);
if (pages == 0) return;
List<ChunkPos> chunks = loadedPlayerChunks(level);
if (chunks.isEmpty()) return;
int maximumChunks = Math.min(chunks.size(), PortConfig.ACCELERATED_MAX_CHUNKS.get());
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
List<TickSection> eligible = new ArrayList<>();
for (int offset = 0; offset < maximumChunks; offset++) {
ChunkPos pos = chunks.get((first + offset) % chunks.size());
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x(), pos.z());
if (chunk == null) continue;
LevelChunkSection[] sections = chunk.getSections();
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
LevelChunkSection section = sections[sectionIndex];
if (!section.isRandomlyTickingBlocks()) continue;
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
eligible.add(new TickSection(pos, baseY, section));
}
}
if (eligible.isEmpty()) return;
// Legacy attempts three positions per random-ticking section per page. Divide
// the bounded total between sections before ticking any one of them, so a
// lower section cannot exhaust the entire budget ahead of surface crops.
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
int perSection = total / eligible.size();
int remainder = total % eligible.size();
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
RandomSource random = level.getRandom();
for (int offset = 0; offset < eligible.size(); offset++) {
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
int samples = perSection + (offset < remainder ? 1 : 0);
for (int sample = 0; sample < samples; sample++) {
int x = random.nextInt(16);
int y = random.nextInt(16);
int z = random.nextInt(16);
BlockState state = candidate.section().getBlockState(x, y, z);
if (state.isRandomlyTicking()) {
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
}
}
}
}
static int attemptBudget(int pages, int sections, int maximumAttempts) {
if (pages < 0 || sections < 0 || maximumAttempts < 0)
throw new IllegalArgumentException("Negative accelerated tick budget input");
if (sections == 0) return 0;
long perSection = 3L * pages;
return perSection > maximumAttempts / (long) sections
? maximumAttempts : (int) (perSection * sections);
}
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
int chunkX = center.x() + x;
int chunkZ = center.z() + z;
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
chunks.add(new ChunkPos(chunkX, chunkZ));
}
}
}
}
return new ArrayList<>(chunks);
}
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local authored page count, retained across unload, restart and reseeding. */
final class PortAccelerationSettings extends SavedData {
private static final Codec<PortAccelerationSettings> CODEC = Codec.intRange(0, Integer.MAX_VALUE)
.fieldOf("pages").codec().xmap(PortAccelerationSettings::new, settings -> settings.pages);
static final SavedDataType<PortAccelerationSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "acceleration"),
() -> new PortAccelerationSettings(0), CODEC);
private int pages;
private PortAccelerationSettings(int pages) { this.pages = pages; }
int pages() { return pages; }
void setPages(int pages) {
if (pages < 0) throw new IllegalArgumentException("Negative Accelerated page count");
this.pages = pages;
setDirty();
}
}
@@ -69,7 +69,8 @@ public record PortAgeColors(int sky, int ambient, int celestial) {
}
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
// Numbers only attach to the immediately following target. Misplaced
// numbers are diagnosed by PortWritingRecovery, never moved to a later color.
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
@@ -35,12 +35,18 @@ public final class PortAgeCommands {
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("recipe_discovery_probe")
.executes(context -> {
PortProgressionProbe.recipeDiscovery(context.getSource().getServer().overworld());
context.getSource().sendSuccess(() -> Component.literal("Recipe discovery checks passed for all six crafting unlocks"), false);
return 1;
}))
.then(Commands.literal("compat")
.executes(context -> {
StringBuilder mods = new StringBuilder("Optional mods: ");
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
"sereneseasons", "glitchcore", "ruins", "create",
"ponder", "immersive_portals_core"}) {
"ponder", "immersive_portals_core", "seamlessportals", "immersive_portals_reborn"}) {
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
mods.append(id).append('=').append(ModList.get().isLoaded(id));
}
@@ -69,6 +75,177 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create_drop_conversion_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBookDropConversionProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Drop sources queued; run drop_conversion_probe after server tasks complete"), false);
return 1;
}))))
.then(Commands.literal("drop_conversion_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBookDropConversionProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Universal book drop conversion/ownership checks passed"), false);
return 1;
})))
.then(Commands.literal("create_book_stand_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBookStandProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Book Stand conversion/ownership/source checks passed"), false);
return 1;
}))))
.then(Commands.literal("book_stand_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBookStandProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Book Stand persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_writing_desk_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortWritingDeskProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk transaction/removal checks passed"), false);
return 1;
}))))
.then(Commands.literal("progression_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortProgressionProbe.run(level);
context.getSource().sendSuccess(() -> Component.literal("Progression data/action checks passed"), false);
return 1;
})))
.then(Commands.literal("writing_desk_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortWritingDeskProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_collection_extras_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortCollectionExtrasProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook checks passed"), false);
return 1;
}))))
.then(Commands.literal("collection_extras_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortCollectionExtrasProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_binder_collection_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBinderCollectionProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection transaction/assembly checks passed"), false);
return 1;
}))))
.then(Commands.literal("binder_collection_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBinderCollectionProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_notebook_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortNotebookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook menu/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("notebook_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortNotebookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook chest persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_dropped_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortDroppedBookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book ownership/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("dropped_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
level.getChunk(0, 0);
PortDroppedBookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book persistence checks passed"), false);
return 1;
})))
.then(Commands.literal("create_return_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createReturnFixture))))
.then(Commands.literal("return_link_status")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::returnLinkStatus)))
.then(Commands.literal("create_platform")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createPlatform))))
.then(Commands.literal("platform_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probePlatform)))
.then(Commands.literal("create_accelerated")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("pages", IntegerArgumentType.integer(0, 256))
.executes(PortAgeCommands::createAccelerated)))))
.then(Commands.literal("acceleration")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::acceleration)))
.then(Commands.literal("create_auto_terrain")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("page", IdentifierArgument.id())
.executes(PortAgeCommands::createAutomaticTerrain)))))
.then(Commands.literal("create_biome_surface")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -108,7 +285,10 @@ public final class PortAgeCommands {
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.then(Commands.argument("distribution", StringArgumentType.word())
.executes(PortAgeCommands::createCompletedBiomes))))))
.executes(context -> createCompletedBiomes(context, null))
.then(Commands.argument("structure", IdentifierArgument.id())
.executes(context -> createCompletedBiomes(context,
IdentifierArgument.getId(context, "structure")))))))))
.then(Commands.literal("biome_pool")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeBiomePool)))
@@ -639,8 +819,8 @@ public final class PortAgeCommands {
|| new PortDayCycle(true, 2, 0).ticks(12000) != 6000
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|| new PortDayCycle(true, 0, 321).ticks(123456) != 321
|| PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|| !PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| !PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
throw new IllegalStateException("Day cycle fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
@@ -692,6 +872,63 @@ public final class PortAgeCommands {
return 1;
}
private static int createAutomaticTerrain(CommandContext<CommandSourceStack> context) {
try {
var server = context.getSource().getServer();
verifyAutomaticTerrain(server);
long seed = LongArgumentType.getLong(context, "seed");
var source = java.util.List.of(IdentifierArgument.getId(context, "page"));
var selected = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, source, seed);
var pages = selected.generationPages();
int created = createAge(context, selected.terrain(), selected.biomes(),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(pages, seed),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.vegetationPlan(pages),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount(pages),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(pages, seed),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed));
if (created != 0) context.getSource().sendSuccess(() -> Component.literal("Automatic terrain: " + selected), false);
return created;
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static void verifyAutomaticTerrain(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var sky = java.util.List.of(symbol.apply("sky/overworld"));
var water = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(Identifier.withDefaultNamespace("water"));
var plains = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("plains"));
var seen = java.util.EnumSet.noneOf(PortAgeTerrain.class);
for (long seed = 0; seed < 64; seed++) {
var selected = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, sky, seed);
seen.add(selected.terrain());
if (selected.terrain() != PortRandomAgeSelection.terrain(seed)
|| !selected.generationPages().subList(0, sky.size()).equals(sky)
|| !selected.equals(net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, sky, seed)))
throw new IllegalStateException("Automatic terrain seed/determinism/page-prefix check failed");
var fortress = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server,
java.util.List.of(symbol.apply("feature/nether_fortresses")), seed);
if (fortress.terrain() != PortAgeTerrain.CAVE)
throw new IllegalStateException("Omitted terrain cannot host a Cave-only structure");
var wet = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, java.util.List.of(water), seed);
if (wet.terrain() == PortAgeTerrain.FLAT || wet.terrain() == PortAgeTerrain.VOID || wet.terrain() == PortAgeTerrain.CAVE)
throw new IllegalStateException("Automatic terrain cannot host its implied lake");
var biome = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, java.util.List.of(plains), seed);
if (biome.terrain() == PortAgeTerrain.CAVE || !biome.biomes().biomes().contains(Identifier.withDefaultNamespace("plains")))
throw new IllegalStateException("Automatic terrain discarded a supplied surface biome");
var explicit = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server,
java.util.List.of(symbol.apply("terrain/flat"), sky.getFirst()), seed);
if (explicit.terrain() != PortAgeTerrain.FLAT || explicit.generationPages().size() != 2)
throw new IllegalStateException("Explicit terrain lost precedence");
}
if (seen.size() != PortAgeTerrain.values().length
|| !net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(symbol.apply("feature/nether_fortresses")))
|| net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(
symbol.apply("terrain/flat"), symbol.apply("feature/dungeons"))))
throw new IllegalStateException("Automatic terrain availability/coverage failed");
}
private static int createBiomeSurface(CommandContext<CommandSourceStack> context) {
try {
var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
@@ -946,16 +1183,24 @@ public final class PortAgeCommands {
return 1;
}
private static int createCompletedBiomes(CommandContext<CommandSourceStack> context) {
private static int createCompletedBiomes(CommandContext<CommandSourceStack> context, Identifier structure) {
try {
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
var distribution = PortBiomePlan.Distribution.valueOf(
StringArgumentType.getString(context, "distribution").toUpperCase(Locale.ROOT));
String distributionName = StringArgumentType.getString(context, "distribution");
var distribution = distributionName.equalsIgnoreCase("auto") ? null
: PortBiomePlan.Distribution.valueOf(distributionName.toUpperCase(Locale.ROOT));
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = java.util.List.of(symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)),
symbol.apply("biome/" + distribution.name().toLowerCase(Locale.ROOT)));
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)));
if (distribution != null) pages.add(symbol.apply("biome/" + distribution.name().toLowerCase(Locale.ROOT)));
else verifyAutomaticBiomes(server);
if (structure != null) {
if (!PortStructurePages.supports(structure)) throw new IllegalArgumentException("Unsupported structure page " + structure);
pages.add(structure);
verifyStructureBiomes(server);
}
var plan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server, seed);
// Controller-only and partial pools complete; already-sufficient pools never gain extras.
var plains = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("plains"));
@@ -980,7 +1225,10 @@ public final class PortAgeCommands {
var encoded = PortBiomePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, plan).getOrThrow();
if (!plan.equals(PortBiomePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow()))
throw new IllegalStateException("Completed biome plan codec mismatch");
int created = createAge(context, terrain, plan);
var features = net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed);
int created = createAge(context, terrain, plan, null, null, 0, null, features);
if (created != 0 && terrain == PortAgeTerrain.CAVE && plan.distribution() == PortBiomePlan.Distribution.NATIVE)
verifyNativeCave(server, ageId(context), seed, plan);
if (created != 0) context.getSource().sendSuccess(() -> Component.literal("Completed biome plan: " + plan), false);
return created == 0 ? 0 : probeBiomePool(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
@@ -989,6 +1237,121 @@ public final class PortAgeCommands {
}
}
private static void verifyAutomaticBiomes(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var standard = symbol.apply("terrain/standard");
var end = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("the_end"));
var forest = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("forest"));
var seen = java.util.EnumSet.noneOf(PortBiomePlan.Distribution.class);
var caveIds = java.util.Set.copyOf(PortRandomAgeSelection.eligibleBiomes(server, PortAgeTerrain.CAVE));
for (long seed = 0; seed < 128; seed++) {
var expected = PortRandomAgeSelection.distribution(seed);
var selected = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard), server, seed);
seen.add(selected.distribution());
if (selected.distribution() != expected || !selected.equals(
net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(standard, symbol.apply("weather/off")), server, seed)))
throw new IllegalStateException("Missing biome controller is not seeded/independent");
var authored = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, end, forest), server, seed);
if (authored.biomes().size() != 2 || !authored.biomes().equals(java.util.List.of(
Identifier.withDefaultNamespace("the_end"), Identifier.withDefaultNamespace("forest")))
|| authored.distribution() != (expected == PortBiomePlan.Distribution.NATIVE
|| expected == PortBiomePlan.Distribution.SINGLE ? PortBiomePlan.Distribution.TILED : expected))
throw new IllegalStateException("Automatic controller discarded or extended authored biomes");
var one = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, end), server, seed);
if (!one.biomes().getFirst().equals(Identifier.withDefaultNamespace("the_end"))
|| one.distribution() != (expected == PortBiomePlan.Distribution.NATIVE ? PortBiomePlan.Distribution.SINGLE : expected)
|| one.biomes().size() != (one.distribution() == PortBiomePlan.Distribution.SINGLE ? 1 : 2))
throw new IllegalStateException("Automatic controller lost a supplied biome");
var cave = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/cave")), server, seed);
if (cave.distribution() != expected || !caveIds.containsAll(cave.biomes())
|| expected == PortBiomePlan.Distribution.NATIVE && !java.util.Set.copyOf(cave.biomes()).equals(caveIds))
throw new IllegalStateException("Automatic Cave controller/biome pool is invalid");
var emptyVoid = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/void")), server, seed);
var authoredVoid = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/void"), end), server, seed);
if (emptyVoid.distribution() != PortBiomePlan.Distribution.SINGLE
|| !emptyVoid.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_void")))
|| !authoredVoid.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_end"))))
throw new IllegalStateException("Automatic Void controller changed biome fallback");
var village = symbol.apply("feature/villages");
var withStructure = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, village), server, seed);
if (withStructure.distribution() != PortBiomePlan.Distribution.NATIVE
&& withStructure.biomes().stream().noneMatch(PortStructurePages.compatibleBiomes(village, server.registryAccess())::contains))
throw new IllegalStateException("Automatic controller skipped structure-aware completion");
}
if (seen.size() != PortBiomePlan.Distribution.values().length)
throw new IllegalStateException("Automatic biome controller fixture lacks distribution coverage");
}
private static void verifyNativeCave(net.minecraft.server.MinecraftServer server, Identifier age, long seed, PortBiomePlan plan) {
var expected = PortRandomAgeSelection.caveBiomes(server.registryAccess());
if (!plan.biomes().equals(expected)) throw new IllegalStateException("Native Cave did not freeze the recognized biome pool");
var old = PortBiomePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"distribution\":\"NATIVE\",\"biomes\":[]}")).getOrThrow();
if (!old.biomes().isEmpty() || old.distribution() != PortBiomePlan.Distribution.NATIVE)
throw new IllegalStateException("Old Native Cave plan was rewritten");
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, age));
var source = level.getChunkSource().getGenerator().getBiomeSource();
if (expected.size() < 2) return;
if (!(source instanceof PortScaledBiomeSource scaled)) throw new IllegalStateException("Native Cave is not using region distribution");
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
var holders = plan.biomes().stream().map(id -> (net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>)
registry.getOrThrow(ResourceKey.create(Registries.BIOME, id))).toList();
var tiny = new PortScaledBiomeSource(net.minecraft.core.HolderSet.direct(holders), seed, 0);
var sampled = new java.util.HashSet<Identifier>();
for (int x = -64; x <= 64; x += 4) for (int z = -64; z <= 64; z += 4) {
var biome = scaled.getNoiseBiome(x, 0, z);
if (!biome.equals(tiny.getNoiseBiome(x, 0, z)) || !biome.equals(scaled.getNoiseBiome(x, 16, z)))
throw new IllegalStateException("Native Cave does not match root's Tiny-region sampler");
sampled.add(biome.unwrapKey().orElseThrow().identifier());
}
if (!java.util.Set.copyOf(expected).containsAll(sampled) || sampled.size() < 2)
throw new IllegalStateException("Native Cave sampler did not distribute its recognized biome pool");
}
private static void verifyStructureBiomes(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
java.util.function.Function<String, Identifier> biome = name ->
net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace(name));
var village = symbol.apply("feature/villages");
var fortress = symbol.apply("feature/nether_fortresses");
for (long seed = 0; seed < 32; seed++) {
var singlePages = java.util.List.of(symbol.apply("biome/single"), village);
var single = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(singlePages, server, seed);
var cave = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/cave"), symbol.apply("biome/single"), fortress), server, seed);
if (!PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(single.biomes().getFirst())
|| !single.equals(net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(singlePages, server, seed))
|| !PortStructurePages.compatibleBiomes(fortress, server.registryAccess()).contains(cave.biomes().getFirst()))
throw new IllegalStateException("Structure-aware Single completion failed");
var stronghold = symbol.apply("feature/strongholds");
var shared = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(
symbol.apply("biome/single"), village, stronghold), server, seed);
if (!PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(shared.biomes().getFirst())
|| !PortStructurePages.compatibleBiomes(stronghold, server.registryAccess()).contains(shared.biomes().getFirst()))
throw new IllegalStateException("Single completion did not satisfy common structure requirements");
var explicit = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("biome/single"), biome.apply("the_end"), village), server, seed);
var nativePlan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("biome/native"), village), server, seed);
if (!explicit.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_end")))
|| nativePlan.distribution() != PortBiomePlan.Distribution.NATIVE || !nativePlan.biomes().isEmpty())
throw new IllegalStateException("Explicit Single/Native biome precedence changed");
var multiPages = java.util.List.of(symbol.apply("biome/tiny"), biome.apply("forest"), biome.apply("the_end"), village);
var multi = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(multiPages, server, seed);
if (multi.biomes().size() != 3 || !multi.biomes().subList(0, 2).equals(java.util.List.of(
Identifier.withDefaultNamespace("forest"), Identifier.withDefaultNamespace("the_end")))
|| !PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(multi.biomes().get(2)))
throw new IllegalStateException("Structure biome completion discarded authored choices");
var full = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(
symbol.apply("biome/tiny"), biome.apply("plains"), biome.apply("forest"), village, village), server, seed);
if (full.biomes().size() != 2) throw new IllegalStateException("Compatible/repeated structures added unnecessary biomes");
}
}
private static int probeBiomePool(CommandContext<CommandSourceStack> context) {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
@@ -1253,7 +1616,7 @@ public final class PortAgeCommands {
|| Math.abs(moving.bodyAngle(origin + 100) + 3F) > 0.0001F
|| !PortDayCycle.validWriting(java.util.List.of(target, PortDayCycle.NORMAL_TIME))
|| !PortDayCycle.validWriting(java.util.List.of(target, target))
|| PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
|| !PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
throw new IllegalStateException("Single-body modifier/order invariant failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
@@ -1414,7 +1777,7 @@ public final class PortAgeCommands {
private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) {
var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(),
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server));
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server));
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
server.registryAccess(), net.neoforged.neoforge.network.connection.ConnectionType.NEOFORGE);
try {
@@ -1947,6 +2310,119 @@ public final class PortAgeCommands {
return 1;
}
private static int createReturnFixture(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var local = new PortAgeVisits(java.util.List.of());
var disabled = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.DISABLED;
var always = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS;
var once = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ON_CREATION;
if (PortReturnLinks.shouldIssue(disabled, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(always, local, id)
|| !PortReturnLinks.shouldIssue(always, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(once, local, id)
|| PortReturnLinks.shouldIssue(once, local, id))
throw new IllegalStateException("Return Link mode regression");
var encoded = PortAgeVisits.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, local).getOrThrow();
var decoded = PortAgeVisits.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow();
if (!decoded.hasVisited(id) || PortReturnLinks.shouldIssue(once, decoded, id))
throw new IllegalStateException("Return Link visit codec regression");
var target = new LinkBookTarget(Identifier.fromNamespaceAndPath("minecraft", "overworld"),
12.25, 80.5, -42.75, 135.0F, -20.0F);
var book = PortReturnLinks.createBook(target);
if (!book.is(Rivencraft.LINKING_BOOK.get())
|| !target.equals(book.get(Rivencraft.LINK_BOOK_TARGET.get()))
|| !book.getHoverName().getString().equals("Return Link")
|| book.has(Rivencraft.LINK_PROPERTIES.get()))
throw new IllegalStateException("Return Link book component regression");
var server = context.getSource().getServer();
RuntimeAgeBackend.create(server, id, LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var visits = server.overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
if (!PortReturnLinks.shouldIssue(once, visits, id) || PortReturnLinks.shouldIssue(once, visits, id))
throw new IllegalStateException("Return Link world marker regression");
server.overworld().getDataStorage().saveAndJoin();
return returnLinkStatus(context);
}
private static int returnLinkStatus(CommandContext<CommandSourceStack> context) {
var visits = context.getSource().getServer().overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
context.getSource().sendSuccess(() -> Component.literal(ageId(context) + " Return Link mode="
+ net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get()
+ "; ON_CREATION issued=" + visits.hasVisited(ageId(context))), false);
return 1;
}
private static int createPlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var landing = PortAgeTravel.findLanding(level);
if (!new net.minecraft.core.BlockPos(0, 64, 0).equals(landing))
throw new IllegalStateException("Unexpected Void landing: " + landing);
return probePlatform(context);
}
/** Inspect the disposable Void fixture without regenerating or repairing its platform. */
private static int probePlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + ageId(context)));
return 0;
}
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
var pos = new net.minecraft.core.BlockPos(x, 63, z);
var state = level.getBlockState(pos);
if (!state.is(Rivencraft.CLOUD_PLATFORM.get())
|| state.getDestroySpeed(level, pos) != 0.3F
|| !state.isSolidRender() || !state.isCollisionShapeFullBlock(level, pos)
|| !state.getCloneItemStack(level, pos, false).isEmpty()
|| !net.minecraft.world.level.block.Block.getDrops(state, level, pos, null).isEmpty())
throw new IllegalStateException("Platform properties mismatch at " + pos);
}
Identifier id = Identifier.fromNamespaceAndPath("rivencraft", "cloud_platform");
if (net.dnorberg.rivencraft.writing.PortRegistryPages.solidMaterialIds().contains(id)
|| net.dnorberg.rivencraft.writing.PortRegistryPages.find(
net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(id)) != null
|| !Rivencraft.CLOUD_PLATFORM.get().asItem().equals(net.minecraft.world.item.Items.AIR))
throw new IllegalStateException("Platform is obtainable as an item or material Page");
context.getSource().sendSuccess(() -> Component.literal(ageId(context)
+ " platform passed: solid/opaque, strength 0.3, no loot/pick item/material Page"), false);
return 1;
}
private static int createAccelerated(CommandContext<CommandSourceStack> context) {
int count = IntegerArgumentType.getInteger(context, "pages");
var pages = new java.util.ArrayList<Identifier>();
pages.add(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
for (int i = 0; i < count; i++) pages.add(PortAcceleratedTicks.PAGE);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| !net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(PortAcceleratedTicks.PAGE)
|| PortAcceleratedTicks.pageCount(pages) != count
|| PortAcceleratedTicks.attemptBudget(1, 8, 16384) != 24
|| PortAcceleratedTicks.attemptBudget(32, 8, 16384) != 768
|| PortAcceleratedTicks.attemptBudget(Integer.MAX_VALUE, Integer.MAX_VALUE, 16384) != 16384
|| PortAcceleratedTicks.attemptBudget(32, 8, 0) != 0)
throw new IllegalStateException("Accelerated writing/budget regression");
ServerLevel level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.FLAT);
PortAcceleratedTicks.configure(level, count);
return acceleration(context);
}
private static int acceleration(CommandContext<CommandSourceStack> context) {
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + ageId(context)));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(ageId(context) + " Accelerated pages="
+ PortAcceleratedTicks.pages(level) + "; extra attempts/eligible section="
+ 3L * PortAcceleratedTicks.pages(level)
+ "; max loaded chunks=" + net.dnorberg.rivencraft.PortConfig.ACCELERATED_MAX_CHUNKS.get()
+ "; max attempts/tick=" + net.dnorberg.rivencraft.PortConfig.ACCELERATED_MAX_ATTEMPTS.get()), false);
return 1;
}
private static int sampleBiome(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import java.util.Map;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Public presentation only: no seed, instability score or provider information. */
public record PortAgeDescription(PortAgeTerrain terrain, PortBiomePlan.Distribution distribution,
PortLighting lighting, PortWeatherMode weather) {
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeDescription> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8.map(PortAgeTerrain::valueOf, PortAgeTerrain::name), PortAgeDescription::terrain,
ByteBufCodecs.STRING_UTF8.map(PortBiomePlan.Distribution::valueOf, PortBiomePlan.Distribution::name), PortAgeDescription::distribution,
ByteBufCodecs.STRING_UTF8.map(PortLighting::valueOf, PortLighting::name), PortAgeDescription::lighting,
ByteBufCodecs.STRING_UTF8.map(PortWeatherMode::valueOf, PortWeatherMode::name), PortAgeDescription::weather,
PortAgeDescription::new);
private static volatile Map<Identifier, PortAgeDescription> clientDescriptions = Map.of();
public static void updateClient(Map<Identifier, PortAgeDescription> descriptions) {
clientDescriptions = Map.copyOf(descriptions);
}
public static PortAgeDescription forClient(Identifier id) { return clientDescriptions.get(id); }
}
@@ -51,6 +51,8 @@ final class PortAgeLevel extends ServerLevel {
return ageWeather == null ? super.getWeatherData() : ageWeather;
}
boolean caveTerrain() { return cave; }
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
void configureWeather(PortWeatherMode mode) {
@@ -18,26 +18,49 @@ public final class PortAgeTravel {
}
public static String enter(ServerPlayer player, Identifier id) {
return enter(player, id, net.dnorberg.rivencraft.item.LinkProperties.NONE);
}
public static String enter(ServerPlayer player, Identifier id, net.dnorberg.rivencraft.item.LinkProperties properties) {
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (target == null) return "Age not loaded: " + id;
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = resolveLanding(target, player, properties);
if (landing == null) return "No safe landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); }
public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity,
net.dnorberg.rivencraft.item.LinkProperties properties) {
return resolveLanding(target, (ServerLevel) entity.level(), entity.blockPosition(), properties);
}
public static BlockPos resolveLanding(ServerLevel target, ServerLevel source, BlockPos departure,
net.dnorberg.rivencraft.item.LinkProperties properties) {
BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM));
if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing;
return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin(
source, target, departure)).orElse(null);
}
public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
if (landing != null) landing = isCaveTerrain(target)
? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
if (landing == null) landing = findLanding(target, generatePlatform);
if (landing == null) return null;
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
return landing;
}
private static BlockPos findLanding(ServerLevel target) {
static BlockPos findLanding(ServerLevel target) { return findLanding(target, true); }
static BlockPos findLanding(ServerLevel target, boolean generatePlatform) {
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
@@ -53,7 +76,7 @@ public final class PortAgeTravel {
}
}
}
return makePlatform(target, new BlockPos(0, 64, 0));
return generatePlatform ? makePlatform(target, new BlockPos(0, 64, 0)) : null;
}
BlockPos surface = findSurfaceLanding(target, 0, 0);
if (surface != null) return surface;
@@ -61,7 +84,7 @@ public final class PortAgeTravel {
// beneath it. Void Ages retain the usual platform floor at Y=63.
BlockPos obstruction = highestObstruction(target, 0, 0);
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
return makePlatform(target, new BlockPos(0, y, 0));
return generatePlatform ? makePlatform(target, new BlockPos(0, y, 0)) : null;
}
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
@@ -112,7 +135,7 @@ public final class PortAgeTravel {
}
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
target.setBlock(foot.offset(x, -1, z), Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** World-wide record of Ages for which an ON_CREATION Return Link was issued. */
final class PortAgeVisits extends SavedData {
static final Codec<PortAgeVisits> CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec()
.xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList());
static final SavedDataType<PortAgeVisits> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "age_visits"),
() -> new PortAgeVisits(List.of()), CODEC);
private final Set<Identifier> ages;
PortAgeVisits(List<Identifier> ages) { this.ages = new HashSet<>(ages); }
boolean hasVisited(Identifier age) { return ages.contains(age); }
boolean markFirstVisit(Identifier age) {
if (!ages.add(age)) return false;
setDirty();
return true;
}
}
@@ -0,0 +1,157 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
/** Disposable fixture for atomic collection imports and real Binder assembly. */
final class PortBinderCollectionProbe {
private static final BlockPos BINDER = new BlockPos(0, 64, 0);
private static final BlockPos CHEST = new BlockPos(1, 64, 0);
private static final List<Identifier> EXPECTED = List.of(id("weather/off"), id("lighting/bright"),
id("terrain/flat"), id("biome/single"));
private PortBinderCollectionProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static ItemStack page(String path) { return PageItem.createSymbolPage(id(path)); }
private static ItemStack collection(boolean folder, List<ItemStack> pages) {
ItemStack result = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
result.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return result;
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(BINDER, Rivencraft.BOOK_BINDER.get().defaultBlockState(), 3);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var binder = (PortBookBinderBlockEntity) level.getBlockEntity(BINDER);
var chest = (Container) level.getBlockEntity(CHEST);
ItemStack folder = collection(true, List.of(page("weather/off"), ItemStack.EMPTY, page("lighting/bright")));
ItemStack before = folder.copy();
for (int i = 0; i < PortBookBinderBlockEntity.PAGE_COUNT; i++)
binder.setItem(PortBookBinderBlockEntity.FIRST_PAGE + i, page("terrain/flat"));
if (!binder.importCollection(folder, 0).isEmpty() || binder.nextPageIndex() != 252
|| !ItemStack.isSameItemSameComponents(before, folder)
|| binder.symbolPages().stream().anyMatch(symbol -> !symbol.equals(id("terrain/flat"))))
throw new IllegalStateException("Full Binder import was not atomic");
binder.clearSymbolPages();
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
if (!binder.importCollection(collection(true, List.of(page("weather/off"), PageItem.createLinkPanelPage())), 0).isEmpty()
|| binder.hasSymbolPages() || binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Conflicting Link Panel import was not atomic");
binder.setItem(PortBookBinderBlockEntity.PANEL, ItemStack.EMPTY);
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
throw new IllegalStateException("Invalid collection insertion point accepted");
binder.insertPage(0, page("terrain/flat"));
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
player.setPos(0.5, 64, 2.5);
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
try {
menu.setCarried(folder);
if (!menu.clickMenuButton(player, PortBookBinderMenu.STRIP_CLICK_BASE + 1)
|| menu.getCarried().get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED.subList(0, 3)))
throw new IllegalStateException("Cursor collection order/empty-cover regression");
chest.setItem(0, menu.getCarried().copy());
menu.setCarried(ItemStack.EMPTY);
player.getInventory().setItem(9, collection(false, List.of(page("biome/single"))));
menu.quickMoveStack(player, PortBookBinderBlockEntity.SIZE);
ItemStack emptied = player.getInventory().getItem(9);
if (!emptied.is(Rivencraft.NOTEBOOK.get())
|| emptied.get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED))
throw new IllegalStateException("Shift collection import regression");
chest.setItem(1, emptied.copy());
binder.setItem(PortBookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
menu.assemble(player, "Collection Fixture104");
ItemStack book = menu.getCarried();
if (!book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !EXPECTED.equals(book.get(Rivencraft.BOOK_SYMBOLS.get()))
|| !book.has(Rivencraft.AGE_BOOK_ID.get()) || binder.hasSymbolPages()
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Collection-backed Binder assembly regression");
if (!PortProgressionProbe.isComplete(player, "bind_age"))
throw new IllegalStateException("Missing bind_age award");
if (!player.getGameProfile().name().equals(book.get(Rivencraft.BOOK_AUTHOR.get())))
throw new IllegalStateException("Binder author was not server assigned");
var pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
if (pages.lastPage() != EXPECTED.size() || pages.symbolAt(0).isPresent()
|| !pages.symbolAt(1).orElseThrow().equals(EXPECTED.getFirst())
|| !pages.symbolAt(EXPECTED.size()).orElseThrow().equals(EXPECTED.getLast())
|| pages.symbolAt(EXPECTED.size() + 1).isPresent()
|| pages.turn(0, -1) != 0 || pages.turn(pages.lastPage(), 1) != pages.lastPage())
throw new IllegalStateException("Authored book page indexing failed");
var old = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()));
if (old.lastPage() != 1 || !old.author().isEmpty() || old.symbolAt(1).isPresent())
throw new IllegalStateException("Older panel-only book fallback failed");
var repeated = new net.dnorberg.rivencraft.writing.DescriptiveBookPages(
List.of(EXPECTED.getFirst(), EXPECTED.getFirst()), "Test");
if (repeated.lastPage() != 2 || !repeated.symbolAt(1).equals(repeated.symbolAt(2)))
throw new IllegalStateException("Repeated symbol display was collapsed");
var ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
var server = level.getServer();
if (!RuntimeAgeBackend.savedAgeName(server, ageId).orElseThrow().equals("Collection Fixture104"))
throw new IllegalStateException("Created Age lost its display name");
var namedLevel = server.getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, ageId));
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel).equals("Collection Fixture104"))
throw new IllegalStateException("Link binding name lookup did not use Age title");
RuntimeAgeBackend.setAgeDisplayName(server, ageId, "Crystal Garden's Dawn");
var target = new net.dnorberg.rivencraft.item.LinkBookTarget(ageId, -0.5, 64, -1.25, 90, 0);
var link = new ItemStack(Rivencraft.LINKING_BOOK.get());
link.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
link.set(Rivencraft.LINK_DESTINATION_NAME.get(), net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel));
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(link, target).getString().equals("Crystal Garden's Dawn"))
throw new IllegalStateException("Link destination label ignored saved name");
var mode = net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get();
player.getInventory().clearContent();
try {
net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS);
PortReturnLinks.afterSuccessfulTravel(player, ageId, target);
var returned = java.util.stream.IntStream.range(0, player.getInventory().getContainerSize())
.mapToObj(i -> player.getInventory().getItem(i)).filter(i -> i.is(Rivencraft.LINKING_BOOK.get()))
.findFirst().orElseThrow();
if (!"Crystal Garden's Dawn".equals(returned.get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Automatic Return Link lost departure name");
chest.setItem(4, returned.copy());
} finally { net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(mode); }
chest.setItem(3, link);
chest.setItem(2, book.copy());
menu.setCarried(ItemStack.EMPTY);
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.COLLATION_FOLDER.get()) || !chest.getItem(1).is(Rivencraft.NOTEBOOK.get()))
throw new IllegalStateException("Empty collection covers were not preserved");
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
throw new IllegalStateException("Imported pages remained in a collection");
if (!"RivenBinderProbe".equals(chest.getItem(2).get(Rivencraft.BOOK_AUTHOR.get())))
throw new IllegalStateException("Authored book lost its author");
var ageId = chest.getItem(2).get(Rivencraft.AGE_BOOK_ID.get());
if (!RuntimeAgeBackend.savedAgeName(level.getServer(), ageId).orElseThrow().equals("Crystal Garden's Dawn")
|| !"Crystal Garden's Dawn".equals(chest.getItem(3).get(Rivencraft.LINK_DESTINATION_NAME.get()))
|| !"Crystal Garden's Dawn".equals(chest.getItem(4).get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Age/Linking/Return destination names did not survive persistence");
var old = new ItemStack(Rivencraft.LINKING_BOOK.get());
var overworld = new net.dnorberg.rivencraft.item.LinkBookTarget(net.minecraft.world.level.Level.OVERWORLD.identifier(), 0, 64, 0, 0, 0);
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(old, overworld).getString()
.equals(net.minecraft.network.chat.Component.translatable("dimension.minecraft.overworld").getString()))
throw new IllegalStateException("Older book vanilla name fallback changed");
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
throw new IllegalStateException("Authored book page order was not preserved");
}
}
@@ -16,12 +16,18 @@ final class PortBiomeMaterialRules {
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
var biomes = registries.lookupOrThrow(Registries.BIOME);
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
var nether = java.util.stream.Stream.concat(java.util.stream.Stream.of(Biomes.NETHER_WASTES,
Biomes.SOUL_SAND_VALLEY, Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries).stream()
.map(id -> net.minecraft.resources.ResourceKey.create(Registries.BIOME, id)))
.distinct().toList();
@SuppressWarnings("unchecked")
net.minecraft.resources.ResourceKey<net.minecraft.world.level.biome.Biome>[] netherKeys = nether.toArray(net.minecraft.resources.ResourceKey[]::new);
var materials = MaterialRules.sequence(
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
MaterialRules.getRule(rules, EndMaterialRules.END)),
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY,
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, netherKeys),
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
base.materialRule().value());
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
@@ -5,7 +5,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.minecraft.resources.Identifier;
/** Frozen authored biome choice; repeated entries retain their selection weight. */
/** Frozen biome choice; Native may carry an explicit Cave pool. Repeats retain weight. */
public record PortBiomePlan(Distribution distribution, List<Identifier> biomes) {
public enum Distribution { NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE }
@@ -17,8 +17,7 @@ public record PortBiomePlan(Distribution distribution, List<Identifier> biomes)
public PortBiomePlan {
biomes = List.copyOf(biomes);
if (distribution == Distribution.NATIVE && !biomes.isEmpty()
|| distribution == Distribution.SINGLE && biomes.size() != 1
if (distribution == null || distribution == Distribution.SINGLE && biomes.size() != 1
|| distribution != Distribution.NATIVE && distribution != Distribution.SINGLE && biomes.size() < 2)
throw new IllegalArgumentException("Invalid authored biome distribution");
}
@@ -0,0 +1,314 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.common.Tags;
import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy-style excess-block profiling with gradual spawn-area generation. */
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortBlockInstability {
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
// Storage blocks are deliberate extensions, including the requested diamond-block case.
private static final Map<Identifier, Double> VANILLA_WEIGHTS = weights();
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
private static final int MAX_PENDING = 2048;
private static final int SAMPLES_PER_TICK = 4;
private static final int GENERATION_INTERVAL_TICKS = 20;
private static final List<ChunkPos> SPAWN_OFFSETS = spawnOffsets();
private static final Map<MinecraftServer, ArrayDeque<Pending>> PENDING = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Map<Identifier, Integer>> NEXT_SPAWN_SAMPLE =
new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Integer> NEXT_TARGET = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Boolean> GENERATION_IN_FLIGHT =
new java.util.WeakHashMap<>();
private PortBlockInstability() {}
/** Track recognized materials from installed mods without requiring any mod-specific API. */
static boolean tracked(Identifier id) {
if (VANILLA_WEIGHTS.containsKey(id)) return true;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
var block = BuiltInRegistries.BLOCK.getValue(id);
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
}
static double weight(Identifier id) {
Double vanilla = VANILLA_WEIGHTS.get(id);
if (vanilla != null) return vanilla;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
var block = BuiltInRegistries.BLOCK.getValue(id);
if (!eligibleMaterial(block)) return 0;
return moddedWeight(id, inferredModdedWeight(id, block),
PortInstabilityConfig.moddedMaterialOverrides());
}
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
return PortRegistryPages.materialCategory(block) != null;
}
private static Double configuredOverride(Identifier id) {
return configuredOverride(id, PortInstabilityConfig.moddedMaterialOverrides());
}
private static Double configuredOverride(Identifier id, List<? extends String> overrides) {
for (String entry : overrides) {
int separator = entry.lastIndexOf('=');
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
return (double) Integer.parseInt(entry.substring(separator + 1));
}
return null;
}
static double moddedWeight(Identifier id, double inferred, List<? extends String> overrides) {
Double override = configuredOverride(id, overrides);
return override == null ? inferred : override;
}
private static double inferredModdedWeight(Identifier id, net.minecraft.world.level.block.Block block) {
if ("rivencraft:fluid".equals(PortRegistryPages.materialCategory(block))) return 1.25;
var state = block.defaultBlockState();
String path = id.getPath();
boolean ore = state.is(Tags.BlockItems.ORES.block()) || path.endsWith("_ore") || path.contains("_ore_");
boolean storage = state.is(Tags.BlockItems.STORAGE_BLOCKS.block())
|| (path.endsWith("_block") || path.startsWith("block_of_"))
&& namedMineralWeight(path) != null;
return inferredModdedWeight(id, ore, storage, false);
}
static double inferredModdedWeight(Identifier id, boolean ore, boolean storage, boolean fluid) {
if (fluid) return 1.25;
Double named = namedMineralWeight(id.getPath());
if (ore) return named == null ? 60.0 : named;
if (storage) return named == null ? 120.0 : named;
return 0;
}
private static Double namedMineralWeight(String path) {
for (String part : path.split("[/_]")) {
Double value = COMMON_MINERAL_WEIGHTS.get(part);
if (value != null) return value;
}
return null;
}
@net.neoforged.bus.api.SubscribeEvent
public static void onChunkLoad(ChunkEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel level)
|| !(event.getChunk() instanceof LevelChunk chunk)) return;
boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD;
if (!overworld && !PortInstabilityRuntime.isAge(level)) return;
// Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data
// access to a bounded server-tick lane to avoid reentering that storage path.
synchronized (PENDING) {
var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>());
if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk));
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerTickPost(ServerTickEvent.Post event) {
for (int index = 0; index < SAMPLES_PER_TICK; index++) {
Pending pending;
synchronized (PENDING) {
var queue = PENDING.get(event.getServer());
pending = queue == null ? null : queue.pollFirst();
}
if (pending == null) break;
var pos = pending.chunk().getPos();
if (pending.level().getServer().getLevel(pending.level().dimension()) == pending.level()
&& pending.level().getChunkSource().getChunkNow(pos.x(), pos.z()) == pending.chunk())
PortBlockProfileSavedData.get(pending.level()).sample(pending.chunk());
}
if (event.getServer().getTickCount() % GENERATION_INTERVAL_TICKS == 0) {
generateNextSpawnSample(event.getServer());
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerStopping(ServerStoppingEvent event) {
synchronized (PENDING) { PENDING.remove(event.getServer()); }
NEXT_SPAWN_SAMPLE.remove(event.getServer());
NEXT_TARGET.remove(event.getServer());
GENERATION_IN_FLIGHT.remove(event.getServer());
}
/** Generate at most one chunk per second across the active Age and its Overworld baseline. */
private static void generateNextSpawnSample(MinecraftServer server) {
if (GENERATION_IN_FLIGHT.containsKey(server)) return;
List<SampleTarget> targets = new ArrayList<>();
for (ServerLevel level : server.getAllLevels()) {
if (!PortInstabilityRuntime.isAge(level)
|| level.players().isEmpty()) continue;
var data = PortInstabilityData.get(server);
if (!data.supportsProfiling(level)) continue;
var age = data.age(level);
targets.add(new SampleTarget(level, age.spawn().orElseGet(() -> level.getRespawnData().pos()),
PortBlockProfileSavedData.MIN_AGE_SAMPLES));
}
if (targets.isEmpty()) return;
ServerLevel overworld = server.overworld();
targets.add(0, new SampleTarget(overworld, overworld.getRespawnData().pos(),
PortBlockProfileSavedData.MIN_BASELINE_SAMPLES));
int first = Math.floorMod(NEXT_TARGET.getOrDefault(server, 0), targets.size());
for (int offset = 0; offset < targets.size(); offset++) {
int index = (first + offset) % targets.size();
SampleTarget target = targets.get(index);
if (generateNextSpawnSample(target.level(), target.origin(), target.minimum(), server)) {
NEXT_TARGET.put(server, index + 1);
return;
}
}
}
private static boolean generateNextSpawnSample(ServerLevel level, net.minecraft.core.BlockPos origin,
int minimum, MinecraftServer server) {
PortBlockProfileSavedData profile = PortBlockProfileSavedData.get(level);
if (profile.count() >= minimum) return false;
Map<Identifier, Integer> cursors = NEXT_SPAWN_SAMPLE.computeIfAbsent(server,
ignored -> new HashMap<>());
Identifier dimension = level.dimension().identifier();
int index = cursors.getOrDefault(dimension, 0);
ChunkPos center = ChunkPos.containing(origin);
while (index < SPAWN_OFFSETS.size()) {
ChunkPos offset = SPAWN_OFFSETS.get(index++);
ChunkPos target = new ChunkPos(center.x() + offset.x(), center.z() + offset.z());
if (profile.contains(target)) continue;
cursors.put(dimension, index);
GENERATION_IN_FLIGHT.put(server, true);
// getChunkFuture blocks when called on the server thread. Its off-thread
// path schedules generation on Minecraft's chunk executor instead.
CompletableFuture.supplyAsync(() -> level.getChunkSource()
.getChunkFuture(target.x(), target.z(), ChunkStatus.FULL, true))
.thenCompose(future -> future)
.whenComplete((result, error) -> {
if (server.isStopped()) return;
server.execute(() -> {
if (GENERATION_IN_FLIGHT.remove(server) == null) return;
if (error != null) {
Rivencraft.LOGGER.warn("Could not profile chunk {} in {}", target, dimension, error);
} else if (server.getLevel(level.dimension()) == level
&& result.orElse(null) instanceof LevelChunk chunk) {
profile.sample(chunk);
}
});
});
return true;
}
return false;
}
private static List<ChunkPos> spawnOffsets() {
List<ChunkPos> offsets = new ArrayList<>(PortBlockProfileSavedData.MAX_SAMPLES);
offsets.add(new ChunkPos(0, 0));
for (int radius = 1; offsets.size() < PortBlockProfileSavedData.MAX_SAMPLES; radius++) {
for (int x = -radius; x <= radius; x++) {
offsets.add(new ChunkPos(x, -radius));
offsets.add(new ChunkPos(x, radius));
}
for (int z = -radius + 1; z < radius; z++) {
offsets.add(new ChunkPos(-radius, z));
offsets.add(new ChunkPos(radius, z));
}
}
return List.copyOf(offsets.subList(0, PortBlockProfileSavedData.MAX_SAMPLES));
}
/** Zero until the Age exceeds 400 samples and the baseline reaches 500. */
static int score(PortBlockProfileSavedData age, PortBlockProfileSavedData baseline) {
return score(age.totals(), age.count(), baseline.totals(), baseline.count());
}
static int score(Map<Identifier, Long> age, int ageChunks,
Map<Identifier, Long> baseline, int baselineChunks) {
if (ageChunks < PortBlockProfileSavedData.MIN_AGE_SAMPLES
|| baselineChunks < PortBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
double result = 0;
Set<Identifier> watched = new HashSet<>(age.keySet());
watched.addAll(baseline.keySet());
for (Identifier id : watched) {
double weight = weight(id);
if (weight <= 0) continue;
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
if (excess > 0) result += excess * weight;
}
return (int) Math.min(100_000, Math.round(result));
}
public static int score(ServerLevel level) {
if (!PortInstabilityRuntime.isAge(level) || !PortInstabilityData.get(level.getServer()).supportsProfiling(level)) return 0;
return score(PortBlockProfileSavedData.get(level), PortBlockProfileSavedData.get(level.getServer().overworld()));
}
public static int sampledChunks(ServerLevel level) { return PortBlockProfileSavedData.get(level).count(); }
public static int difficultyScore(long rawScore, int difficulty) {
double multiplier = switch (difficulty) {
case 0 -> 0.25;
case 1 -> 0.5;
case 2 -> 1.0;
case 3 -> 1.75;
default -> throw new IllegalArgumentException("Unknown instability difficulty " + difficulty);
};
return (int) Math.min(Integer.MAX_VALUE, Math.max(0, rawScore * multiplier));
}
private static Map<Identifier, Double> weights() {
Map<Identifier, Double> values = new HashMap<>();
add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore");
add(values, 60, "iron_ore", "deepslate_iron_ore");
add(values, 200, "emerald_ore", "deepslate_emerald_ore");
add(values, 250, "redstone_ore", "deepslate_redstone_ore");
add(values, 750, "gold_ore", "deepslate_gold_ore");
add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block");
add(values, 20, "nether_quartz_ore");
add(values, 50, "glowstone");
values.put(Identifier.parse("rivencraft:crystal_block"), 20.0);
// Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft.
add(values, 200, "emerald_block");
add(values, 750, "gold_block", "raw_gold_block");
add(values, 4000, "ancient_debris");
add(values, 60, "iron_block", "raw_iron_block");
add(values, 8000, "netherite_block");
return Map.copyOf(values);
}
private static void add(Map<Identifier, Double> values, int weight, String... paths) {
for (String path : paths) values.put(Identifier.withDefaultNamespace(path), (double) weight);
}
private record Pending(ServerLevel level, LevelChunk chunk) {}
private record SampleTarget(ServerLevel level, net.minecraft.core.BlockPos origin, int minimum) {}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Persistent generated-chunk snapshots. Each chunk is counted once, up to the 1.21.1 cap. */
final class PortBlockProfileSavedData extends SavedData {
static final int MIN_AGE_SAMPLES = 401, MIN_BASELINE_SAMPLES = 500, MAX_SAMPLES = 1024;
private static final int VERSION = 2;
private record Sample(long chunk, Map<Identifier, Integer> counts) {
private static final Codec<Sample> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("chunk").forGetter(Sample::chunk),
Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, Integer.MAX_VALUE))
.fieldOf("blocks").forGetter(Sample::counts)
).apply(instance, Sample::new));
}
private static final Codec<PortBlockProfileSavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("version").forGetter(data -> VERSION),
Sample.CODEC.listOf(0, MAX_SAMPLES).fieldOf("samples").forGetter(data -> data.samples.entrySet().stream()
.map(entry -> new Sample(entry.getKey(), entry.getValue())).toList())
).apply(instance, PortBlockProfileSavedData::new));
private static final SavedDataType<PortBlockProfileSavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "block_profile"),
() -> new PortBlockProfileSavedData(VERSION, List.of()), CODEC);
private final Map<Long, Map<Identifier, Integer>> samples = new HashMap<>();
private final Map<Identifier, Long> totals = new HashMap<>();
private PortBlockProfileSavedData(int version, List<Sample> values) {
if (version == VERSION) for (var value : values) put(value.chunk(), value.counts());
}
static PortBlockProfileSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
int count() { return samples.size(); }
boolean contains(ChunkPos pos) { return samples.containsKey(pos.pack()); }
Map<Identifier, Long> totals() { return Map.copyOf(totals); }
boolean sample(LevelChunk chunk) {
if (count() >= MAX_SAMPLES || contains(chunk.getPos())) return false;
Map<Identifier, Integer> counts = new HashMap<>();
for (var section : chunk.getSections()) {
if (section.hasOnlyAir()) continue;
section.getStates().count((state, amount) -> {
Identifier id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
if (PortBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
});
}
put(chunk.getPos().pack(), counts);
setDirty();
return true;
}
private void put(long pos, Map<Identifier, Integer> counts) {
if (count() >= MAX_SAMPLES || samples.containsKey(pos)) return;
samples.put(pos, Map.copyOf(counts));
counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum));
}
}
@@ -0,0 +1,110 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.TickTask;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Join conversion runs on queued server tasks: create and inspect on separate commands. */
final class PortBookDropConversionProbe {
private static final UUID OWNER = UUID.fromString("cc5909e5-aafa-43d6-b841-a917ec9c0115");
private static final AABB AREA = new AABB(-16, -64, -16, 32, 320, 32);
private static ItemStack book(boolean descriptive, String label) {
var stack = new ItemStack(descriptive ? Rivencraft.DESCRIPTIVE_BOOK.get() : Rivencraft.LINKING_BOOK.get());
stack.set(DataComponents.CUSTOM_NAME, Component.literal(label));
return stack;
}
static void create(ServerLevel level) {
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) level.setBlock(new BlockPos(x, 63, z), Blocks.STONE.defaultBlockState(), 2);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(UUID.fromString("84f38604-48fe-48d3-a4fb-f858a7200115"), "RivenDropProbe"));
player.setPos(8.5, 64, 8.5);
// Test exact factory state before physics can advance position or pickup timers.
var sample = new ItemEntity(level, 8.5, 66, 8.5, book(false, "state_copy"));
sample.getItem().set(Rivencraft.LINK_BOOK_TARGET.get(), new net.dnorberg.rivencraft.item.LinkBookTarget(
level.dimension().identifier(), -0.5, 64, -1.25, 135, -20));
sample.getItem().set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
sample.getItem().set(Rivencraft.LINK_PROPERTIES.get(), new net.dnorberg.rivencraft.item.LinkProperties(net.dnorberg.rivencraft.item.LinkProperties.DISARM
| net.dnorberg.rivencraft.item.LinkProperties.MAINTAIN_MOMENTUM));
sample.setTarget(OWNER);
sample.setThrower(player);
sample.setPickUpDelay(80);
sample.setDeltaMovement(new Vec3(0.2, 0.1, -0.3));
sample.setYRot(37);
sample.setXRot(12);
sample.setNoGravity(true);
var copy = DroppedBookEntity.fromDrop(sample);
if (!copy.position().equals(sample.position()) || !copy.getDeltaMovement().equals(sample.getDeltaMovement())
|| copy.getYRot() != 37 || copy.getXRot() != 12 || !copy.isNoGravity()
|| !ItemStack.matches(copy.getItem(), sample.getItem()) || !OWNER.equals(copy.getTarget())
|| copy.getUUID().equals(sample.getUUID()) || copy.getAge() != -32768)
throw new IllegalStateException("Drop conversion lost placement, ownership or item components");
var originalTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
var copiedTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
sample.saveWithoutId(originalTag); copy.saveWithoutId(copiedTag);
for (String key : List.of("PickupDelay", "Owner", "Thrower", "Item"))
if (!java.util.Objects.equals(originalTag.buildResult().get(key), copiedTag.buildResult().get(key)))
throw new IllegalStateException("Drop conversion lost metadata: " + key);
// Four ordinary book variants must all convert through the actual event route.
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description")) {
var stack = book(name.endsWith("description"), name);
if (name.equals("manual_link")) {
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), sample.getItem().get(Rivencraft.LINK_BOOK_TARGET.get()));
stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
}
if (name.equals("manual_description")) stack.set(Rivencraft.AGE_BOOK_ID.get(), level.dimension().identifier());
var item = new ItemEntity(level, 8.5, 66, 8.5, stack);
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); item.setTarget(OWNER);
level.addFreshEntity(item);
}
player.drop(book(false, "player_toss"), false, net.minecraft.util.Prediction.SERVER_ONLY);
net.minecraft.world.Containers.dropItemStack(level, 8.5, 65, 8.5, book(true, "container"));
var standPos = new BlockPos(10, 64, 8);
level.setBlock(standPos, Rivencraft.BOOK_STAND.get().defaultBlockState(), 3);
((net.dnorberg.rivencraft.block.entity.BookStandBlockEntity) level.getBlockEntity(standPos)).setBook(book(false, "stand"));
level.destroyBlock(standPos, true);
var custom = new DroppedBookEntity(level, new Vec3(8.5, 66, 8.5), book(false, "already_custom"));
custom.setNoGravity(true);
level.addFreshEntity(custom);
level.addFreshEntity(new ItemEntity(level, 8.5, 66, 8.5, net.dnorberg.rivencraft.item.PageItem.createLinkPanelPage()));
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity dropped
&& dropped.getItem().getHoverName().getString().equals("cancel_preserve")) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
var item = new ItemEntity(level, 8.5, 66, 8.5, book(false, "cancel_preserve"));
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO);
level.addFreshEntity(item);
} finally {
// Queued after that source's conversion task; no fixture listener remains installed.
level.getServer().schedule(new TickTask(0, () -> net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel)));
}
}
static void verify(ServerLevel level) {
var drops = level.getEntitiesOfClass(ItemEntity.class, AREA);
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description",
"player_toss", "container", "stand", "already_custom", "cancel_preserve")) {
var found = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals(name)).toList();
if (found.size() != 1 || !name.equals("cancel_preserve") && !(found.getFirst() instanceof DroppedBookEntity))
throw new IllegalStateException("Expected one open book for " + name + ", found " + found.size());
}
var named = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals("manual_link")).findFirst().orElseThrow();
if (!OWNER.equals(named.getTarget()) || !"Named destination".equals(named.getItem().get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Converted book lost owner or destination");
if (drops.stream().filter(e -> e.getItem().is(Rivencraft.PAGE.get())).anyMatch(e -> e instanceof DroppedBookEntity))
throw new IllegalStateException("Non-book item was converted");
}
}
@@ -0,0 +1,93 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseBookStandPayload;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
/** Isolated conversion, ownership, stale-screen and persistence checks. */
final class PortBookStandProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
static void create(ServerLevel level) {
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("a5c323a1-3b58-4dd6-927f-e08000800110"), "RivenStandProbe"));
player.setPos(0.5, 64, 2.5);
try {
for (Direction facing : Direction.Plane.HORIZONTAL) for (InteractionHand hand : InteractionHand.values())
for (var sample : java.util.List.of(new ItemStack(Rivencraft.LINKING_BOOK.get()),
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), PageItem.createLinkPanelPage().copyWithCount(3))) {
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.FACING, facing), 3);
player.setItemInHand(hand, sample.copy());
if (!BookStandConversionHandler.convert(player, hand, POS)) throw new IllegalStateException("Conversion failed");
var stand = (BookStandBlockEntity) level.getBlockEntity(POS);
var stored = sample.copyWithCount(1);
if (!ItemStack.matches(stand.getBook(), stored) || player.getItemInHand(hand).getCount() != sample.getCount() - 1
|| stand.getBlockState().getValue(BookStandBlock.FACING) != facing)
throw new IllegalStateException("Conversion lost ownership or facing");
var packet = new UseBookStandPayload(level.dimension().identifier(), POS, stored.copy());
if (PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Valid source rejected");
player.setPos(100, 64, 100);
if (!PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Distant source accepted");
player.setPos(0.5, 64, 2.5);
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(
net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "overworld"), POS, stored)).isEmpty())
throw new IllegalStateException("Wrong dimension accepted");
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(packet.dimension(), POS, new ItemStack(Items.DIRT))).isEmpty())
throw new IllegalStateException("Replaced book accepted");
var loaded = net.minecraft.world.level.block.entity.BlockEntity.loadStatic(POS, stand.getBlockState(),
stand.saveWithFullMetadata(level.registryAccess()), level.registryAccess());
if (!(loaded instanceof BookStandBlockEntity copy) || !ItemStack.matches(copy.getBook(), stored))
throw new IllegalStateException("Stand serialization lost content");
player.getInventory().clearContent();
player.setShiftKeyDown(true);
stand.getBlockState().useWithoutItem(level, player, new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false));
player.setShiftKeyDown(false);
if (!level.getBlockState(POS).is(Blocks.LECTERN)
|| level.getBlockState(POS).getValue(LecternBlock.FACING) != facing
|| player.getInventory().countItem(stored.getItem()) != 1
|| !PortBookNetworking.validatedStandBook(player, packet).isEmpty())
throw new IllegalStateException("Retrieval did not restore lectern or recover item exactly once");
player.getInventory().clearContent();
player.setItemInHand(hand, stored.copy());
BookStandConversionHandler.convert(player, hand, POS);
level.destroyBlock(POS, true);
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-2, 62, -2, 3, 68, 3));
int items = drops.stream().filter(e -> e.getItem().is(stored.getItem())).mapToInt(e -> e.getItem().getCount()).sum();
int lecterns = drops.stream().filter(e -> e.getItem().is(Items.LECTERN)).mapToInt(e -> e.getItem().getCount()).sum();
if (items != 1 || lecterns != 1) throw new IllegalStateException("Break drops duplicated/lost: " + items + "/" + lecterns);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.HAS_BOOK, true), 3);
player.setItemInHand(InteractionHand.MAIN_HAND, PageItem.createLinkPanelPage());
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Occupied lectern converted");
level.setBlock(POS, Blocks.LECTERN.defaultBlockState(), 3);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIRT));
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Unrelated item accepted");
var named = new ItemStack(Rivencraft.LINKING_BOOK.get());
named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Stand persistence"));
player.setItemInHand(InteractionHand.MAIN_HAND, named);
if (!BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Final stand missing");
} finally { player.setShiftKeyDown(false); player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof BookStandBlockEntity stand)
|| !stand.getBook().is(Rivencraft.LINKING_BOOK.get())
|| !stand.getBook().getHoverName().getString().equals("Stand persistence"))
throw new IllegalStateException("Stand restart persistence failed");
}
}
@@ -0,0 +1,109 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
/** Disposable-world boundary, booster-use and persistence checks. */
final class PortCollectionExtrasProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortCollectionExtrasProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static NotebookContents contents(ItemStack stack) { return stack.get(Rivencraft.NOTEBOOK_CONTENTS.get()); }
private static List<Identifier> symbols(ItemStack collection) {
return contents(collection).pages().stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
}
private static void folder(ItemStack stack) {
if (!stack.is(Rivencraft.COLLATION_FOLDER.get()) || contents(stack).pageCount() != 16
|| symbols(stack).stream().anyMatch(id -> !PortSymbolPages.sealedCandidateIds().contains(id)))
throw new IllegalStateException("Sealed Notebook page pool/count regression");
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var chest = (Container) level.getBlockEntity(CHEST);
var pages = new java.util.ArrayList<ItemStack>();
for (int i = 0; i < 253; i++) pages.add(PageItem.createSymbolPage(id(i % 2 == 0 ? "terrain/flat" : "weather/off")));
var volumes = SymbolPortfolioItem.createVolumes(id("feature"), pages);
if (volumes.size() != 2 || contents(volumes.get(0)).pageCount() != 252
|| contents(volumes.get(1)).pageCount() != 1
|| !volumes.stream().flatMap(volume -> symbols(volume).stream()).toList()
.equals(pages.stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList())
|| !SymbolPortfolioItem.createVolumes(id("feature"), List.of()).isEmpty())
throw new IllegalStateException("Portfolio volume boundary/order regression");
chest.setItem(0, volumes.get(0));
chest.setItem(1, volumes.get(1));
var actual = net.dnorberg.rivencraft.registry.ModCreativeTabs.creativeSymbolPortfolios(level.registryAccess());
var allPages = PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, level.registryAccess()).stream())
.map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
if (!actual.stream().flatMap(volume -> symbols(volume).stream()).toList().equals(allPages))
throw new IllegalStateException("Creative portfolios omit/duplicate/reorder pages");
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("790b69dd-b62b-45be-9017-caf16515169a"), "RivenExtrasProbe"));
player.setPos(0.5, 64, 2.5);
try {
player.getAbilities().instabuild = false;
player.getRandom().setSeed(105);
var first = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
player.getRandom().setSeed(105);
var repeat = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
folder(first);
if (!symbols(first).equals(symbols(repeat))) throw new IllegalStateException("Booster seeded selection changed");
chest.setItem(2, first);
for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(hand, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get()));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, hand);
folder(player.getItemInHand(hand));
}
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (player.getMainHandItem().getCount() != 1) throw new IllegalStateException("Booster stack consumption regression");
player.getAbilities().instabuild = true;
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (!player.getMainHandItem().is(Rivencraft.SEALED_NOTEBOOK.get()) || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Creative booster consumed its input");
player.getAbilities().instabuild = false;
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
var dropped = level.getEntitiesOfClass(ItemEntity.class, new AABB(-3, 60, -3, 4, 70, 6),
entity -> entity.getItem().is(Rivencraft.COLLATION_FOLDER.get()));
if (dropped.size() != 1 || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Full-inventory booster delivery regression");
folder(dropped.getFirst().getItem());
dropped.getFirst().discard();
} finally {
player.getAbilities().instabuild = false;
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
}
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(0)).pageCount() != 252
|| !chest.getItem(1).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(1)).pageCount() != 1)
throw new IllegalStateException("Portfolio persistence regression");
folder(chest.getItem(2));
}
}
@@ -127,19 +127,15 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static boolean validWriting(List<Identifier> pages) {
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
for (Identifier page : pages) {
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|| isSize(page) && ++sizes > 1) return false;
if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
periods = phases = directions = angles = sizes = 0;
}
int suns = 0, moons = 0;
for (Identifier page : pages) if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
}
return periods + phases + directions + angles + sizes == 0;
// Repeated modifiers overwrite the pending value. Unconsumed modifiers
// earn grammar instability, but never affect an earlier body.
return true;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
@@ -148,15 +144,20 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
throw new IllegalArgumentException("Invalid celestial modifier order");
return resolve(pages, seed, nativeTicks, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks, PortRandomPagePolicy policy) {
if (!validWriting(pages))
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
// Match root AgeSkyProfile.random's count draw positions: sky style,
// mixed sky color, then independent 0..3 sun and moon counts.
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5);
randomColor(random);
int suns = random.nextInt(4), moons = random.nextInt(4);
int suns = policy.allows(Identifier.parse("rivencraft:celestial/sun")) ? random.nextInt(4) : 0;
int moons = policy.allows(Identifier.parse("rivencraft:celestial/moon")) ? random.nextInt(4) : 0;
var bodies = new java.util.ArrayList<PortCelestialBody>();
for (int i = 0; i < suns + moons; i++)
bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies);
@@ -167,7 +168,7 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons");
if (!validWriting(pages)) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
return compileOne(pages, seed, nativeTicks, 0);
var bodies = new java.util.ArrayList<PortCelestialBody>();
@@ -190,8 +191,13 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
if (controller == null) return NATIVE;
int controllerIndex = -1;
for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) { controllerIndex = i; break; }
// Only preceding modifiers belong to this controller. In particular, a
// trailing dangling modifier must not rewrite the single-body fast path.
if (controllerIndex < 0) return NATIVE;
pages = pages.subList(0, controllerIndex + 1);
var controller = pages.getLast();
int mode = controller.getPath().equals("celestial/sun") ? SUN
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
@@ -308,6 +314,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
}
public static PortDayCycle forClient(Identifier id) { return clientCycles.getOrDefault(id, NATIVE); }
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
public static PortDayCycle forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.dayCycle();
@@ -0,0 +1,202 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
public final class PortDecayProviders {
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
private static final Map<Identifier, DecayBlock.Color> COLORS = Map.of(
id("decayblue"), DecayBlock.Color.BLUE,
id("decaypurple"), DecayBlock.Color.PURPLE,
id("decayred"), DecayBlock.Color.RED,
id("decaywhite"), DecayBlock.Color.WHITE);
private static final Direction[] DIRECTIONS = Direction.values();
private PortDecayProviders() {}
public static void register() {
COLORS.keySet().forEach(id -> PortInstabilityRuntime.register(id, true, PortDecayProviders::apply));
}
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
ServerLevel level = player.level();
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|| !PortInstabilityRuntime.isAge(level)
|| level.getChunkSource().getChunkNow(player.chunkPosition().x(), player.chunkPosition().z()) == null) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
Identifier id = id("decay" + color.getSerializedName());
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return -1;
int changed = 0;
for (int i = 0; i < Math.min(count, 32); i++) {
PortInstabilityRuntime.Budget budget = new PortInstabilityRuntime.Budget(1);
apply(new PortInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
level.getServer().getTickCount(), budget), id, 1);
changed += budget.applied();
}
return changed;
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|| !PortInstabilityRuntime.isAge(context.level())
|| context.loadedChunks().isEmpty()) return;
DecayBlock.Color color = COLORS.get(id);
if (color == null) return;
PortDecaySavedData data = PortDecaySavedData.get(context.level());
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
List<BlockPos> frontier = data.frontier(color);
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
if (source == null) {
seed(context, color, data, random);
return;
}
BlockState sourceState = context.level().getBlockState(source);
if (!sourceState.is(Rivencraft.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
data.remove(color, source);
return;
}
for (int attempt = 0; attempt < PortInstabilityConfig.decayMaxAttempts(); attempt++) {
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|| isSpawnProtected(context.age(), target)) continue;
BlockState prior = context.level().getBlockState(target);
if (!canReplace(context.level(), target, prior, color)) continue;
int difficulty = conversionDifficulty(context.level(), target, prior, color);
if (random.nextInt(difficulty) != 0) continue;
if (replace(context, target, prior, color)) data.add(color, target);
return;
}
}
private static void seed(PortInstabilityRuntime.Context context, DecayBlock.Color color,
PortDecaySavedData data, Random random) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
BlockPos pos = new BlockPos(x,
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|| isSpawnProtected(context.age(), pos)) return;
BlockState prior = context.level().getBlockState(pos);
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
data.add(color, pos);
}
}
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
List<BlockPos> frontier, Random random) {
if (frontier.isEmpty()) return null;
int start = random.nextInt(frontier.size());
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
BlockPos pos = frontier.get((start + i) % frontier.size());
if (isLoadedTarget(level, chunks, pos)) return pos;
}
return null;
}
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
return level.isInWorldBounds(pos) && level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) != null
&& chunks.contains(ChunkPos.containing(pos));
}
static boolean isSpawnProtected(PortInstabilityData.Age age, BlockPos pos) {
BlockPos spawn = age.spawn().orElse(null);
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
}
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
if (!PortInstabilityRuntime.isAge(level)
|| !level.isInWorldBounds(pos) || level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) == null
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|| isCritical(prior)) return false;
return !prior.is(Rivencraft.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
}
private static boolean isCritical(BlockState prior) {
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|| prior.getBlock() != Rivencraft.DECAY.get()
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
.getNamespace().equals(Rivencraft.MOD_ID);
}
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
if (state.isAir()) return switch (color) {
case BLUE, RED -> 20;
case PURPLE -> 8;
case WHITE -> 50;
};
if (state.is(Rivencraft.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
return switch (color) {
case BLUE -> Math.max(1, (int) hardness * 2);
case RED -> Math.max(1, (int) hardness);
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
case WHITE -> 1;
};
}
private static boolean replace(PortInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
DecayBlock.Color color) {
ServerLevel level = context.level();
// A fake player gives claim/protection listeners the Age and exact block position.
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breakEvent = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breakEvent);
if (breakEvent.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
BlockState decay = Rivencraft.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
if (!level.setBlock(pos, decay, 3)) return false;
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placeEvent);
if (placeEvent.isCanceled()) {
snapshot.restore();
return false;
}
context.budget().recordApplied();
return true;
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Persisted per-Age frontier and attempt sequence, bounded to 1,024 positions per color. */
public final class PortDecaySavedData extends SavedData {
private static final int MAX_FRONTIER_PER_COLOR = 1024;
private static final Codec<PortDecaySavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
Codec.unboundedMap(StringRepresentable.fromEnum(DecayBlock.Color::values),
BlockPos.CODEC.listOf(0, MAX_FRONTIER_PER_COLOR)).fieldOf("frontiers").forGetter(data -> data.frontiers)
).apply(instance, PortDecaySavedData::new));
private static final SavedDataType<PortDecaySavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "decay"), () -> new PortDecaySavedData(0, Map.of()), CODEC);
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
private long attempts;
private PortDecaySavedData(long attempts, Map<DecayBlock.Color, List<BlockPos>> positions) {
this.attempts = attempts;
for (var color : DecayBlock.Color.values()) frontiers.put(color,
new ArrayList<>(positions.getOrDefault(color, List.of()).stream().distinct().map(BlockPos::immutable).toList()));
}
public static PortDecaySavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
public long nextAttempt() { setDirty(); return attempts++; }
public List<BlockPos> frontier(DecayBlock.Color color) { return frontiers.get(color); }
public void add(DecayBlock.Color color, BlockPos pos) {
var frontier = frontiers.get(color);
if (frontier.contains(pos)) return;
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.removeFirst();
frontier.add(pos.immutable()); setDirty();
}
public void remove(DecayBlock.Color color, BlockPos pos) { if (frontiers.get(color).remove(pos)) setDirty(); }
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.age;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.PortHandheldTravel;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
/** Disposable-world checks for book ownership and the new entity save format. */
final class PortDroppedBookProbe {
private PortDroppedBookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = FakePlayerFactory.get(level, new com.mojang.authlib.GameProfile(
UUID.fromString("4db5c544-0a0c-421d-a128-c520bda64642"), "RivenBookProbe"));
player.setPos(0.5, 64, 0.5);
try {
for (var hand : InteractionHand.values()) {
ItemStack book = new ItemStack(hand == InteractionHand.MAIN_HAND
? Rivencraft.LINKING_BOOK.get() : Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(DataComponents.CUSTOM_NAME, Component.literal("Dropped fixture " + hand));
book.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(
Identifier.fromNamespaceAndPath("minecraft", "overworld"), 12.25, 80.5, -42.75, 135, -20));
player.setItemInHand(hand, book);
String failure = PortHandheldTravel.travel(player, hand, book, () -> "expected failure");
if (!"expected failure".equals(failure) || player.getItemInHand(hand) != book)
throw new IllegalStateException("Failed travel consumed the book");
try {
PortHandheldTravel.travel(player, hand, book, () -> { throw new IllegalStateException("fixture"); });
throw new AssertionError("Fixture exception was swallowed");
} catch (IllegalStateException expected) {
if (!"fixture".equals(expected.getMessage()) || player.getItemInHand(hand) != book) throw expected;
}
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || player.getItemInHand(hand) != book)
throw new IllegalStateException("Rejected dropped entity consumed the book");
} finally { net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel); }
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || !player.getItemInHand(hand).isEmpty())
throw new IllegalStateException("Successful travel retained the active book");
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
var books = level.getEntitiesOfClass(DroppedBookEntity.class, new AABB(-2, 60, -2, 3, 68, 3));
if (books.size() != 2) throw new IllegalStateException("Expected two dropped books, found " + books.size());
for (var entity : books) {
var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
entity.saveWithoutId(output);
var copy = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), level);
copy.load(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult()));
if (!ItemStack.isSameItemSameComponents(copy.getItem(), entity.getItem())
|| copy.getAge() != -32768 || copy.getItem().getCount() != 1)
throw new IllegalStateException("Dropped book persistence regression");
var copied = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
copy.saveWithoutId(copied);
if (copied.buildResult().getIntOr("BookDecayTicks", -1)
!= output.buildResult().getIntOr("BookDecayTicks", -2))
throw new IllegalStateException("Dropped book decay counter was not preserved");
}
}
}
@@ -1,6 +1,5 @@
package net.dnorberg.rivencraft.age;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import net.minecraft.resources.Identifier;
@@ -8,21 +7,28 @@ import net.minecraft.resources.Identifier;
/** Seed-completes missing environment controllers once, before an Age is saved. */
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
return resolve(pages, seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
}
private static <T> T rankedDefault(Random random, T[] choices) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain,
PortRandomPagePolicy policy) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values(), "lighting", policy));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values(), "weather", policy));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed, policy));
}
private static <T extends Enum<T>> T rankedDefault(Random random, T[] choices, String category, PortRandomPagePolicy policy) {
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
// Its rank-1 weight is the complete rare group plus one. Derive this
// from the supported pool, rather than selecting unported weather.
int[] weights = new int[choices.length];
Arrays.fill(weights, 1);
weights[0] = choices.length;
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
var options = new java.util.ArrayList<PortRankedChoice.Option<T>>();
for (int i = 0; i < choices.length; i++)
options.add(new PortRankedChoice.Option<>(choices[i], i == 0 ? 1 : 2));
return PortRankedChoice.choose(random, policy.required(options,
option -> PortRandomPagePolicy.page(category, option.value()),
new PortRankedChoice.Option<>(choices[0], 1)));
}
}
@@ -0,0 +1,178 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Active Legacy burning and the 1.21.1 test-enabled erosion policy. */
public final class PortEnvironmentalProviders {
public static final Identifier BURNING = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "burning");
public static final Identifier EROSION = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "erosion");
private static final Direction[] FLUID_SIDES = {
Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST
};
private PortEnvironmentalProviders() {}
public static void register() {
PortInstabilityRuntime.register(BURNING, false, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) scorch(context, level);
});
PortInstabilityRuntime.register(EROSION, true, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) erode(context);
});
}
public static boolean isEnvironmental(Identifier id) { return id.equals(BURNING) || id.equals(EROSION); }
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
var cards = providers.stream().filter(card -> isEnvironmental(card.id())).toList();
boolean erosion = PortInstabilityConfig.erosionEnabled() && PortInstabilityRuntime.effectiveScore(level)
>= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
if (cards.isEmpty() && !erosion) return;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (var card : cards) {
int budget = card.id().equals(BURNING) ? PortInstabilityConfig.scorchMaxTargets() : 1;
PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(budget)), card.id(), card.level());
}
if (erosion && cards.stream().noneMatch(card -> card.id().equals(EROSION)))
PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(1)), EROSION, 1);
}
/** Skips random burning selection or looks at one erosion target, preserving policy. */
public static int pulseForTesting(ServerPlayer player, String effect) {
ServerLevel level = player.level();
if (!PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
Identifier id = effect.equals("burning") ? BURNING : EROSION;
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return 0;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
var context = new PortInstabilityRuntime.Context(level, age, chunks,
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1));
if (effect.equals("burning")) {
if (!PortInstabilityConfig.scorchEnabled() || !PortInstabilityConfig.scorchPlayers()
|| !eligibleScorch(context, player)) return 0;
if (player.isInWaterOrRain()) return -2;
player.igniteForSeconds(4);
return 1;
}
if (!effect.equals("erosion") || !PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(level)
|| PortInstabilityRuntime.effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|| !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit)
|| hit.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK) return 0;
return erodeAt(context, hit.getBlockPos()) ? 1 : 0;
}
private static void scorch(PortInstabilityRuntime.Context context, int effectiveLevel) {
if (!PortInstabilityConfig.scorchEnabled() || PortInstabilityConfig.scorchMaxTargets() == 0) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt()
* 0x9E3779B97F4A7C15L ^ BURNING.hashCode());
int start = random.nextInt(context.loadedChunks().size());
int affected = 0;
for (int offset = 0; offset < Math.min(8, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
AABB bounds = new AABB(chunk.getMinBlockX(), level.getMinY(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, level.getMaxY(), chunk.getMaxBlockZ() + 1);
List<LivingEntity> candidates = new ArrayList<>();
level.getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligibleScorch(context, entity), candidates, 8);
candidates.sort(Comparator.comparing(Entity::getUUID));
for (LivingEntity entity : candidates) {
if (affected >= PortInstabilityConfig.scorchMaxTargets() || context.budget().remaining() == 0) return;
// Legacy sampled each chunk each tick with a 1/10 gate. With 20 ticks/pass this
// gives at least one opportunity for nearly every inspected loaded chunk.
if (random.nextDouble() >= PortHazardProviders.legacyChance(1,
PortInstabilityConfig.instabilityCooldown(), 1, 10)) continue;
if (!context.budget().claim()) return;
entity.igniteForSeconds(Math.min(12, 4 * effectiveLevel));
context.budget().recordApplied();
affected++;
break;
}
}
}
private static boolean eligibleScorch(PortInstabilityRuntime.Context context, LivingEntity entity) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != context.level()
|| !context.level().canSeeSky(entity.blockPosition())
|| PortDecayProviders.isSpawnProtected(context.age(), entity.blockPosition())) return false;
if (entity instanceof Player player) return PortInstabilityConfig.scorchPlayers()
&& !player.isCreative() && !player.isSpectator();
return true;
}
private static void erode(PortInstabilityRuntime.Context context) {
if (!PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(context.level())) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt()
* 0xC2B2AE3D27D4EB4FL ^ EROSION.hashCode());
for (int attempt = 0; attempt < 8; attempt++) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
int y = level.getMinY() + 1 + random.nextInt(level.getHeight() - 2);
if (erodeAt(context, new BlockPos(x, y, z))) return;
}
}
static boolean erodeAt(PortInstabilityRuntime.Context context, BlockPos pos) {
ServerLevel level = context.level();
if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), pos)
|| PortDecayProviders.isSpawnProtected(context.age(), pos)) return false;
BlockState prior = level.getBlockState(pos);
if (!PortDecayProviders.canReplace(level, pos, prior, DecayBlock.Color.BLUE)) return false;
for (Direction direction : FLUID_SIDES) {
BlockPos neighbor = pos.relative(direction);
if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), neighbor)) continue;
var fluid = level.getFluidState(neighbor);
if (fluid.isSource() && replaceFluid(context, pos, prior, fluid.createLegacyBlock())) return true;
}
return false;
}
private static boolean replaceFluid(PortInstabilityRuntime.Context context, BlockPos pos,
BlockState prior, BlockState fluidBlock) {
ServerLevel level = context.level();
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breaking = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breaking);
if (breaking.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
if (!level.setBlock(pos, fluidBlock, 3)) return false;
var placing = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placing);
if (placing.isCanceled()) {
snapshot.restore();
return false;
}
context.budget().recordApplied();
return true;
}
}
@@ -70,10 +70,12 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
.forGetter(generator -> Optional.ofNullable(generator.lakes)),
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) ->
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
com.mojang.serialization.Codec.BOOL.optionalFieldOf("libraries", false).forGetter(generator -> generator.libraries),
Identifier.CODEC.listOf().optionalFieldOf("library_symbols", List.of()).forGetter(generator -> generator.librarySymbols)
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed, libraries, symbols) ->
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed, libraries, symbols)));
private final ChunkGenerator base;
private final PortRockPlan rocks;
@@ -82,10 +84,19 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
private final PortLakePlan lakes;
private final PortTerrainFeaturePlan terrainFeatures;
private final long ageSeed;
private final boolean libraries;
private final List<Identifier> librarySymbols;
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
this(base, rocks, vegetation, dungeons, lakes, terrainFeatures, ageSeed, false, List.of());
}
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, long ageSeed,
boolean libraries, List<Identifier> librarySymbols) {
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
this.base = base;
this.rocks = rocks;
@@ -94,6 +105,8 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
this.lakes = lakes;
this.terrainFeatures = terrainFeatures;
this.ageSeed = ageSeed;
this.libraries = libraries;
this.librarySymbols = List.copyOf(librarySymbols);
}
public ChunkGenerator base() { return base; }
@@ -286,5 +299,6 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
}
}
if (libraries) PortLibraryFeature.generate(level, chunk, ageSeed, librarySymbols);
}
}
@@ -0,0 +1,356 @@
package net.dnorberg.rivencraft.age;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LightningBolt;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Explosion;
import net.minecraft.world.level.ExplosionDamageCalculator;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */
public final class PortHazardProviders {
private static final Identifier LIGHTNING = id("lightning");
private static final Identifier EXPLOSIONS = id("explosions");
private PortHazardProviders() {}
public static void register() {
// Visuals and entity damage remain available when block mutation is disabled.
PortInstabilityRuntime.register(LIGHTNING, false, PortHazardProviders::apply);
PortInstabilityRuntime.register(EXPLOSIONS, false, PortHazardProviders::apply);
}
static boolean isHazard(Identifier id) {
return LIGHTNING.equals(id) || EXPLOSIONS.equals(id);
}
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
if (!PortInstabilityConfig.hazardsEnabled()) return;
List<PortInstabilityPlan.ProviderLevel> hazards = providers.stream()
.filter(provider -> isHazard(provider.id())).toList();
if (hazards.isEmpty()) return;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (PortInstabilityPlan.ProviderLevel hazard : hazards) {
var context = new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(1));
PortInstabilityRuntime.invoke(context, hazard.id(), hazard.level());
}
}
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
List<ChunkPos> players = level.players().stream()
.sorted(Comparator.comparing(Entity::getUUID))
.map(ServerPlayer::chunkPosition).toList();
return candidateChunks(players, maximum,
chunk -> level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null);
}
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
Predicate<ChunkPos> loaded) {
if (maximum <= 0) return List.of();
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (int radius = 0; radius <= 4; radius++) {
for (int dx = -radius; dx <= radius; dx++) {
for (int dz = -radius; dz <= radius; dz++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
for (ChunkPos center : players) {
int x = center.x() + dx;
int z = center.z() + dz;
ChunkPos candidate = new ChunkPos(x, z);
if (loaded.test(candidate)) chunks.add(candidate);
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
}
return List.copyOf(chunks);
}
/** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */
public static int pulseForTesting(ServerPlayer player, boolean lightning) {
return pulseForTesting(player.level(), player.blockPosition(), lightning);
}
public static int pulseForTesting(ServerLevel level, BlockPos origin, boolean lightning) {
if (!PortInstabilityConfig.hazardsEnabled()
|| !PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
PortHazardSavedData data = PortHazardSavedData.get(level);
if (!data.ready(lightning, level.getGameTime(), PortInstabilityConfig.hazardsMinInterval())) return 0;
ChunkPos center = ChunkPos.containing(origin);
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
ChunkPos chunk = new ChunkPos(center.x() + dx, center.z() + dz);
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null) continue;
int x = chunk.getMinBlockX() + 8;
int z = chunk.getMinBlockZ() + 8;
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: origin.getY();
BlockPos pos = new BlockPos(x, y, z);
var context = new PortInstabilityRuntime.Context(level, age, List.of(chunk),
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1));
if (!safeTarget(context, pos, 8)) continue;
data.nextAttempt();
data.record(lightning, level.getGameTime());
return (lightning ? strike(level, age, chunk, pos) : explode(level, age, chunk, pos)) ? 1 : 0;
}
}
return 0;
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int effectiveLevel) {
ServerLevel level = context.level();
if (!PortInstabilityConfig.hazardsEnabled() || context.loadedChunks().isEmpty()
|| !PortInstabilityRuntime.isAge(level)) return;
boolean lightning = id.equals(LIGHTNING);
if (!lightning && !id.equals(EXPLOSIONS)) return;
PortHazardSavedData data = PortHazardSavedData.get(level);
long gameTime = level.getGameTime();
if (!data.ready(lightning, gameTime, PortInstabilityConfig.hazardsMinInterval())) return;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000;
// Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks.
double chance = legacyChance(context.loadedChunks().size(), PortInstabilityConfig.instabilityCooldown(),
effectiveLevel, legacyDenominator);
if (random.nextDouble() >= chance) return;
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int margin = lightning ? 2 : 6;
int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2);
int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2);
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: level.getMinY() + 1 + random.nextInt(level.getHeight() - 2);
BlockPos pos = new BlockPos(x, y, z);
if (!safeTarget(context, pos, 8) || !context.budget().claim()) return;
data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event
if (lightning ? strike(level, context.age(), chunk, pos) : explode(level, context.age(), chunk, pos))
context.budget().recordApplied();
}
static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) {
if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0;
return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies);
}
static boolean safeTarget(PortInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) {
if (!PortDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false;
BlockPos spawn = context.age().spawn().orElse(null);
return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin
|| Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin;
}
private static boolean strike(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
AABB bounds = new AABB(pos).inflate(2);
if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return false;
LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED);
if (bolt == null) return false;
bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets
if (!level.addFreshEntity(bolt)) return false;
List<LivingEntity> targets = level.getEntitiesOfClass(LivingEntity.class, bounds,
entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity));
targets.sort(Comparator.comparing(Entity::getUUID));
int count = 0;
for (LivingEntity target : targets) {
if (count++ >= PortInstabilityConfig.hazardsMaxEntities()) break;
if (PortInstabilityConfig.hazardsMaxDamage() > 0)
target.hurtServer(level, level.damageSources().lightningBolt(), Math.min(5, PortInstabilityConfig.hazardsMaxDamage()));
}
// Legacy lightning started fires. Keep it separately gated by block policy and game rule.
if (canMutate(level) && PortInstabilityConfig.hazardsFire()
&& level.canSpreadFireAround(pos)
&& !PortDecayProviders.isSpawnProtected(age, pos)
&& PortDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos);
return true;
}
private static boolean explode(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
return explodeBounded(level, age, chunk, pos, PortInstabilityConfig.hazardsBlockDamage(),
PortInstabilityConfig.hazardsPlayerDamage(), PortInstabilityConfig.hazardsFire(),
PortInstabilityConfig.hazardsMaxBlocks(), PortInstabilityConfig.hazardsMaxEntities(),
PortInstabilityConfig.hazardsMaxDamage(), PortInstabilityConfig.hazardsExplosionRadius());
}
/** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */
static boolean explodeBounded(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos,
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
int maxDamage, int radius) {
boolean grief = destructiveBlocksAllowed(PortInstabilityConfig.instabilityWorldMutation(),
level.getGameRules().get(GameRules.MOB_GRIEFING), blockDamage, maxBlocks);
boolean fire = grief && ignite
&& level.canSpreadFireAround(pos);
BoundedExplosion calculator = new BoundedExplosion(level, age, chunk, grief,
playerDamage, maxBlocks, maxEntities, maxDamage);
level.explode(null, null, calculator,
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
radius, false,
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
if (fire) {
int ignited = 0;
for (BlockPos affected : calculator.approved) {
if (ignited >= 4) break;
if (level.canSpreadFireAround(affected) && !PortDecayProviders.isSpawnProtected(age, affected)
&& ignite(level, affected)) ignited++;
}
}
return calculator.started;
}
private static boolean ignite(ServerLevel level, BlockPos pos) {
if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos))
return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos);
BlockState prior = level.getBlockState(pos);
if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false;
var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(place);
if (place.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
static boolean canMutate(ServerLevel level) {
return PortInstabilityConfig.instabilityWorldMutation()
&& level.getGameRules().get(GameRules.MOB_GRIEFING);
}
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
boolean blockDamage, int maxBlocks) {
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
}
static boolean protectedBlock(ServerLevel level, PortInstabilityData.Age age, BlockPos pos, BlockState state) {
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|| state.is(PortDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|| PortDecayProviders.isSpawnProtected(age, pos)
|| state.getDestroySpeed(level, pos) < 0
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
}
private static boolean eligibleEntity(Entity entity) {
return eligibleEntity(entity, PortInstabilityConfig.hazardsPlayerDamage());
}
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
return !(entity instanceof Player player) || playerDamage
&& !player.isCreative() && !player.isSpectator();
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private static final class BoundedExplosion extends ExplosionDamageCalculator {
private final ServerLevel level;
private final PortInstabilityData.Age age;
private final ChunkPos chunk;
private final boolean grief;
private final boolean playerDamage;
private final int maxBlocks;
private final int maxEntities;
private final int maxDamage;
private final Set<BlockPos> approved = new LinkedHashSet<>();
private final Set<UUID> damagedEntities = new HashSet<>();
private int damaged;
private boolean started;
private BoundedExplosion(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, boolean grief,
boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) {
this.level = level;
this.age = age;
this.chunk = chunk;
this.grief = grief;
this.playerDamage = playerDamage;
this.maxBlocks = maxBlocks;
this.maxEntities = maxEntities;
this.maxDamage = maxDamage;
}
@Override
public Optional<Float> getBlockExplosionResistance(Explosion explosion, BlockGetter reader,
BlockPos pos, BlockState state, FluidState fluid) {
started = true;
if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE);
return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid);
}
@Override
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
BlockState state, float power) {
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
if (approved.contains(pos)) return true;
if (approved.size() >= maxBlocks) return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var event = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, state, actor);
NeoForge.EVENT_BUS.post(event);
if (event.isCanceled()) return false;
approved.add(pos.immutable());
return true;
}
@Override
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage)
|| maxDamage == 0) return false;
damaged++;
damagedEntities.add(entity.getUUID());
return true;
}
@Override
public float getEntityDamageAmount(Explosion explosion, Entity entity, float exposure) {
return Math.min(super.getEntityDamageAmount(explosion, entity, exposure), maxDamage);
}
@Override
public float getKnockbackMultiplier(Entity entity) {
return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F;
}
private boolean inLoadedChunk(BlockPos pos) {
return level.isInWorldBounds(pos) && ChunkPos.containing(pos).equals(chunk)
&& level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null;
}
}
}
@@ -0,0 +1,34 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Per-Age random sequence and event cooldowns survive unload and restart. */
public final class PortHazardSavedData extends SavedData {
private static final Codec<PortHazardSavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
Codec.LONG.optionalFieldOf("last_lightning", Long.MIN_VALUE).forGetter(data -> data.lastLightning),
Codec.LONG.optionalFieldOf("last_explosion", Long.MIN_VALUE).forGetter(data -> data.lastExplosion),
Codec.LONG.optionalFieldOf("last_meteor", Long.MIN_VALUE).forGetter(data -> data.lastMeteor)
).apply(instance, PortHazardSavedData::new));
private static final SavedDataType<PortHazardSavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "hazards"),
() -> new PortHazardSavedData(0, Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE), CODEC);
private long attempts, lastLightning, lastExplosion, lastMeteor;
private PortHazardSavedData(long attempts, long lightning, long explosion, long meteor) {
this.attempts = attempts; lastLightning = lightning; lastExplosion = explosion; lastMeteor = meteor;
}
public static PortHazardSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
public long nextAttempt() { setDirty(); return attempts++; }
public boolean ready(boolean lightning, long time, int interval) {
long last = lightning ? lastLightning : lastExplosion;
return last == Long.MIN_VALUE || time < last || time - last >= interval;
}
public void record(boolean lightning, long time) { if (lightning) lastLightning = time; else lastExplosion = time; setDirty(); }
public boolean meteorReady(long time, int interval) { return lastMeteor == Long.MIN_VALUE || time < lastMeteor || time - lastMeteor >= interval; }
public void recordMeteor(long time) { lastMeteor = time; setDirty(); }
}
@@ -0,0 +1,172 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.BoolArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
/** Scores and test overrides are deliberately operator-only, never book tooltip data. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortInstabilityCommands {
private PortInstabilityCommands() {}
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("instability")
.then(Commands.literal("status").executes(context -> {
var source = context.getSource(); var level = requireAge(source);
var age = PortInstabilityData.get(source.getServer()).age(level);
source.sendSuccess(() -> Component.literal("Age " + level.dimension().identifier()
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
source.sendSuccess(() -> Component.literal(" live block score=" + PortBlockInstability.score(level)
+ "; effective score=" + PortInstabilityRuntime.effectiveScore(level)
+ "; difficulty=" + PortInstabilityConfig.instabilityDifficulty()
+ "; samples Age=" + PortBlockInstability.sampledChunks(level) + "/" + PortBlockProfileSavedData.MIN_AGE_SAMPLES
+ ", Overworld=" + PortBlockInstability.sampledChunks(source.getServer().overworld()) + "/" + PortBlockProfileSavedData.MIN_BASELINE_SAMPLES
+ "; profiling=" + PortInstabilityData.get(source.getServer()).supportsProfiling(level)), false);
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
source.sendSuccess(() -> Component.literal(" erosion testing policy (not a saved card): "
+ PortInstabilityRuntime.providerStatus(level, PortEnvironmentalProviders.EROSION)
+ "; current score=" + PortInstabilityRuntime.effectiveScore(level)), false);
for (var provider : PortInstabilityRuntime.plan(level).providers())
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
return 1;
}))
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
.executes(context -> {
var level = requireAge(context.getSource());
boolean enabled = BoolArgumentType.getBool(context, "enabled");
PortInstabilityData.get(level.getServer()).enabled(level, enabled);
if (!enabled) PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Age instability enabled=" + enabled
+ "; saved score and provider selection unchanged"), false);
return 1;
})))
.then(Commands.literal("test_score").then(Commands.argument("score", IntegerArgumentType.integer(0, 1_000_000))
.executes(context -> {
var level = requireAge(context.getSource());
PortInstabilityRuntime.testScore(level, IntegerArgumentType.getInteger(context, "score"));
PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Session-only score override set; frozen score unchanged. Restart or clear_test_score removes it."), false);
return 1;
})))
.then(Commands.literal("clear_test_score").executes(context -> {
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Cleared test override; restored live effective provider selection"), false); return 1;
}))
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
.suggests((context, builder) -> {
for (var color : net.dnorberg.rivencraft.block.DecayBlock.Color.values())
if (color.getSerializedName().startsWith(builder.getRemaining())) builder.suggest(color.getSerializedName());
return builder.buildFuture();
}).executes(context -> decay(context, 1))
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
.then(Commands.literal("environment").then(Commands.argument("effect", StringArgumentType.word())
.suggests((context, builder) -> {
for (String effect : java.util.List.of("burning", "erosion"))
if (effect.startsWith(builder.getRemaining())) builder.suggest(effect);
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); requireAge(source);
String effect = StringArgumentType.getString(context, "effect");
if (!effect.equals("burning") && !effect.equals("erosion")) {
source.sendFailure(Component.literal("Choose burning or erosion")); return 0;
}
int result = PortEnvironmentalProviders.pulseForTesting(source.getPlayerOrException(), effect);
if (result > 0) source.sendSuccess(() -> Component.literal("Applied " + effect + "; saved score/plan unchanged"), false);
else source.sendFailure(Component.literal(result == -2 ? "Burning would be extinguished by water or rain"
: "No effect applied. Check Age/config/provider policy, spawn protection and sky exposure; burning requires Survival/Adventure, erosion requires score >=10000 and a source fluid beside/above the targeted block."));
return Math.max(0, result);
})))
.then(Commands.literal("meteor").executes(context -> {
var source = context.getSource(); requireAge(source);
int result = PortMeteorProvider.pulseForTesting(source.getPlayerOrException());
if (result > 0) source.sendSuccess(() -> Component.literal("Meteor descent scheduled; impact in 30 ticks. Saved score/plan unchanged."), false);
else source.sendFailure(Component.literal("No meteor scheduled: check cooldown (200 ticks), active flight, safe loaded terrain and Age/config/provider policy."));
return Math.max(0, result);
}))
.then(Commands.literal("hazard").then(Commands.argument("type", StringArgumentType.word())
.suggests((context, builder) -> {
for (String type : java.util.List.of("lightning", "explosions"))
if (type.startsWith(builder.getRemaining())) builder.suggest(type);
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); var level = requireAge(source);
String type = StringArgumentType.getString(context, "type");
if (!type.equals("lightning") && !type.equals("explosions")) {
source.sendFailure(Component.literal("Choose lightning or explosions")); return 0;
}
int result = PortHazardProviders.pulseForTesting(level, BlockPos.containing(source.getPosition()), type.equals("lightning"));
if (result > 0) source.sendSuccess(() -> Component.literal("Triggered " + type
+ " in a nearby loaded chunk; saved score/plan unchanged"), false);
else source.sendFailure(Component.literal(result < 0 ? "Hazard disabled by config, Age or provider policy"
: "No hazard triggered: cooldown, protected spawn, unloaded terrain or event cancellation"));
return Math.max(0, result);
})))
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
.suggests((context, builder) -> {
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
if (card.id().getPath().startsWith(builder.getRemaining())) builder.suggest(card.id().getPath());
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); var level = requireAge(source);
String name = StringArgumentType.getString(context, "provider");
Identifier id = Identifier.tryParse(name.contains(":") ? name : "rivencraft:" + name);
if (id == null || !InstabilityProviderCatalog.contains(id)) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Unknown instability provider")).create();
var chunk = net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(source.getPosition()));
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null)
throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Current chunk must already be loaded")).create();
var budget = new PortInstabilityRuntime.Budget(1);
var effect = new PortInstabilityRuntime.Context(level, PortInstabilityData.get(level.getServer()).age(level),
java.util.List.of(chunk), source.getServer().getTickCount(), budget);
boolean attempted = PortInstabilityRuntime.invoke(effect, id, 1);
boolean applied = attempted && budget.applied() > 0;
if (applied) source.sendSuccess(() -> Component.literal("Applied " + id + " (" + budget.applied() + " successful action in this chunk)"), false);
else source.sendFailure(Component.literal("No effect applied. Check eligible targets/terrain, sky exposure, existing effects and protection. Provider status: "
+ PortInstabilityRuntime.providerStatus(level, id)));
return applied ? 1 : 0;
})))));
}
private static int decay(com.mojang.brigadier.context.CommandContext<CommandSourceStack> context, int attempts)
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
requireAge(context.getSource());
var color = java.util.Arrays.stream(net.dnorberg.rivencraft.block.DecayBlock.Color.values())
.filter(value -> value.getSerializedName().equalsIgnoreCase(StringArgumentType.getString(context, "color")))
.findFirst().orElse(null);
if (color == null) {
context.getSource().sendFailure(Component.literal("Choose blue, purple, red or white")); return 0;
}
int changed = PortDecayProviders.pulseForTesting(context.getSource().getPlayerOrException(), color, attempts);
if (changed < 0) {
context.getSource().sendFailure(Component.literal("Decay is disabled by config, Age or provider policy")); return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Decay " + color.getSerializedName() + ": " + changed
+ " block changes from " + attempts + " bounded attempts; saved score/plan unchanged"), false);
var player = context.getSource().getPlayerOrException();
var nearby = PortDecaySavedData.get(player.level()).frontier(color).reversed().stream()
.filter(pos -> net.minecraft.world.level.ChunkPos.containing(pos).equals(player.chunkPosition()))
.limit(4).map(BlockPos::toShortString).toList();
if (!nearby.isEmpty()) context.getSource().sendSuccess(() -> Component.literal("Nearby tracked decay: " + nearby), false);
return changed;
}
private static net.minecraft.server.level.ServerLevel requireAge(CommandSourceStack source)
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
if (!PortInstabilityRuntime.isAge(source.getLevel())) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(
Component.literal("Use this command inside a Rivencraft Age")).create();
return source.getLevel();
}
}
@@ -0,0 +1,147 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.common.ModConfigSpec;
/** The 1.21.1 scheduler and status-provider defaults, in the port's server-owned config. */
public final class PortInstabilityConfig {
private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay;
private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
private static ModConfigSpec.BooleanValue hazards, hazardBlocks, hazardPlayers, hazardFire;
private static ModConfigSpec.IntValue hazardInterval, hazardChunks, hazardMaxBlocks, hazardEntities, hazardDamage, hazardRadius;
private static ModConfigSpec.BooleanValue meteor, meteorBlocks, meteorPlayers, meteorFire;
private static ModConfigSpec.IntValue meteorMaxBlocks, meteorEntities, meteorDamage;
private static ModConfigSpec.BooleanValue scorch, scorchPlayers, erosion;
private static ModConfigSpec.IntValue scorchTargets;
private static ModConfigSpec.IntValue difficulty;
private static ModConfigSpec.ConfigValue<List<? extends String>> materialOverrides;
private PortInstabilityConfig() {}
public static void define(ModConfigSpec.Builder builder) {
builder.push("instability");
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
.define("enabled", true);
difficulty = builder.comment("Legacy instability difficulty: 0=quarter, 1=half, 2=normal, 3=1.75x; saved scores stay unchanged.")
.defineInRange("difficulty", 2, 0, 3);
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
.define("world_mutation", true);
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
ages = builder.defineInRange("max_ages_per_tick", 4, 1, 16);
providers = builder.defineInRange("max_providers_per_age", 2, 1, 16);
actions = builder.defineInRange("max_actions_per_age", 1, 1, 16);
level = builder.defineInRange("max_effect_level", 3, 1, 16);
chunks = builder.defineInRange("max_loaded_chunks", 16, 1, 64);
disabledProviders = builder.comment("Disable particular providers without changing saved selections. Accepts poison or rivencraft:poison.")
.defineListAllowEmpty("disabled_providers", List.of(), () -> "poison", value -> value instanceof String text
&& Identifier.tryParse(text) != null);
builder.pop();
builder.push("instability_status_effects");
status = builder.define("enabled", true);
players = builder.comment("Affect Survival/Adventure players; Creative and Spectator remain exempt.")
.define("affect_players", true);
nonplayers = builder.define("affect_nonplayers", true);
hostiles = builder.define("buff_hostiles", true);
active = builder.defineInRange("max_active_per_age", 32, 1, 128);
candidates = builder.defineInRange("max_candidates_per_attempt", 8, 1, 16);
duration = builder.defineInRange("max_duration_ticks", 200, 20, 200);
builder.pop();
builder.push("instability_decay");
decay = builder.comment("Allow colored decay in loaded Ages. Also requires instability.world_mutation.")
.define("enabled", true);
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
.defineInRange("max_attempts_per_call", 4, 1, 6);
builder.pop();
builder.push("instability_hazards");
hazards = builder.comment("Run Legacy lightning and explosion cards in loaded Ages.").define("enabled", true);
hazardBlocks = builder.comment("Allow block destruction; also requires world_mutation and mobGriefing.").define("block_damage", true);
hazardPlayers = builder.comment("Allow damage to Survival/Adventure players.").define("player_damage", true);
hazardFire = builder.comment("Allow ignition where the vanilla fire-spread rule permits it.").define("fire", true);
hazardInterval = builder.defineInRange("minimum_gap_ticks", 20, 20, 12000);
hazardChunks = builder.defineInRange("max_loaded_chunks", 81, 1, 169);
hazardMaxBlocks = builder.defineInRange("max_explosion_blocks", 16, 0, 64);
hazardEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
hazardDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
hazardRadius = builder.comment("Legacy strength 3, bounded to one already-loaded chunk.").defineInRange("explosion_radius", 3, 1, 3);
builder.pop();
builder.push("instability_meteor");
meteor = builder.comment("Run the active Legacy meteor card in loaded Ages.").define("enabled", true);
meteorBlocks = builder.comment("Allow impact block destruction; also requires world_mutation and mobGriefing.")
.define("block_damage", true);
meteorPlayers = builder.comment("Allow impacts to hurt Survival/Adventure players.").define("player_damage", true);
meteorFire = builder.comment("Allow impact fire where the vanilla fire-spread rule permits it.").define("fire", true);
meteorMaxBlocks = builder.defineInRange("max_impact_blocks", 24, 0, 64);
meteorEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
meteorDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
builder.pop();
builder.push("instability_scorching");
scorch = builder.comment("Run Legacy's active burning card on sky-exposed entities.").define("enabled", true);
scorchPlayers = builder.comment("Allow burning on Survival/Adventure players.").define("affect_players", true);
scorchTargets = builder.defineInRange("max_targets_per_pass", 4, 0, 16);
builder.pop();
builder.push("instability_erosion");
erosion = builder.comment("Enable Legacy's dormant erosion effect for testing; no saved card is added.",
"Ages with a score of at least 10000 can spread adjacent source fluid into one eligible block per pass.")
.define("enabled", true);
builder.pop();
builder.push("instability_modded_materials");
materialOverrides = builder.comment("Recognized ores, mineral blocks and source fluids from installed mods are scored automatically.",
"Optional exact modid:block_id=points overrides; zero excludes a block. No content mod is required.")
.defineListAllowEmpty("block_weight_overrides", List.of(), () -> "examplemod:rare_ore=4000", PortInstabilityConfig::validMaterialOverride);
builder.pop();
}
private static boolean validMaterialOverride(Object value) {
if (!(value instanceof String entry)) return false;
int separator = entry.lastIndexOf('=');
if (separator < 1) return false;
Identifier id = Identifier.tryParse(entry.substring(0, separator));
if (id == null || !entry.substring(0, separator).contains(":")
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
try { int weight = Integer.parseInt(entry.substring(separator + 1)); return weight >= 0 && weight <= 100000; }
catch (NumberFormatException ignored) { return false; }
}
public static int instabilityDifficulty() { return difficulty.get(); }
public static List<? extends String> moddedMaterialOverrides() { return materialOverrides.get(); }
public static boolean scorchEnabled() { return scorch.get(); }
public static boolean scorchPlayers() { return scorchPlayers.get(); }
public static int scorchMaxTargets() { return scorchTargets.get(); }
public static boolean erosionEnabled() { return erosion.get(); }
public static boolean meteorEnabled() { return meteor.get(); }
public static boolean meteorBlockDamage() { return meteorBlocks.get(); }
public static boolean meteorPlayerDamage() { return meteorPlayers.get(); }
public static boolean meteorFire() { return meteorFire.get(); }
public static int meteorMaxBlocks() { return meteorMaxBlocks.get(); }
public static int meteorMaxEntities() { return meteorEntities.get(); }
public static int meteorMaxDamage() { return meteorDamage.get(); }
public static boolean hazardsEnabled() { return hazards.get(); }
public static boolean hazardsBlockDamage() { return hazardBlocks.get(); }
public static boolean hazardsPlayerDamage() { return hazardPlayers.get(); }
public static boolean hazardsFire() { return hazardFire.get(); }
public static int hazardsMinInterval() { return hazardInterval.get(); }
public static int hazardsMaxChunks() { return hazardChunks.get(); }
public static int hazardsMaxBlocks() { return hazardMaxBlocks.get(); }
public static int hazardsMaxEntities() { return hazardEntities.get(); }
public static int hazardsMaxDamage() { return hazardDamage.get(); }
public static int hazardsExplosionRadius() { return hazardRadius.get(); }
public static boolean instabilityWorldMutation() { return worldMutation.get(); }
public static boolean decayEnabled() { return decay.get(); }
public static int decayMaxAttempts() { return decayAttempts.get(); }
public static boolean instabilityEnabled() { return enabled.get(); }
public static boolean instabilityProviderEnabled(Identifier id) {
return disabledProviders.get().stream().noneMatch(value -> value.equals(id.toString())
|| id.getNamespace().equals("rivencraft") && value.equals(id.getPath()));
}
public static int instabilityCooldown() { return cooldown.get(); }
public static int instabilityMaxAges() { return ages.get(); }
public static int instabilityMaxProviders() { return providers.get(); }
public static int instabilityMaxActions() { return actions.get(); }
public static int instabilityMaxLevel() { return level.get(); }
public static int instabilityMaxChunks() { return chunks.get(); }
public static boolean statusEffectsEnabled() { return status.get(); }
public static boolean statusEffectsPlayers() { return players.get(); }
public static boolean statusEffectsNonplayers() { return nonplayers.get(); }
public static boolean statusEffectsHostiles() { return hostiles.get(); }
public static int statusEffectsMaxActive() { return active.get(); }
public static int statusEffectsMaxCandidates() { return candidates.get(); }
public static int statusEffectsMaxDuration() { return duration.get(); }
}
@@ -0,0 +1,124 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Frozen creation-time contributions and Legacy deck selections. Never silently rescores older Ages. */
public final class PortInstabilityData extends SavedData {
public record Contribution(String reason, int page, int points) {
public static final Codec<Contribution> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("reason").forGetter(Contribution::reason),
Codec.INT.fieldOf("page").forGetter(Contribution::page),
Codec.intRange(0, Integer.MAX_VALUE).fieldOf("points").forGetter(Contribution::points)
).apply(instance, Contribution::new));
}
public record Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled, java.util.Optional<net.minecraft.core.BlockPos> spawn) {
public static final Codec<Age> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("seed").forGetter(Age::seed),
Contribution.CODEC.listOf().fieldOf("contributions").forGetter(Age::contributions),
PortInstabilityPlan.CODEC.fieldOf("plan").forGetter(Age::plan),
Codec.BOOL.optionalFieldOf("enabled", true).forGetter(Age::enabled),
net.minecraft.core.BlockPos.CODEC.optionalFieldOf("spawn").forGetter(Age::spawn)
).apply(instance, Age::new));
public Age { contributions = List.copyOf(contributions); spawn = spawn.map(net.minecraft.core.BlockPos::immutable); }
public Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled) {
this(seed, contributions, plan, enabled, java.util.Optional.empty());
}
public int score() { return contributions.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); }
}
private static final SavedDataType<PortInstabilityData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "instability"), () -> new PortInstabilityData(Map.of()),
Codec.unboundedMap(Identifier.CODEC, Age.CODEC).fieldOf("ages").codec()
.xmap(PortInstabilityData::new, data -> Map.copyOf(data.ages)));
private final Map<Identifier, Age> ages = new HashMap<>();
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
Age snapshot(Identifier id) { return ages.get(id); }
void restore(Identifier id, Age snapshot) {
if (snapshot == null) ages.remove(id); else ages.put(id, snapshot);
setDirty();
}
public Age age(ServerLevel level) {
Age age = ages.get(level.dimension().identifier());
return age != null ? age : new Age(level.getSeed(),
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
}
/** Older neutral Ages stay neutral even if an arrival or enabled flag was later saved. */
public boolean supportsProfiling(ServerLevel level) {
Age state = ages.get(level.dimension().identifier());
return state != null && state.contributions().stream().noneMatch(cost -> cost.reason().equals("predates_instability"));
}
public void enabled(ServerLevel level, boolean enabled) {
Age previous = age(level);
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
setDirty();
}
/** First resolved arrival remains protected without rescoring or rerolling the Age. */
public void recordSpawn(ServerLevel level, net.minecraft.core.BlockPos spawn) {
Age previous = age(level);
if (previous.spawn().isPresent()) return;
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(),
previous.enabled(), java.util.Optional.of(spawn.immutable())));
setDirty();
}
public void create(ServerLevel level, List<Identifier> pages, PortAgeTerrain terrain,
PortLighting lighting, PortDayCycle cycle) {
Identifier id = level.dimension().identifier();
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
List<Contribution> costs = new ArrayList<>();
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
for (var diagnostic : net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages).diagnostics())
costs.add(new Contribution("writing_" + diagnostic.code(), diagnostic.index(), diagnostic.points()));
boolean authoredBright = false;
Map<Identifier, Integer> structures = new HashMap<>();
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).toList();
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
if (!page.getNamespace().equals("rivencraft")) continue;
String path = page.getPath();
if (path.equals("lighting/bright")) { costs.add(new Contribution("bright_lighting", i, 500)); authoredBright = true; }
if (page.equals(PortAcceleratedTicks.PAGE)) costs.add(new Contribution("accelerated", i, 1000));
if (isCorrupted(page)) costs.add(new Contribution("corrupted_" + path.substring(10), i,
path.endsWith("small") ? 2000 : path.endsWith("medium") ? 1000 : 0));
// Legacy charges for sky/weather controllers that Cave terrain cannot apply.
if (terrain == PortAgeTerrain.CAVE && (PortWeatherMode.forPage(page) != null
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(page)) {
if (structures.merge(page, 1, Integer::sum) > 3)
costs.add(new Contribution("excess_" + path, i, 100));
}
if (PortStructurePages.supports(page)) {
int count = structures.merge(page, 1, Integer::sum);
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
var allowed = PortStructurePages.compatibleBiomes(page, level.registryAccess());
if (!allowed.isEmpty() && biomes.stream().anyMatch(biome -> !allowed.contains(biome)))
costs.add(new Contribution("forced_biome_" + path, i, 100));
}
}
if (lighting == PortLighting.BRIGHT && !authoredBright) costs.add(new Contribution("seeded_bright_lighting", -1, 500));
int suns = 0;
for (var body : cycle.bodies()) if (body.kind() == PortDayCycle.SUN && ++suns > 2)
costs.add(new Contribution("extra_sun_" + suns, -1, 1000 * (suns - 2)));
int score = costs.stream().mapToInt(Contribution::points).reduce(0, Math::addExact);
ages.put(id, new Age(level.getSeed(), costs, PortInstabilityPlan.select(score, level.getSeed()), true));
setDirty();
}
public static boolean isCorrupted(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "corrupted/large", "corrupted/medium", "corrupted/small" -> true;
default -> false;
};
}
}
@@ -0,0 +1,60 @@
package net.dnorberg.rivencraft.age;
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.resources.Identifier;
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
public record PortInstabilityPlan(int version, List<ProviderLevel> providers) {
public static final com.mojang.serialization.Codec<PortInstabilityPlan> CODEC =
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
com.mojang.serialization.Codec.INT.fieldOf("version").forGetter(PortInstabilityPlan::version),
ProviderLevel.CODEC.listOf().fieldOf("providers").forGetter(PortInstabilityPlan::providers)
).apply(instance, PortInstabilityPlan::new));
public static final int CURRENT_VERSION = 1;
public PortInstabilityPlan {
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 PortInstabilityPlan empty() { return new PortInstabilityPlan(CURRENT_VERSION, List.of()); }
public static PortInstabilityPlan select(int score, long ageSeed) {
int roundedScore = score / 500 * 500;
Map<Identifier, 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 PortInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
}
public record ProviderLevel(Identifier id, int level) {
public static final com.mojang.serialization.Codec<ProviderLevel> CODEC =
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("id").forGetter(ProviderLevel::id),
com.mojang.serialization.Codec.intRange(1, 255).fieldOf("level").forGetter(ProviderLevel::level)
).apply(instance, ProviderLevel::new));
public ProviderLevel {
Objects.requireNonNull(id, "id");
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
}
}
}
@@ -0,0 +1,178 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortInstabilityRuntime {
private static final Map<ServerLevel, CachedPlan> PLANS = new WeakHashMap<>();
private record CachedPlan(int score, long seed, PortInstabilityPlan plan) {}
private static final Map<Identifier, Handler> HANDLERS = new HashMap<>();
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
private PortInstabilityRuntime() {}
public static void register(Identifier id, ProviderEffect handler) {
register(id, false, handler);
}
public static void register(Identifier id, boolean worldMutating, ProviderEffect handler) {
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, handler)) != null)
throw new IllegalArgumentException("Duplicate instability provider " + id);
}
public static boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; }
public static PortInstabilityPlan plan(ServerLevel level) {
var age = PortInstabilityData.get(level.getServer()).age(level);
Integer test = testScore(level);
int profile = test == null ? PortBlockInstability.score(level) : 0;
int difficulty = PortInstabilityConfig.instabilityDifficulty();
if (test == null && difficulty == 2 && profile == 0) return age.plan();
int score = test != null ? test : PortBlockInstability.difficultyScore((long) age.score() + profile, difficulty);
var cached = PLANS.get(level);
if (cached == null || cached.score() != score || cached.seed() != age.seed()) {
cached = new CachedPlan(score, age.seed(), PortInstabilityPlan.select(score, age.seed()));
PLANS.put(level, cached);
}
return cached.plan();
}
public static Integer testScore(ServerLevel level) {
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
}
public static int effectiveScore(ServerLevel level) {
Integer test = testScore(level);
if (test != null) return test;
return PortBlockInstability.difficultyScore((long) PortInstabilityData.get(level.getServer()).age(level).score()
+ PortBlockInstability.score(level), PortInstabilityConfig.instabilityDifficulty());
}
public static void testScore(ServerLevel level, Integer score) {
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score);
}
@SubscribeEvent public static void tick(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level) || !isAge(level) || level.players().isEmpty()
|| !PortInstabilityConfig.instabilityEnabled()) return;
var server = level.getServer();
long tick = server.getTickCount();
int cooldown = PortInstabilityConfig.instabilityCooldown();
if (tick % cooldown != 0) return;
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
if (state.tick != tick) {
state.tick = tick;
var eligible = new ArrayList<Identifier>();
for (var loaded : server.getAllLevels()) if (isAge(loaded) && !loaded.players().isEmpty()
&& PortInstabilityData.get(server).age(loaded).enabled() && !plan(loaded).providers().isEmpty())
eligible.add(loaded.dimension().identifier());
eligible.sort(Comparator.comparing(Identifier::toString));
state.eligible = List.copyOf(eligible);
}
int index = state.eligible.indexOf(level.dimension().identifier());
if (index < 0 || state.eligible.size() > PortInstabilityConfig.instabilityMaxAges()
&& Math.floorMod(index - (int) Math.floorMod(tick / cooldown, state.eligible.size()), state.eligible.size())
>= PortInstabilityConfig.instabilityMaxAges()) return;
var age = PortInstabilityData.get(server).age(level);
if (!age.enabled()) return;
var providers = plan(level).providers();
PortHazardProviders.tickPlanned(level, age, tick, providers);
PortMeteorProvider.tickPlanned(level, age, tick, providers);
PortEnvironmentalProviders.tickPlanned(level, age, tick, providers);
var chunks = loadedChunks(level);
if (providers.isEmpty() || chunks.isEmpty()) return;
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders()
&& context.budget().remaining() > 0; offset++) {
var provider = providers.get((first + offset) % providers.size());
if (!PortHazardProviders.isHazard(provider.id()) && !provider.id().equals(PortMeteorProvider.METEORS)
&& !PortEnvironmentalProviders.isEnvironmental(provider.id()) && invoke(context, provider.id(), provider.level())) ran++;
}
}
public static boolean invoke(Context context, Identifier id, int level) {
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.instabilityProviderEnabled(id)
|| !PortInstabilityData.get(context.level().getServer()).age(context.level()).enabled()) return false;
Handler handler = HANDLERS.get(id);
if (handler == null || handler.worldMutating() && !PortInstabilityConfig.instabilityWorldMutation()) return false;
var failures = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>());
if (context.serverTick() < failures.getOrDefault(id, 0L)) return false;
try {
handler.effect().apply(context, id, Math.min(level, PortInstabilityConfig.instabilityMaxLevel()));
} catch (RuntimeException exception) {
failures.put(id, context.serverTick() + Math.max(200L, PortInstabilityConfig.instabilityCooldown() * 10L));
Rivencraft.LOGGER.error("Instability provider {} failed in {}; temporarily quarantined", id,
context.level().dimension().identifier(), exception);
}
return true;
}
public static String providerStatus(ServerLevel level, Identifier id) {
if (!HANDLERS.containsKey(id)) return "not ported yet";
if (!PortInstabilityConfig.instabilityEnabled()) return "global config off";
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off";
if (PortHazardProviders.isHazard(id)) {
if (!PortInstabilityConfig.hazardsEnabled()) return "hazards config off";
} else if (id.equals(PortMeteorProvider.METEORS)) {
if (!PortInstabilityConfig.meteorEnabled()) return "meteor config off";
} else if (id.equals(PortEnvironmentalProviders.BURNING)) {
if (!PortInstabilityConfig.scorchEnabled()) return "burning config off";
if (PortInstabilityConfig.scorchMaxTargets() == 0) return "burning target budget zero";
} else if (HANDLERS.get(id).worldMutating()) {
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
if (id.equals(PortEnvironmentalProviders.EROSION)) {
if (!PortInstabilityConfig.erosionEnabled()) return "erosion config off";
if (!PortHazardProviders.canMutate(level)) return "mobGriefing off";
if (effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()) return "erosion threshold not met";
}
} else {
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
if (!id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsPlayers()
&& !PortInstabilityConfig.statusEffectsNonplayers()) return "all general targets disabled";
}
if (level.getServer().getTickCount() < FAILURES.getOrDefault(level, Map.of()).getOrDefault(id, 0L)) return "quarantined";
return level.players().isEmpty() ? "no players in Age" : "eligible; sky, targets and budgets still apply";
}
static List<ChunkPos> loadedChunks(ServerLevel level) {
var chunks = new LinkedHashSet<ChunkPos>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
if (level.getChunkSource().getChunkNow(center.x() + x, center.z() + z) != null)
chunks.add(new ChunkPos(center.x() + x, center.z() + z));
if (chunks.size() >= PortInstabilityConfig.instabilityMaxChunks()) return List.copyOf(chunks);
}
}
return List.copyOf(chunks);
}
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); PLANS.clear();
}
public interface ProviderEffect { void apply(Context context, Identifier id, int level); }
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
long serverTick, Budget budget) { public Context { loadedChunks = List.copyOf(loadedChunks); } }
public static final class Budget {
private int remaining;
private int applied;
public Budget(int maximum) { remaining = maximum; }
public boolean claim() { if (remaining <= 0) return false; remaining--; return true; }
public int remaining() { return remaining; }
public void recordApplied() { applied++; }
public int applied() { return applied; }
}
private record Handler(boolean worldMutating, ProviderEffect effect) {}
private static final class TickState { long tick = Long.MIN_VALUE; List<Identifier> eligible = List.of(); }
}
@@ -0,0 +1,121 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import net.minecraft.commands.Commands;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
/** Explicit operator tools; normal generation does not search or force-load remote chunks. */
@EventBusSubscriber(modid = "rivencraft")
public final class PortLibraryCommands {
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
var root = Commands.literal("rivencraft_port").requires(Commands.hasPermission(Commands.LEVEL_ADMINS));
root.then(Commands.literal("library")
.then(Commands.literal("test_place").executes(context -> {
var level = context.getSource().getLevel();
var direction = context.getSource().getEntity() instanceof net.minecraft.world.entity.player.Player player
? player.getDirection() : net.minecraft.core.Direction.NORTH;
var result = PortLibraryFeature.testPlace(level, BlockPos.containing(context.getSource().getPosition()), direction,
level.getSeed(), PortLibraryWriting.get(level.getServer()).symbols(level.dimension().identifier()));
if (result.isEmpty()) { context.getSource().sendFailure(Component.literal("No clear placement nearby; use open ground with headroom.")); return 0; }
context.getSource().sendSuccess(() -> Component.literal("Library placed. Chest: " + result.get().toShortString()), false);
return 1;
}))
.then(Commands.literal("test_spacing").then(Commands.argument("chunks", IntegerArgumentType.integer(1, 32))
.executes(context -> {
var level = context.getSource().getLevel();
if (!RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier()))
throw new IllegalArgumentException("Use this test override inside a Rivencraft Age");
int spacing = IntegerArgumentType.getInteger(context, "chunks");
PortLibraryFeature.setTestSpacing(level.getSeed(), spacing);
context.getSource().sendSuccess(() -> Component.literal("Session-only library spacing: " + spacing
+ " chunks. Applies to newly generated chunks; 32 restores normal spacing."), false);
return 1;
})))
.then(Commands.literal("candidate").executes(context -> {
var level = context.getSource().getLevel();
var origin = ChunkPos.containing(BlockPos.containing(context.getSource().getPosition()));
ChunkPos best = null;
long distance = Long.MAX_VALUE;
for (int spacing : new int[]{32, PortLibraryFeature.spacing(level.getSeed())}) {
int rx = Math.floorDiv(origin.x(), spacing), rz = Math.floorDiv(origin.z(), spacing);
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
var candidate = PortLibraryFeature.candidateChunk(level.getSeed(), rx + dx, rz + dz, spacing);
long x = candidate.x() - origin.x(), z = candidate.z() - origin.z();
long d = x * x + z * z;
if (d < distance) { best = candidate; distance = d; }
}
}
var candidate = best;
context.getSource().sendSuccess(() -> Component.literal("Nearest candidate chunk " + candidate
+ ", center X=" + (candidate.getMinBlockX() + 8) + " Z=" + (candidate.getMinBlockZ() + 8)
+ ". A candidate is not a guaranteed library: footing, slope and clear space must pass."), false);
return 1;
}))
.then(Commands.literal("probe").executes(context -> {
var level = context.getSource().getLevel();
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
var chest = PortLibraryFeature.libraryChest(chunk);
if (chest.isPresent()) context.getSource().sendSuccess(() -> Component.literal("Generated library chest: " + chest.get().toShortString()), false);
else {
var check = PortLibraryFeature.check(level, chunk.getPos(), level.getSeed());
context.getSource().sendSuccess(() -> Component.literal(check.eligible() ? "Eligible library candidate, floor Y=" + check.floorY()
: "No library in this chunk: " + check.reason()), false);
}
return 1;
}))
.then(Commands.literal("inspect_loot").executes(context -> {
var level = context.getSource().getLevel();
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
var pos = PortLibraryFeature.libraryChest(chunk);
if (pos.isEmpty()) { context.getSource().sendFailure(Component.literal("No marked library chest in this chunk.")); return 0; }
var chest = (net.minecraft.world.level.block.entity.ChestBlockEntity) level.getBlockEntity(pos.get());
var items = new java.util.ArrayList<String>();
for (int i = 0; i < chest.getContainerSize(); i++) {
var item = chest.getItem(i); // Materializes loot exactly as opening the chest does.
if (!item.isEmpty()) items.add(item.getCount() + "x " + item.getHoverName().getString());
}
long stands = chunk.getBlockEntities().values().stream()
.filter(be -> be instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity).count();
context.getSource().sendSuccess(() -> Component.literal("Library at " + pos.get().toShortString()
+ "; stands in this chunk=" + stands + "; chest=" + items), false);
return 1;
}))
.then(Commands.literal("nearest_loaded").executes(context -> {
var player = context.getSource().getPlayerOrException();
var level = player.level();
var origin = player.chunkPosition();
BlockPos best = null; double distance = Double.MAX_VALUE;
for (int x = origin.x() - 16; x <= origin.x() + 16; x++) for (int z = origin.z() - 16; z <= origin.z() + 16; z++) {
var chunk = level.getChunkSource().getChunkNow(x, z);
if (chunk == null) continue;
var found = PortLibraryFeature.libraryChest(chunk);
if (found.isPresent()) {
double d = player.distanceToSqr(Vec3.atCenterOf(found.get()));
if (d < distance) { best = found.get(); distance = d; }
}
}
if (best == null) { context.getSource().sendFailure(Component.literal("No library in loaded chunks within 16 chunks. Use candidate or test_spacing to explore new terrain.")); return 0; }
var facing = level.getBlockState(best).getValue(ChestBlock.FACING);
var feet = best.relative(facing, 2).relative(facing.getCounterClockWise());
var target = Vec3.atBottomCenterOf(feet);
if (!level.getBlockState(feet.below()).isFaceSturdy(level, feet.below(), net.minecraft.core.Direction.UP)
|| !level.getFluidState(feet).isEmpty() || !level.getFluidState(feet.above()).isEmpty()
|| !level.noCollision(player, player.getBoundingBox().move(target.subtract(player.position())))) {
context.getSource().sendFailure(Component.literal("Library found at " + best.toShortString() + " but its aisle is obstructed.")); return 0;
}
player.teleportTo(level, target.x, target.y, target.z, java.util.Set.of(), facing.getOpposite().toYRot(), 0, true);
return 1;
})));
event.getDispatcher().register(root);
}
@SubscribeEvent public static void stopped(net.neoforged.neoforge.event.server.ServerStoppedEvent event) {
PortLibraryFeature.clearTestSpacing();
}
}
@@ -0,0 +1,363 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.Identifier;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
public final class PortLibraryFeature {
public static final int REGION = 32;
static final int FOOTPRINT = 11;
private static final int OFFSET_BOUND = 24;
private static final int MAX_SUPPORT_DEPTH = 24;
private static final int SALT = 14357617;
private static final int TEST_SALT = 0x4C494252;
private static final String CHEST_MARKER = "RivencraftLibrary";
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
// x, y, z, clockwise turns relative to the left stand. The middle row
// faces across the room; the far stand faces back toward the entrance.
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
{8, 1, 6, 1}, {6, 1, 8, 2}};
private static final Identifier TEMPLATE = Identifier.fromNamespaceAndPath(
"rivencraft", "mystcraft_library");
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
Identifier.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
private PortLibraryFeature() {}
record Site(int x, int z, int rotation) {}
public static int spacing(long seed) {
return TEST_SPACING.getOrDefault(seed, REGION);
}
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
public static void setTestSpacing(long seed, int chunks) {
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
if (chunks == REGION) TEST_SPACING.remove(seed);
else TEST_SPACING.put(seed, chunks);
}
public static void clearTestSpacing() {
TEST_SPACING.clear();
}
/** The single deterministic candidate in one Legacy-sized region. */
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
return candidateChunk(seed, regionX, regionZ, REGION);
}
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
return new ChunkPos(regionX * spacing + random.nextInt(bound),
regionZ * spacing + random.nextInt(bound));
}
static Site site(long seed, ChunkPos chunk) {
Site normal = site(seed, chunk, REGION);
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
}
private static Site site(long seed, ChunkPos chunk, int spacing) {
int rx = Math.floorDiv(chunk.x(), spacing);
int rz = Math.floorDiv(chunk.z(), spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, rx, rz,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
int candidateX = rx * spacing + random.nextInt(bound);
int candidateZ = rz * spacing + random.nextInt(bound);
if (chunk.x() != candidateX || chunk.z() != candidateZ) return null;
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
}
/** A saved marker distinguishes a generated library from player-built chests. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
return chunk.getBlockEntities().entrySet().stream()
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
&& chest.getPersistentData().getBooleanOr(CHEST_MARKER, false))
.map(java.util.Map.Entry::getKey).findFirst();
}
/** Recognize libraries generated before the chest marker was introduced. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
Optional<BlockPos> marked = libraryChest(chunk);
if (marked.isPresent()) return marked;
Site site = site(seed, chunk.getPos());
if (site == null) return Optional.empty();
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
int y = entry.getKey().getY();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
continue;
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
return Optional.of(entry.getKey());
}
return Optional.empty();
}
/** Open interior aisle, directly above the cobblestone floor. */
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
Site site = site(seed, chunk);
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
}
public record PlacementCheck(String reason, int floorY, int[][] heights) {
public boolean eligible() { return reason == null; }
}
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
Site site = site(seed, chunk);
if (site == null) return rejected("not a candidate chunk");
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int low = Integer.MAX_VALUE;
int high = Integer.MIN_VALUE;
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
int wx = site.x() + x;
int wz = site.z() + z;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
while (y > level.getMinY() + 2) {
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!below.isAir() && !isVegetation(below)) break;
y--;
}
if (y < level.getMinY() + 2) return rejected("no surface footing");
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
return rejected("ground is not sturdy terrain");
heights[x][z] = y;
low = Math.min(low, y);
high = Math.max(high, y);
}
if (high + 10 >= level.getMaxY()) return rejected("insufficient build height");
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
// Check the entire raised foundation and house. Foliage can be cleared, but not
// structures, fluids, or block entities already occupying this volume.
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = heights[x][z]; y <= high + 10; y++) {
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
return rejected("solid obstruction inside footprint: "
+ state.getBlock().getDescriptionId());
}
return new PlacementCheck(null, high, heights);
}
private static PlacementCheck rejected(String reason) {
return new PlacementCheck(reason, 0, null);
}
private static boolean isVegetation(BlockState state) {
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
}
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
List<Identifier> ageSymbols) {
Site site = site(seed, chunk.getPos());
if (site == null) return false;
PlacementCheck check = check(level, chunk.getPos(), seed);
if (!check.eligible()) return false;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
}
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
}
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
long seed, List<Identifier> ageSymbols) {
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
BlockPos center = playerPos.relative(horizontal, 13);
int rotation = switch (horizontal) {
case NORTH -> 2;
case EAST -> 3;
case WEST -> 1;
default -> 0;
};
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
int floorY = playerPos.getY() + 2;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
site.x() + x, site.z() + z) + 1);
if (floorY + 10 >= level.getMaxY() || floorY - playerPos.getY() > 20)
return Optional.empty();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = floorY; y <= floorY + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
return Optional.empty();
}
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
return Optional.empty();
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
}
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
long seed, List<Identifier> ageSymbols) {
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
var template = level.getLevel().getStructureTemplateManager().get(TEMPLATE).orElse(null);
if (template == null || template.getSize().getX() != FOOTPRINT
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
return false;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
}
Rotation rotation = switch (site.rotation()) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> Rotation.NONE;
};
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
var settings = new StructurePlaceSettings().setRotation(rotation)
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).pack();
if (!template.placeInWorld(level, origin, origin, settings,
RandomSource.create(seed ^ positionSalt), 2)) return false;
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
Direction.from2DDataValue(site.rotation())), 2);
PortConfig.LibraryLootMode mode = PortConfig.LIBRARY_LOOT_MODE.get();
boolean ageOnly = mode == PortConfig.LibraryLootMode.AGE_ONLY;
List<Identifier> symbols = symbolsForLoot(ageSymbols, mode);
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
if (ageOnly) {
// Pages are frozen in the Age generator, then saved as real chest items.
boolean[] occupied = new boolean[chest.getContainerSize()];
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
int slot;
do slot = random.nextInt(occupied.length); while (occupied[slot]);
occupied[slot] = true;
chest.setItem(slot, page(symbols, random));
}
} else {
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
}
}
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
if (!level.getBlockState(pos).isAir()) continue;
level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
.setValue(BookStandBlock.HAS_BOOK, true), 2);
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
}
}
return true;
}
static Direction standFacing(Site site, int clockwiseTurns) {
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
}
/** Correct older generated stands in place, retaining their block entities and pages. */
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
Site site = site(seed, chunk.getPos());
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
int changed = 0;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
BlockState state = level.getBlockState(pos);
if (!state.is(Rivencraft.BOOK_STAND.get())) continue;
Direction facing = standFacing(site, standCoords[3]);
if (state.getValue(BookStandBlock.FACING) != facing) {
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
changed++;
}
}
return changed;
}
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
return symbolsForLoot(ageSymbols, mode, PortRandomPagePolicy.configured());
}
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode,
PortRandomPagePolicy policy) {
return mode == PortConfig.LibraryLootMode.AGE_ONLY
? ageSymbols.stream().filter(policy::allows).filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
: PortSymbolPages.libraryCandidateIds().stream().filter(policy::allows).toList();
}
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
return PageItem.createSymbolPage(symbols.get(random.nextInt(symbols.size())));
}
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
return switch (site.rotation()) {
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
default -> cursor.set(site.x() + x, y, site.z() + z);
};
}
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
int x, int y, int z, BlockState state) {
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
}
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Written pages for library Age-only loot, kept outside the legacy backend index codec. */
final class PortLibraryWriting extends SavedData {
static final Codec<PortLibraryWriting> CODEC = Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC.listOf())
.fieldOf("ages").codec().xmap(PortLibraryWriting::new, data -> Map.copyOf(data.pages));
static final SavedDataType<PortLibraryWriting> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "library_writing"), () -> new PortLibraryWriting(Map.of()), CODEC);
private final Map<Identifier, List<Identifier>> pages = new HashMap<>();
private PortLibraryWriting(Map<Identifier, List<Identifier>> pages) { pages.forEach((id, list) -> this.pages.put(id, List.copyOf(list))); }
static PortLibraryWriting get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
List<Identifier> symbols(Identifier id) { return pages.getOrDefault(id, List.of()); }
void put(Identifier id, List<Identifier> symbols) {
if (symbols.isEmpty()) pages.remove(id); else pages.put(id, List.copyOf(symbols));
setDirty();
}
}
@@ -0,0 +1,133 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.level.LevelEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** The 1.21.1 meteor's short particle descent and bounded impact, without a new entity. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortMeteorProvider {
public static final Identifier METEORS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "meteors");
private static final int METEOR_FLIGHT_TICKS = 30;
private static final int MAX_ACTIVE_AGES = 16;
private static final Map<ServerLevel, Meteor> ACTIVE = new WeakHashMap<>();
private PortMeteorProvider() {}
public static void register() {
PortInstabilityRuntime.register(METEORS, false, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level())) scheduleMeteor(context, level, false);
});
}
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
var card = providers.stream().filter(provider -> provider.id().equals(METEORS)).findFirst();
if (card.isEmpty() || !PortInstabilityConfig.meteorEnabled()) return;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (!chunks.isEmpty()) PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks,
tick, new PortInstabilityRuntime.Budget(1)), METEORS, card.get().level());
}
/** Skip the chance roll only; retain per-Age policy, loading, active-flight and cooldown checks. */
public static int pulseForTesting(ServerPlayer player) {
ServerLevel level = player.level();
if (!PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|| !age.enabled() || !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) return 0;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
return scheduleMeteor(new PortInstabilityRuntime.Context(level, age, chunks,
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)), 1, true) ? 1 : 0;
}
private static boolean scheduleMeteor(PortInstabilityRuntime.Context context, int effectiveLevel, boolean force) {
if (!PortInstabilityConfig.meteorEnabled() || ACTIVE.containsKey(context.level())
|| ACTIVE.size() >= MAX_ACTIVE_AGES) return false;
PortHazardSavedData data = PortHazardSavedData.get(context.level());
long gameTime = context.level().getGameTime();
if (!data.meteorReady(gameTime, 200)) return false;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L
^ METEORS.hashCode());
if (!force && random.nextDouble() >= PortHazardProviders.legacyChance(context.loadedChunks().size(),
PortInstabilityConfig.instabilityCooldown(), effectiveLevel, 50000)) return false;
int start = random.nextInt(context.loadedChunks().size());
for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
int x = chunk.getMinBlockX() + 6 + random.nextInt(4);
int z = chunk.getMinBlockZ() + 6 + random.nextInt(4);
int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
BlockPos impact = new BlockPos(x, surfaceY, z);
if (surfaceY >= context.level().getMaxY() - 8
|| !PortHazardProviders.safeTarget(context, impact, 8)) continue;
if (!context.budget().claim()) return false;
int startY = Math.min(context.level().getMaxY() - 2, surfaceY + 48);
data.recordMeteor(gameTime);
ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS));
context.level().playSound(null, impact, Rivencraft.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F);
context.budget().recordApplied();
return true;
}
return false;
}
@SubscribeEvent
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level)) return;
Meteor meteor = ACTIVE.get(level);
if (meteor == null) return;
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|| !PortInstabilityRuntime.isAge(level)
|| level.players().isEmpty()
|| level.getChunkSource().getChunkNow(meteor.chunk().x(), meteor.chunk().z()) == null) {
ACTIVE.remove(level);
return;
}
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) {
ACTIVE.remove(level);
return;
}
int remaining = meteor.remaining() - 1;
if (remaining <= 0) {
ACTIVE.remove(level); // clear before impact so an exception cannot replay it
PortHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(),
PortInstabilityConfig.meteorBlockDamage(), PortInstabilityConfig.meteorPlayerDamage(),
PortInstabilityConfig.meteorFire(), PortInstabilityConfig.meteorMaxBlocks(),
PortInstabilityConfig.meteorMaxEntities(), PortInstabilityConfig.meteorMaxDamage(), 3);
return;
}
double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS;
double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress;
double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0;
double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0;
level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01);
level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02);
ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining));
}
@SubscribeEvent
public static void onServerStopping(ServerStoppingEvent event) {
ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart
}
@SubscribeEvent
public static void onLevelUnload(LevelEvent.Unload event) {
if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level);
}
private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
/** Disposable-world menu, ownership, codec and chest-persistence fixtures. */
final class PortNotebookProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortNotebookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
Container chest = (Container) level.getBlockEntity(CHEST);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("1043b9f3-f348-4a73-b585-e0de13fd5c27"), "RivenNotebookProbe"));
int output = 0;
try {
for (boolean folder : List.of(false, true)) for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.getInventory().setSelectedSlot(0);
ItemStack collection = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
player.setItemInHand(hand, collection);
var menu = new NotebookMenu(1, player.getInventory(), hand);
int capacity = CollationFolderItem.capacity(collection);
if (!menu.stillValid(player) || menu.pageCapacity() != capacity
|| menu.getSlot(0).mayPlace(PageItem.createLinkPanelPage())
|| menu.getSlot(0).mayPlace(new ItemStack(net.minecraft.world.item.Items.DIAMOND)))
throw new IllegalStateException("Notebook capacity/page acceptance regression");
ItemStack page = PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
player.getInventory().setItem(9, page.copyWithCount(3));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ContainerInput.QUICK_MOVE, player);
if (!player.getInventory().getItem(9).isEmpty() || contents(collection).pageCount() != 3)
throw new IllegalStateException("Notebook shift insertion regression");
for (int i = 0; i < 3; i++) menu.clicked(i, 0, ContainerInput.QUICK_MOVE, player);
if (contents(collection).pageCount() != 0) throw new IllegalStateException("Notebook shift extraction regression");
menu.getSlot(capacity - 1).set(page.copy());
if (contents(collection).pageCount() != 1 || !contents(collection).pages().get(0).isEmpty()
|| contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook sparse position regression");
if (!menu.clickMenuButton(player, NotebookMenu.MOVE_PREVIOUS_BASE + capacity - 1)
|| !contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook page movement regression");
if (folder && menu.getSlot(capacity).mayPlace(page))
throw new IllegalStateException("Folder exceeded 27 positions");
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS + 27, 0, ContainerInput.PICKUP, player);
if (player.getItemInHand(hand) != collection || !menu.getCarried().isEmpty())
throw new IllegalStateException("Notebook active item was movable");
var ops = RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, level.registryAccess());
var encoded = NotebookContents.CODEC.encodeStart(ops, contents(collection)).getOrThrow();
var decoded = NotebookContents.CODEC.parse(ops, encoded).getOrThrow();
var buffer = new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), level.registryAccess());
try {
NotebookContents.STREAM_CODEC.encode(buffer, decoded);
decoded = NotebookContents.STREAM_CODEC.decode(buffer);
if (decoded.pageCount() != 1 || decoded.pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook save/network codec regression");
} finally { buffer.release(); }
chest.setItem(output++, collection.copy());
player.setItemInHand(hand, ItemStack.EMPTY);
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
private static NotebookContents contents(ItemStack stack) {
return stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
for (int i = 0; i < 4; i++) {
var collection = chest.getItem(i);
int capacity = i < 2 ? 252 : 27;
if (CollationFolderItem.capacity(collection) != capacity || contents(collection).pageCount() != 1
|| contents(collection).pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook chest persistence regression: " + i);
}
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.age;
import com.mojang.authlib.GameProfile;
import net.minecraft.server.PlayerAdvancements;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixers;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.util.FakePlayer;
/** FakePlayer normally discards all advancement progress; fixtures need vanilla tracking. */
final class PortProgressionPlayer extends FakePlayer {
private PlayerAdvancements progress;
PortProgressionPlayer(ServerLevel level, GameProfile profile) { super(level, profile); }
@Override public PlayerAdvancements getAdvancements() {
if (progress == null) {
var server = level().getServer();
progress = new PlayerAdvancements(DataFixers.getDataFixer(), server.getPlayerList(),
server.getAdvancements(), server.getWorldPath(LevelResource.PLAYER_ADVANCEMENTS_DIR)
.resolve(getUUID() + ".json"), this);
}
return progress;
}
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable operator fixture for loaded advancement data and committed action awards. */
final class PortProgressionProbe {
/** Operator-fixture assertion; deliberately requires the real loaded advancement. */
static boolean isComplete(net.minecraft.server.level.ServerPlayer player, String path) {
var advancement = player.level().getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement == null) throw new IllegalStateException("Missing progression advancement: " + path);
return player.getAdvancements().getOrStartProgress(advancement).isDone();
}
static void recipeDiscovery(ServerLevel level) {
var materials = java.util.Map.of(
"ink_vial", Items.DYE.black(),
"ink_mixer", Items.GLASS_BOTTLE,
"book_binder", Items.IRON_INGOT,
"crystal_block", Rivencraft.CRYSTAL_SHARD.get(),
"notebook", Items.LEATHER,
"writing_desk", Items.FEATHER);
for (var entry : materials.entrySet()) {
var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", entry.getKey());
var key = net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, id);
var player = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
java.util.UUID.nameUUIDFromBytes(id.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)),
"RivenRecipe"));
var advancement = level.getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "recipes/misc/" + entry.getKey()));
if (advancement == null || level.getServer().getRecipeManager().byKey(key).isEmpty())
throw new IllegalStateException("Missing recipe/discovery data: " + id);
var progress = player.getAdvancements();
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player,
player.getInventory(), new ItemStack(Items.COBBLESTONE));
if (player.getRecipeBook().contains(key))
throw new IllegalStateException("Wrong material unlocked " + id);
var material = new ItemStack(entry.getValue());
player.getInventory().setItem(0, material);
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), material);
if (!player.getRecipeBook().contains(key) || !progress.getOrStartProgress(advancement).isDone())
throw new IllegalStateException("Material did not unlock " + id);
// The second criterion supports /recipe give without first obtaining its material.
var other = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
java.util.UUID.randomUUID(), "RivenRecipeGive"));
if (other.getRecipeBook().contains(key)) throw new IllegalStateException("Recipe leaked between players");
other.awardRecipes(java.util.List.of(level.getServer().getRecipeManager().byKey(key).orElseThrow()));
if (!other.getAdvancements().getOrStartProgress(advancement).isDone())
throw new IllegalStateException("Recipe unlock criterion did not complete " + id);
}
}
static void run(ServerLevel level) {
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("b428c3ef-57a6-43dc-95e8-441b6a171107"), "RivenProgression"));
var pos = new BlockPos(4, 64, 0);
player.setPos(4.5, 64, 2.5);
level.setBlock(pos, Rivencraft.INK_MIXER.get().defaultBlockState(), 3);
var mixer = (InkMixerBlockEntity) level.getBlockEntity(pos);
try {
// Every tree node must decode; portal advancement waits for its missing item/feature port.
for (String path : new String[]{"root", "page", "linking_book", "link_panel", "copy_page", "bind_age", "enter_age"})
if (isComplete(player, path))
throw new IllegalStateException("Fixture requires fresh player advancement data");
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
if (isComplete(player, "link_panel"))
throw new IllegalStateException("Empty Mixer awarded progression");
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
menu.broadcastChanges();
if (isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer preview awarded progression");
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
if (!isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer pickup did not award progression");
menu.setCarried(ItemStack.EMPTY);
var advancement = level.getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "progression/link_panel"));
player.getAdvancements().revoke(advancement, "completed");
menu.quickMoveStack(player, InkMixerMenu.RESULT);
if (!isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer shift pickup did not award progression");
ProgressionAdvancements.awardAgeTravel(player);
if (!isComplete(player, "enter_age"))
throw new IllegalStateException("Registered Age did not award entry");
} finally {
player.getInventory().clearContent();
level.removeBlock(pos, false);
}
}
}
@@ -17,32 +17,35 @@ public final class PortRandomAgeSelection {
public record Selection(PortAgeTerrain terrain, PortBiomePlan biomes) {}
public static Selection select(MinecraftServer server, long seed) {
return select(server, seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(MinecraftServer server, long seed, PortRandomPagePolicy policy) {
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
// the other terrain families are rarer. The sorted order is deliberate.
PortAgeTerrain terrain = weighted(new Random(seed ^ 0x5445525241494E4CL),
List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID),
new int[]{1, 1, 5, 1, 6, 1});
PortAgeTerrain terrain = terrain(seed, policy);
if (terrain == PortAgeTerrain.VOID)
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
List.of(Identifier.withDefaultNamespace("the_void"))));
PortBiomePlan.Distribution distribution = weighted(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
PortBiomePlan.Distribution distribution = distribution(seed, policy);
List<Identifier> eligible = eligibleBiomes(server, terrain);
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
// 26.3 has no native multi-cave biome source; choose a seeded Cave
// biome rather than silently using Lush Caves for every random Age.
if (terrain == PortAgeTerrain.CAVE && distribution == PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
if (distribution != PortBiomePlan.Distribution.NATIVE) {
var filtered = new ArrayList<>(policy.eligible(eligible, PortRegistryPages::biomePageId));
// Native presets are not Page-selected. Other controllers retain their minimum,
// with the same preferred-biome/registry fallback as 1.21.1.
int minimum = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
Identifier preferred = Identifier.withDefaultNamespace(terrain == PortAgeTerrain.CAVE ? "deep_dark" : "plains");
if (filtered.size() < minimum && eligible.contains(preferred) && !filtered.contains(preferred))
filtered.add(preferred);
for (var candidate : eligible)
if (filtered.size() < minimum && !filtered.contains(candidate)) filtered.add(candidate);
eligible = List.copyOf(filtered);
if (eligible.size() < 2) distribution = PortBiomePlan.Distribution.SINGLE;
}
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new Selection(terrain, new PortBiomePlan(distribution, List.of()));
return new Selection(terrain, new PortBiomePlan(distribution,
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
int count = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
if (count > 1) {
@@ -54,29 +57,65 @@ public final class PortRandomAgeSelection {
return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count)));
}
public static List<PortAgeTerrain> terrains() {
return List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID);
}
public static PortAgeTerrain terrain(long seed) {
return terrain(seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortAgeTerrain terrain(long seed, PortRandomPagePolicy policy) {
return ranked(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{3, 3, 2, 3, 1, 3},
policy, value -> PortRandomPagePolicy.page("terrain", value), PortAgeTerrain.STANDARD);
}
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
public static PortBiomePlan.Distribution distribution(long seed) {
return distribution(seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortBiomePlan.Distribution distribution(long seed, PortRandomPagePolicy policy) {
return ranked(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{1, 1, 1, 2, 2, 2, 2, 2, 2}, policy,
value -> PortRandomPagePolicy.page("biome", value), PortBiomePlan.Distribution.NATIVE);
}
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
if (terrain == PortAgeTerrain.VOID) return List.of(Identifier.withDefaultNamespace("the_void"));
if (terrain == PortAgeTerrain.CAVE) {
List<Identifier> caves = server.registryAccess().lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome)
.map(holder -> holder.key().identifier())
.sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
return caveBiomes(server.registryAccess());
}
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
.map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList();
.map(key -> key.identifier()).filter(id -> !id.equals(PortRegistryPages.DEFERRED_PLACEHOLDER))
.distinct().sorted(Comparator.naturalOrder()).toList();
surface = java.util.stream.Stream.concat(surface.stream(),
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.overworld(server.registryAccess()).stream())
.distinct().sorted().toList();
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
}
static <T> T weighted(Random random, List<T> choices, int[] weights) {
int total = 0;
for (int weight : weights) total += weight;
int ticket = random.nextInt(total);
for (int index = 0; index < choices.size(); index++) {
ticket -= weights[index];
if (ticket < 0) return choices.get(index);
}
throw new IllegalStateException("Random Age ticket was not consumed");
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
}
private static <T> T ranked(Random random, List<T> choices, int[] ranks, PortRandomPagePolicy policy,
java.util.function.Function<T, Identifier> page, T fallback) {
var options = new ArrayList<PortRankedChoice.Option<T>>();
for (int i = 0; i < choices.size(); i++)
options.add(new PortRankedChoice.Option<>(choices.get(i), ranks[i]));
var eligible = policy.eligible(options, option -> page.apply(option.value()));
return eligible.isEmpty() ? fallback : PortRankedChoice.choose(random, eligible);
}
}
@@ -14,16 +14,25 @@ public final class PortRandomFeatureSelection {
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes) {
var base = select(seed, terrain);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes);
return select(server, seed, terrain, biomes, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes, PortRandomPagePolicy policy) {
var base = select(seed, terrain, policy);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes, policy);
var plan = base.terrain();
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
structures.structures(), plan.carvers(), true);
structures.structures(), plan.carvers(), true, plan.ruins());
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
}
public static Selection select(long seed, PortAgeTerrain terrain) {
return select(seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(long seed, PortAgeTerrain terrain, PortRandomPagePolicy policy) {
var random = new Random(seed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT));
@@ -31,12 +40,13 @@ public final class PortRandomFeatureSelection {
for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks",
"surface_lakes", "deep_lakes", "lava_lakes")) {
var page = id("feature/" + name);
if (PortAuthoredWriting.supports(List.of(terrainPage, page)))
if (policy.allows(page) && PortAuthoredWriting.supports(List.of(terrainPage, page)))
candidates.add(new PortRankedChoice.Option<>(page, rank(name)));
}
var pages = new ArrayList<Identifier>();
pages.add(terrainPage);
List<Identifier> solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds();
List<Identifier> solids = policy.eligible(PortRegistryPages.solidMaterialIds(), PortRegistryPages::blockPageId);
List<Identifier> fluids = policy.eligible(PortRegistryPages.fluidMaterialIds(), PortRegistryPages::blockPageId);
for (int selected = 0; selected < count && !candidates.isEmpty();) {
Identifier feature = PortRankedChoice.choose(random, candidates);
candidates.removeIf(option -> option.value().equals(feature));
@@ -46,12 +56,12 @@ public final class PortRandomFeatureSelection {
pages.add(feature);
selected++;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
if (policy.allows(PortTerrainFeaturePlan.SKYLANDS) && terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
&& Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0)
pages.add(PortTerrainFeaturePlan.SKYLANDS);
if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
if (policy.allows(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS) && terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS);
if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
if (policy.allows(PortTerrainFeaturePlan.STAR_FISSURES) && randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0);
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import net.dnorberg.rivencraft.PortConfig;
import net.minecraft.resources.Identifier;
/** Creation-time eligibility; the resolved Age settings are saved, never reselected on reload. */
public record PortRandomPagePolicy(Set<Identifier> allowlist, Set<Identifier> blocklist) {
public static final PortRandomPagePolicy UNRESTRICTED = new PortRandomPagePolicy(Set.of(), Set.of());
public PortRandomPagePolicy {
allowlist = Set.copyOf(allowlist);
blocklist = Set.copyOf(blocklist);
}
public static PortRandomPagePolicy configured() {
return new PortRandomPagePolicy(parse(PortConfig.RANDOM_PAGE_ALLOWLIST.get()),
parse(PortConfig.RANDOM_PAGE_BLOCKLIST.get()));
}
private static Set<Identifier> parse(List<? extends String> ids) {
return ids.stream().map(PortRandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
}
/** Server config IDs may omit the built-in Rivencraft namespace. */
public static Identifier parsePageId(String value) {
Identifier id = Identifier.tryParse(value.contains(":") ? value : "rivencraft:" + value);
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
return id;
}
public boolean allows(Identifier 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, Identifier> pageId) {
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
}
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, Identifier> pageId,
T fallback) {
List<T> eligible = eligible(candidates, pageId);
return eligible.isEmpty() ? List.of(fallback) : eligible;
}
private static String category(Identifier 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 static Identifier page(String category, Enum<?> value) {
return Identifier.fromNamespaceAndPath("rivencraft", category + "/"
+ value.name().toLowerCase(java.util.Locale.ROOT));
}
}
@@ -19,6 +19,11 @@ public final class PortRandomStructureSelection {
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) {
return select(server, seed, terrain, plan, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan,
PortRandomPagePolicy policy) {
Set<Identifier> biomes = actualBiomes(server, terrain, plan);
var eligible = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages",
@@ -32,7 +37,7 @@ public final class PortRandomStructureSelection {
.get(ResourceKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent()
: PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess());
if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
if (allowed && policy.allows(feature)) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
}
var random = new Random(seed ^ 0x5354525543545552L);
int count = random.nextInt(3), dungeons = 0;
@@ -47,7 +52,7 @@ public final class PortRandomStructureSelection {
}
private static Set<Identifier> actualBiomes(MinecraftServer server, PortAgeTerrain terrain, PortBiomePlan plan) {
if (plan != null && plan.distribution() != PortBiomePlan.Distribution.NATIVE)
if (plan != null && !plan.biomes().isEmpty())
return Set.copyOf(plan.biomes());
if (terrain == PortAgeTerrain.FLAT) return Set.of(Identifier.withDefaultNamespace("plains"));
if (terrain == PortAgeTerrain.VOID) return Set.of(Identifier.withDefaultNamespace("the_void"));
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
/** Called only after successful Descriptive Book travel and its Disarm effect. */
public final class PortReturnLinks {
private PortReturnLinks() {}
public static void afterSuccessfulTravel(ServerPlayer player, Identifier age, LinkBookTarget departure) {
var mode = PortConfig.RETURN_LINK_MODE.get();
if (mode == PortConfig.ReturnLinkMode.DISABLED) return;
var storage = player.level().getServer().overworld().getDataStorage();
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
if (!issue) return;
ItemStack book = createBook(departure);
var source = player.level().getServer().getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, departure.dimension()));
if (source != null) book.set(Rivencraft.LINK_DESTINATION_NAME.get(),
net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(source));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
// Persist the once-per-Age marker before the next arrival can issue another.
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
storage.saveAndJoin();
}
static boolean shouldIssue(PortConfig.ReturnLinkMode mode, PortAgeVisits visits, Identifier age) {
return switch (mode) {
case DISABLED -> false;
case ALWAYS -> true;
case ON_CREATION -> visits.markFirstVisit(age);
};
}
static ItemStack createBook(LinkBookTarget departure) {
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
book.set(Rivencraft.LINK_BOOK_TARGET.get(), departure);
book.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
return book;
}
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.age;
import java.util.*;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Frozen per-Age season choice, separate from the backend's legacy index codec. */
public final class PortSeasonPolicies extends SavedData {
public enum Mode {
NO_SEASONS, SEASONS, ETERNAL_WINTER, ETERNAL_SUMMER, ETERNAL_FALL, ETERNAL_SPRING;
public boolean eternal() { return this != NO_SEASONS && this != SEASONS; }
}
public record Policy(Mode mode, boolean authored) {
static final Codec<Policy> CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.xmap(Mode::valueOf, Mode::name).fieldOf("mode").forGetter(Policy::mode),
Codec.BOOL.fieldOf("authored").forGetter(Policy::authored)).apply(i, Policy::new));
}
private static final SavedDataType<PortSeasonPolicies> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "season_policies"), () -> new PortSeasonPolicies(Map.of()),
Codec.unboundedMap(Identifier.CODEC, Policy.CODEC).fieldOf("ages").codec()
.xmap(PortSeasonPolicies::new, data -> Map.copyOf(data.policies)));
private final Map<Identifier, Policy> policies = new HashMap<>();
private PortSeasonPolicies(Map<Identifier, Policy> values) { policies.putAll(values); }
public static PortSeasonPolicies get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
Policy snapshot(Identifier id) { return policies.get(id); }
void put(Identifier id, Policy policy) { if (policy == null) policies.remove(id); else policies.put(id, policy); setDirty(); }
public Policy forLevel(ServerLevel level) {
if (level instanceof PortAgeLevel age && age.caveTerrain()) return new Policy(Mode.NO_SEASONS, true);
// Existing Ages retain cycling behavior when integration is installed later.
return policies.getOrDefault(level.dimension().identifier(), new Policy(Mode.SEASONS, false));
}
public static Mode forPage(Identifier id) {
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("season/")) return null;
try { return Mode.valueOf(id.getPath().substring(7).toUpperCase(Locale.ROOT)); }
catch (IllegalArgumentException e) { return null; }
}
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed) {
return select(pages, seed, terrain, installed, PortRandomPagePolicy.UNRESTRICTED);
}
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed, PortRandomPagePolicy policy) {
if (terrain == PortAgeTerrain.CAVE) return new Policy(Mode.NO_SEASONS, true);
Mode written = null;
for (var page : pages) if (forPage(page) != null) written = forPage(page);
if (written != null) {
if (!installed && written != Mode.NO_SEASONS) throw new IllegalArgumentException("Seasonal pages require Serene Seasons");
return new Policy(written, true);
}
if (!installed) return null;
// 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3.
var modes = List.of(new PortRankedChoice.Option<>(Mode.SEASONS, 1),
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2),
new PortRankedChoice.Option<>(Mode.ETERNAL_WINTER, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_SUMMER, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_FALL, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_SPRING, 3));
Mode mode = PortRankedChoice.choose(new Random(seed ^ 0x5E450A5E5EED21L),
policy.required(modes, option -> PortRandomPagePolicy.page("season", option.value()),
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2)));
return new Policy(mode, false);
}
}
@@ -26,9 +26,13 @@ public enum PortSkyStyle {
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return resolve(pages, seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed, PortRandomPagePolicy policy) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
? OVERWORLD : random(seed));
? OVERWORLD : random(seed, policy));
}
/** Nether-style Ages use their fog clear color as the visible backdrop. */
@@ -50,11 +54,17 @@ public enum PortSkyStyle {
};
}
private static PortSkyStyle random(long seed) {
private static PortSkyStyle random(long seed, PortRandomPagePolicy policy) {
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
// likely INHERITED and OVERWORLD slots currently share the native
// Overworld presentation here. Do not add unported colors/bodies.
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
var styles = policy.required(java.util.stream.IntStream.range(0, 5).boxed()
.filter(slot -> slot != 0 || !policy.restricts("sky")).toList(),
slot -> Identifier.fromNamespaceAndPath("rivencraft", "sky/" + switch (slot) {
case 0 -> "inherited"; case 1 -> "overworld"; case 2 -> "nether";
case 3 -> "end"; default -> "static";
}), 0);
int choice = styles.get(new Random(seed ^ 0x534B5950524F464CL).nextInt(styles.size()));
return switch (choice) {
case 0, 1 -> OVERWORLD;
case 2 -> NETHER;
@@ -0,0 +1,233 @@
package net.dnorberg.rivencraft.age;
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.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
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. */
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortStatusEffectProviders {
private static final int MAX_TRACKED_AGES = 64;
private static final Map<Identifier, Spec> SPECS = createSpecs();
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
private PortStatusEffectProviders() {}
public static void register() {
SPECS.keySet().forEach(id -> PortInstabilityRuntime.register(id, PortStatusEffectProviders::apply));
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
if (!PortInstabilityConfig.statusEffectsEnabled()
|| !PortInstabilityRuntime.isAge(context.level())) return;
Spec spec = SPECS.get(id);
if (spec == null || spec.target() == Target.HOSTILE && !PortInstabilityConfig.statusEffectsHostiles()
|| spec.target() == Target.GENERAL && !PortInstabilityConfig.statusEffectsPlayers()
&& !PortInstabilityConfig.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() >= PortInstabilityConfig.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().getMinY(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, context.level().getMaxY(), chunk.getMaxBlockZ() + 1);
int duration = Math.min(spec.legacyDuration(), PortInstabilityConfig.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,
PortInstabilityConfig.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;
context.budget().recordApplied();
leases.add(new Lease(id, 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 && PortInstabilityConfig.statusEffectsPlayers()
&& !player.isCreative() && !player.isSpectator();
}
return target == Target.GENERAL ? PortInstabilityConfig.statusEffectsNonplayers()
: entity instanceof Monster && PortInstabilityConfig.statusEffectsHostiles();
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerTickPost(ServerTickEvent.Post event) {
if (event.getServer().getTickCount() % 20 != 0) return;
boolean disabled = !PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.statusEffectsEnabled();
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) {
prune(entry.getKey(), entry.getValue(), tick, disabled
|| !PortInstabilityData.get(entry.getKey().getServer()).age(entry.getKey()).enabled());
}
LEASES.values().removeIf(List::isEmpty);
}
@net.neoforged.bus.api.SubscribeEvent
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
Entity entity = event.getEntity();
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
clearEntity(living);
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
@net.neoforged.bus.api.SubscribeEvent
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
@net.neoforged.bus.api.SubscribeEvent
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();
}
public static void clearAge(ServerLevel level) {
var leases = LEASES.remove(level);
if (leases != null) prune(level, leases, level.getServer().getTickCount(), true);
}
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 || !PortInstabilityConfig.instabilityProviderEnabled(lease.provider())
|| 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(Identifier id) {
Spec spec = SPECS.get(id);
return spec == null ? -1 : spec.legacyDuration();
}
static boolean localRequiresSky(Identifier id) {
Spec spec = SPECS.get(id);
return spec != null && !spec.global();
}
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, Identifier id) {
Spec spec = SPECS.get(id);
return spec != null && eligible(level, entity, spec,
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
}
private static Map<Identifier, Spec> createSpecs() {
Map<Identifier, Spec> specs = new HashMap<>();
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
addPair(specs, "fatigue", MobEffects.MINING_FATIGUE, 80);
addPair(specs, "hunger", MobEffects.HUNGER, 80);
addPair(specs, "nausea", MobEffects.NAUSEA, 60);
addPair(specs, "poison", MobEffects.POISON, 80);
addPair(specs, "slow", MobEffects.SLOWNESS, 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.RESISTANCE, 200, true, Target.HOSTILE));
return Map.copyOf(specs);
}
private static void addPair(Map<Identifier, 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 Identifier id(String path) {
return Identifier.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(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target, int amplifier, int originalDuration) {
private Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target) {
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
}
}
}
@@ -95,11 +95,22 @@ public final class PortStructurePages {
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)));
if (set.isEmpty()) return false;
var compatible = set.get().value().structures().stream()
var compatible = compatibleBiomes(set.get().value());
return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains);
}
public static Set<Identifier> compatibleBiomes(Identifier page, HolderLookup.Provider registries) {
if (!supports(page)) return Set.of();
return registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.map(holder -> compatibleBiomes(holder.value())).orElse(Set.of());
}
private static Set<Identifier> compatibleBiomes(StructureSet set) {
return set.structures().stream()
.flatMap(entry -> entry.structure().value().biomes().stream())
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier())
.collect(java.util.stream.Collectors.toSet());
return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains);
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
@@ -10,7 +10,7 @@ import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) {
boolean structureControl, List<Identifier> ruins) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
@@ -32,7 +32,8 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl),
Identifier.CODEC.listOf().optionalFieldOf("ruins", List.of()).forGetter(PortTerrainFeaturePlan::ruins)
).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) {
@@ -50,6 +51,9 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
}
public PortTerrainFeaturePlan {
ruins = List.copyOf(ruins);
if (ruins.size() > 252 || ruins.stream().anyMatch(id -> !net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id)))
throw new IllegalArgumentException("Invalid Ruins template pages");
choices = List.copyOf(choices);
structures = List.copyOf(structures);
carvers = List.copyOf(carvers);
@@ -57,7 +61,7 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl)
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl && ruins.isEmpty())
throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) {
@@ -66,6 +70,12 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
}
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) {
this(choices, skylands, denseOres, starFissures, structures, carvers, structureControl, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
@@ -0,0 +1,100 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable server fixture for copying transactions, page moves and two-part removal. */
final class PortWritingDeskProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
private PortWritingDeskProbe() {}
private static ItemStack page(String path) { return PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", path)); }
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
player.setPos(0.5, 64, 2.5);
// New Minecraft drops Container block-entity contents during removal.
// Removing either half must remove its partner and drop contents once.
for (Direction facing : Direction.Plane.HORIZONTAL) for (boolean extension : List.of(false, true)) {
var state = Rivencraft.WRITING_DESK.get().defaultBlockState().setValue(WritingDeskBlock.FACING, facing);
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
BlockPos other = POS.relative(facing.getClockWise());
if (!level.getBlockState(other).getValue(WritingDeskBlock.EXTENSION))
throw new IllegalStateException("Desk extension not placed");
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 3));
level.destroyBlock(extension ? other : POS, true);
if (level.getBlockState(POS).is(Rivencraft.WRITING_DESK.get()) || level.getBlockState(other).is(Rivencraft.WRITING_DESK.get()))
throw new IllegalStateException("Desk partner survived removal");
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-3, 60, -3, 4, 69, 4));
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
int desks = drops.stream().filter(e -> e.getItem().is(Rivencraft.WRITING_DESK_ITEM.get())).mapToInt(e -> e.getItem().getCount()).sum();
if (paper != 3 || desks != 1) throw new IllegalStateException("Desk removal duplicated/lost drops: " + paper + "/" + desks);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
var state = Rivencraft.WRITING_DESK.get().defaultBlockState();
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
try {
if (desk.canPlaceItem(0, PageItem.createLinkPanelPage())) throw new IllegalStateException("Desk accepted Link Panel");
desk.setItem(0, page("terrain/flat"));
var folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page("weather/off"))));
desk.setItem(1, folder);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
if (desk.inkRemaining() != 16 || !desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE))
throw new IllegalStateException("Desk ink exchange regression");
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
if (menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty() || desk.inkRemaining() != 16)
throw new IllegalStateException("Desk virtual result consumed inputs");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ContainerInput.PICKUP, player);
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
if (!PortProgressionProbe.isComplete(player, "copy_page"))
throw new IllegalStateException("Missing copy_page award");
menu.setCarried(ItemStack.EMPTY);
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
if (!menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty() || desk.inkRemaining() != 15)
throw new IllegalStateException("Full inventory consumed copy inputs");
player.getInventory().clearContent();
if (!menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 0, 1, 26)))
throw new IllegalStateException("Desk sparse page movement failed");
if (!menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 26, -1)) || menu.getCarried().isEmpty())
throw new IllegalStateException("Desk page extraction failed");
menu.setCarried(ItemStack.EMPTY);
for (int i = 0; i < 21; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
if (menu.firstVisibleTab() != 84 || !menu.clickMenuButton(player, 87)) throw new IllegalStateException("88 desk tabs not reachable");
desk.setItem(87, page("lighting/bright"));
menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE);
menu.broadcastChanges();
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof WritingDeskBlockEntity desk)
|| desk.activeCollection() != 87 || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(87).isEmpty()
|| desk.displayPage().isEmpty()) throw new IllegalStateException("Desk persistence regression");
}
}
@@ -149,22 +149,130 @@ public final class RuntimeAgeBackend {
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, weather, lighting, sky, colors, dayCycle, List.of());
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
weather, lighting, sky, colors, dayCycle, librarySymbols, null);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols, String displayName) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
weather, lighting, sky, colors, dayCycle, librarySymbols, displayName, PortRandomPagePolicy.UNRESTRICTED);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols, String displayName, PortRandomPagePolicy policy) {
checkServerThread(server);
ageName(id);
String name = ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index.
server.overworld().getDataStorage().saveAndJoin();
if (index.contains(id) || server.getLevel(dimensionKey(id)) != null)
throw new IllegalArgumentException("Age already exists: " + id);
Path directory = archivePaths(server, name).live();
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS))
throw new IllegalArgumentException("Age storage already exists: " + id);
PortLibraryWriting writing = PortLibraryWriting.get(server);
List<Identifier> priorWriting = writing.symbols(id);
PortInstabilityData instability = PortInstabilityData.get(server);
PortInstabilityData.Age priorInstability = instability.snapshot(id);
PortSeasonPolicies seasons = PortSeasonPolicies.get(server);
PortSeasonPolicies.Policy priorSeason = seasons.snapshot(id);
ServerLevel level = null;
try {
seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain,
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available(), policy));
writing.put(id, librarySymbols);
level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather);
int accelerated = PortAcceleratedTicks.pageCount(librarySymbols);
if (accelerated > 0) PortAcceleratedTicks.configure(level, accelerated);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
instability.create(level, librarySymbols, terrain, lighting, dayCycle);
if (displayName != null) index.setDisplayName(id, displayName);
// Commit all initial metadata before announcing an enterable Age.
level.getDataStorage().saveAndJoin();
server.overworld().getDataStorage().saveAndJoin();
} catch (RuntimeException failure) {
boolean closed = !(failure instanceof FailedAgeCleanupException);
if (level != null) {
try { closeFailedCreation(server, level); }
catch (RuntimeException cleanup) { closed = false; failure.addSuppressed(cleanup); }
}
index.discardCreation(id);
writing.put(id, priorWriting);
instability.restore(id, priorInstability);
seasons.put(id, priorSeason);
try { server.overworld().getDataStorage().saveAndJoin(); }
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
// Only the exact, previously absent directory belongs to this attempt.
// If closing failed, leave its files in place rather than race a writer.
if (closed) {
try { removeFailedCreationDirectory(server, name, directory); }
catch (IOException | RuntimeException cleanup) { failure.addSuppressed(cleanup); }
}
throw failure;
}
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.apply(level);
AgeCatalogSync.onCatalogChanged(server);
return level;
}
private static final class FailedAgeCleanupException extends IllegalStateException {
FailedAgeCleanupException(RuntimeException cause) { super("Failed Age stores could not close; files retained", cause); }
}
private static void closeFailedCreation(MinecraftServer server, ServerLevel level) {
if (!level.players().isEmpty()) throw new IllegalStateException("Failed Age contains players: " + level.dimension().identifier());
RuntimeException error = null;
try { level.getChunkSource().deactivateTicketsOnClosing(); }
catch (RuntimeException failure) { error = failure; }
try { level.close(); }
catch (IOException | RuntimeException failure) {
if (error == null) error = new IllegalStateException("Could not close failed Age", failure);
else error.addSuppressed(failure);
} finally {
server.levels.remove(level.dimension(), level);
try { NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); }
catch (RuntimeException failure) {
if (error == null) error = failure; else error.addSuppressed(failure);
}
}
if (error != null) throw error;
}
private static void removeFailedCreationDirectory(MinecraftServer server, String name, Path directory) throws IOException {
if (!archivePaths(server, name).live().equals(directory)) throw new IOException("Age directory changed during rollback");
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
Files.walkFileTree(directory, new java.nio.file.SimpleFileVisitor<Path>() {
@Override public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return java.nio.file.FileVisitResult.CONTINUE;
}
@Override public java.nio.file.FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException {
if (error != null) throw error;
Files.delete(dir);
return java.nio.file.FileVisitResult.CONTINUE;
}
});
}
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
public static void unload(MinecraftServer server, Identifier id) {
checkServerThread(server);
@@ -266,10 +374,23 @@ public final class RuntimeAgeBackend {
}
throw new IllegalStateException("Could not archive Age " + id, exception);
}
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.retired(server, id);
AgeCatalogSync.onCatalogChanged(server);
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
}
/** Display metadata never changes the dimension identity, seed or filesystem path. */
public static java.util.Optional<String> savedAgeName(MinecraftServer server, Identifier id) {
checkServerThread(server);
return java.util.Optional.ofNullable(index(server).displayName(id));
}
public static void setAgeDisplayName(MinecraftServer server, Identifier id, String name) {
checkServerThread(server);
index(server).setDisplayName(id, name);
server.overworld().getDataStorage().saveAndJoin();
}
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
checkServerThread(server);
return index(server).entries();
@@ -285,6 +406,23 @@ public final class RuntimeAgeBackend {
return index(server).dayCycles();
}
/** Snapshot only loaded Age settings; viewing a book never loads its destination. */
public static Map<Identifier, PortAgeDescription> loadedDescriptions(MinecraftServer server) {
checkServerThread(server);
AgeLevelIndex index = index(server);
var descriptions = new java.util.HashMap<Identifier, PortAgeDescription>();
var knownAges = index.entries().keySet();
for (var level : server.getAllLevels()) {
Identifier id = level.dimension().identifier();
if (!(level instanceof PortAgeLevel age) || !knownAges.contains(id)) continue;
PortBiomePlan biomes = index.biomePlan(id);
descriptions.put(id, new PortAgeDescription(index.terrain(id),
biomes == null ? PortBiomePlan.Distribution.NATIVE : biomes.distribution(),
index.lighting(id), age.weatherMode()));
}
return Map.copyOf(descriptions);
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
@@ -366,15 +504,14 @@ public final class RuntimeAgeBackend {
}
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
if (plan.distribution() == PortBiomePlan.Distribution.NATIVE)
throw new IllegalArgumentException("Native biome distribution has no authored source");
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = plan.biomes().stream().map(id ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, id))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + id)))
.toList();
return switch (plan.distribution()) {
case NATIVE -> throw new IllegalStateException("Native is not an override");
case NATIVE -> biomes.size() == 1 ? new FixedBiomeSource(biomes.getFirst())
: new PortScaledBiomeSource(HolderSet.direct(biomes), seed, 0);
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
plan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
@@ -397,11 +534,15 @@ public final class RuntimeAgeBackend {
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
PortAgeColors colors, PortDayCycle dayCycle) {
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(server.registryAccess());
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
&& !biomePlan.biomes().isEmpty() && terrain != PortAgeTerrain.CAVE)
throw new IllegalArgumentException("Only Native Cave terrain may carry an explicit native biome pool");
// Noise terrains reuse the Overworld biome source unless a
// non-Native authored distribution replaces it.
// non-Native authored distribution or a frozen Native Cave pool replaces it.
// LevelStem.seedOverride controls chunk random state and getSeed().
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
@@ -453,7 +594,8 @@ public final class RuntimeAgeBackend {
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
}
}
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
if (biomePlan != null && (biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE
|| terrain == PortAgeTerrain.CAVE && !biomePlan.biomes().isEmpty())) {
source = authoredBiomeSource(server, biomePlan, seed);
}
var settings = switch (terrain) {
@@ -467,8 +609,8 @@ public final class RuntimeAgeBackend {
};
generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value()));
}
if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null)
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed);
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed,
terrain != PortAgeTerrain.CAVE, PortLibraryWriting.get(server).symbols(id));
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
@@ -479,6 +621,7 @@ public final class RuntimeAgeBackend {
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
try {
net.dnorberg.rivencraft.compat.RuinsCompatibility.registerAge(id, terrainFeatures);
server.levels.put(key, level);
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
server.getPlayerList().addWorldborderListener(level);
@@ -491,11 +634,10 @@ public final class RuntimeAgeBackend {
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
return level;
} catch (RuntimeException exception) {
server.levels.remove(key);
try {
level.close();
} catch (IOException closeException) {
exception.addSuppressed(closeException);
try { closeFailedCreation(server, level); }
catch (RuntimeException cleanup) {
exception.addSuppressed(cleanup);
throw new FailedAgeCleanupException(exception);
}
throw exception;
}
@@ -0,0 +1,136 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.portal.PortalBookTravel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
/** A book-holding crystal attachment, with server-authoritative frame activation. */
public final class BookReceptacleBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.FACING;
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
public BookReceptacleBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos,
CollisionContext context) {
return switch (state.getValue(FACING)) {
case NORTH -> Block.box(0, 0, 10, 16, 16, 16);
case SOUTH -> Block.box(0, 0, 0, 16, 16, 6);
case EAST -> Block.box(0, 0, 0, 6, 16, 16);
case WEST -> Block.box(10, 0, 0, 16, 16, 16);
case UP -> Block.box(0, 0, 0, 16, 6, 16);
case DOWN -> Block.box(0, 10, 0, 16, 16, 16);
};
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(FACING, HAS_BOOK);
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BookReceptacleBlockEntity(pos, state);
}
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
BlockEntityType<T> type) {
return level instanceof ServerLevel
? createTickerHelper(type, Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), (world, pos, current, holder) -> BookReceptacleBlockEntity.serverTick((ServerLevel) world, pos, holder))
: null;
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction face = context.getClickedFace();
if (!context.getLevel().getBlockState(
context.getClickedPos().relative(face.getOpposite())).is(Rivencraft.CRYSTAL_BLOCK.get())) return null;
return defaultBlockState().setValue(FACING, face);
}
@Override protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (!isBoundBook(stack)) return InteractionResult.TRY_WITH_EMPTY_HAND;
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(level instanceof ServerLevel server) ||
!(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return InteractionResult.FAIL;
if (!holder.getBook().isEmpty()) return InteractionResult.FAIL;
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos);
if (!plan.valid()) {
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.portal.invalid_frame"));
return InteractionResult.FAIL;
}
if (!PortalBookTravel.available(server, stack)) {
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"));
return InteractionResult.FAIL;
}
holder.setBook(stack.copyWithCount(1));
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
holder.takeBook();
return InteractionResult.FAIL;
}
if (!player.getAbilities().instabuild) stack.shrink(1);
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
return InteractionResult.SUCCESS;
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder
&& !holder.getBook().isEmpty()) {
CrystalPortalNetwork.shutdown(server, pos);
ItemStack book = holder.takeBook();
if (!player.getInventory().add(book)) player.drop(book, false, net.minecraft.util.Prediction.SERVER_ONLY);
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
if (!level.isClientSide() && level.hasChunkAt(support) && !level.getBlockState(support)
.is(Rivencraft.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
}
private static boolean isBoundBook(ItemStack stack) {
return stack.is(Rivencraft.LINKING_BOOK.get()) && stack.has(Rivencraft.LINK_BOOK_TARGET.get())
|| stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && stack.has(Rivencraft.AGE_BOOK_ID.get());
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/** Vanilla lectern geometry with Rivencraft storage instead of a vanilla book menu. */
public final class BookStandBlock extends LecternBlock {
public BookStandBlock(Properties properties) { super(properties); }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new BookStandBlockEntity(pos, state); }
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return InteractionResult.PASS;
if (player.isShiftKeyDown() && !level.isClientSide()) {
var book = stand.takeBook();
level.setBlock(pos, Blocks.LECTERN.defaultBlockState().setValue(FACING, state.getValue(FACING)), 3);
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
}
return InteractionResult.SUCCESS;
}
@Override protected net.minecraft.world.MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { return null; }
@Override protected int getAnalogOutputSignal(BlockState state, Level level, BlockPos pos, net.minecraft.core.Direction direction) { return 0; }
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid crystal-textured arrival footing that cannot be collected. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) { super(properties); }
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return ItemStack.EMPTY;
}
}
@@ -0,0 +1,80 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.material.Fluid;
/** Non-solid linked surface; all travel is delegated to the held book's rules. */
public final class CrystalPortalBlock extends BaseEntityBlock {
// Retained so portal blocks already saved with this state still deserialize.
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
public CrystalPortalBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(LIVE_VIEW, AXIS);
}
@Override protected RenderShape getRenderShape(BlockState state) {
// Keep the animated fallback until the optional destination view has
// actually been established, including after a world restart.
return net.dnorberg.rivencraft.compat.RebornBookPreviews.available() && state.getValue(LIVE_VIEW)
? RenderShape.INVISIBLE : RenderShape.MODEL;
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new CrystalPortalBlockEntity(pos, state);
}
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, net.minecraft.world.entity.InsideBlockEffectApplier effects, boolean precise) {
if (!(level instanceof ServerLevel server) || !entity.canUsePortal(false)) return;
long now = server.getServer().getTickCount();
if (PortalTravelCooldown.blocked(entity, now)) return;
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
ItemStack book = holder.getBook();
if (book.isEmpty()) return;
PortalTravelCooldown.mark(entity, now);
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
}
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
if (!(level instanceof ServerLevel server)
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() == null) return;
// Like a vanilla portal, validate when the frame changes, not on a
// repeating timer that can race block entities loading after restart.
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
}
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
// Ignore validation ticks saved by older builds. Frame edits are
// handled by neighborChanged, and the receptacle maintains the view.
}
}
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.age.PortInstabilityRuntime;
import net.dnorberg.rivencraft.age.PortInstabilityConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.EnumProperty;
/** The four colored decay variants selected by the current instability deck. */
public final class DecayBlock extends Block {
public static final EnumProperty<Color> COLOR = EnumProperty.create("color", Color.class);
public DecayBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(COLOR);
}
@Override
public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) {
if (level instanceof net.minecraft.server.level.ServerLevel server && state.getValue(COLOR) == Color.WHITE
&& PortInstabilityConfig.decayEnabled() && PortInstabilityConfig.instabilityWorldMutation()
&& PortInstabilityRuntime.isAge(server)) {
entity.hurtServer(server, level.damageSources().magic(), 1.0F);
}
super.stepOn(level, pos, state, entity);
}
public enum Color implements StringRepresentable {
BLUE, PURPLE, RED, WHITE;
@Override
public String getSerializedName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
}
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class LinkModifierBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public LinkModifierBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
BlockState> builder) { builder.add(FACING); }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LinkModifierBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
Component.translatable("container.rivencraft.link_modifier")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.SUCCESS;
}
}
@@ -0,0 +1,83 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.SUCCESS;
}
@Override protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel level,
BlockPos pos, boolean movedByPiston) {
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(), state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)
&& otherState.getValue(FACING) == state.getValue(FACING))
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
Containers.updateNeighboursAfterDestroy(state, level, pos);
}
}
@@ -0,0 +1,110 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One persisted book owns the whole crystal network. Portal cells retain only its position. */
public final class BookReceptacleBlockEntity extends BlockEntity {
private ItemStack book = ItemStack.EMPTY;
private boolean restore = true;
private int skyColor = 0xffffff;
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), pos, state);
}
public ItemStack getBook() { return book; }
public int skyColor() { return skyColor; }
public void setBook(ItemStack value) {
if (level instanceof ServerLevel server)
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
book = value.copyWithCount(1);
restore = true;
skyColor = 0xffffff;
if (level instanceof ServerLevel source) {
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
: destination == null ? null : destination.dimension();
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, id));
if (target != null) {
var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target, !book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(),
net.dnorberg.rivencraft.item.LinkProperties.NONE).has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM))
: BlockPos.containing(destination.x(), destination.y(), destination.z());
if (pos != null) {
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
String type = target.dimensionTypeRegistration().unwrapKey()
.map(key -> key.identifier().getPath()).orElse("");
skyColor = written >= 0 ? written
: target.dimension() == net.minecraft.world.level.Level.NETHER || type.endsWith("_sky_nether")
? 0x6b2725
: target.dimension() == net.minecraft.world.level.Level.END || type.endsWith("_sky_end")
? 0x211835
: net.minecraft.util.ARGB.colorFromVector3f(target.environmentAttributes().getValue(
net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos)) & 0xffffff;
}
}
}
sync();
}
public ItemStack takeBook() {
if (level instanceof ServerLevel server)
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result;
}
private void sync() {
setChanged();
if (level != null && !level.isClientSide()) {
BlockState current = level.getBlockState(worldPosition);
if (current.is(Rivencraft.BOOK_RECEPTACLE.get())) {
level.setBlock(worldPosition, current.setValue(BookReceptacleBlock.HAS_BOOK, !book.isEmpty()), 2);
level.sendBlockUpdated(worldPosition, current, level.getBlockState(worldPosition), 2);
}
}
}
public static void serverTick(ServerLevel level, BlockPos pos, BookReceptacleBlockEntity holder) {
if (holder.book.isEmpty()) { holder.restore = false; return; }
int interval = holder.restore ? 20 : 200;
if (level.getGameTime() % interval != Math.floorMod(pos.asLong(), interval)) return;
var plan = CrystalPortalNetwork.plan(level, pos);
if (plan.waitingForChunks()) return;
boolean restoring = holder.restore;
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
plan = CrystalPortalNetwork.plan(level, pos);
if (restoring && plan.valid()) CrystalPortalNetwork.setLiveView(level, plan, false);
CrystalPortalNetwork.reconcile(level, pos, plan);
holder.restore = false;
if (restoring) holder.sync();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
skyColor = input.getIntOr("SkyColor", 0xffffff);
restore = true;
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
output.putInt("SkyColor", skyColor);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level instanceof ServerLevel server) {
CrystalPortalNetwork.shutdown(server, pos);
ItemStack removed = book;
book = ItemStack.EMPTY;
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, removed);
}
super.preRemoveSideEffects(pos, oldState);
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One stored book or page, synchronized independently of the player's inventory. */
public final class BookStandBlockEntity extends BlockEntity {
private ItemStack book = ItemStack.EMPTY;
public BookStandBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), pos, state); }
public ItemStack getBook() { return book; }
public void setBook(ItemStack stack) { book = stack; sync(); }
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; sync(); return result; }
private void sync() {
setChanged();
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level != null && !level.isClientSide())
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5, takeBook());
super.preRemoveSideEffects(pos, oldState);
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One stored book or page, synchronized independently of the player's inventory. */
public final class CrystalPortalBlockEntity extends BlockEntity {
private BlockPos receptacle;
public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.CRYSTAL_PORTAL_BLOCK_ENTITY.get(), pos, state); }
public BlockPos receptacle() { return receptacle; }
public void setReceptacle(BlockPos pos) {
receptacle = pos.immutable();
setChanged();
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
receptacle = input.read("Receptacle", BlockPos.CODEC).orElse(null);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (receptacle != null) output.store("Receptacle", BlockPos.CODEC, receptacle);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
}
@@ -0,0 +1,69 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** One persisted book slot; edits are performed by the server menu. */
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.LINK_MODIFIER_BLOCK_ENTITY.get(), pos, state);
}
@Override public int getContainerSize() { return 1; }
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return slot == 0 && (stack.is(Rivencraft.LINKING_BOOK.get())
|| stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()));
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level != null && !level.isClientSide()) {
net.minecraft.world.Containers.dropContents(level, pos, this);
clearContent();
}
super.preRemoveSideEffects(pos, oldState);
}
@Override public void clearContent() {
items = NonNullList.withSize(1, ItemStack.EMPTY);
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(1, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
}
}
@@ -112,6 +112,36 @@ public final class PortBookBinderBlockEntity extends BlockEntity implements Cont
return true;
}
/** Validate the complete collection before moving any page, as in 1.21.1. */
public ItemStack importCollection(ItemStack collection, int index) {
if (!net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(collection)
|| collection.getCount() != 1) return ItemStack.EMPTY;
var contents = collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(),
net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : contents.pages()) {
if (page.isEmpty()) continue;
if (!page.is(Rivencraft.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.isLinkPanel(page)) {
if (!panel.isEmpty() || !items.get(PANEL).isEmpty()) return ItemStack.EMPTY;
panel = page;
} else if (validSymbolPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && panel.isEmpty()) return ItemStack.EMPTY;
if (!panel.isEmpty()) items.set(PANEL, panel.copy());
for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
ItemStack emptied = collection.copy();
emptied.set(Rivencraft.NOTEBOOK_CONTENTS.get(), net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
setChanged();
return emptied;
}
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end) return ItemStack.EMPTY;
@@ -0,0 +1,173 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 88;
public static final int INK = COLLECTIONS;
public static final int RETURN = INK + 1;
public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
activeCollection = Math.clamp(input.getIntOr("ActiveCollection", 0), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
displayPage = input.read("DisplayPage", ItemStack.CODEC).orElse(ItemStack.EMPTY);
refillEmptyInkBar();
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("ActiveCollection", activeCollection);
output.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) output.store("DisplayPage", ItemStack.CODEC, displayPage);
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide()) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -20,16 +20,23 @@ public final class AgeCatalogClient {
@SubscribeEvent
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
PortBookPreview.logout();
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(java.util.Map.of());
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.BookPreviewPayload.TYPE, (payload, context) -> PortBookPreview.accept(payload));
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
event.register(net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload.TYPE, (payload, context) ->
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.acceptClient(payload.dimensionId(), payload.enabled()));
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(payload.descriptions());
if (listener.getLevel() != null) {
listener.getLevel().environmentAttributes().invalidateTickCache();
listener.getLevel().updateSkyBrightness();
@@ -0,0 +1,67 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.phys.Vec3;
/** The 1.21.1 receptacle book orientation in the 26.3 extracted renderer. */
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity, BookReceptacleRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
}
public static final class State extends BlockEntityRenderState {
boolean present, descriptive;
Direction facing = Direction.NORTH;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(BookReceptacleBlockEntity holder, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(holder, state, partialTick, camera, overlay);
state.present = !holder.getBook().isEmpty() && holder.getBlockState().getValue(BookReceptacleBlock.HAS_BOOK);
state.descriptive = holder.getBook().is(Rivencraft.DESCRIPTIVE_BOOK.get());
state.facing = holder.getBlockState().getValue(BookReceptacleBlock.FACING);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
if (!state.present) return;
pose.pushPose();
pose.translate(0.5F, 0.5F, 0.5F);
switch (state.facing) {
case UP -> {
pose.rotateDegrees(Axis.ZP, 180);
pose.rotateDegrees(Axis.XP, -90);
pose.rotateDegrees(Axis.YP, 90);
pose.rotateDegrees(Axis.ZP, 180);
}
case DOWN -> {
pose.rotateDegrees(Axis.XP, -90);
pose.rotateDegrees(Axis.YP, 90);
pose.rotateDegrees(Axis.ZP, 180);
}
case NORTH -> pose.rotateDegrees(Axis.YP, -90);
case SOUTH -> pose.rotateDegrees(Axis.YP, 90);
case EAST -> pose.rotateDegrees(Axis.YP, 180);
case WEST -> { }
}
pose.scale(0.8F, 0.8F, 0.8F);
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.1F, 0.005F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
pose.popPose();
}
}
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.minecraft.client.gui.Font;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.phys.Vec3;
/** Extracted version of the 1.21.1 lectern display, retaining its final page orientation. */
public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlockEntity, BookStandRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
private final ItemModelResolver resolver;
private final Font font;
public BookStandRenderer(BlockEntityRendererProvider.Context context) {
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
resolver = context.itemModelResolver();
font = context.font();
}
public static final class State extends BlockEntityRenderState {
final ItemStackRenderState page = new ItemStackRenderState();
boolean present, isPage, descriptive;
float rotation;
Component name;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(BookStandBlockEntity stand, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(stand, state, partialTick, camera, overlay);
var book = stand.getBook();
state.present = !book.isEmpty();
state.isPage = book.is(Rivencraft.PAGE.get());
state.descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
state.name = state.present && !state.isPage ? book.getHoverName().copy() : null;
Direction facing = stand.getBlockState().getValue(BookStandBlock.FACING);
state.rotation = state.isPage ? facing.toYRot() - 180 + (facing.getAxis() == Direction.Axis.X ? 180 : 0)
: -facing.getClockWise().toYRot();
resolver.updateForTopItem(state.page, state.isPage ? book : net.minecraft.world.item.ItemStack.EMPTY,
ItemDisplayContext.FIXED, stand.getLevel(), null, 0);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
if (!state.present) return;
pose.pushPose();
pose.translate(0.5F, 1.0625F, 0.5F);
pose.rotateDegrees(Axis.YP, state.rotation);
if (state.isPage) {
pose.rotateDegrees(Axis.XP, 67.5F);
pose.scale(0.7F, 0.7F, 0.7F);
state.page.submit(pose, collector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
} else {
pose.rotateDegrees(Axis.ZP, 67.5F);
pose.translate(0, -0.125F, 0);
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.9F, 1.2F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
}
pose.popPose();
if (state.name != null) {
pose.pushPose();
pose.translate(0.5F, 1.6F, 0.5F);
pose.rotate(camera.orientation);
pose.scale(0.025F, -0.025F, 0.025F);
collector.submitText(pose, -font.width(state.name) / 2.0F, 0, state.name.getVisualOrderText(),
false, Font.DisplayMode.NORMAL, state.lightCoords, 0xFFFFFFFF, 0, 0);
pose.popPose();
}
}
}
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.client.Minecraft;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public final class ClientWritingDeskCatalog {
private ClientWritingDeskCatalog() {}
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
context.enqueueWork(() -> {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
&& menu.containerId == payload.containerId()) {
menu.setCatalog(payload.symbols());
}
});
}
}
@@ -0,0 +1,57 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.Identifier;
/** Uses the same skyward-facing open book and textures as the 1.21.1 renderer. */
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity, DroppedBookRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public DroppedBookRenderer(EntityRendererProvider.Context context) {
super(context);
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
shadowRadius = 0.25F;
}
public static final class State extends EntityRenderState {
float yaw;
boolean descriptive;
}
@Override
public State createRenderState() { return new State(); }
@Override
public void extractRenderState(DroppedBookEntity entity, State state, float partialTick) {
super.extractRenderState(entity, state, partialTick);
state.yaw = entity.getYRot();
state.descriptive = entity.getItem().is(Rivencraft.DESCRIPTIVE_BOOK.get());
}
@Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
pose.pushPose();
pose.translate(0.0F, 0.0625F, 0.0F);
pose.rotateDegrees(Axis.YP, state.yaw + 90.0F);
pose.rotateDegrees(Axis.ZP, 90.0F);
pose.scale(0.8F, 0.8F, 0.8F);
collector.submitModel(model, BookModel.State.forAnimation(0, 0, 0, 1.2F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY,
state.outlineColor);
pose.popPose();
super.submit(state, pose, collector, camera);
}
}
@@ -0,0 +1,198 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Existing Legacy single-slot layout, using the 26.3 extraction/input APIs. */
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
private EditBox titleInput;
private EditBox seedInput;
private Identifier seedAge;
private long lastSeed = Long.MIN_VALUE;
private ItemStack lastBook = ItemStack.EMPTY;
private boolean refreshing;
private Button retireButton;
private Button confirmRetireButton;
private final Button[] options = new Button[6];
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 166);
inventoryLabelY = 72;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.title"));
titleInput.setMaxLength(32);
titleInput.setResponder(value -> {
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
});
addRenderableWidget(titleInput);
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.seed"));
seedInput.setMaxLength(21);
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
addRenderableWidget(seedInput);
for (int index = 0; index < options.length; index++) {
final int option = index;
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
topPos + 10 + (index % 3) * 20, 18, 18, label(index), button -> {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, IDS[option]);
}, java.util.function.Supplier::get) {
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = getX(), y = getY();
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
graphics.fill(x + 2, y + 2, x + 17, y + 17, !active ? 0xFF555555
: selected(option, menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE))
? 0xFFBBBBBB : 0xFF777777);
}
});
options[index].setTooltip(Tooltip.create(label(index)));
}
retireButton = addRenderableWidget(new RetirementButton(120, LinkModifierMenu.RETIRE_AGE, "retire", false));
confirmRetireButton = addRenderableWidget(new RetirementButton(140, LinkModifierMenu.CONFIRM_RETIRE_AGE, "confirm", true));
refreshControls();
}
private final class RetirementButton extends Button {
private final boolean danger;
RetirementButton(int x, int action, String name, boolean danger) {
super(leftPos + x, topPos + 32, 18, 18,
Component.translatable("screen.rivencraft.link_modifier." + name), button -> {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, action);
}, DEFAULT_NARRATION);
this.danger = danger;
setTooltip(Tooltip.create(getMessage()));
}
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = getX(), y = getY();
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
graphics.fill(x + 2, y + 2, x + 17, y + 17, danger ? 0xFFAA5555
: menu.awaitingRetirementConfirmation() ? 0xFFBBBBBB : 0xFF777777);
}
}
@Override protected void containerTick() { refreshControls(); }
private void refreshControls() {
ItemStack book = menu.book();
// Changing book kinds refreshes immediately; otherwise avoid replacing
// focused text with an earlier server echo while the player is typing.
if (!ItemStack.isSameItem(book, lastBook) || !titleInput.isFocused()) {
if (!ItemStack.isSameItemSameComponents(book, lastBook)) {
Component name = book.get(DataComponents.CUSTOM_NAME);
refreshing = true;
titleInput.setValue(name == null ? "" : name.getString());
refreshing = false;
lastBook = book.copy();
}
}
titleInput.setEditable(!book.isEmpty());
boolean canRetire = minecraft != null && minecraft.player != null && menu.canRetire(minecraft.player);
if (retireButton != null) {
retireButton.visible = canRetire;
retireButton.active = canRetire && !menu.awaitingRetirementConfirmation();
confirmRetireButton.visible = canRetire && menu.awaitingRetirementConfirmation();
confirmRetireButton.active = confirmRetireButton.visible;
}
Identifier age = book.get(Rivencraft.AGE_BOOK_ID.get());
if (!java.util.Objects.equals(seedAge, age)) {
seedInput.setFocused(false);
seedAge = age;
seedInput.setValue(Long.toString(menu.seed()));
lastSeed = menu.seed();
}
seedInput.visible = menu.hasSeed();
seedInput.setEditable(seedInput.visible);
if (!seedInput.visible) seedInput.setFocused(false);
else if (!seedInput.isFocused() && (lastSeed != menu.seed()
|| !seedInput.getValue().equals(Long.toString(menu.seed())))) {
lastSeed = menu.seed();
seedInput.setValue(Long.toString(lastSeed));
}
for (int i = 0; i < options.length; i++) if (options[i] != null) {
options[i].active = available(i);
Component label = label(i);
if (!options[i].getMessage().equals(label)) {
options[i].setMessage(label);
options[i].setTooltip(Tooltip.create(label));
}
}
}
private boolean available(int index) {
return !menu.book().isEmpty() && ((index != 1 && index != 2) || menu.book().is(Rivencraft.LINKING_BOOK.get()));
}
private Component label(int index) {
Component name = Component.translatable("item.rivencraft.link_property." + NAMES[index]);
return name.copy().append(selected(index,
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
}
private void submitSeed() {
if (seedInput.visible && seedAge != null && !seedInput.getValue().equals(Long.toString(menu.seed())))
ClientPacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedAge, seedInput.getValue()));
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (seedInput.isFocused() && !seedInput.isMouseOver(event.x(), event.y())) {
submitSeed();
seedInput.setFocused(false);
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean keyPressed(KeyEvent event) {
if (seedInput != null && seedInput.isFocused()) {
if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_NUMPADENTER) {
submitSeed();
seedInput.setFocused(false);
setFocused(null);
return true;
}
if (!event.isEscape()) {
seedInput.keyPressed(event);
return true;
}
}
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
titleInput.keyPressed(event);
return true; // Character input must never activate inventory/hotbar shortcuts.
}
return super.keyPressed(event);
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
}
private boolean selected(int index, LinkProperties properties) {
if (index != 5) return properties.has(FLAGS[index]);
return menu.book().is(Rivencraft.LINKING_BOOK.get()) ? properties.has(LinkProperties.GENERATE_PLATFORM)
: !properties.has(LinkProperties.DISABLE_PLATFORM);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
}
@@ -0,0 +1,317 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private static final Identifier PAGE_ATLAS = texture("gui/notebook_pages");
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 213);
inventoryLabelY = 123;
}
@Override public boolean isPauseScreen() { return false; }
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 26, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> scroll = 0);
addRenderableWidget(textInput);
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractContents(graphics, mouseX, mouseY, partialTick);
int pageSlots = displayedPageSlots();
scroll = Math.min(scroll, maxScroll(pageSlots));
graphics.nextStratum();
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT, topPos + SURFACE_BOTTOM);
for (int index = 0; index < pageSlots; index++) {
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) drawPage(graphics, index, x, y);
}
graphics.disableScissor();
drawScrollbar(graphics, pageSlots);
}
@Override
protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
int hovered = pageAt(mouseX, mouseY);
if (hovered >= 0 && menu.getCarried().isEmpty() && !menu.getSlot(hovered).getItem().isEmpty()) {
ItemStack page = menu.getSlot(hovered).getItem();
if (matchesSearch(page)) graphics.setTooltipForNextFrame(font, page, mouseX, mouseY);
} else super.extractTooltip(graphics, mouseX, mouseY);
}
@Override
protected void extractSlots(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
// Collection slots are drawn above as large pages. Do not submit up to
// 252 additional item models at their off-screen server-slot positions.
for (int index = NotebookMenu.NOTEBOOK_SLOTS; index < menu.slots.size(); index++) {
var slot = menu.getSlot(index);
if (slot.isActive()) extractSlot(graphics, slot, mouseX, mouseY);
}
}
@Override
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (inScrollbar(mouseX, mouseY) && button == InputConstants.MOUSE_BUTTON_LEFT) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
int index = pageAt(mouseX, mouseY);
if (index >= 0 && (button == InputConstants.MOUSE_BUTTON_LEFT || button == InputConstants.MOUSE_BUTTON_RIGHT)) {
ItemStack page = menu.getSlot(index).getItem();
if ((!page.isEmpty() && !matchesSearch(page))
|| (page.isEmpty() && (!menu.getCarried().is(Rivencraft.PAGE.get())
|| PageItem.isLinkPanel(menu.getCarried())))) {
return true;
}
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0, ContainerInput.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
double mouseY = event.y();
int button = event.button();
if (draggingScrollbar && button == InputConstants.MOUSE_BUTTON_LEFT) {
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(event);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(displayedPageSlots()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(KeyEvent event) {
// Let the container close normally, including returning its carried
// stack, even while the search field owns keyboard focus.
if (event.isEscape()) return super.keyPressed(event);
if (textInput != null && textInput.isFocused()) {
textInput.keyPressed(event);
// EditBox does not consume ordinary letter key presses; the matching charTyped
// event handles text input. Do not pass them to the container's inventory keys.
return true;
}
if (selectedSlot >= 0 && (event.isLeft() || event.isRight())) {
moveSelected(event.isRight());
return true;
}
return super.keyPressed(event);
}
private int displayedPageSlots() {
if (menu.getCarried().is(Rivencraft.PAGE.get())) {
return menu.pageCapacity();
}
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
}
return 0;
}
private boolean matchesSearch(ItemStack page) {
return textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
? index : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphicsExtractor graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.outline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.outline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphicsExtractor graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean dimmed = page.isEmpty() || !matchesSearch(page);
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
graphics.nextStratum();
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS, x, y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!page.isEmpty() && page.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
graphics.nextStratum();
ItemStack glyph = page.copy();
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 6);
graphics.pose().scale(1.75F, 1.75F);
graphics.fakeItem(glyph, 0, 0);
graphics.pose().popMatrix();
graphics.nextStratum();
}
if (dimmed) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
} else if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(
page.get(Rivencraft.SYMBOL_PAGE_ID.get()))) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF3333);
}
if (index == selectedSlot && !page.isEmpty()) {
graphics.outline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
}
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphicsExtractor graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphicsExtractor graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -18,12 +18,34 @@ public final class PortBookBinderClientEvents {
public static void registerBlockEntityRenderers(
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), BookReceptacleRenderer::new);
}
@SubscribeEvent
public static void portalTint(net.neoforged.neoforge.client.event.RegisterColorHandlersEvent.BlockTintSources event) {
event.register(java.util.List.of(new net.minecraft.client.color.block.BlockTintSource() {
@Override public int color(net.minecraft.world.level.block.state.BlockState state) { return 0xffffffff; }
@Override public int colorInWorld(net.minecraft.world.level.block.state.BlockState state,
net.minecraft.client.renderer.block.BlockAndTintGetter level, net.minecraft.core.BlockPos pos) {
if (level.getBlockEntity(pos) instanceof net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity portal
&& portal.receptacle() != null
&& level.getBlockEntity(portal.receptacle()) instanceof net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity holder)
return 0xff000000 | holder.skyColor();
return 0xffffffff;
}
}), Rivencraft.CRYSTAL_PORTAL.get());
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
event.register(Rivencraft.LINK_MODIFIER_MENU.get(), LinkModifierScreen::new);
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
}
@SubscribeEvent
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more