Port libraries, crystal portals and instability decay to 26.3 beta

This commit is contained in:
dnorberg committed 2026-11-10 04:14:59 -05:00
1 parent 35980338e1
commit c48671d32d
47 files changed
+3438 -71

No files matched your search

+5 -2
View File
@@ -19,11 +19,14 @@ independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
feature completion, and random structure selection. Nether/End noise-biome feature completion, and random structure selection. Nether/End noise-biome
materials now follow their native rules. Books now have Legacy-style spreads, materials now follow their native rules. Books now have Legacy-style spreads,
animated fallback panels and saved destination labels; lectern stands, open-book animated fallback panels and saved destination labels; lectern stands, open-book
drops, page collections, stations and basic progression are also ported. drops, page collections, stations and basic progression are also ported. The latest
beta adds Age libraries, crystal portals with animated fallback surfaces, saved
instability scoring/provider selection, potion providers and four-color decay.
Full writing recovery, live material profiling and other hazards remain pending.
The beta does not yet provide full The beta does not yet provide full
1.21.1 gameplay or optional-integration parity. 1.21.1 gameplay or optional-integration parity.
See the [26.3 beta guide](ports/mc263/README.md) and See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md). [latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md).
Keep the builds and their saves separate, and back up worlds before updating. Keep the builds and their saves separate, and back up worlds before updating.
## Getting started ## Getting started
+157
View File
@@ -2169,3 +2169,160 @@ Server stopped and temporary world removed. Manual checks: Q/inventory toss,
death/Disarm, broken stand, full-inventory delivery/retrieval, open-book visual death/Disarm, broken stand, full-inventory delivery/retrieval, open-book visual
orientation, motion, ownership/pickup, component preservation and restart. Root orientation, motion, ownership/pickup, component preservation and restart. Root
1.21.1 remains unchanged and #114 stays open until both tracks are complete. 1.21.1 remains unchanged and #114 stays open until both tracks are complete.
## Handheld Link Panel resource policy
`1.0.1.116-beta.1` adapts root WritingInkService and the configurable
`writing.link_panel_ink_cost` (2 default, 1–16 allowed). The item delegates the
server transaction to the service; Writing Desk cost reads also use its shared
getter. Mixer cost remains independently fixed at one ink per Link Panel.
Unsynchronized LOCAL config is not consulted for client affordability prediction;
the server validates both inputs and constructs outputs before mutation.
Build passed. No additional server fixtures were added; the user offered manual
validation. Check default/custom cost, insufficient ink, final bottle, both hands,
stacked/full-inventory inputs and dedicated-server cost differing from the client.
Root 1.21.1 is unchanged; no commit/push/publication was performed.
## Library discovery port
`1.0.1.117-beta.1` ports MystcraftLibraryFeature from the 1.21.1 implementation,
using the shared schematic/loot table and final five-stand orientation. Natural
placement runs after all vanilla/authored decoration via the Age feature wrapper,
with the same bounded footprint, candidate RNG/spacing, slope/footing and obstruction
rules. The wrapper now applies to every runtime Age; Cave terrain disables libraries
as in root. Existing chunks are not rewritten and vanilla Overworld generation is
not modified.
A separate saved writing map holds the original symbol list for AGE_ONLY loot,
avoiding expansion of the already-large backend index record. Creation stores it
before constructing/publishing the level; the wrapper captures an immutable list
and serializes library flags/list in its codec. Reload reconstructs the same pool.
LEGACY is the default; AGE_ONLY uses known distinct written IDs. Full random-page
policy integration remains pending. The snapshot list does not alter generation
traits or the book's original contents.
Added operator placement, session-only spacing override, cheap candidate reporting,
current-chunk probe/loot inspection and a locator restricted to loaded nearby chunks.
The locator checks a clear, dry, footed aisle; it never performs a mass remote
chunk-generation search. Explicit test placement checks empty space and entities.
Build passed; shared schematic/Sealed Notebook loot packaging verified. Dedicated
server startup passed. The user offered manual testing, so generation/loot checks
were handed over before running them. Temporary server stopped and world removed.
No new test fixture, commit or publication. This is a substantial discovery-system
slice; instability/recovery, crystal portals and remaining optional integrations
remain the other large parity gaps.
## .118 crystal portal gameplay port
The player confirmed .117 library generation and retracted the missing-library report.
Ported the 1.21.1 Book Receptacle, bounded crystal flood/tension network, portal
surface ownership and orientation, activation protection checks and rollback, removal
cleanup, queued transport and cooldown. Reused existing crystal, portal and receptacle
assets, recipe and progression node. The book renderer retains all final six mounting
orientations and explicitly clears its extracted presence when the book is removed.
Player traversal uses existing Linking/Descriptive Book paths, with portal audio
precedence. Descriptive arrival resolution is now shared; no separate portal offset
or generated exit position is introduced. Existing Intra-Linking, Disarm, momentum,
client Age acknowledgement, Return Link configuration and progression remain in force.
Non-player traversal uses modern TeleportTransition, collision/permission checks,
and the same landing resolver. A shared Disarm helper supports container/horse/mob
inventory and equipment without requiring the old protected-method mixin.
Book and owner pointers use modern ValueInput/ValueOutput and update packets. Startup
waits for loaded frame chunks; periodic bounded reconciliation retains root's cleanup
of disconnected cells, staggered every 200 ticks per holder. The surface remains the
animated facing-only fallback, tinted to the saved destination sky. Live portal views
are still optional integration work, not advertised as ported.
Build/resources and dedicated-server startup passed. A command-seeded 3x3 opening
verified exact-coordinate entity transport, persistence and traversal after restart,
and support-removal teardown/open-book recovery. Player interaction and client visuals
are handed over for manual testing. Temporary server stopped/world deleted; no new
permanent GameTest, commit, push or publication. Version: 1.0.1.118-beta.1.
## .119 instability foundation and status-effect gameplay
The player passed .118. Ported the complete five-deck catalog and deterministic
provider selection from 1.21.1. Separate modern codec SavedData freezes new Ages'
scoring contributions, seed, selected providers/levels and an enabled flag. Older
Ages remain neutral. Creation-time scoring currently covers Bright Lighting,
Accelerated, all three Corrupted sizes, ignored Cave weather/celestial modifiers,
excess/biome-forced structures and additional suns. Original page indices are retained.
Corrupted Pages now bind and contribute their Legacy costs; existing redacted rendering
is reused. The new Abandoned Camps structure follows the same modern-structure costs.
Adapted the bounded occupied-Age scheduler and the 18 local/global/general/hostile
potion providers, preserving 1.21.1 defaults, effect durations, level-based amplifiers,
sky exposure and Survival/Adventure targeting. The owned-instance lease system revokes
only Rivencraft's effects on exit/disable/shutdown, preserving other sources. Runtime
uses already-loaded chunks and shared per-Age work budgets; failed providers receive
a temporary quarantine. The config retains server ownership and can disable providers
without rerolling stored plans.
OP-only `/rivencraft_port instability` supplies status/breakdown, persistent enabled
control, session-only score override/reset and one-provider pulses. Pulse output tracks
successful application separately from attempted work so event-cancelled effects are
not reported as applied. No scoring data is sent to player book/Binder UI. Unimplemented
hazards stay selected with an explicit status; they are never replaced or rerolled.
Build/startup passed. Temporary dedicated-server checks verified neutral creation,
provider selection at a test score, Poison application/duration, immediate owned-effect
revocation, unrelated stronger-effect retention, local-versus-global roof behavior,
enabled-state persistence and test-score reset across restart. Server stopped/world
deleted; no new permanent GameTests, commit, push or publication. Manual checks cover
authored Corrupted/Accelerated/lighting score contributions, automatic player-occupied
scheduling, Survival/Creative policy, exit cleanup and hostile buffs. Version .119.
Still pending: full writing grammar recovery/penalties, live block abundance, decay,
lightning/explosions, meteors, burning and erosion, plus optional integration parity.
## .120 colored decay gameplay
The player passed .119. Ported the 1.21.1 DecayBlock, four provider handlers and bounded
frontier persistence. Reused all four active 16x16 block textures/models and the
protected-block tag. Modern adaptation uses BreakBlockEvent, Value/codec SavedData,
ChunkPos accessors, loaded-chunk lookup and server-side magic damage for White.
No decay item/recipe/drop is added.
Provider metadata now distinguishes world-mutating handlers, so world_mutation gates
decay independently of potion policy. Defaults remain enabled/four neighbor attempts,
one successful conversion per call under the shared scheduler budget. Conversion
hardness/air/color odds, infrastructure/BE/fluid/unbreakable protection, positioned
fake-player claim events and placement rollback are retained from 1.21.1.
The instability record adds an optional first resolved spawn, preserving old records'
scores/plans and enable flags. Shared Descriptive Book landing resolution records it
once; decay protects the same +/-4 horizontal and -2..+4 vertical region as root.
Older Ages record this on the next book/portal arrival; OP status exposes the point.
Frontiers and attempt counters live in each Age, capped at 1,024 positions per color.
Added OP-only `/rivencraft_port instability decay <color> [1..32 attempts]`, reporting
actual changes and nearby tracked positions. Existing generic pulses now report block
as well as entity actions. Invalid input/out-of-Age commands use proper command errors.
White block contact damage retains root's decay/world_mutation policy; disabling an
Age scheduler stops spread but does not remove existing decay or alter its block form.
Build/startup passed. All four colors seeded/spread through bounded command pulses.
A full restart retained each frontier and advanced attempts from 32 to 40 while Red
continued growing. Bedrock and water generated empty frontiers, and the saved Age
disable blocked further provider changes. Textures verified 16x16. A stationary NoAI
sheep did not exercise the stepping hook, so White stepping damage remains a player
manual check, alongside visuals, arrival protection, normal scheduler/claim integration.
Server stopped/test world removed; no permanent tests, commit, push or publication.
Version: 1.0.1.120-beta.1. Other hazards, full writing recovery and live abundance
profiling remain pending.
## .120 release sign-off
The user passed .120 and authorized commit, push and publication of the accumulated
.116–.120 work. Both target builds passed. Consolidated release notes distinguish
implemented libraries, fallback crystal portals and partial instability/decay from
remaining gameplay and optional-integration parity. Tracking issues #98/#102/#104
remain open; #114 still requires the matching 1.21.1 fix.
+254 -3
View File
@@ -8,9 +8,10 @@ author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages, selected material/vegetation/structure features, and biome-distribution pages, selected material/vegetation/structure features, and
independent Age weather and lighting, multiple authored or seeded celestial independent Age weather and lighting, multiple authored or seeded celestial
bodies and eclipses, biome/material completion, and random features/structures. bodies and eclipses, biome/material completion, and random features/structures.
Instability, crystal portals, remaining discovery/stations, writing recovery and optional Crystal portals and libraries are ported. Instability scoring/provider selection,
integration parity remain unfinished. The latest published beta is status effects and colored decay are partially ported; remaining hazards, live material profiling,
[1.0.1.115-beta.1](RELEASE_NOTES_1.0.1.115-beta.1.md). remaining discovery/stations, writing recovery and optional integrations remain unfinished. The latest published beta is
[1.0.1.120-beta.1](RELEASE_NOTES_1.0.1.120-beta.1.md).
Use a test world and keep a backup; do not Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves. substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
@@ -1891,3 +1892,253 @@ offered to perform the remaining validation, so no further automated restart run
was started. Server stopped and world removed. Shared issue #114 remains open: was started. Server stopped and world removed. Shared issue #114 remains open:
1.21.1 implementation and manual client validation are pending. Nothing committed 1.21.1 implementation and manual client validation are pending. Nothing committed
or published. or published.
### Configurable handheld Link Panel ink cost
The local `1.0.1.116-beta.1` slice ports root WritingInkService and its
`writing.link_panel_ink_cost` option in `config/rivencraft-local.toml`.
The allowed range is 1–16, default 2. Preparing a baseline Link Panel by hand
consumes one Paper and that many ink units; insufficient ink changes neither
input. A drained vial returns a vanilla Glass Bottle. Stacked inputs and
inventory-full give/drop behavior retain the root transaction rules. The
Ink Mixer still consumes exactly one ink unit and one Paper per panel, separately
from this setting. Writing Desk symbol-copy cost uses the shared service getter.
Handheld client prediction checks only eligible item types and a valid nonempty
vial; the server uses its own configured cost to accept/refuse and consume inputs.
This avoids relying on unsynchronized LOCAL config for client affordability.
No new recipe, packet, Page variant or progression award is introduced.
The build passed. Following the player's offer to do testing, no test server
or new fixtures were started for this small resource-policy port. In game, check
two ink per panel by default; then set `link_panel_ink_cost = 4` under `[writing]`
and restart to check four ink per panel, insufficient-ink refusal, last-use bottle,
both hands and stacked/full-inventory cases. Confirm the Mixer still costs one
ink. On a dedicated server, change the server's value; the client need not match.
Nothing committed, pushed or published.
### Age library discovery
The local `1.0.1.117-beta.1` slice ports the 1.21.1 library discovery system:
the existing 11×11×11 schematic, five stocked lecterns, chest loot, deterministic
32-chunk-region spread and the terrain/obstruction checks. Libraries generate
at the end of decoration in newly generated, non-Cave Age chunks. The footprint
stays inside its candidate chunk. Trees/foliage can be cleared, while fluids,
block entities, solid obstructions, excessive slopes and absent sturdy footing
reject placement. Vanilla Overworld generation is unchanged. Old chunks are
not revisited; existing Ages gain libraries only in eligible new chunks.
`[libraries] loot_mode` in `config/rivencraft-local.toml` defaults to `LEGACY`:
writing supplies and possible Sealed Notebooks in chests, with random symbol
Pages on five lecterns. `AGE_ONLY` limits new library chest/lectern Pages to
known symbols written into that Age, using distinct IDs. Page-free Ages then
have no symbol loot and the stands display ordinary Books, matching root.
The original written list is saved independently and copied into the generator
before generation, then restored on reload. It does not change the book's writing,
seed, folder or other trait completion. Older Ages without this saved list have
an empty Age-only pool. The broader random-page eligibility policy remains a
separate unported system. The Legacy pool may include currently unsupported red
pages, like the existing Sealed Notebook pool.
Operator tools under `/rivencraft_port library`:
- `test_place`: place a raised library in clear space ahead, without overwriting
existing blocks/entities. A console source may supply position via `execute`.
- `nearest_loaded`: teleport into the nearest marked library aisle in loaded
chunks within 16 chunks. It checks footing/fluids/collision and does not scan
or generate remote chunks.
- `test_spacing 1` through `32`: session-only increased candidate density in an
Age, for new chunks only. `32` restores normal spacing; restart clears overrides.
- `candidate`: report the closest deterministic candidate without loading it;
eligibility is still required and a library is not guaranteed.
- `probe`: identify a marked library or explain current-chunk placement rejection.
- `inspect_loot`: materialize the current chunk's library chest as opening it would,
then print contents and the number of stands in that chunk.
Build passed and the schematic/loot resources were verified in the JAR. A local
dedicated server started with the data; at the player's request it was stopped
before generation/loot tests. Its world was deleted. The player will check actual
natural placement, five lectern pages and orientation/retrieval, chest loot/Sealed
Notebook opening, both loot modes, locator/test tools and persistence after restart.
No new GameTest fixtures were added. No commit, push or publication was made.
## Beta .118 — crystal portals and Book Receptacles
The player confirmed natural library generation in .117; the earlier missing-library
report was withdrawn. .118 adapts the complete fallback crystal network from 1.21.1:
- Book Receptacles mount on any of the six faces of Crystal Blocks, with the final
1.21.1 model/book orientations. Eight Crystal Blocks surrounding an empty crafting
square craft one receptacle; its recipe unlocks on obtaining a Crystal Block.
- Insert a bound Linking or Descriptive Book into a valid connected crystal frame.
The Legacy tension/flood algorithm accepts supported irregular openings, using
the same 16-block radius, 2,048-crystal and 1,024-surface limits. Placement respects
NeoForge protection events and rolls back rejected activation.
- Empty-hand interaction removes the book and closes the portal. Breaking its
support or holder closes it and drops the book as the recoverable open-book entity.
- Player travel delegates to the existing book transport, catalog acknowledgement,
safe landing, Link Panel effects and configured Return Link handling. Descriptive
Books share the exact same landing resolver with portals. Linking Books retain
their exact coordinates, facing and same-dimension Intra-Linking requirement.
- Non-player transport uses the same target rules, collision checks and momentum
rotation. Disarm refuses loose items and leaves container contents/eligible mob
equipment behind. The shared Disarm helper also replaces the player-only loop.
- Queued collision travel waits until the entering movement packet is complete.
A 40-tick session-only cooldown prevents repeated traversal. Portal owner pointers,
the one stored book, and destination sky tint persist and synchronize to clients.
Startup waits for required frame chunks before repair. Loaded networks receive a
staggered bounded maintenance pass every 200 ticks, without generating chunks.
- The animated fallback only renders its two facing surfaces. Its tint follows the
destination sky, including authored colors and the 1.21.1 Nether/End palette.
Optional live destination views remain part of the integration port; installing
another portal mod alone does not hide this fallback.
- Successful activation awards the restored Open Portal advancement.
Validation: build and dedicated-server startup passed. A small command-seeded
crystal ring verified persisted book/owner data, entity collision travel to exact
saved coordinates before and after a full server restart, and removal/drop cleanup
when the support was broken. This was not a substitute for player-driven activation:
the player should test book insertion/removal, the six model orientations, both book
types and their Link Panel effects, survival/creative item handling, frame edits,
reopening after restart, and travel with destination chunks initially unloaded.
The temporary server was stopped and its world deleted. No new permanent GameTests,
commit, push or publication.
## Beta .119 — saved instability and status-effect providers
The player passed .118 crystal portal testing. This slice begins the major instability
port using the 1.21.1 scoring rules, complete Legacy card catalog, deterministic deck
selection, bounded scheduler and potion-provider implementation.
New Ages save creation-time contributions and selected provider IDs/levels in separate
Overworld SavedData (`rivencraft/instability`), alongside an independent enabled flag.
Creation saves this with the Age identity before catalog publication. Reload and
configuration changes do not reroll the frozen plan. Older Ages remain neutral rather
than being rescored. No score or provider detail is added to player books or Binder UI.
The first scoring set includes Bright Lighting (+500, also when seed-completed),
Accelerated (+1,000 each), Corrupted Medium (+1,000), Small (+2,000) and Large (+0),
Cave-ineffective weather/celestial modifiers (+100 each), structure occurrences after
the first three (+100 each), authored structures forced into non-native biomes (+100
per occurrence), and growing costs for the third and later suns (+1,000, +2,000, ...).
The same structure policy includes the new 26.3 Abandoned Camps page. Corrupted Pages
are now implemented in authoring/creative availability; they retain animated redacted
text and supply instability rather than a terrain decoration.
All five Legacy decks and card costs/copy counts are retained. Scoring rounds down to
500 before selecting/shuffling from the Age seed. Unported hazard cards remain selected
and are reported as unavailable; no replacement reroll biases the deck toward potions.
Writing-recovery penalties, live valuable-material profiling, decay, lightning,
explosions, meteors, burning and erosion remain later slices. This is not yet full
instability parity, and the existing authoring validation rules still apply.
Implemented providers: local/global Blindness, Mining Fatigue, Hunger, Nausea, Poison,
Slowness, Weakness and Wither, plus global hostile Regeneration and Resistance. Local
variants require visible sky; global variants work under cover. Harmful general effects
can affect Survival/Adventure players and non-player living entities; Creative and
Spectator are exempt. Hostile buffs target monsters. Duration follows the 1.21.1 values:
60 ticks Blindness/Nausea, 80 Fatigue/Hunger/Poison/Slowness/Weakness, 30 Wither, and 200
hostile buffs. Provider level raises amplifier, not duration; default effective cap is 3.
The scheduler runs only in occupied runtime Ages, selecting already-loaded nearby
chunks. Defaults match 1.21.1: 20-tick interval, four Ages, two providers and one action
per run, at most 16 chunks. Status leases cap at 32 per Age, eight candidates per attempt,
and 200 ticks per effect. A failed handler is quarantined temporarily. Owned effects
are revoked on departure/logout/disable/shutdown without deleting replacements or
stronger/extended effects from another source. Config entries are under `[instability]`
and `[instability_status_effects]` in the server's `rivencraft-local.toml`; provider
exclusions accept `poison` or `rivencraft:poison`.
Operator commands, used inside a runtime Age:
- `/rivencraft_port instability status`: frozen score/contributions, saved or test
provider levels, enabled/availability information.
- `/rivencraft_port instability enabled false` (or `true`): persistently pause/resume
this Age; pausing immediately clears owned effects without changing its saved plan.
- `/rivencraft_port instability test_score 5000`: session-only deterministic provider
selection at a test score, leaving creation-time contributions unchanged.
- `/rivencraft_port instability clear_test_score`: restore saved selection. Restart
also clears the test override.
- `/rivencraft_port instability pulse poison_global`: attempt one implemented level-1
effect on an eligible living entity in the current already-loaded chunk. The command
respects config, enabled state, target policy and effect ownership; it reports when
no effect was applied. Existing effects are not replaced. Other catalog providers
are suggested too, with unavailable handlers explicitly reported.
Validation: build and fresh dedicated-server startup passed. A small temporary Age
confirmed neutral scoring, deterministic test deck selection, exact Poison duration,
owned-effect cleanup on disable, retention of a stronger command-supplied Weakness,
local shelter versus global effects, saved enabled state after restart and session
score reset. The server was stopped and its world deleted. No permanent test fixtures
were added. Player authoring/scoring, automatic occupied-Age scheduling, Survival versus
Creative behavior, departure cleanup, hostile buffs and multiplayer fairness remain
manual checks. No commit, push or publication.
## Beta .120 — colored decay
The player passed .119. This slice ports the four active 1.21.1 colored decay cards,
the block and all four existing 16x16 textures, per-Age frontier persistence, surface
seeding and six-neighbor conversion. Decay is selected by the existing saved Legacy
decks; older saved provider selections are not rerolled.
Blue, Red and Purple use the same hardness-dependent conversion odds as 1.21.1.
White converts eligible solid blocks without a hardness roll. Air conversion remains
rarer (Blue/Red 1-in-20, Purple 1-in-8, White 1-in-50). Other decay colors can be
converted; White wins that conversion immediately and others use a 1-in-5 roll.
White also deals one point of magic damage on stepping contact, using the modern
server damage API. All decay variants are solid, hardness 5/resistance 10, have no
item form or drops, and do not spread through autonomous/random block ticks.
A provider call samples at most four six-neighbor targets by default and changes at
most one block, sharing the existing scheduler's Age/provider/action budgets. It only
examines supplied already-loaded player-nearby chunks. Per-Age SavedData retains a
monotonic attempt sequence and at most 1,024 frontier coordinates per color; no chunk
or entity references are persisted. Destroyed/recolored stale sources are discarded.
Protection matches 1.21.1: fluids, unbreakable/critical blocks, block entities,
portals and Rivencraft infrastructure resist conversion. Data packs can add entries
to `rivencraft:decay_protected`. A positioned fake player supplies modern NeoForge
BreakBlockEvent and placement events, with rollback for cancelled placement.
The first resolved Descriptive Book landing is saved as a protected spawn without
changing scoring or provider selection. It protects +/-4 blocks horizontally and
Y-2 through Y+4. Portals and hand-held books share that resolver. For an Age entered
before .120, re-enter it once to record this point; OP status reports whether it is
recorded. Cloud Platforms and Rivencraft infrastructure remain protected independently.
`[instability] world_mutation` and `[instability_decay] enabled` default to true,
matching 1.21.1. `max_attempts_per_call` defaults to 4 (range 1–6). Per-Age enable,
global enable and provider exclusions also gate seeding/spread. Disabling does not
remove existing decay. As in 1.21.1, existing White block contact damage follows the
decay/world-mutation toggles rather than the Age provider scheduler toggle.
Operator testing, inside an Age and preferably away from its protected arrival:
- `/rivencraft_port instability decay blue 32` makes up to 32 bounded provider
attempts in your current loaded chunk. Use `purple`, `red` or `white` too. It prints
the number of actual changes and up to four nearby tracked coordinates, without
changing score/selection. A failed roll or protected location can yield no change.
- The existing `/rivencraft_port instability pulse decayblue` (and other colors)
attempts one call. Its output now describes successful actions for either blocks
or living entities.
- `/rivencraft_port instability test_score 50000` allows natural deck selection for
observation; inspect `status` for the selected colors, then use `clear_test_score`
to restore the saved plan. Merely placing decay does not register an active card.
Validation: build and fresh dedicated-server startup passed. Bounded pulses seeded
and spread all four colors. On restart, the saved attempt sequence advanced from
32 to 40 and the original frontiers persisted, with further Red growth. Bedrock and
water seeding produced no conversions/frontier entries; disabling the Age blocked
further changes. All packaged textures are 16x16. The temporary server was stopped
and world deleted. Manual checks remain for visual variants, White stepping damage,
protected arrival, normal occupied-Age spreading, claim-mod cancellation and controls.
No permanent test fixtures, commit, push or publication.
## .116–.120 release validation
The player passed every slice, including .120 colored decay, and authorized commit,
push and publication as 1.0.1.120-beta.1. Both Minecraft targets build successfully.
See the consolidated release notes for the published scope and remaining parity gaps.
@@ -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.
+15 -1
View File
@@ -193,6 +193,10 @@ tasks.named('processResources', ProcessResources).configure {
include 'data/rivencraft/worldgen/structure_set/bastions.json' include 'data/rivencraft/worldgen/structure_set/bastions.json'
include 'assets/rivencraft/models/block/star_fissure.json' include 'assets/rivencraft/models/block/star_fissure.json'
include 'assets/rivencraft/blockstates/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.json'
include 'assets/rivencraft/sounds/**/*.ogg' include 'assets/rivencraft/sounds/**/*.ogg'
include 'assets/rivencraft/textures/item/crystal_shard.png' include 'assets/rivencraft/textures/item/crystal_shard.png'
@@ -221,7 +225,6 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/blockstates/writing_desk.json' include 'assets/rivencraft/blockstates/writing_desk.json'
include 'data/rivencraft/loot_table/blocks/writing_desk.json' include 'data/rivencraft/loot_table/blocks/writing_desk.json'
include 'data/rivencraft/advancement/progression/*.json' include 'data/rivencraft/advancement/progression/*.json'
exclude 'data/rivencraft/advancement/progression/open_portal.json'
exclude 'data/rivencraft/advancement/progression/root.json' exclude 'data/rivencraft/advancement/progression/root.json'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta' include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json' include 'assets/rivencraft/models/item/ink_vial.json'
@@ -253,8 +256,19 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/models/block/book_binder.json' include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json' include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/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 'assets/rivencraft/blockstates/book_stand.json'
include 'data/rivencraft/loot_table/blocks/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 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json' include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json' include 'assets/rivencraft/blockstates/crystal_block.json'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft mod_id=rivencraft
mod_name=Rivencraft mod_name=Rivencraft
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
mod_version=1.0.1.115-beta.1 mod_version=1.0.1.120-beta.1
@@ -4,9 +4,12 @@ import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-owned gameplay policies adapted from 1.21.1. */ /** Server-owned gameplay policies adapted from 1.21.1. */
public final class PortConfig { 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 enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec SPEC; public static final ModConfigSpec SPEC;
public static final ModConfigSpec.IntValue SYMBOL_INK_COST; 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.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS; public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS; public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
@@ -18,9 +21,16 @@ public final class PortConfig {
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.") "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); .defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop(); 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"); builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.") SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 1, 16); .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.pop();
builder.push("accelerated_age"); builder.push("accelerated_age");
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.") ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
@@ -28,6 +38,7 @@ public final class PortConfig {
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.") 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); .defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop(); builder.pop();
net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder);
SPEC = builder.build(); SPEC = builder.build();
} }
@@ -131,6 +131,23 @@ public final class Rivencraft {
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new, BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK) () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable()); .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 = public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new, BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6)); () -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
@@ -235,6 +252,8 @@ public final class Rivencraft {
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
modEventBus.addListener(AgeCatalogSync::registerPayloads); modEventBus.addListener(AgeCatalogSync::registerPayloads);
modEventBus.addListener(PortBookNetworking::registerPayloads); modEventBus.addListener(PortBookNetworking::registerPayloads);
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
net.dnorberg.rivencraft.age.PortDecayProviders.register();
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)"); LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
} }
} }
@@ -7,6 +7,7 @@ import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */ /** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements { public final class ProgressionAdvancements {
public static final String OPEN_PORTAL = "open_portal";
public static final String LINK_PANEL = "link_panel"; public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page"; public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age"; public static final String BIND_AGE = "bind_age";
@@ -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);
}
}
}
@@ -23,6 +23,14 @@ public final class PortAgeTravel {
if (!AgeCatalogSync.ready(player, id)) { if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension"; return "Age catalog not acknowledged by client; remaining in source dimension";
} }
BlockPos landing = resolveLanding(target);
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) {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures(); && features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null; BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
@@ -30,11 +38,10 @@ public final class PortAgeTravel {
? findFootedDestination(target, landing).orElse(null) ? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ()); : findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target); if (landing == null) landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension"; if (landing == null) return null;
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target)); if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
Set.of(), player.getYRot(), player.getXRot(), true); return landing;
return moved ? null : "Teleportation failed; remaining in source dimension";
} }
static BlockPos findLanding(ServerLevel target) { static BlockPos findLanding(ServerLevel target) {
@@ -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(); }
}
@@ -70,10 +70,12 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
.forGetter(generator -> Optional.ofNullable(generator.lakes)), .forGetter(generator -> Optional.ofNullable(generator.lakes)),
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features") PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)), .forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed) 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.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), 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 ChunkGenerator base;
private final PortRockPlan rocks; private final PortRockPlan rocks;
@@ -82,10 +84,19 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
private final PortLakePlan lakes; private final PortLakePlan lakes;
private final PortTerrainFeaturePlan terrainFeatures; private final PortTerrainFeaturePlan terrainFeatures;
private final long ageSeed; private final long ageSeed;
private final boolean libraries;
private final List<Identifier> librarySymbols;
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks, public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, long ageSeed) { 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); super(base.getBiomeSource(), base::getBiomeGenerationSettings);
this.base = base; this.base = base;
this.rocks = rocks; this.rocks = rocks;
@@ -94,6 +105,8 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
this.lakes = lakes; this.lakes = lakes;
this.terrainFeatures = terrainFeatures; this.terrainFeatures = terrainFeatures;
this.ageSeed = ageSeed; this.ageSeed = ageSeed;
this.libraries = libraries;
this.librarySymbols = List.copyOf(librarySymbols);
} }
public ChunkGenerator base() { return base; } public ChunkGenerator base() { return base; }
@@ -286,5 +299,6 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null; if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
} }
} }
if (libraries) PortLibraryFeature.generate(level, chunk, ageSeed, librarySymbols);
} }
} }
@@ -0,0 +1,121 @@
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);
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), 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("Restored saved 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("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,67 @@
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 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);
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();
}
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,108 @@
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); }
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);
}
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));
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 (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,145 @@
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<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 = TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
return test == null ? age.plan() : PortInstabilityPlan.select(test, age.seed());
}
public static Integer testScore(ServerLevel level) {
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
}
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();
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 (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 (HANDLERS.get(id).worldMutating()) {
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
} 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();
}
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,358 @@
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 mode == PortConfig.LibraryLootMode.AGE_ONLY
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
: PortSymbolPages.sealedCandidateIds();
}
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,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());
}
}
}
@@ -149,14 +149,26 @@ public final class RuntimeAgeBackend {
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) { 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) {
checkServerThread(server); checkServerThread(server);
ageName(id); ageName(id);
AgeLevelIndex index = index(server); AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
PortLibraryWriting.get(server).put(id, librarySymbols);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle); terrainFeatures, lighting, sky, colors, dayCycle);
((PortAgeLevel) level).configureWeather(weather); ((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle);
// A newly bound book may be used immediately. Persist its Age identity // A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that // before publishing the catalog so a later crash cannot leave that
// book pointing at a world omitted from the on-disk index. // book pointing at a world omitted from the on-disk index.
@@ -482,8 +494,8 @@ public final class RuntimeAgeBackend {
}; };
generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value())); 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), LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
generator, OptionalLong.of(seed)); generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed()); long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
@@ -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,79 @@
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 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,101 @@
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) {
book = value.copyWithCount(1);
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)
: 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() { 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);
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,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); }
}
@@ -116,13 +116,13 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
public boolean canConsumeCopyMaterials() { public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER); ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false; if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get(); int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost); return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
} }
public boolean consumeCopyMaterials() { public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false; if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get(); int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
if (inkRemaining < cost) { if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN); inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
} }
@@ -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();
}
}
@@ -21,6 +21,22 @@ public final class PortBookBinderClientEvents {
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new); event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::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_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 @SubscribeEvent
@@ -3,13 +3,13 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer; import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult; import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player; import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item; import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items; import net.minecraft.world.item.Items;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
@@ -17,8 +17,6 @@ import net.minecraft.world.level.Level;
/** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */ /** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */
public final class InkVialItem extends Item { public final class InkVialItem extends Item {
public static final int CAPACITY = 16; public static final int CAPACITY = 16;
// Match the 1.21.1 default; port the configurable cost with the full configuration system.
public static final int LINK_PANEL_COST = 2;
public InkVialItem(Properties properties) { public InkVialItem(Properties properties) {
super(properties); super(properties);
@@ -30,37 +28,16 @@ public final class InkVialItem extends Item {
@Override @Override
public InteractionResult use(Level level, Player player, InteractionHand hand) { public InteractionResult use(Level level, Player player, InteractionHand hand) {
InteractionHand paperHand = hand == InteractionHand.MAIN_HAND if (level.isClientSide()) {
? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; // LOCAL gameplay config is not synchronized. Predict only item eligibility;
ItemStack vial = player.getItemInHand(hand); // the server decides affordability using its own configured cost.
ItemStack paper = player.getItemInHand(paperHand); var paperHand = hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
int ink = remaining(vial); int ink = remaining(player.getItemInHand(hand));
if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER) return player.getItemInHand(hand).is(Rivencraft.INK_VIAL.get())
|| ink < LINK_PANEL_COST || ink > CAPACITY) { && player.getItemInHand(paperHand).is(Items.PAPER) && ink > 0 && ink <= CAPACITY
return InteractionResult.PASS; ? InteractionResult.SUCCESS : InteractionResult.PASS;
} }
if (level.isClientSide()) return InteractionResult.SUCCESS; return WritingInkService.prepareLinkPanel(player, hand) ? InteractionResult.SUCCESS : InteractionResult.PASS;
ItemStack panel = PageItem.createLinkPanelPage();
int newInk = ink - LINK_PANEL_COST;
ItemStack spentVial = newInk == 0
? new ItemStack(Items.GLASS_BOTTLE)
: new ItemStack(Rivencraft.INK_VIAL.get());
if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
if (paper.getCount() == 1) {
player.setItemInHand(paperHand, panel);
} else {
paper.shrink(1);
giveOrDrop(player, panel);
}
if (vial.getCount() == 1) {
player.setItemInHand(hand, spentVial);
} else {
vial.shrink(1);
giveOrDrop(player, spentVial);
}
return InteractionResult.SUCCESS;
} }
@Override @Override
@@ -70,7 +47,4 @@ public final class InkVialItem extends Item {
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage")); tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage"));
} }
private static void giveOrDrop(Player player, ItemStack stack) {
if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY);
}
} }
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.item;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.Container;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.animal.equine.AbstractHorse;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
public final class LinkDisarmService {
private LinkDisarmService() {}
public static boolean mayTravel(Entity entity) {
// Legacy refuses loose items and dropped Link Books on Disarm links.
return !(entity instanceof ItemEntity);
}
public static void disarmAt(Entity entity, ServerLevel source, Vec3 departure) {
if (entity instanceof ServerPlayer player) {
eject(player.getInventory(), source, departure);
player.containerMenu.broadcastChanges();
}
if (entity instanceof Container container) eject(container, source, departure);
if (entity instanceof AbstractHorse horse) eject(horse.getInventory(), source, departure);
if (entity instanceof Mob mob) dropEquipment(mob, source, departure);
}
private static void eject(Container inventory, ServerLevel source, Vec3 departure) {
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
ItemStack stack = inventory.getItem(slot);
if (stack.isEmpty()) continue;
inventory.setItem(slot, ItemStack.EMPTY);
drop(stack, source, departure);
}
inventory.setChanged();
}
private static void dropEquipment(Mob mob, ServerLevel source, Vec3 departure) {
for (EquipmentSlot slot : EquipmentSlot.values()) {
ItemStack stack = mob.getItemBySlot(slot);
if (stack.isEmpty()) continue;
float chance = mob.getDropChances().byEquipment(slot);
mob.setItemSlot(slot, ItemStack.EMPTY);
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
if (chance <= 1 && stack.isDamageableItem()) {
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
1 + mob.getRandom().nextInt(minimum));
stack.setDamageValue(Math.clamp(damage, 1, minimum));
}
drop(stack, source, departure);
}
}
private static void drop(ItemStack stack, ServerLevel source, Vec3 position) {
var dropped = new ItemEntity(source, position.x, position.y, position.z, stack);
dropped.setDefaultPickUpDelay();
source.addFreshEntity(dropped);
}
}
@@ -18,7 +18,6 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.item.component.TooltipDisplay;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */ /** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
@@ -40,6 +39,10 @@ public final class LinkingBookItem extends Item {
} }
public static String travel(ServerPlayer player, ItemStack book) { public static String travel(ServerPlayer player, ItemStack book) {
return travel(player, book, false);
}
public static String travel(ServerPlayer player, ItemStack book, boolean portal) {
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get()); LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (destination == null) return "Unbound Linking Book (26.3 port preview)"; if (destination == null) return "Unbound Linking Book (26.3 port preview)";
@@ -71,14 +74,20 @@ public final class LinkingBookItem extends Item {
Vec3 incoming = player.getDeltaMovement(); Vec3 incoming = player.getDeltaMovement();
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(), boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true); Set.of(), destination.yaw(), destination.pitch(), true);
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw()); if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
return moved ? null : "Teleportation failed"; return moved ? null : "Teleportation failed";
} }
/** Link Panel effects are shared by Linking and Descriptive Books. */ /** Link Panel effects are shared by Linking and Descriptive Books. */
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties, public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) { ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
LinkVisualEffects.play(source, departure, player, properties, false); applySuccessfulLink(player, properties, source, departure, incoming, destinationYaw, false);
}
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw,
boolean portal) {
LinkVisualEffects.play(source, departure, player, properties, portal);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) { if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z); double speed = Math.hypot(incoming.x, incoming.z);
double yaw = Math.toRadians(destinationYaw); double yaw = Math.toRadians(destinationYaw);
@@ -88,17 +97,7 @@ public final class LinkingBookItem extends Item {
player.setDeltaMovement(Vec3.ZERO); player.setDeltaMovement(Vec3.ZERO);
} }
if (properties.has(LinkProperties.DISARM)) { if (properties.has(LinkProperties.DISARM)) {
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) { LinkDisarmService.disarmAt(player, source, departure);
ItemStack carried = player.getInventory().getItem(slot);
if (carried.isEmpty()) continue;
player.getInventory().setItem(slot, ItemStack.EMPTY);
ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z,
carried.copy());
dropped.setDefaultPickUpDelay();
source.addFreshEntity(dropped);
}
player.getInventory().setChanged();
player.containerMenu.broadcastChanges();
} }
} }
@@ -160,7 +160,7 @@ public final class PortBookNetworking {
environment.weather(), environment.lighting(), environment.sky(), environment.weather(), environment.lighting(), environment.sky(),
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed), net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed, net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()))); net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages);
net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level, net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level,
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages)); net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages));
RuntimeAgeBackend.setAgeDisplayName(server, id, title); RuntimeAgeBackend.setAgeDisplayName(server, id, title);
@@ -199,6 +199,10 @@ public final class PortBookNetworking {
} }
private static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand) { private static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand) {
travelDescriptive(player, stack, heldHand, false);
}
public static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand, boolean portal) {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
if (id == null) return; if (id == null) return;
ServerLevel source = player.level(); ServerLevel source = player.level();
@@ -209,7 +213,7 @@ public final class PortBookNetworking {
String result = PortAgeTravel.enter(player, id); String result = PortAgeTravel.enter(player, id);
if (result == null) LinkingBookItem.applySuccessfulLink(player, if (result == null) LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE), stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot()); source, departure, incoming, player.getYRot(), portal);
return result; return result;
}; };
String failure = heldHand == null ? travel.get() String failure = heldHand == null ? travel.get()
@@ -0,0 +1,352 @@
package net.dnorberg.rivencraft.portal;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
public final class CrystalPortalNetwork {
public static final int MAX_RADIUS = 16;
private static final int CLEANUP_RADIUS = MAX_RADIUS + 1;
private static final int MAX_CRYSTALS = 2048;
private static final int MAX_PORTALS = 1024;
private static final int[][] EXPANSION = {
{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1},
{1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0},
{1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1},
{0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1}
};
private CrystalPortalNetwork() {}
public record Plan(List<BlockPos> portalCells, boolean waitingForChunks) {
public Plan(List<BlockPos> portalCells) { this(portalCells, false); }
public boolean valid() { return !portalCells.isEmpty(); }
}
public enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS }
public static Plan plan(ServerLevel level, BlockPos receptacle) {
return plan(level, receptacle, false);
}
private static Plan plan(ServerLevel level, BlockPos receptacle, boolean allowUnownedCells) {
BlockState holder = level.getBlockState(receptacle);
if (!holder.is(Rivencraft.BOOK_RECEPTACLE.get())) return new Plan(List.of());
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
if (!level.hasChunkAt(base)) return new Plan(List.of(), true);
if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return new Plan(List.of());
Set<BlockPos> crystals = new HashSet<>();
ArrayDeque<BlockPos> pendingCrystals = new ArrayDeque<>();
pendingCrystals.add(base);
while (!pendingCrystals.isEmpty()) {
BlockPos pos = pendingCrystals.removeFirst();
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
if (!level.getBlockState(pos).is(Rivencraft.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
if (atBoundary(base, pos)) return new Plan(List.of());
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
for (Direction direction : Direction.values()) pendingCrystals.add(pos.relative(direction));
}
LinkedHashSet<BlockPos> portals = new LinkedHashSet<>();
ArrayDeque<BlockPos> candidates = new ArrayDeque<>();
Set<BlockPos> queued = new HashSet<>();
for (BlockPos crystal : crystals) addCandidates(crystal, base, queued, candidates);
while (!candidates.isEmpty()) {
BlockPos pos = candidates.removeFirst();
queued.remove(pos);
if (!level.isInWorldBounds(pos)) return new Plan(List.of());
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
if (portals.contains(pos) || !mayFill(level, pos, receptacle, allowUnownedCells)) continue;
if (adjacentCount(pos, crystals, portals) < 2) continue;
portals.add(pos);
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
addCandidates(pos, base, queued, candidates);
}
// Legacy's second pass deletes every cell without opposing neighbors on
// at least two axes. Repeat because deleting one cell can unbrace another.
boolean changed;
do {
changed = portals.removeIf(pos -> !hasTension(pos, crystals, portals));
} while (changed);
if (portals.isEmpty()) return new Plan(List.of());
// Every surviving cell must still be reachable from the receptacle's
// crystal base through this one network, never through another portal.
Set<BlockPos> reachable = new HashSet<>();
ArrayDeque<BlockPos> frontier = new ArrayDeque<>();
frontier.add(base);
while (!frontier.isEmpty()) {
BlockPos pos = frontier.removeFirst();
if (!reachable.add(pos)) continue;
for (Direction direction : Direction.values()) {
BlockPos next = pos.relative(direction);
if (!reachable.contains(next) && (crystals.contains(next) || portals.contains(next)))
frontier.add(next);
}
}
if (!reachable.containsAll(portals)) return new Plan(List.of());
return new Plan(List.copyOf(portals));
}
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
return activate(level, receptacle, plan, null);
}
/** Repairs only existing, framed cells whose owner pointer was lost by an older live-view update. */
public static boolean recoverSavedOwners(ServerLevel level, BlockPos receptacle) {
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|| holder.getBook().isEmpty()) return false;
Plan plan = plan(level, receptacle, true);
if (!plan.valid()) return false;
for (BlockPos pos : plan.portalCells()) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| portal.receptacle() != null && !receptacle.equals(portal.receptacle())) return false;
}
for (BlockPos pos : plan.portalCells()) {
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
if (portal.receptacle() == null) portal.setReceptacle(receptacle);
}
return true;
}
/** The player who inserted the book owns each newly placed portal cell for claim checks. */
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan, Entity actor) {
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|| holder.getBook().isEmpty()) return false;
List<BlockPos> placed = new ArrayList<>();
for (BlockPos pos : plan.portalCells()) {
if (!mayFill(level, pos, receptacle)) {
rollback(level, placed);
return false;
}
if (level.getBlockState(pos).isAir()) {
BlockSnapshot snapshot = actor == null ? null : BlockSnapshot.create(level.dimension(), level, pos);
BlockState prior = level.getBlockState(pos);
// Defer neighbor notifications until the entire opening exists.
// Otherwise the first cells see an incomplete brace and the
// portal's neighbor validator removes them during activation.
if (!level.setBlock(pos, Rivencraft.CRYSTAL_PORTAL.get().defaultBlockState(), 2)) {
rollback(level, placed);
return false;
}
if (snapshot != null) {
var event = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(event);
if (event.isCanceled()) {
snapshot.restore();
rollback(level, placed);
return false;
}
}
placed.add(pos);
}
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
portal.setReceptacle(receptacle);
} else {
rollback(level, placed);
return false;
}
}
orientSurfaces(level, plan);
for (BlockPos pos : plan.portalCells()) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| !receptacle.equals(portal.receptacle())) {
rollback(level, placed);
return false;
}
}
return true;
}
public static void shutdown(ServerLevel level, BlockPos receptacle) {
// A broken support can disconnect the graph. Scan a bounded volume rather
// than trusting a path through blocks that no longer exist.
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
if (!level.hasChunkAt(pos)) continue;
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& receptacle.equals(portal.receptacle()))
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
}
}
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
public static boolean reconcile(ServerLevel level, BlockPos receptacle) {
return reconcile(level, receptacle, plan(level, receptacle));
}
public static boolean reconcile(ServerLevel level, BlockPos receptacle, Plan plan) {
if (!plan.valid()) {
// Neighboring chunks can still be loading when the receptacle first
// ticks after a restart. An incomplete view is not a broken frame.
if (!plan.waitingForChunks()) shutdown(level, receptacle);
return false;
}
Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
// A background pass must not create blocks without the owner present:
// that would bypass the placement/protection event used at activation.
for (BlockPos pos : wanted) {
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|| !receptacle.equals(portal.receptacle())) {
shutdown(level, receptacle);
return false;
}
}
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
if (!level.hasChunkAt(pos)) continue;
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
}
orientSurfaces(level, plan);
return true;
}
private static void orientSurfaces(ServerLevel level, Plan plan) {
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) {
for (BlockPos pos : surface.cells()) {
BlockState state = level.getBlockState(pos);
Direction.Axis axis = surface.normal().getAxis();
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.AXIS, axis));
}
}
}
/** The vanilla-style surface remains visible until its optional live view is ready. */
public static void setLiveView(ServerLevel level, Plan plan, boolean live) {
for (BlockPos pos : plan.portalCells()) {
BlockState state = level.getBlockState(pos);
if (state.is(Rivencraft.CRYSTAL_PORTAL.get()) && state.getValue(CrystalPortalBlock.LIVE_VIEW) != live)
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.LIVE_VIEW, live));
}
}
private static void updatePortalState(ServerLevel level, BlockPos pos, BlockState replacement) {
BlockPos owner = level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
? portal.receptacle() : null;
if (!level.setBlock(pos, replacement, 2) || owner == null) return;
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& !owner.equals(portal.receptacle())) portal.setReceptacle(owner);
}
public static CellStatus cellStatus(ServerLevel level, BlockPos pos, BlockPos receptacle) {
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID;
if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS;
BlockState state = level.getBlockState(receptacle);
if (!state.is(Rivencraft.BOOK_RECEPTACLE.get())) return CellStatus.INVALID;
BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite());
if (!level.hasChunkAt(base)) return CellStatus.WAITING_FOR_CHUNKS;
if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return CellStatus.INVALID;
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder))
return CellStatus.WAITING_FOR_CHUNKS;
if (holder.getBook().isEmpty()) return CellStatus.INVALID;
// A neighboring chunk may hold one of the braces. Do not tear down a
// persisted portal until all six neighbors are available to inspect.
for (Direction direction : Direction.values())
if (!level.hasChunkAt(pos.relative(direction))) return CellStatus.WAITING_FOR_CHUNKS;
for (Direction.Axis axis : Direction.Axis.values()) {
// Quick tension check before the bounded network/path revalidation.
if (!axisBraced(level, pos, axis)) continue;
int other = 0;
for (Direction.Axis alternative : Direction.Axis.values())
if (alternative != axis && axisBraced(level, pos, alternative)) other++;
if (other > 0) return CellStatus.VALID;
}
return CellStatus.INVALID;
}
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
Direction negative = positive.getOpposite();
BlockPos a = pos.relative(positive);
BlockPos b = pos.relative(negative);
return level.hasChunkAt(a) && level.hasChunkAt(b)
&& isFrameOrPortal(level.getBlockState(a)) && isFrameOrPortal(level.getBlockState(b));
}
private static boolean isFrameOrPortal(BlockState state) {
return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get());
}
private static void rollback(ServerLevel level, List<BlockPos> placed) {
for (BlockPos pos : placed) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
}
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
return mayFill(level, pos, receptacle, false);
}
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle, boolean allowUnowned) {
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
BlockState state = level.getBlockState(pos);
return state.isAir() || state.is(Rivencraft.CRYSTAL_PORTAL.get())
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
&& (receptacle.equals(portal.receptacle()) || allowUnowned && portal.receptacle() == null);
}
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
int count = 0;
for (Direction direction : Direction.values())
if (crystals.contains(pos.relative(direction)) || portals.contains(pos.relative(direction))) count++;
return count;
}
private static boolean hasTension(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
int axes = 0;
for (Direction.Axis axis : Direction.Axis.values()) {
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
if (member(pos.relative(positive), crystals, portals)
&& member(pos.relative(positive.getOpposite()), crystals, portals)) axes++;
}
return axes >= 2;
}
private static boolean member(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
return crystals.contains(pos) || portals.contains(pos);
}
private static void addCandidates(BlockPos around, BlockPos base, Set<BlockPos> queued,
ArrayDeque<BlockPos> pending) {
for (int[] offset : EXPANSION) {
BlockPos candidate = around.offset(offset[0], offset[1], offset[2]);
if (within(base, candidate) && queued.add(candidate)) pending.add(candidate);
}
}
private static boolean within(BlockPos center, BlockPos pos) {
return Math.abs(center.getX() - pos.getX()) <= MAX_RADIUS
&& Math.abs(center.getY() - pos.getY()) <= MAX_RADIUS
&& Math.abs(center.getZ() - pos.getZ()) <= MAX_RADIUS;
}
private static boolean atBoundary(BlockPos center, BlockPos pos) {
return Math.abs(center.getX() - pos.getX()) == MAX_RADIUS
|| Math.abs(center.getY() - pos.getY()) == MAX_RADIUS
|| Math.abs(center.getZ() - pos.getZ()) == MAX_RADIUS;
}
}
@@ -0,0 +1,59 @@
package net.dnorberg.rivencraft.portal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.AABB;
/** Groups portal cells into coherent, coplanar views while retaining irregular outlines. */
public final class CrystalPortalSurfaces {
private CrystalPortalSurfaces() {}
public record Surface(Direction normal, AABB bounds, List<BlockPos> cells) {}
private record Plane(Direction normal, int coordinate) {}
public static List<Surface> group(ServerLevel level, CrystalPortalNetwork.Plan plan) {
Map<Plane, List<BlockPos>> planes = new LinkedHashMap<>();
for (BlockPos cell : plan.portalCells()) {
Direction normal = planeNormal(level, cell);
if (normal == null) continue;
int coordinate = normal.getAxis().choose(cell.getX(), cell.getY(), cell.getZ());
planes.computeIfAbsent(new Plane(normal, coordinate), ignored -> new ArrayList<>()).add(cell);
}
List<Surface> result = new ArrayList<>();
for (Map.Entry<Plane, List<BlockPos>> entry : planes.entrySet()) {
List<BlockPos> cells = entry.getValue();
int minX = cells.stream().mapToInt(BlockPos::getX).min().orElseThrow();
int minY = cells.stream().mapToInt(BlockPos::getY).min().orElseThrow();
int minZ = cells.stream().mapToInt(BlockPos::getZ).min().orElseThrow();
int maxX = cells.stream().mapToInt(BlockPos::getX).max().orElseThrow() + 1;
int maxY = cells.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 1;
int maxZ = cells.stream().mapToInt(BlockPos::getZ).max().orElseThrow() + 1;
result.add(new Surface(entry.getKey().normal(), new AABB(minX, minY, minZ, maxX, maxY, maxZ),
List.copyOf(cells)));
}
return List.copyOf(result);
}
private static Direction planeNormal(ServerLevel level, BlockPos cell) {
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.EAST)) return Direction.NORTH;
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.SOUTH)) return Direction.EAST;
if (braced(level, cell, Direction.EAST) && braced(level, cell, Direction.SOUTH)) return Direction.UP;
return null;
}
private static boolean braced(ServerLevel level, BlockPos cell, Direction direction) {
return linked(level, cell.relative(direction)) && linked(level, cell.relative(direction.getOpposite()));
}
private static boolean linked(ServerLevel level, BlockPos pos) {
if (!level.hasChunkAt(pos)) return false;
var state = level.getBlockState(pos);
return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get());
}
}
@@ -0,0 +1,80 @@
package net.dnorberg.rivencraft.portal;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.LinkDisarmService;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.LinkVisualEffects;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
/** Portal transport uses the book's arrival rules; no independent portal exit offset. */
public final class PortalBookTravel {
private PortalBookTravel() {}
public static boolean available(ServerLevel source, ItemStack book) {
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
: book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null;
return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null;
}
public static void travel(Entity entity, ServerLevel source, ItemStack book) {
if (entity instanceof ServerPlayer player) {
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
net.dnorberg.rivencraft.network.PortBookNetworking.travelDescriptive(player, book, null, true);
} else {
String failure = LinkingBookItem.travel(player, book, true);
if (failure != null) player.sendOverlayMessage(net.minecraft.network.chat.Component.literal(failure));
else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
}
return;
}
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
if (descriptive) {
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) return;
var landing = PortAgeTravel.resolveLanding(level);
if (landing == null) return;
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
entity.getYRot(), entity.getXRot());
}
if (destination == null || !destination.valid()) return;
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null || !entity.canTeleport(source, target) || !descriptive && target == source && !properties.has(LinkProperties.INTRA_LINKING)) return;
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) return;
Vec3 arrival = new Vec3(destination.x(), destination.y(), destination.z());
target.getChunkAt(feet);
if (!target.noCollision(entity, entity.getBoundingBox().move(arrival.subtract(entity.position())))) return;
Vec3 incoming = entity.getDeltaMovement();
Vec3 velocity = Vec3.ZERO;
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z), yaw = Math.toRadians(destination.yaw());
velocity = new Vec3(speed * Math.cos(yaw), incoming.y + 0.2, speed * Math.sin(yaw));
}
Vec3 departure = entity.position();
// Disarm only after a successful transfer, leaving equipment in the source world.
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
if (arrived == null) return;
if (properties.has(LinkProperties.DISARM)) {
LinkDisarmService.disarmAt(arrived, source, departure);
}
arrived.resetFallDistance();
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
var sound = LinkVisualEffects.sound(properties, true);
source.playSound(null, departure.x, departure.y, departure.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
target.playSound(null, arrival.x, arrival.y, arrival.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.portal;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.world.entity.Entity;
/** Session-only portal cooldowns: tick counters must never be saved with players. */
public final class PortalTravelCooldown {
public static final long TICKS = 40;
private static final Map<Entity, Long> LAST_USE = new WeakHashMap<>();
private PortalTravelCooldown() {}
public static boolean blocked(long now, long previous) {
return previous >= 0 && now >= previous && now - previous < TICKS;
}
public static boolean blocked(Entity entity, long now) {
return blocked(now, LAST_USE.getOrDefault(entity, -1L));
}
public static void mark(Entity entity, long now) {
LAST_USE.put(entity, now);
}
public static void forget(Entity entity) {
LAST_USE.remove(entity);
}
public static void clear() {
LAST_USE.clear();
}
}
@@ -0,0 +1,54 @@
package net.dnorberg.rivencraft.portal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
/** Collision callbacks enqueue once; transport occurs after the entering movement packet finishes. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortalTravelQueue {
private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long tick) {}
private static final Map<Entity, Pending> PENDING = new IdentityHashMap<>();
private PortalTravelQueue() {}
public static void enqueue(Entity entity, ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long now) {
PENDING.putIfAbsent(entity, new Pending(source, portal.immutable(), receptacle.immutable(), book.copyWithCount(1), now));
}
@SubscribeEvent public static void tick(ServerTickEvent.Post event) {
var server = event.getServer();
var ready = new ArrayList<Map.Entry<Entity, Pending>>();
var iterator = PENDING.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
if (server.getTickCount() <= entry.getValue().tick()) continue;
ready.add(Map.entry(entry.getKey(), entry.getValue()));
iterator.remove();
}
for (var entry : ready) {
Entity entity = entry.getKey();
Pending pending = entry.getValue();
if (entity.isRemoved() || !entity.isAlive() || entity.level() != pending.source()
|| entity instanceof ServerPlayer player && server.getPlayerList().getPlayer(player.getUUID()) != player
|| !pending.source().hasChunkAt(pending.portal()) || !pending.source().hasChunkAt(pending.receptacle())
|| !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal)
|| !pending.receptacle().equals(portal.receptacle())
|| !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder)
|| !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())
|| CrystalPortalNetwork.cellStatus(pending.source(), pending.portal(), pending.receptacle())
!= CrystalPortalNetwork.CellStatus.VALID) continue;
PortalBookTravel.travel(entity, pending.source(), pending.book());
}
}
@SubscribeEvent public static void stopped(ServerStoppedEvent event) { PENDING.clear(); PortalTravelCooldown.clear(); }
}
@@ -23,6 +23,7 @@ public final class ModCreativeTabs {
output.accept(Rivencraft.DESCRIPTIVE_BOOK.get()); output.accept(Rivencraft.DESCRIPTIVE_BOOK.get());
output.accept(Rivencraft.LINKING_BOOK.get()); output.accept(Rivencraft.LINKING_BOOK.get());
output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get()); output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get());
output.accept(Rivencraft.BOOK_RECEPTACLE_ITEM.get());
output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get()); output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get());
output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get()); output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get());
output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get()); output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get());
@@ -57,7 +57,8 @@ public final class PortAuthoredWriting {
if (entry == null) return false; if (entry == null) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true; || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) return true; if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
|| net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) return true;
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null
|| PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true; || PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true;
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true; if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
@@ -129,6 +130,8 @@ public final class PortAuthoredWriting {
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) { || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) {
// Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky. // Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky.
} else if (net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) {
// Legacy corrupted pages contribute instability, not terrain features.
} else if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) { } else if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) {
// Every Accelerated page adds random-tick attempts; it is not a controller. // Every Accelerated page adds random-tick attempts; it is not a controller.
} else if (isTerrain(page) && !terrain) { } else if (isTerrain(page) && !terrain) {
@@ -0,0 +1,77 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.util.Prediction;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Server-owned costs and the baseline blank-page-to-link-panel transaction. */
public final class WritingInkService {
private WritingInkService() {
}
public static int symbolCopyCost() {
return PortConfig.SYMBOL_INK_COST.get();
}
public static int linkPanelCost() {
return PortConfig.LINK_PANEL_INK_COST.get();
}
public static boolean canPrepareLinkPanel(Player player, InteractionHand vialHand) {
ItemStack vial = player.getItemInHand(vialHand);
ItemStack page = player.getItemInHand(other(vialHand));
int remaining = InkVialItem.remaining(vial);
return vial.is(Rivencraft.INK_VIAL.get()) && remaining >= linkPanelCost()
&& remaining <= InkVialItem.CAPACITY
&& page.is(Items.PAPER);
}
public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) {
if (player.level().isClientSide() || !canPrepareLinkPanel(player, vialHand)) {
return false;
}
InteractionHand pageHand = other(vialHand);
ItemStack page = player.getItemInHand(pageHand);
ItemStack vial = player.getItemInHand(vialHand);
int newInk = InkVialItem.remaining(vial) - linkPanelCost();
ItemStack panel = PageItem.createLinkPanelPage();
ItemStack spentVial = newInk == 0
? new ItemStack(Items.GLASS_BOTTLE)
: new ItemStack(Rivencraft.INK_VIAL.get());
if (newInk > 0) {
spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
}
// All validation and output construction is complete before changing either input.
if (page.getCount() == 1) {
player.setItemInHand(pageHand, panel);
} else {
page.shrink(1);
giveOrDrop(player, panel);
}
if (vial.getCount() == 1) {
player.setItemInHand(vialHand, spentVial);
} else {
vial.shrink(1);
giveOrDrop(player, spentVial);
}
return true;
}
private static InteractionHand other(InteractionHand hand) {
return hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
}
private static void giveOrDrop(Player player, ItemStack stack) {
if (!player.getInventory().add(stack)) {
player.drop(stack, false, Prediction.SERVER_ONLY);
}
}
}
@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "rivencraft:item/book_receptacle"
}
}
@@ -0,0 +1,34 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "rivencraft:crystal_block"
}
]
}
},
"has_the_recipe": {
"trigger": "minecraft:recipe_unlocked",
"conditions": {
"recipes": [
"rivencraft:book_receptacle"
]
}
}
},
"requirements": [
[
"has_material",
"has_the_recipe"
]
],
"rewards": {
"recipes": [
"rivencraft:book_receptacle"
]
}
}
@@ -0,0 +1,16 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"pattern": [
"CCC",
"C C",
"CCC"
],
"key": {
"C": "rivencraft:crystal_block"
},
"result": {
"id": "rivencraft:book_receptacle",
"count": 1
}
}