Port live instability, writing recovery and Link Modifier controls to 26.3
Build / build (push) Waiting to run

This commit is contained in:
dnorberg committed 2026-11-10 17:40:52 -05:00
1 parent d2904eaa8d
commit 32c52e635f
41 files changed
+2611 -77

No files matched your search

+262
View File
@@ -2326,3 +2326,265 @@ The user passed .120 and authorized commit, push and publication of the accumula
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.
## .121 lightning/explosion providers
The player passed .120. Adapted the 1.21.1 hazard implementation, independent hazard
scheduler lane, loaded-only ring candidates, Legacy aggregated rates, bounded explosion
calculator, entity/Creative policy, protected-block/spawn checks and event guards.
Modern differences: EntityTypes registry constants, explicit entity spawn reason,
server damage API, gamerule registry, spatial fire-spread permission, BreakBlockEvent,
and explosion damage exposure argument. Since Level.explode now returns void, the
calculator retains its approved positions for bounded post-blast fire and records
whether an uncancelled explosion actually began. Lightning dispatch also reports
whether its entity was accepted. Existing optional mods are not required.
Per-Age modern SavedData persists attempt count and separate hazard cooldowns (with
the meteor field retained for the next provider port). Default config matches 1.21.1.
Added OP `/rivencraft_port instability hazard lightning|explosions` to skip probability
without bypassing cooldowns, loaded terrain, Age/config/provider policy or spawn
exclusion. Console sources may supply dimension/position explicitly. Neither the
command nor provider registration changes saved instability scores/decks.
Version 1.0.1.121-beta.1 builds and starts a dedicated server. The user offered manual
damage/block-destruction testing, so the temporary server was stopped before running
those fixtures and the world removed. Effects, visual/audio behavior, damage/block
caps and policy/restart checks are handed over; they are not reported as tested.
No new permanent GameTests, commit, push or publication. Meteor, burning, erosion,
crumble, full grammar recovery and live material profiling remain pending.
## .122 meteor provider
The player passed .121. Extracted the meteor-specific portion of the existing 1.21.1
AgeEnvironmentalProviders into a focused modern provider. The effect remains the
30-tick flame/smoke flight and roar followed by a strength-3 bounded impact, without
inventing a new rendered entity. Natural chance, randomized chunk/central coordinates,
48-block descent height, headroom/spawn checks, 200-tick cooldown, one active flight
per Age and 16 active Ages retain the root values.
The selected card uses an independent scheduler pass, sharing the existing hazard
candidate ring and saved attempt/cooldown data. The impact reuses the .121 blast
guards with meteor-specific config (24 blocks, eight entities, 12 damage by default).
Event handlers cancel active flights on disabled policy, absent players, unavailable
target chunk, Age unload or shutdown; session-only descent state is not replayed.
Modern changes use loaded ChunkPos accessors, max-Y API and codec SavedData already
ported for hazards. No optional mod dependency or new asset was introduced.
Added OP `/rivencraft_port instability meteor`, bypassing chance only. Status reports
meteor config distinctly from potion policy. The original roar is verified in the
built JAR. Version 1.0.1.122-beta.1 builds; effects, damage/protection, cooldown and
cancellation/restart behavior are delegated to the player's manual testing as offered.
No server/test world was launched, no permanent test added, and no commit/publication.
Burning, erosion/crumble, full grammar recovery and live material profiling remain.
## .123 burning and erosion
The player passed .122. Extracted/adapted the 1.21.1 burning/erosion handlers into a
focused provider class, retaining bounded candidate scans, seeded attempt sequence,
independent scheduler budgets and protection/event logic. Burning keeps the active
Legacy card, sky exposure, Survival/Adventure targeting, four targets per pass and
4..12-second duration. The manual pulse targets the player and uses modern water/rain
checking rather than the removed isInWaterRainOrBubble API.
Erosion stays absent from the Legacy deck, with the prior requested testing default
of enabled at >=10,000 current instability. It converts at most one eligible sampled
block to an adjacent above/horizontal source fluid. Current score resolves creation
contributions or an explicit session override; live profiling is not yet claimed.
The pulse requires a real aimed block within six blocks and the same threshold and
mutation/protection policy. Modern BreakBlockEvent and snapshot placement rollback
are reused. Provider status distinguishes these controls from potion policy.
Added OP `/rivencraft_port instability environment burning|erosion`. No scores/plans
are changed by provider registration or test pulses. Config names/defaults match the
1.21.1 scorching/erosion sections. Version 1.0.1.123-beta.1 builds; visual/damage/fluid,
exemption, policy and natural scheduling checks are handed to the player. No temporary
server/world or permanent fixtures were created; no commit/push/publication.
## .124 live material profiling
The player passed .123. Crumble has no active 1.21.1 handler, so it remains explicitly
unavailable. Ported AgeBlockInstability and modern per-dimension profile SavedData,
using the same 401/500 readiness thresholds, 1,024 sample cap, watched/native/modded
weights, 5% Overworld allowance, 100,000-point cap and difficulty scaling. Registry
material eligibility reuses the Page pool rules; common ore/storage tags use modern
BlockItems tag accessors. Installed content remains optional; exact weight overrides
retain the existing root syntax and validation.
Deferred chunk-load sampling processes four entries/tick with a bounded queue. Spawn
completion requests one chunk per second across occupied profiling Ages and their
baseline, with at most one request in flight. The off-thread getChunkFuture path was
verified in the local 26.3 source to avoid main-thread managedBlock. Completion checks
server stop/level identity. Existing sampled chunks are not recounted. Effective
provider plans now combine live points and frozen writing, cache stable totals/seeds,
and retain the original saved plan. Old neutral Ages stay excluded. OP status shows
progress/totals and session override reset restores the live effective plan.
Version 1.0.1.124-beta.1 builds and starts a dedicated server. Real chunk-load sampling
persisted 25 snapshots and counted a fixture Diamond Block. A bounded restart test
used synthetic sample records, plus resumed actual loads: 423 Age / 522 baseline
samples yielded 2,987 live points (independently calculated), a frozen writing score
of zero and live selected providers. Override reset restored that plan. No hundreds-
of-chunks generation test was run; occupied-Age background generation and generated
material/modded/config checks are delegated to the player. Server stopped and world
removed. No permanent fixtures, commit, push or publication.
## .125 ordered controller recovery
The player passed .124. Added semantic-role metadata to the port symbol index and
a focused recovery pass for singular-controller duplicates/conflicts, category
order, Native-with-biomes and Single-with-extra-biomes. Last singular choice wins;
Single retains the first biome; Native drops authored biome choices. Duplicate or
conflict diagnostics contribute 500 points and order diagnostics 100, following
the corresponding 1.21.1 grammar rules. Costs retain original page indices.
Binder support and server terrain/environment selection share recovered choices.
Original book/library pages, color boundaries and celestial-chain indices remain
intact; raw number/celestial validation and all unimplemented-page guards still
apply. Missing biome registry entries are checked even if recovery would discard
the page. Ambient Color has its own stage and is not a singular lighting choice.
Existing Ages are unchanged. This is the first recovery slice, not completion of
celestial/dangling/material grammar recovery or season integration.
Version 1.0.1.125-beta.1 builds; 13 temporary focused checks pass, covering recovery,
costs, preserved chains and rejection boundaries. No server/test world or permanent
fixtures. In-game last-choice behavior, original-page retention and OP diagnostics
are delegated to the player. No commit, push or publication.
## .126 ordered celestial and number recovery
The player passed .125. Ported the 1.21.1 grammar treatment of pending celestial
modifiers and numeric adjacency. Repeated Length/Phase/Angle/Size/Direction pages
before a celestial use the final value of that kind without a duplicate penalty.
Only the final unconsumed modifier of each kind adds 100 dangling-modifier points.
A number without an immediately following supported Color/Length/Phase/Angle/Size
page adds 100 points (misplaced, or dangling at the end). Consecutive numbers leave
only the last available for the next target. Colors still mix in written order.
These defects no longer block binding. Numbers and celestial compilation retain
the original page sequence and seeded color indices; controller recovery never
moves a number next to a new target. A trailing modifier cannot rewrite an earlier
body, including the optimized single-body compilation path. Missing celestial
branches retain their existing seeded completion; dangling controls do not apply
to those generated bodies. All original book/library pages remain. Unsupported
pages and the ten-sun/ten-moon limit still block binding; existing Ages are unchanged.
Version 1.0.1.126-beta.1 builds. Seventeen temporary focused checks passed, covering
last-value selection, dangling costs/indices, numeric adjacency, per-body isolation,
seeded color stability, valid numbered writing, body limits, unsupported pages and
cycle codec round-trip. No server/world or permanent test fixture was created.
Manual validation: bind a book with Half Length, Double Length, Sun and verify the
last length; bind Sun followed by Large Size and confirm it does not resize that
sun. Test a trailing Number page and inspect the added writing contribution with
`/rivencraft_port instability status`. Original pages should remain visible.
No commit, push or publication.
## .127 Link Modifier station: title and core link properties
The player passed .126. Started the missing 1.21.1 Link Modifier station port,
reusing its block model, textures, horizontal facing variants and Legacy 176x166
single-book GUI. The item is available in the main creative tab or via
`/give @s rivencraft:link_modifier`; it has no crafting recipe. As in 1.21.1,
creative acquisition does not impose a new permission restriction on ordinary
book edits once the station has been placed.
One persistent slot accepts Linking and Descriptive Books, including unbound
books, with normal and shift-click transfers. The server menu handles renaming
and Disarm, Maintain Momentum and Intra Linking toggles. Descriptive Books cannot
toggle Intra Linking, which only governs same-dimension Linking Book travel.
Existing target, author, symbols and Age identity are retained. Renaming changes
the item's custom title, not the Age's saved folder/registry identity. Removing
the title restores the item's normal name. Text editing consumes inventory/hotbar
shortcuts while focused; Escape closes the screen. Programmatic text refreshes do
not send rename packets. Menu identity, owner, distance and live station checks
protect edits; unknown button IDs and non-book inputs are rejected.
The book is saved with modern ValueInput/ValueOutput container serialization.
Station removal uses block-entity preRemoveSideEffects to release the stored book
once, including non-player removal. No tick handler or renderer is needed.
This is a station slice, not full Link Modifier parity. Seed editing, Age retirement,
Relative Link, Intra-Linking Only and platform overrides remain unported and are
not presented as working controls. Existing core flag behavior is reused unchanged.
Manual validation: place the station in multiple orientations, insert/shift-click
both book types, rename using text containing E, toggle each available property,
remove the book and use it to check travel effects. Confirm Descriptive Book Intra
Linking is unavailable, restart with a book left in the station, and break a loaded
station to recover one book. Build and dedicated persistence results are recorded
below. No commit, push or publication.
Version 1.0.1.127-beta.1 builds. Packaged model, GUI, texture, item definition,
loot table and localization checks passed; no station recipe is included. A
fresh dedicated server placed the station and serialized a named Linking Book
with all three core flags. Restart retained the same book/name/flags and station
removal completed normally. The first harness run expected the wrong wording
for vanilla force-load cleanup; the corrected harness completed successfully.
Both temporary worlds were deleted and servers stopped. No permanent tests were
added. UI interaction, transfer counts and modifier travel effects await the
player's in-game check.
## .128 six Link Modifier controls
The player reported that .127 exposed only three of 1.21.1's six controls. Added
the remaining Intra-Linking Only, Relative Link and Generate Platform controls in
the existing two-column, three-row Legacy layout, with matching bit values and
server-owned menu actions. Enabling Intra-Linking Only enables Intra Linking too;
disabling Intra Linking clears Intra-Linking Only. Both intra controls are disabled
for Descriptive Books. Unknown actions and out-of-range menu edits remain rejected.
Linking Book Intra-Linking Only blocks cross-dimension travel. Relative Link uses
the traveller's block offset from the source world's spawn, applied to destination
spawn, preserving destination yaw/pitch. Normal links retain their precise bound
coordinates. Player and non-player portal travel share the new flags and relative
coordinate helper. Descriptive relative arrivals still require proper footing.
Generate Platform is off by default for Linking Books. When enabled, a successful
link fills one missing floor block with cloud platform only if the block two above
the arrival is air, matching the 1.21.1 helper. Existing floors are retained.
Descriptive Books instead default to enabled and use Disable Platform internally;
turning it off prevents fallback platform creation and refuses travel if no footed
landing is available. Receptacle tint preparation respects that setting too.
New flags serialize in the shared component and appear in book/panel tooltips.
Existing three-flag books remain valid. Seed editing and Age retirement remain
separate, unfinished station work.
Version 1.0.1.128-beta.1 builds. A temporary dedicated-server fixture passed 19
checks: menu actions/dependencies, descriptive intra rejection, numeric flag codec,
relative offsets, platform-off/default behavior, platform placement/preservation,
invalid input and remote edit protection. It did not perform real client travel.
The test server stopped and its world/fixture sources were removed. The production
JAR is rebuilt without that fixture. Manual checks: all six buttons and tooltips,
same/cross-dimension Intra-Linking Only, Relative Link offsets, and platform on/off
for both book types (including a new Void Age without footing). No commit, push
or publication.
## .129 Link Modifier mouse input repair
The player reported that none of the six controls responded. Confirmed the
26.3 input constants in the local Minecraft artifact: left/middle/right are
1/2/3, while the custom Link Modifier handler checked the old left-button value
0. Its server actions therefore passed the prior direct-menu fixture while
real mouse clicks never sent an action.
Replaced the manually drawn/clicked areas with six registered Minecraft Button
widgets at the same Legacy positions. Their callbacks send the existing validated
menu action IDs, and their custom rendering retains the square controls. Native
input provides correct mouse mapping, focus, click sound and keyboard activation.
All six tooltips expose their On/Off state; unavailable Descriptive Book intra
controls remain disabled. Tooltip objects update only when state changes so their
hover delay is not reset every tick. Book mutations/travel code is unchanged.
Version 1.0.1.129-beta.1 builds. The native widget's left-button mapping and callback
dispatch were verified in the local Minecraft bytecode. No client click test or
new server run was performed; the previous 19 checks covered server actions, not
client input. Please click each option with an inserted Linking Book and verify
its selected shading and tooltip/book properties change, then repeat supported
options with a Descriptive Book. No commit, push or publication.
+392 -3
View File
@@ -9,9 +9,11 @@ biome-distribution pages, selected material/vegetation/structure features, and
independent Age weather and lighting, multiple authored or seeded celestial
bodies and eclipses, biome/material completion, and random features/structures.
Crystal portals and libraries are ported. Instability scoring/provider selection,
status effects and colored decay are partially ported; remaining hazards, live material profiling,
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).
status effects, colored decay, lightning, explosions, meteors, burning and erosion,
ordered writing recovery, live material profiling and all six Link Modifier controls are now ported.
Link Modifier seed editing/retirement, remaining discovery/configuration and optional
integrations remain unfinished. The latest published beta is
[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md).
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.
@@ -2142,3 +2144,390 @@ No permanent test fixtures, commit, push or publication.
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.
## Beta .121 — lightning and explosions
The player passed .120. This slice ports the 1.21.1 lightning/explosion providers,
their separate scheduling lane, persisted per-Age attempt sequence/cooldowns,
loaded-chunk candidate selection and existing damage/protection settings.
Each selected hazard receives a roll on each admitted Age scheduler pass, independent
of rotating potion/decay providers. Legacy per-chunk/per-tick odds are aggregated as
`1 - (1 - 1/denominator)^(loadedChunks * interval * effectiveCopies)`. Denominators
remain 100,000 for clear-weather lightning, 5,000 during rain plus thunder, and 1,000
for explosions. Candidate chunks are already-loaded rings around Age players, capped
at 81 by default; no chunks are generated for hazards. Lightning uses the motion-blocking
surface; natural explosions choose a random Y across the dimension's modern height.
Defaults match 1.21.1: hazards/block damage/player damage/fire enabled, 20-tick minimum
gap per hazard type, strength-3 blasts, at most 16 distinct destructible blocks and
eight damaged entities, and at most 12 damage per entity (lightning itself caps at 5).
Creative/Spectator are excluded. Spawn columns within eight blocks horizontally are
excluded as targets; protected infrastructure, block entities, TNT, portals and
unbreakable blocks resist blasts. Break and placement cancellation checks remain.
World mutation and mobGriefing gate block damage; entity damage and visual/audio
feedback can remain active when block mutation is disabled.
The modern API no longer returns an explosion/block list. The damage calculator
retains its bounded approved positions for post-blast ignition, only where the block
is now air and fire placement is allowed. Cancelled explosion startup does not count
as dispatch or generate follow-up fire. NeoForge's renamed BreakBlockEvent and 26.3's
`canSpreadFireAround` replace the old break event and doFireTick query respectively.
Vanilla explosion networking and lightning entities provide existing visuals/sounds.
The `instability_hazards` config section retains the 1.21.1 setting names/defaults.
Operator checks inside an Age:
- `/rivencraft_port instability hazard lightning`
- `/rivencraft_port instability hazard explosions`
These skip random probability only, retaining loaded-chunk, cooldown, Age/config/provider
and protected-spawn checks. They target a nearby loaded chunk center, using its surface
for lightning and the command's Y for explosions, rather than the player's exact feet.
Move away from the arrival area and allow at least one second between repeats of the
same hazard. The command reports no dispatch when blocked/cancelled. It also works with
an explicitly positioned console source. Generic `pulse lightning`/`pulse explosions`
retain their natural probability roll; `test_score`/`status` exercise normal selection.
The saved score/plan and catalog protocol are unchanged.
Build and dedicated-server startup passed. The user offered to validate damage and
block destruction manually, so the server was stopped before effect/terrain fixtures
were run and its temporary world was removed. Manual checks include lightning/thunder,
explosion visuals/sound, damage caps and Creative immunity, block/protected-block limits,
spawn exclusion, world-mutation/mobGriefing/fire settings, cooldown/restart persistence,
and natural rates in occupied Ages. No permanent tests, commit, push or publication.
## Beta .122 — meteor flight and impacts
The player passed .121 lightning/explosion testing. Meteors now reuse the 1.21.1
particle descent, roar sound, selected Legacy provider card, independent scheduling
lane and bounded blast helper. They are not a new falling-block/entity model: a
flame/smoke trail descends for 30 ticks (1.5 seconds) from up to 48 blocks above a
captured surface target, followed by the impact. The existing meteor roar is bundled.
Natural scheduling aggregates the Legacy 1-in-50,000 per-chunk/per-tick odds across
loaded chunks, scheduler interval and effective card copies, as in 1.21.1. Targets
are randomized among already-loaded nearby candidate chunks; X/Z vary within the
safe central area of each chunk. Up to 16 candidate chunks are considered per
schedule attempt. Target selection rejects protected spawn columns and surfaces
without headroom. No terrain is loaded or generated by the provider.
The shared hazard SavedData preserves the meteor cooldown (200 game ticks) and random
attempt sequence. At most one meteor can descend per Age and 16 Ages can have active
flights. A pending descent is session-only: server stop, Age unload, no players,
unloaded target chunk, or disabled Age/global/provider/meteor policy cancels it.
Interrupted flights never replay after restart; the saved cooldown remains. The
flight is removed before impact dispatch so a failed impact cannot repeat.
The independent `[instability_meteor]` settings match 1.21.1: enabled/block damage/
player damage/fire default true, maximum 24 impact blocks, eight damaged entities,
and 12 damage per entity, strength 3. Impact reuses protected block/BE/infrastructure,
claim events, Creative/Spectator immunity, world-mutation/mobGriefing and modern fire
permission checks. Disabling block mutation still permits descent, sound and eligible
entity damage. Generic instability status reports meteor availability separately
from potion policy; saved scores/decks and catalog protocol are unchanged.
Test inside an Age, away from its arrival area:
`/rivencraft_port instability meteor`
This skips the natural probability roll, retaining cooldown, loaded target, active
flight and all policy checks. It uses randomized nearby impact locations, unlike
the deliberately fixed chunk-center lightning/explosion test commands. Wait at
least ten seconds before repeating. `/rivencraft_port instability pulse meteors`
keeps the natural chance roll; test-score/status tools exercise saved deck selection.
The build passed and JAR sound/class packaging was verified. Following the player's
manual validation preference, particle/audio presentation, impacts/damage/protection,
repeat cooldown, disabling/leaving during flight and restart cancellation are handed
over for in-game testing. No server or new world was launched for this slice. No new
permanent tests, commit, push or publication.
## Beta .123 — burning and erosion
The player passed .122 meteor testing. This slice adapts the remaining burning and
erosion portions of 1.21.1 AgeEnvironmentalProviders, using the existing hazard
candidate selection, saved attempt sequence, protection helpers and independent
provider scheduling. No new textures, entities, sounds or optional mods are required.
Burning remains an active Legacy deck card. Sky-exposed eligible living entities
outside protected spawn can ignite; Survival/Adventure players are included by
default and Creative/Spectator are excluded. Natural processing inspects at most
eight loaded chunks/eight eligible candidates per chunk, ignites at most one per
inspected chunk and four entities per pass by default. The old 1-in-10 per-tick gate
is aggregated over the scheduler interval. Fire lasts four seconds per effective
provider level, capped at twelve. Vanilla water/rain extinguishing and fire immunity
remain in force. Disabling the provider stops new ignitions; existing fire follows
vanilla behavior rather than the potion lease cleanup mechanism.
Erosion remains outside Legacy's saved card deck. As requested for testing in 1.21.1,
its config defaults enabled and natural attempts run in admitted occupied Ages at a
current score of at least 10,000. Current score uses the frozen creation total or an
explicit session test-score override; live material profiling remains pending.
At most eight random positions are sampled in already-loaded candidate chunks per
pass, with at most one conversion. An eligible block next to a source fluid above or
on a horizontal side is replaced with that fluid's source block; fluid below does
not grow upward. Normal fluid ticks can subsequently spread it.
Erosion requires world_mutation and mobGriefing, and reuses decay's protection against
spawn/infrastructure, block entities, fluids, critical/unbreakable blocks and protected
tags. Modern break/place claim events and rollback remain. Its `instability_erosion`
enabled toggle does not add a new saved card or alter the Age score. Burning uses
`instability_scorching`: enabled/affect_players default true and max_targets_per_pass
is 4 (range 0–16), matching 1.21.1.
Operator manual checks inside an Age:
- On dry ground under open sky, outside arrival protection and in Survival, run
`/rivencraft_port instability environment burning`. It targets the player directly
for four seconds, skips the chance roll, and explicitly reports water/rain that
would immediately extinguish the fire.
- Place ordinary Stone beside or below source water/lava, outside protected arrival.
Use `/rivencraft_port instability test_score 10000`, look at the Stone within six
blocks, and run `/rivencraft_port instability environment erosion`. The aimed
block should become that adjacent source fluid. Existing policies still apply.
End with `/rivencraft_port instability clear_test_score` to restore the saved plan.
- Automatic burning needs a selected burning card; natural erosion uses the enabled
test policy/threshold. `status` exposes both config/threshold information when
inspecting the respective provider, without adding scores to ordinary books.
Build passed. The player is performing visual/effect testing, so no server/world or
new permanent fixtures were created. Manual validation covers burning/fire damage,
Creative/shelter/spawn exclusion, wet extinguishing feedback, source-fluid conversion,
protected blocks, mobGriefing/world-mutation/config switches and natural scheduling.
No commit, push or publication. Crumble, full writing recovery and live abundance
profiling remain major instability work.
## Beta .124 — live material-abundance instability
The player passed .123. This slice ports 1.21.1's Legacy-style generated-block
profiling and live effective provider selection. Crumble remains a catalog entry
without a registered handler in 1.21.1; no new Crumble behavior is invented here.
Overworld and runtime-Age chunk loads enqueue bounded sampling work, rather than
accessing SavedData during chunk-storage callbacks. Four snapshots are processed
per server tick, with a queue cap of 2,048. Paletted section counts record watched
block IDs efficiently. Each chunk is sampled once, up to 1,024 per dimension, and
samples/totals persist in per-dimension `rivencraft/block_profile` SavedData. Subsequent
player edits or chunk reloads do not replace or double-count a stored snapshot.
Live points remain zero until at least **401 Age samples and 500 Overworld baseline
samples** exist, matching 1.21.1. For each watched material, positive excess blocks
per Age chunk above 1.05 times the Overworld average are multiplied by that material's
weight. The total rounds to an integer and caps at 100,000. The watched list retains
Legacy ores/quartz/glowstone/crystal and the existing modern/storage extensions,
including Diamond Blocks, Ancient Debris and Netherite Blocks.
Recognized ores, mineral storage blocks and source-fluid block types from installed
mods participate automatically via current NeoForge common tags, names and the same
full-block/fluid material eligibility used for Pages. No content mod/API is required.
Unknown ore/storage forms use the 1.21.1 fallback weights; known minerals retain its
named weights. `[instability_modded_materials] block_weight_overrides` accepts exact
`modid:block_id=points` entries, including zero to exclude a modded block. This is
installation-based optional support, not a required mod or per-mod opt-in switch.
While a profiling-enabled Age is occupied, missing spawn-area samples are requested
gradually: at most one chunk request per second, one in flight across the server,
round-robin between active Ages and the Overworld baseline. Existing samples are
skipped. The modern getChunkFuture API still blocks when invoked on the server
thread, so requests use its off-thread scheduling path and completion returns to
the server thread. Closed levels and stopped-server callbacks are discarded. No
permanent force-load tickets or generation while no Age is occupied are introduced.
Natural exploration also fills the sample pool and can reach readiness sooner.
Writing contributions and the original saved provider plan remain frozen. The live
score is added only at runtime, and the effective deck is reselected deterministically
from the same saved seed as the total changes. Providers can therefore appear or
change as abundance is measured. Unchanged totals/seeds reuse a cached plan rather
than reshuffling on each query. Older Ages predating instability records remain
neutral; saved neutral arrivals/enable flags do not opt them into new scoring.
`[instability] difficulty` matches 1.21.1: 0=quarter, 1=half, 2=normal (default),
3=1.75x. It scales writing plus live points without rewriting saved contributions.
A session `test_score` remains an explicit final-score override and is cleared on
restart; clearing it now restores the live effective plan. Erosion threshold checks
use this same effective score.
Use `/rivencraft_port instability status` inside an Age to inspect frozen/live/effective
scores, difficulty, profile eligibility and both sample counts. Normal book/Binder
screens receive no score/profiler data. Zero live points before both thresholds are
ready are expected. To validate, create/explore an Age with Diamond Block Rocks or
another abundant watched material, watch progress, then confirm live points and
providers change while the frozen writing contribution remains unchanged. Reopen the
world to check sample persistence; repeated reloads should not duplicate counts.
Build and temporary dedicated-server checks passed. Real loaded snapshots saved 25
samples per test dimension and captured one deliberately placed fixture Diamond Block.
A restart with synthetic profile records exercised scoring/decoding without generating
hundreds of chunks: resumed real chunk samples produced 423 Age and 522 baseline
samples, with the expected 2,987 live points, frozen score zero and effective providers.
Clearing a session override restored the same live result. This verifies save/scoring
plumbing, not natural bulk generation. The player will check background progress,
actual generated material distribution, modded recognition and difficulty/config
changes. Test server stopped/world deleted; no permanent tests, commit or publication.
## .125 ordered controller recovery
The player passed .124. New authored Ages now recover repeated singular terrain,
biome-distribution, weather, lighting and sky choices: the last choice wins, with
500 instability per duplicate or conflict. Supported category-order mistakes add
100 points instead of blocking binding. Native distribution discards authored
biome choices with a 500-point conflict; Single retains the first authored biome
with a 500-point conflict when extras exist. These diagnostics use the 1.21.1
writing rules. Season pages remain unsupported until their integration is ported.
The original pages remain in the book and library source list. Generation receives
recovered choices without rearranging material/feature or per-celestial modifier
chains. Existing Ages are not rescored. OP instability status shows the writing
contributions; normal book/Binder screens do not expose the score or new warnings.
Unknown/unported pages and other unsupported combinations still prevent binding.
Celestial, number and dangling-modifier recovery is a later slice: their existing
validation still applies to the original page sequence, before controller recovery.
Version 1.0.1.125-beta.1 builds. Thirteen temporary focused checks passed for
last-choice selection, duplicate/conflict/order costs, Native/Single recovery,
unimplemented pages, raw numeric guards, celestial-chain preservation, intentional
feature repeats and Ambient Color ordering. No server/world was launched. In-game
validation: bind books with conflicting terrain/weather choices and confirm the
last choice takes effect; test Native with biome pages and Single with two biomes;
check that all original pages remain and OP status reports the writing costs.
No commit, push or publication.
## .126 ordered celestial and number recovery
The player passed .125. Ported the 1.21.1 grammar treatment of pending celestial
modifiers and numeric adjacency. Repeated Length/Phase/Angle/Size/Direction pages
before a celestial use the final value of that kind without a duplicate penalty.
Only the final unconsumed modifier of each kind adds 100 dangling-modifier points.
A number without an immediately following supported Color/Length/Phase/Angle/Size
page adds 100 points (misplaced, or dangling at the end). Consecutive numbers leave
only the last available for the next target. Colors still mix in written order.
These defects no longer block binding. Numbers and celestial compilation retain
the original page sequence and seeded color indices; controller recovery never
moves a number next to a new target. A trailing modifier cannot rewrite an earlier
body, including the optimized single-body compilation path. Missing celestial
branches retain their existing seeded completion; dangling controls do not apply
to those generated bodies. All original book/library pages remain. Unsupported
pages and the ten-sun/ten-moon limit still block binding; existing Ages are unchanged.
Version 1.0.1.126-beta.1 builds. Seventeen temporary focused checks passed, covering
last-value selection, dangling costs/indices, numeric adjacency, per-body isolation,
seeded color stability, valid numbered writing, body limits, unsupported pages and
cycle codec round-trip. No server/world or permanent test fixture was created.
Manual validation: bind a book with Half Length, Double Length, Sun and verify the
last length; bind Sun followed by Large Size and confirm it does not resize that
sun. Test a trailing Number page and inspect the added writing contribution with
`/rivencraft_port instability status`. Original pages should remain visible.
No commit, push or publication.
## .127 Link Modifier station: title and core link properties
The player passed .126. Started the missing 1.21.1 Link Modifier station port,
reusing its block model, textures, horizontal facing variants and Legacy 176x166
single-book GUI. The item is available in the main creative tab or via
`/give @s rivencraft:link_modifier`; it has no crafting recipe. As in 1.21.1,
creative acquisition does not impose a new permission restriction on ordinary
book edits once the station has been placed.
One persistent slot accepts Linking and Descriptive Books, including unbound
books, with normal and shift-click transfers. The server menu handles renaming
and Disarm, Maintain Momentum and Intra Linking toggles. Descriptive Books cannot
toggle Intra Linking, which only governs same-dimension Linking Book travel.
Existing target, author, symbols and Age identity are retained. Renaming changes
the item's custom title, not the Age's saved folder/registry identity. Removing
the title restores the item's normal name. Text editing consumes inventory/hotbar
shortcuts while focused; Escape closes the screen. Programmatic text refreshes do
not send rename packets. Menu identity, owner, distance and live station checks
protect edits; unknown button IDs and non-book inputs are rejected.
The book is saved with modern ValueInput/ValueOutput container serialization.
Station removal uses block-entity preRemoveSideEffects to release the stored book
once, including non-player removal. No tick handler or renderer is needed.
This is a station slice, not full Link Modifier parity. Seed editing, Age retirement,
Relative Link, Intra-Linking Only and platform overrides remain unported and are
not presented as working controls. Existing core flag behavior is reused unchanged.
Manual validation: place the station in multiple orientations, insert/shift-click
both book types, rename using text containing E, toggle each available property,
remove the book and use it to check travel effects. Confirm Descriptive Book Intra
Linking is unavailable, restart with a book left in the station, and break a loaded
station to recover one book. Build and dedicated persistence results are recorded
below. No commit, push or publication.
Version 1.0.1.127-beta.1 builds. Packaged model, GUI, texture, item definition,
loot table and localization checks passed; no station recipe is included. A
fresh dedicated server placed the station and serialized a named Linking Book
with all three core flags. Restart retained the same book/name/flags and station
removal completed normally. The first harness run expected the wrong wording
for vanilla force-load cleanup; the corrected harness completed successfully.
Both temporary worlds were deleted and servers stopped. No permanent tests were
added. UI interaction, transfer counts and modifier travel effects await the
player's in-game check.
## .128 six Link Modifier controls
The player reported that .127 exposed only three of 1.21.1's six controls. Added
the remaining Intra-Linking Only, Relative Link and Generate Platform controls in
the existing two-column, three-row Legacy layout, with matching bit values and
server-owned menu actions. Enabling Intra-Linking Only enables Intra Linking too;
disabling Intra Linking clears Intra-Linking Only. Both intra controls are disabled
for Descriptive Books. Unknown actions and out-of-range menu edits remain rejected.
Linking Book Intra-Linking Only blocks cross-dimension travel. Relative Link uses
the traveller's block offset from the source world's spawn, applied to destination
spawn, preserving destination yaw/pitch. Normal links retain their precise bound
coordinates. Player and non-player portal travel share the new flags and relative
coordinate helper. Descriptive relative arrivals still require proper footing.
Generate Platform is off by default for Linking Books. When enabled, a successful
link fills one missing floor block with cloud platform only if the block two above
the arrival is air, matching the 1.21.1 helper. Existing floors are retained.
Descriptive Books instead default to enabled and use Disable Platform internally;
turning it off prevents fallback platform creation and refuses travel if no footed
landing is available. Receptacle tint preparation respects that setting too.
New flags serialize in the shared component and appear in book/panel tooltips.
Existing three-flag books remain valid. Seed editing and Age retirement remain
separate, unfinished station work.
Version 1.0.1.128-beta.1 builds. A temporary dedicated-server fixture passed 19
checks: menu actions/dependencies, descriptive intra rejection, numeric flag codec,
relative offsets, platform-off/default behavior, platform placement/preservation,
invalid input and remote edit protection. It did not perform real client travel.
The test server stopped and its world/fixture sources were removed. The production
JAR is rebuilt without that fixture. Manual checks: all six buttons and tooltips,
same/cross-dimension Intra-Linking Only, Relative Link offsets, and platform on/off
for both book types (including a new Void Age without footing). No commit, push
or publication.
## .129 Link Modifier mouse input repair
The player reported that none of the six controls responded. Confirmed the
26.3 input constants in the local Minecraft artifact: left/middle/right are
1/2/3, while the custom Link Modifier handler checked the old left-button value
0. Its server actions therefore passed the prior direct-menu fixture while
real mouse clicks never sent an action.
Replaced the manually drawn/clicked areas with six registered Minecraft Button
widgets at the same Legacy positions. Their callbacks send the existing validated
menu action IDs, and their custom rendering retains the square controls. Native
input provides correct mouse mapping, focus, click sound and keyboard activation.
All six tooltips expose their On/Off state; unavailable Descriptive Book intra
controls remain disabled. Tooltip objects update only when state changes so their
hover delay is not reset every tick. Book mutations/travel code is unchanged.
Version 1.0.1.129-beta.1 builds. The native widget's left-button mapping and callback
dispatch were verified in the local Minecraft bytecode. No client click test or
new server run was performed; the previous 19 checks covered server actions, not
client input. Please click each option with an inserted Linking Book and verify
its selected shading and tooltip/book properties change, then repeat supported
options with a Descriptive Book. No commit, push or publication.
@@ -0,0 +1,68 @@
# Rivencraft 26.3 beta 1.0.1.129-beta.1
Collects the player-validated .121–.129 port slices since 1.0.1.120-beta.1.
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent Minecraft
1.21.1 release is unchanged. Full parity and optional integrations remain in progress.
## Instability hazards and live profiling
- Ported lightning, explosions, falling meteor effects, burning and erosion with
bounded loaded-chunk scans, spawn protection, claim events and configurable
world/entity damage. Meteor flight uses the existing sound and particle effects.
- Natural hazards choose randomized locations; operator test commands provide
targeted checks with independent cooldowns. Erosion retains its requested testing
default of enabled above 10,000 effective instability, outside the Legacy deck.
- Ported persistent live material profiling, including native ores, valuable blocks,
recognized installed-mod materials and configurable modded block weight overrides.
No content mod is required. Profiling starts scoring after 401 Age and 500
Overworld baseline samples, with bounded gradual background sampling while an
eligible Age is occupied. A chunk snapshot is counted once.
- Effective instability and deterministic provider selection now update as material
abundance is measured; the original writing contribution remains frozen. Added
difficulty scaling and operator-only sampling/score diagnostics. Older neutral
Ages remain neutral. Normal book/Binder screens do not reveal scores.
## Ordered writing recovery
- Repeated singular terrain, distribution, weather, lighting and sky controllers
use the last choice, adding 500 instability per duplicate/conflict. Category-order
mistakes add 100. Native distribution ignores supplied biomes; Single retains the
first biome when too many are supplied, with conflict penalties.
- Repeated celestial modifiers use the final pending value of each kind. Dangling
modifiers and misplaced/trailing numbers allow binding and add 100 instability.
Numbers only attach to the immediately following supported target.
- Preserved original book/library pages, ordered color mixing and independent
celestial chains. A trailing modifier cannot change an earlier sun or moon.
Unported pages, unsupported combinations and body limits remain enforced.
## Link Modifier station
- Added the creative-only Link Modifier with its existing model, textures and
Legacy single-book GUI. There is no crafting recipe.
- Books can be renamed and edited through all six controls: Disarm, Intra-Linking,
Intra-Linking Only, Maintain Momentum, Relative Link and Generate Platform.
- Intra-Linking Only enables Intra-Linking and prevents cross-dimension Linking
Book travel. Both intra controls are unavailable for Descriptive Books.
- Relative Link preserves the traveller's offset from the source dimension spawn
at the destination spawn. Linking Book platform generation defaults off;
Descriptive Book fallback platforms default on and can be disabled.
- Player and non-player portal paths share the new properties. Book contents and
flags persist in the station. Removal releases its stored book.
- Fixed the controls ignoring real clicks: 26.3 left-click is button 1, not 0.
Registered native button widgets now handle input, focus, sound and state tooltips.
## Validation and remaining work
The player passed every included slice, including the six controls and click fix.
Both Minecraft builds pass (no standalone Gradle test sources). Targeted temporary
checks covered grammar/celestial recovery, component serialization, station actions,
platform rules and dedicated-server save/restart behavior. Live profiling was checked
with real snapshots plus synthetic threshold records; natural gameplay was validated
by the player. Temporary servers were stopped and their test worlds removed.
Link Modifier seed editing/retirement, remaining discovery/config policy and optional
integrations are not complete. Live previews remain unavailable; books and portals
use the animated fallback. Crumble has no active 1.21.1 handler and is not invented
in this port. The dual-version follow-up issues and full multiplayer/parity gate
remain open. Use matching client/server beta builds and keep Minecraft version
tracks and their saves separate.
+6
View File
@@ -244,6 +244,12 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/block/book_binder.png'
include 'assets/rivencraft/textures/block/book_binder_press.png'
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
include 'assets/rivencraft/textures/gui/link_modifier_legacy.png'
include 'assets/rivencraft/textures/block/link_modifier_*.png'
include 'assets/rivencraft/models/block/link_modifier.json'
include 'assets/rivencraft/models/item/link_modifier.json'
include 'assets/rivencraft/blockstates/link_modifier.json'
include 'data/rivencraft/loot_table/blocks/link_modifier.json'
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
include 'assets/rivencraft/textures/block/inkwell_base.png'
include 'assets/rivencraft/textures/block/inkwell_cup.png'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.120-beta.1
mod_version=1.0.1.129-beta.1
@@ -164,6 +164,15 @@ public final class Rivencraft {
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
public static final DeferredBlock<net.dnorberg.rivencraft.block.LinkModifierBlock> LINK_MODIFIER = BLOCKS.registerBlock(
"link_modifier", net.dnorberg.rivencraft.block.LinkModifierBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).noOcclusion());
public static final DeferredItem<BlockItem> LINK_MODIFIER_ITEM = ITEMS.registerSimpleBlockItem(LINK_MODIFIER);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity>> LINK_MODIFIER_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("link_modifier", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity::new, LINK_MODIFIER.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.LinkModifierMenu>> LINK_MODIFIER_MENU =
MENUS.register("link_modifier", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.LinkModifierMenu::new));
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
"ink_mixer", InkMixerBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
@@ -254,6 +263,9 @@ public final class Rivencraft {
modEventBus.addListener(PortBookNetworking::registerPayloads);
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
net.dnorberg.rivencraft.age.PortDecayProviders.register();
net.dnorberg.rivencraft.age.PortHazardProviders.register();
net.dnorberg.rivencraft.age.PortMeteorProvider.register();
net.dnorberg.rivencraft.age.PortEnvironmentalProviders.register();
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -69,7 +69,8 @@ public record PortAgeColors(int sky, int ambient, int celestial) {
}
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
// Numbers only attach to the immediately following target. Misplaced
// numbers are diagnosed by PortWritingRecovery, never moved to a later color.
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
@@ -819,8 +819,8 @@ public final class PortAgeCommands {
|| new PortDayCycle(true, 2, 0).ticks(12000) != 6000
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|| new PortDayCycle(true, 0, 321).ticks(123456) != 321
|| PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|| !PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|| !PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
throw new IllegalStateException("Day cycle fixture failed");
var server = context.getSource().getServer();
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
@@ -1616,7 +1616,7 @@ public final class PortAgeCommands {
|| Math.abs(moving.bodyAngle(origin + 100) + 3F) > 0.0001F
|| !PortDayCycle.validWriting(java.util.List.of(target, PortDayCycle.NORMAL_TIME))
|| !PortDayCycle.validWriting(java.util.List.of(target, target))
|| PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
|| !PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
throw new IllegalStateException("Single-body modifier/order invariant failed");
}
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
@@ -18,33 +18,45 @@ public final class PortAgeTravel {
}
public static String enter(ServerPlayer player, Identifier id) {
return enter(player, id, net.dnorberg.rivencraft.item.LinkProperties.NONE);
}
public static String enter(ServerPlayer player, Identifier id, net.dnorberg.rivencraft.item.LinkProperties properties) {
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (target == null) return "Age not loaded: " + id;
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = resolveLanding(target);
BlockPos landing = resolveLanding(target, player, properties);
if (landing == null) return "No safe landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
public static BlockPos resolveLanding(ServerLevel target) {
public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); }
public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity,
net.dnorberg.rivencraft.item.LinkProperties properties) {
BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM));
if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing;
return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin(
(ServerLevel) entity.level(), target, entity.blockPosition())).orElse(null);
}
public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
if (landing != null) landing = isCaveTerrain(target)
? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target);
if (landing == null) landing = findLanding(target, generatePlatform);
if (landing == null) return null;
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
return landing;
}
static BlockPos findLanding(ServerLevel target) {
static BlockPos findLanding(ServerLevel target) { return findLanding(target, true); }
static BlockPos findLanding(ServerLevel target, boolean generatePlatform) {
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
@@ -60,7 +72,7 @@ public final class PortAgeTravel {
}
}
}
return makePlatform(target, new BlockPos(0, 64, 0));
return generatePlatform ? makePlatform(target, new BlockPos(0, 64, 0)) : null;
}
BlockPos surface = findSurfaceLanding(target, 0, 0);
if (surface != null) return surface;
@@ -68,7 +80,7 @@ public final class PortAgeTravel {
// beneath it. Void Ages retain the usual platform floor at Y=63.
BlockPos obstruction = highestObstruction(target, 0, 0);
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
return makePlatform(target, new BlockPos(0, y, 0));
return generatePlatform ? makePlatform(target, new BlockPos(0, y, 0)) : null;
}
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
@@ -0,0 +1,314 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.neoforged.neoforge.common.Tags;
import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy-style excess-block profiling with gradual spawn-area generation. */
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortBlockInstability {
// Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight.
// Storage blocks are deliberate extensions, including the requested diamond-block case.
private static final Map<Identifier, Double> VANILLA_WEIGHTS = weights();
private static final Map<String, Double> COMMON_MINERAL_WEIGHTS = Map.ofEntries(
Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0),
Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0),
Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0),
Map.entry("zinc", 60.0), Map.entry("copper", 60.0),
Map.entry("iron", 60.0), Map.entry("nickel", 200.0),
Map.entry("osmium", 200.0), Map.entry("silver", 200.0),
Map.entry("emerald", 200.0), Map.entry("ruby", 200.0),
Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0),
Map.entry("gold", 750.0), Map.entry("uranium", 750.0),
Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0),
Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0));
private static final int MAX_PENDING = 2048;
private static final int SAMPLES_PER_TICK = 4;
private static final int GENERATION_INTERVAL_TICKS = 20;
private static final List<ChunkPos> SPAWN_OFFSETS = spawnOffsets();
private static final Map<MinecraftServer, ArrayDeque<Pending>> PENDING = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Map<Identifier, Integer>> NEXT_SPAWN_SAMPLE =
new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Integer> NEXT_TARGET = new java.util.WeakHashMap<>();
private static final Map<MinecraftServer, Boolean> GENERATION_IN_FLIGHT =
new java.util.WeakHashMap<>();
private PortBlockInstability() {}
/** Track recognized materials from installed mods without requiring any mod-specific API. */
static boolean tracked(Identifier id) {
if (VANILLA_WEIGHTS.containsKey(id)) return true;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
if (!BuiltInRegistries.BLOCK.containsKey(id)) return false;
var block = BuiltInRegistries.BLOCK.getValue(id);
return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0);
}
static double weight(Identifier id) {
Double vanilla = VANILLA_WEIGHTS.get(id);
if (vanilla != null) return vanilla;
if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")
|| !BuiltInRegistries.BLOCK.containsKey(id)) return 0;
var block = BuiltInRegistries.BLOCK.getValue(id);
if (!eligibleMaterial(block)) return 0;
return moddedWeight(id, inferredModdedWeight(id, block),
PortInstabilityConfig.moddedMaterialOverrides());
}
private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) {
return PortRegistryPages.materialCategory(block) != null;
}
private static Double configuredOverride(Identifier id) {
return configuredOverride(id, PortInstabilityConfig.moddedMaterialOverrides());
}
private static Double configuredOverride(Identifier id, List<? extends String> overrides) {
for (String entry : overrides) {
int separator = entry.lastIndexOf('=');
if (separator > 0 && entry.substring(0, separator).equals(id.toString()))
return (double) Integer.parseInt(entry.substring(separator + 1));
}
return null;
}
static double moddedWeight(Identifier id, double inferred, List<? extends String> overrides) {
Double override = configuredOverride(id, overrides);
return override == null ? inferred : override;
}
private static double inferredModdedWeight(Identifier id, net.minecraft.world.level.block.Block block) {
if ("rivencraft:fluid".equals(PortRegistryPages.materialCategory(block))) return 1.25;
var state = block.defaultBlockState();
String path = id.getPath();
boolean ore = state.is(Tags.BlockItems.ORES.block()) || path.endsWith("_ore") || path.contains("_ore_");
boolean storage = state.is(Tags.BlockItems.STORAGE_BLOCKS.block())
|| (path.endsWith("_block") || path.startsWith("block_of_"))
&& namedMineralWeight(path) != null;
return inferredModdedWeight(id, ore, storage, false);
}
static double inferredModdedWeight(Identifier id, boolean ore, boolean storage, boolean fluid) {
if (fluid) return 1.25;
Double named = namedMineralWeight(id.getPath());
if (ore) return named == null ? 60.0 : named;
if (storage) return named == null ? 120.0 : named;
return 0;
}
private static Double namedMineralWeight(String path) {
for (String part : path.split("[/_]")) {
Double value = COMMON_MINERAL_WEIGHTS.get(part);
if (value != null) return value;
}
return null;
}
@net.neoforged.bus.api.SubscribeEvent
public static void onChunkLoad(ChunkEvent.Load event) {
if (!(event.getLevel() instanceof ServerLevel level)
|| !(event.getChunk() instanceof LevelChunk chunk)) return;
boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD;
if (!overworld && !PortInstabilityRuntime.isAge(level)) return;
// Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data
// access to a bounded server-tick lane to avoid reentering that storage path.
synchronized (PENDING) {
var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>());
if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk));
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerTickPost(ServerTickEvent.Post event) {
for (int index = 0; index < SAMPLES_PER_TICK; index++) {
Pending pending;
synchronized (PENDING) {
var queue = PENDING.get(event.getServer());
pending = queue == null ? null : queue.pollFirst();
}
if (pending == null) break;
var pos = pending.chunk().getPos();
if (pending.level().getServer().getLevel(pending.level().dimension()) == pending.level()
&& pending.level().getChunkSource().getChunkNow(pos.x(), pos.z()) == pending.chunk())
PortBlockProfileSavedData.get(pending.level()).sample(pending.chunk());
}
if (event.getServer().getTickCount() % GENERATION_INTERVAL_TICKS == 0) {
generateNextSpawnSample(event.getServer());
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerStopping(ServerStoppingEvent event) {
synchronized (PENDING) { PENDING.remove(event.getServer()); }
NEXT_SPAWN_SAMPLE.remove(event.getServer());
NEXT_TARGET.remove(event.getServer());
GENERATION_IN_FLIGHT.remove(event.getServer());
}
/** Generate at most one chunk per second across the active Age and its Overworld baseline. */
private static void generateNextSpawnSample(MinecraftServer server) {
if (GENERATION_IN_FLIGHT.containsKey(server)) return;
List<SampleTarget> targets = new ArrayList<>();
for (ServerLevel level : server.getAllLevels()) {
if (!PortInstabilityRuntime.isAge(level)
|| level.players().isEmpty()) continue;
var data = PortInstabilityData.get(server);
if (!data.supportsProfiling(level)) continue;
var age = data.age(level);
targets.add(new SampleTarget(level, age.spawn().orElseGet(() -> level.getRespawnData().pos()),
PortBlockProfileSavedData.MIN_AGE_SAMPLES));
}
if (targets.isEmpty()) return;
ServerLevel overworld = server.overworld();
targets.add(0, new SampleTarget(overworld, overworld.getRespawnData().pos(),
PortBlockProfileSavedData.MIN_BASELINE_SAMPLES));
int first = Math.floorMod(NEXT_TARGET.getOrDefault(server, 0), targets.size());
for (int offset = 0; offset < targets.size(); offset++) {
int index = (first + offset) % targets.size();
SampleTarget target = targets.get(index);
if (generateNextSpawnSample(target.level(), target.origin(), target.minimum(), server)) {
NEXT_TARGET.put(server, index + 1);
return;
}
}
}
private static boolean generateNextSpawnSample(ServerLevel level, net.minecraft.core.BlockPos origin,
int minimum, MinecraftServer server) {
PortBlockProfileSavedData profile = PortBlockProfileSavedData.get(level);
if (profile.count() >= minimum) return false;
Map<Identifier, Integer> cursors = NEXT_SPAWN_SAMPLE.computeIfAbsent(server,
ignored -> new HashMap<>());
Identifier dimension = level.dimension().identifier();
int index = cursors.getOrDefault(dimension, 0);
ChunkPos center = ChunkPos.containing(origin);
while (index < SPAWN_OFFSETS.size()) {
ChunkPos offset = SPAWN_OFFSETS.get(index++);
ChunkPos target = new ChunkPos(center.x() + offset.x(), center.z() + offset.z());
if (profile.contains(target)) continue;
cursors.put(dimension, index);
GENERATION_IN_FLIGHT.put(server, true);
// getChunkFuture blocks when called on the server thread. Its off-thread
// path schedules generation on Minecraft's chunk executor instead.
CompletableFuture.supplyAsync(() -> level.getChunkSource()
.getChunkFuture(target.x(), target.z(), ChunkStatus.FULL, true))
.thenCompose(future -> future)
.whenComplete((result, error) -> {
if (server.isStopped()) return;
server.execute(() -> {
if (GENERATION_IN_FLIGHT.remove(server) == null) return;
if (error != null) {
Rivencraft.LOGGER.warn("Could not profile chunk {} in {}", target, dimension, error);
} else if (server.getLevel(level.dimension()) == level
&& result.orElse(null) instanceof LevelChunk chunk) {
profile.sample(chunk);
}
});
});
return true;
}
return false;
}
private static List<ChunkPos> spawnOffsets() {
List<ChunkPos> offsets = new ArrayList<>(PortBlockProfileSavedData.MAX_SAMPLES);
offsets.add(new ChunkPos(0, 0));
for (int radius = 1; offsets.size() < PortBlockProfileSavedData.MAX_SAMPLES; radius++) {
for (int x = -radius; x <= radius; x++) {
offsets.add(new ChunkPos(x, -radius));
offsets.add(new ChunkPos(x, radius));
}
for (int z = -radius + 1; z < radius; z++) {
offsets.add(new ChunkPos(-radius, z));
offsets.add(new ChunkPos(radius, z));
}
}
return List.copyOf(offsets.subList(0, PortBlockProfileSavedData.MAX_SAMPLES));
}
/** Zero until the Age exceeds 400 samples and the baseline reaches 500. */
static int score(PortBlockProfileSavedData age, PortBlockProfileSavedData baseline) {
return score(age.totals(), age.count(), baseline.totals(), baseline.count());
}
static int score(Map<Identifier, Long> age, int ageChunks,
Map<Identifier, Long> baseline, int baselineChunks) {
if (ageChunks < PortBlockProfileSavedData.MIN_AGE_SAMPLES
|| baselineChunks < PortBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0;
double result = 0;
Set<Identifier> watched = new HashSet<>(age.keySet());
watched.addAll(baseline.keySet());
for (Identifier id : watched) {
double weight = weight(id);
if (weight <= 0) continue;
double excess = age.getOrDefault(id, 0L) / (double) ageChunks
- 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks;
if (excess > 0) result += excess * weight;
}
return (int) Math.min(100_000, Math.round(result));
}
public static int score(ServerLevel level) {
if (!PortInstabilityRuntime.isAge(level) || !PortInstabilityData.get(level.getServer()).supportsProfiling(level)) return 0;
return score(PortBlockProfileSavedData.get(level), PortBlockProfileSavedData.get(level.getServer().overworld()));
}
public static int sampledChunks(ServerLevel level) { return PortBlockProfileSavedData.get(level).count(); }
public static int difficultyScore(long rawScore, int difficulty) {
double multiplier = switch (difficulty) {
case 0 -> 0.25;
case 1 -> 0.5;
case 2 -> 1.0;
case 3 -> 1.75;
default -> throw new IllegalArgumentException("Unknown instability difficulty " + difficulty);
};
return (int) Math.min(Integer.MAX_VALUE, Math.max(0, rawScore * multiplier));
}
private static Map<Identifier, Double> weights() {
Map<Identifier, Double> values = new HashMap<>();
add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore");
add(values, 60, "iron_ore", "deepslate_iron_ore");
add(values, 200, "emerald_ore", "deepslate_emerald_ore");
add(values, 250, "redstone_ore", "deepslate_redstone_ore");
add(values, 750, "gold_ore", "deepslate_gold_ore");
add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block");
add(values, 20, "nether_quartz_ore");
add(values, 50, "glowstone");
values.put(Identifier.parse("rivencraft:crystal_block"), 20.0);
// Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft.
add(values, 200, "emerald_block");
add(values, 750, "gold_block", "raw_gold_block");
add(values, 4000, "ancient_debris");
add(values, 60, "iron_block", "raw_iron_block");
add(values, 8000, "netherite_block");
return Map.copyOf(values);
}
private static void add(Map<Identifier, Double> values, int weight, String... paths) {
for (String path : paths) values.put(Identifier.withDefaultNamespace(path), (double) weight);
}
private record Pending(ServerLevel level, LevelChunk chunk) {}
private record SampleTarget(ServerLevel level, net.minecraft.core.BlockPos origin, int minimum) {}
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Persistent generated-chunk snapshots. Each chunk is counted once, up to the 1.21.1 cap. */
final class PortBlockProfileSavedData extends SavedData {
static final int MIN_AGE_SAMPLES = 401, MIN_BASELINE_SAMPLES = 500, MAX_SAMPLES = 1024;
private static final int VERSION = 2;
private record Sample(long chunk, Map<Identifier, Integer> counts) {
private static final Codec<Sample> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("chunk").forGetter(Sample::chunk),
Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, Integer.MAX_VALUE))
.fieldOf("blocks").forGetter(Sample::counts)
).apply(instance, Sample::new));
}
private static final Codec<PortBlockProfileSavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.INT.fieldOf("version").forGetter(data -> VERSION),
Sample.CODEC.listOf(0, MAX_SAMPLES).fieldOf("samples").forGetter(data -> data.samples.entrySet().stream()
.map(entry -> new Sample(entry.getKey(), entry.getValue())).toList())
).apply(instance, PortBlockProfileSavedData::new));
private static final SavedDataType<PortBlockProfileSavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "block_profile"),
() -> new PortBlockProfileSavedData(VERSION, List.of()), CODEC);
private final Map<Long, Map<Identifier, Integer>> samples = new HashMap<>();
private final Map<Identifier, Long> totals = new HashMap<>();
private PortBlockProfileSavedData(int version, List<Sample> values) {
if (version == VERSION) for (var value : values) put(value.chunk(), value.counts());
}
static PortBlockProfileSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
int count() { return samples.size(); }
boolean contains(ChunkPos pos) { return samples.containsKey(pos.pack()); }
Map<Identifier, Long> totals() { return Map.copyOf(totals); }
boolean sample(LevelChunk chunk) {
if (count() >= MAX_SAMPLES || contains(chunk.getPos())) return false;
Map<Identifier, Integer> counts = new HashMap<>();
for (var section : chunk.getSections()) {
if (section.hasOnlyAir()) continue;
section.getStates().count((state, amount) -> {
Identifier id = BuiltInRegistries.BLOCK.getKey(state.getBlock());
if (PortBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum);
});
}
put(chunk.getPos().pack(), counts);
setDirty();
return true;
}
private void put(long pos, Map<Identifier, Integer> counts) {
if (count() >= MAX_SAMPLES || samples.containsKey(pos)) return;
samples.put(pos, Map.copyOf(counts));
counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum));
}
}
@@ -127,19 +127,15 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static boolean validWriting(List<Identifier> pages) {
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
for (Identifier page : pages) {
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|| isSize(page) && ++sizes > 1) return false;
if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
periods = phases = directions = angles = sizes = 0;
}
int suns = 0, moons = 0;
for (Identifier page : pages) if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
}
return periods + phases + directions + angles + sizes == 0;
// Repeated modifiers overwrite the pending value. Unconsumed modifiers
// earn grammar instability, but never affect an earlier body.
return true;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
@@ -148,8 +144,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
throw new IllegalArgumentException("Invalid celestial modifier order");
if (!validWriting(pages))
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
// Match root AgeSkyProfile.random's count draw positions: sky style,
// mixed sky color, then independent 0..3 sun and moon counts.
@@ -167,7 +163,7 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons");
if (!validWriting(pages)) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
return compileOne(pages, seed, nativeTicks, 0);
var bodies = new java.util.ArrayList<PortCelestialBody>();
@@ -190,8 +186,13 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
if (controller == null) return NATIVE;
int controllerIndex = -1;
for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) { controllerIndex = i; break; }
// Only preceding modifiers belong to this controller. In particular, a
// trailing dangling modifier must not rewrite the single-body fast path.
if (controllerIndex < 0) return NATIVE;
pages = pages.subList(0, controllerIndex + 1);
var controller = pages.getLast();
int mode = controller.getPath().equals("celestial/sun") ? SUN
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
@@ -0,0 +1,178 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Active Legacy burning and the 1.21.1 test-enabled erosion policy. */
public final class PortEnvironmentalProviders {
public static final Identifier BURNING = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "burning");
public static final Identifier EROSION = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "erosion");
private static final Direction[] FLUID_SIDES = {
Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST
};
private PortEnvironmentalProviders() {}
public static void register() {
PortInstabilityRuntime.register(BURNING, false, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) scorch(context, level);
});
PortInstabilityRuntime.register(EROSION, true, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) erode(context);
});
}
public static boolean isEnvironmental(Identifier id) { return id.equals(BURNING) || id.equals(EROSION); }
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
var cards = providers.stream().filter(card -> isEnvironmental(card.id())).toList();
boolean erosion = PortInstabilityConfig.erosionEnabled() && PortInstabilityRuntime.effectiveScore(level)
>= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost();
if (cards.isEmpty() && !erosion) return;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (var card : cards) {
int budget = card.id().equals(BURNING) ? PortInstabilityConfig.scorchMaxTargets() : 1;
PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(budget)), card.id(), card.level());
}
if (erosion && cards.stream().noneMatch(card -> card.id().equals(EROSION)))
PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(1)), EROSION, 1);
}
/** Skips random burning selection or looks at one erosion target, preserving policy. */
public static int pulseForTesting(ServerPlayer player, String effect) {
ServerLevel level = player.level();
if (!PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
Identifier id = effect.equals("burning") ? BURNING : EROSION;
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return 0;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
var context = new PortInstabilityRuntime.Context(level, age, chunks,
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1));
if (effect.equals("burning")) {
if (!PortInstabilityConfig.scorchEnabled() || !PortInstabilityConfig.scorchPlayers()
|| !eligibleScorch(context, player)) return 0;
if (player.isInWaterOrRain()) return -2;
player.igniteForSeconds(4);
return 1;
}
if (!effect.equals("erosion") || !PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(level)
|| PortInstabilityRuntime.effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()
|| !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit)
|| hit.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK) return 0;
return erodeAt(context, hit.getBlockPos()) ? 1 : 0;
}
private static void scorch(PortInstabilityRuntime.Context context, int effectiveLevel) {
if (!PortInstabilityConfig.scorchEnabled() || PortInstabilityConfig.scorchMaxTargets() == 0) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt()
* 0x9E3779B97F4A7C15L ^ BURNING.hashCode());
int start = random.nextInt(context.loadedChunks().size());
int affected = 0;
for (int offset = 0; offset < Math.min(8, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
AABB bounds = new AABB(chunk.getMinBlockX(), level.getMinY(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, level.getMaxY(), chunk.getMaxBlockZ() + 1);
List<LivingEntity> candidates = new ArrayList<>();
level.getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligibleScorch(context, entity), candidates, 8);
candidates.sort(Comparator.comparing(Entity::getUUID));
for (LivingEntity entity : candidates) {
if (affected >= PortInstabilityConfig.scorchMaxTargets() || context.budget().remaining() == 0) return;
// Legacy sampled each chunk each tick with a 1/10 gate. With 20 ticks/pass this
// gives at least one opportunity for nearly every inspected loaded chunk.
if (random.nextDouble() >= PortHazardProviders.legacyChance(1,
PortInstabilityConfig.instabilityCooldown(), 1, 10)) continue;
if (!context.budget().claim()) return;
entity.igniteForSeconds(Math.min(12, 4 * effectiveLevel));
context.budget().recordApplied();
affected++;
break;
}
}
}
private static boolean eligibleScorch(PortInstabilityRuntime.Context context, LivingEntity entity) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != context.level()
|| !context.level().canSeeSky(entity.blockPosition())
|| PortDecayProviders.isSpawnProtected(context.age(), entity.blockPosition())) return false;
if (entity instanceof Player player) return PortInstabilityConfig.scorchPlayers()
&& !player.isCreative() && !player.isSpectator();
return true;
}
private static void erode(PortInstabilityRuntime.Context context) {
if (!PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(context.level())) return;
ServerLevel level = context.level();
Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt()
* 0xC2B2AE3D27D4EB4FL ^ EROSION.hashCode());
for (int attempt = 0; attempt < 8; attempt++) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
int y = level.getMinY() + 1 + random.nextInt(level.getHeight() - 2);
if (erodeAt(context, new BlockPos(x, y, z))) return;
}
}
static boolean erodeAt(PortInstabilityRuntime.Context context, BlockPos pos) {
ServerLevel level = context.level();
if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), pos)
|| PortDecayProviders.isSpawnProtected(context.age(), pos)) return false;
BlockState prior = level.getBlockState(pos);
if (!PortDecayProviders.canReplace(level, pos, prior, DecayBlock.Color.BLUE)) return false;
for (Direction direction : FLUID_SIDES) {
BlockPos neighbor = pos.relative(direction);
if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), neighbor)) continue;
var fluid = level.getFluidState(neighbor);
if (fluid.isSource() && replaceFluid(context, pos, prior, fluid.createLegacyBlock())) return true;
}
return false;
}
private static boolean replaceFluid(PortInstabilityRuntime.Context context, BlockPos pos,
BlockState prior, BlockState fluidBlock) {
ServerLevel level = context.level();
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breaking = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breaking);
if (breaking.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
if (!level.setBlock(pos, fluidBlock, 3)) return false;
var placing = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placing);
if (placing.isCanceled()) {
snapshot.restore();
return false;
}
context.budget().recordApplied();
return true;
}
}
@@ -0,0 +1,356 @@
package net.dnorberg.rivencraft.age;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.function.Predicate;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.LightningBolt;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Explosion;
import net.minecraft.world.level.ExplosionDamageCalculator;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */
public final class PortHazardProviders {
private static final Identifier LIGHTNING = id("lightning");
private static final Identifier EXPLOSIONS = id("explosions");
private PortHazardProviders() {}
public static void register() {
// Visuals and entity damage remain available when block mutation is disabled.
PortInstabilityRuntime.register(LIGHTNING, false, PortHazardProviders::apply);
PortInstabilityRuntime.register(EXPLOSIONS, false, PortHazardProviders::apply);
}
static boolean isHazard(Identifier id) {
return LIGHTNING.equals(id) || EXPLOSIONS.equals(id);
}
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
if (!PortInstabilityConfig.hazardsEnabled()) return;
List<PortInstabilityPlan.ProviderLevel> hazards = providers.stream()
.filter(provider -> isHazard(provider.id())).toList();
if (hazards.isEmpty()) return;
List<ChunkPos> chunks = loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (chunks.isEmpty()) return;
for (PortInstabilityPlan.ProviderLevel hazard : hazards) {
var context = new PortInstabilityRuntime.Context(level, age, chunks, tick,
new PortInstabilityRuntime.Budget(1));
PortInstabilityRuntime.invoke(context, hazard.id(), hazard.level());
}
}
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
List<ChunkPos> players = level.players().stream()
.sorted(Comparator.comparing(Entity::getUUID))
.map(ServerPlayer::chunkPosition).toList();
return candidateChunks(players, maximum,
chunk -> level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null);
}
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
Predicate<ChunkPos> loaded) {
if (maximum <= 0) return List.of();
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (int radius = 0; radius <= 4; radius++) {
for (int dx = -radius; dx <= radius; dx++) {
for (int dz = -radius; dz <= radius; dz++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
for (ChunkPos center : players) {
int x = center.x() + dx;
int z = center.z() + dz;
ChunkPos candidate = new ChunkPos(x, z);
if (loaded.test(candidate)) chunks.add(candidate);
if (chunks.size() >= maximum) return List.copyOf(chunks);
}
}
}
}
return List.copyOf(chunks);
}
/** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */
public static int pulseForTesting(ServerPlayer player, boolean lightning) {
return pulseForTesting(player.level(), player.blockPosition(), lightning);
}
public static int pulseForTesting(ServerLevel level, BlockPos origin, boolean lightning) {
if (!PortInstabilityConfig.hazardsEnabled()
|| !PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
PortHazardSavedData data = PortHazardSavedData.get(level);
if (!data.ready(lightning, level.getGameTime(), PortInstabilityConfig.hazardsMinInterval())) return 0;
ChunkPos center = ChunkPos.containing(origin);
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
ChunkPos chunk = new ChunkPos(center.x() + dx, center.z() + dz);
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null) continue;
int x = chunk.getMinBlockX() + 8;
int z = chunk.getMinBlockZ() + 8;
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: origin.getY();
BlockPos pos = new BlockPos(x, y, z);
var context = new PortInstabilityRuntime.Context(level, age, List.of(chunk),
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1));
if (!safeTarget(context, pos, 8)) continue;
data.nextAttempt();
data.record(lightning, level.getGameTime());
return (lightning ? strike(level, age, chunk, pos) : explode(level, age, chunk, pos)) ? 1 : 0;
}
}
return 0;
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int effectiveLevel) {
ServerLevel level = context.level();
if (!PortInstabilityConfig.hazardsEnabled() || context.loadedChunks().isEmpty()
|| !PortInstabilityRuntime.isAge(level)) return;
boolean lightning = id.equals(LIGHTNING);
if (!lightning && !id.equals(EXPLOSIONS)) return;
PortHazardSavedData data = PortHazardSavedData.get(level);
long gameTime = level.getGameTime();
if (!data.ready(lightning, gameTime, PortInstabilityConfig.hazardsMinInterval())) return;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000;
// Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks.
double chance = legacyChance(context.loadedChunks().size(), PortInstabilityConfig.instabilityCooldown(),
effectiveLevel, legacyDenominator);
if (random.nextDouble() >= chance) return;
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int margin = lightning ? 2 : 6;
int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2);
int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2);
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
: level.getMinY() + 1 + random.nextInt(level.getHeight() - 2);
BlockPos pos = new BlockPos(x, y, z);
if (!safeTarget(context, pos, 8) || !context.budget().claim()) return;
data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event
if (lightning ? strike(level, context.age(), chunk, pos) : explode(level, context.age(), chunk, pos))
context.budget().recordApplied();
}
static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) {
if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0;
return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies);
}
static boolean safeTarget(PortInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) {
if (!PortDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false;
BlockPos spawn = context.age().spawn().orElse(null);
return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin
|| Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin;
}
private static boolean strike(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
AABB bounds = new AABB(pos).inflate(2);
if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return false;
LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED);
if (bolt == null) return false;
bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets
if (!level.addFreshEntity(bolt)) return false;
List<LivingEntity> targets = level.getEntitiesOfClass(LivingEntity.class, bounds,
entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity));
targets.sort(Comparator.comparing(Entity::getUUID));
int count = 0;
for (LivingEntity target : targets) {
if (count++ >= PortInstabilityConfig.hazardsMaxEntities()) break;
if (PortInstabilityConfig.hazardsMaxDamage() > 0)
target.hurtServer(level, level.damageSources().lightningBolt(), Math.min(5, PortInstabilityConfig.hazardsMaxDamage()));
}
// Legacy lightning started fires. Keep it separately gated by block policy and game rule.
if (canMutate(level) && PortInstabilityConfig.hazardsFire()
&& level.canSpreadFireAround(pos)
&& !PortDecayProviders.isSpawnProtected(age, pos)
&& PortDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos);
return true;
}
private static boolean explode(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
return explodeBounded(level, age, chunk, pos, PortInstabilityConfig.hazardsBlockDamage(),
PortInstabilityConfig.hazardsPlayerDamage(), PortInstabilityConfig.hazardsFire(),
PortInstabilityConfig.hazardsMaxBlocks(), PortInstabilityConfig.hazardsMaxEntities(),
PortInstabilityConfig.hazardsMaxDamage(), PortInstabilityConfig.hazardsExplosionRadius());
}
/** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */
static boolean explodeBounded(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos,
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
int maxDamage, int radius) {
boolean grief = destructiveBlocksAllowed(PortInstabilityConfig.instabilityWorldMutation(),
level.getGameRules().get(GameRules.MOB_GRIEFING), blockDamage, maxBlocks);
boolean fire = grief && ignite
&& level.canSpreadFireAround(pos);
BoundedExplosion calculator = new BoundedExplosion(level, age, chunk, grief,
playerDamage, maxBlocks, maxEntities, maxDamage);
level.explode(null, null, calculator,
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
radius, false,
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
if (fire) {
int ignited = 0;
for (BlockPos affected : calculator.approved) {
if (ignited >= 4) break;
if (level.canSpreadFireAround(affected) && !PortDecayProviders.isSpawnProtected(age, affected)
&& ignite(level, affected)) ignited++;
}
}
return calculator.started;
}
private static boolean ignite(ServerLevel level, BlockPos pos) {
if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos))
return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos);
BlockState prior = level.getBlockState(pos);
if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false;
var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(place);
if (place.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
static boolean canMutate(ServerLevel level) {
return PortInstabilityConfig.instabilityWorldMutation()
&& level.getGameRules().get(GameRules.MOB_GRIEFING);
}
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
boolean blockDamage, int maxBlocks) {
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
}
static boolean protectedBlock(ServerLevel level, PortInstabilityData.Age age, BlockPos pos, BlockState state) {
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|| state.is(PortDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|| PortDecayProviders.isSpawnProtected(age, pos)
|| state.getDestroySpeed(level, pos) < 0
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
}
private static boolean eligibleEntity(Entity entity) {
return eligibleEntity(entity, PortInstabilityConfig.hazardsPlayerDamage());
}
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
return !(entity instanceof Player player) || playerDamage
&& !player.isCreative() && !player.isSpectator();
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private static final class BoundedExplosion extends ExplosionDamageCalculator {
private final ServerLevel level;
private final PortInstabilityData.Age age;
private final ChunkPos chunk;
private final boolean grief;
private final boolean playerDamage;
private final int maxBlocks;
private final int maxEntities;
private final int maxDamage;
private final Set<BlockPos> approved = new LinkedHashSet<>();
private final Set<UUID> damagedEntities = new HashSet<>();
private int damaged;
private boolean started;
private BoundedExplosion(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, boolean grief,
boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) {
this.level = level;
this.age = age;
this.chunk = chunk;
this.grief = grief;
this.playerDamage = playerDamage;
this.maxBlocks = maxBlocks;
this.maxEntities = maxEntities;
this.maxDamage = maxDamage;
}
@Override
public Optional<Float> getBlockExplosionResistance(Explosion explosion, BlockGetter reader,
BlockPos pos, BlockState state, FluidState fluid) {
started = true;
if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE);
return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid);
}
@Override
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
BlockState state, float power) {
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
if (approved.contains(pos)) return true;
if (approved.size() >= maxBlocks) return false;
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
var event = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, state, actor);
NeoForge.EVENT_BUS.post(event);
if (event.isCanceled()) return false;
approved.add(pos.immutable());
return true;
}
@Override
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage)
|| maxDamage == 0) return false;
damaged++;
damagedEntities.add(entity.getUUID());
return true;
}
@Override
public float getEntityDamageAmount(Explosion explosion, Entity entity, float exposure) {
return Math.min(super.getEntityDamageAmount(explosion, entity, exposure), maxDamage);
}
@Override
public float getKnockbackMultiplier(Entity entity) {
return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F;
}
private boolean inLoadedChunk(BlockPos pos) {
return level.isInWorldBounds(pos) && ChunkPos.containing(pos).equals(chunk)
&& level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null;
}
}
}
@@ -0,0 +1,34 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Per-Age random sequence and event cooldowns survive unload and restart. */
public final class PortHazardSavedData extends SavedData {
private static final Codec<PortHazardSavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
Codec.LONG.optionalFieldOf("last_lightning", Long.MIN_VALUE).forGetter(data -> data.lastLightning),
Codec.LONG.optionalFieldOf("last_explosion", Long.MIN_VALUE).forGetter(data -> data.lastExplosion),
Codec.LONG.optionalFieldOf("last_meteor", Long.MIN_VALUE).forGetter(data -> data.lastMeteor)
).apply(instance, PortHazardSavedData::new));
private static final SavedDataType<PortHazardSavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "hazards"),
() -> new PortHazardSavedData(0, Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE), CODEC);
private long attempts, lastLightning, lastExplosion, lastMeteor;
private PortHazardSavedData(long attempts, long lightning, long explosion, long meteor) {
this.attempts = attempts; lastLightning = lightning; lastExplosion = explosion; lastMeteor = meteor;
}
public static PortHazardSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
public long nextAttempt() { setDirty(); return attempts++; }
public boolean ready(boolean lightning, long time, int interval) {
long last = lightning ? lastLightning : lastExplosion;
return last == Long.MIN_VALUE || time < last || time - last >= interval;
}
public void record(boolean lightning, long time) { if (lightning) lastLightning = time; else lastExplosion = time; setDirty(); }
public boolean meteorReady(long time, int interval) { return lastMeteor == Long.MIN_VALUE || time < lastMeteor || time - lastMeteor >= interval; }
public void recordMeteor(long time) { lastMeteor = time; setDirty(); }
}
@@ -28,8 +28,17 @@ public final class PortInstabilityCommands {
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
source.sendSuccess(() -> Component.literal(" live block score=" + PortBlockInstability.score(level)
+ "; effective score=" + PortInstabilityRuntime.effectiveScore(level)
+ "; difficulty=" + PortInstabilityConfig.instabilityDifficulty()
+ "; samples Age=" + PortBlockInstability.sampledChunks(level) + "/" + PortBlockProfileSavedData.MIN_AGE_SAMPLES
+ ", Overworld=" + PortBlockInstability.sampledChunks(source.getServer().overworld()) + "/" + PortBlockProfileSavedData.MIN_BASELINE_SAMPLES
+ "; profiling=" + PortInstabilityData.get(source.getServer()).supportsProfiling(level)), false);
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
source.sendSuccess(() -> Component.literal(" erosion testing policy (not a saved card): "
+ PortInstabilityRuntime.providerStatus(level, PortEnvironmentalProviders.EROSION)
+ "; current score=" + PortInstabilityRuntime.effectiveScore(level)), false);
for (var provider : PortInstabilityRuntime.plan(level).providers())
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
@@ -56,7 +65,7 @@ public final class PortInstabilityCommands {
.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;
context.getSource().sendSuccess(() -> Component.literal("Cleared test override; restored live effective provider selection"), false); return 1;
}))
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
.suggests((context, builder) -> {
@@ -66,6 +75,48 @@ public final class PortInstabilityCommands {
}).executes(context -> decay(context, 1))
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
.then(Commands.literal("environment").then(Commands.argument("effect", StringArgumentType.word())
.suggests((context, builder) -> {
for (String effect : java.util.List.of("burning", "erosion"))
if (effect.startsWith(builder.getRemaining())) builder.suggest(effect);
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); requireAge(source);
String effect = StringArgumentType.getString(context, "effect");
if (!effect.equals("burning") && !effect.equals("erosion")) {
source.sendFailure(Component.literal("Choose burning or erosion")); return 0;
}
int result = PortEnvironmentalProviders.pulseForTesting(source.getPlayerOrException(), effect);
if (result > 0) source.sendSuccess(() -> Component.literal("Applied " + effect + "; saved score/plan unchanged"), false);
else source.sendFailure(Component.literal(result == -2 ? "Burning would be extinguished by water or rain"
: "No effect applied. Check Age/config/provider policy, spawn protection and sky exposure; burning requires Survival/Adventure, erosion requires score >=10000 and a source fluid beside/above the targeted block."));
return Math.max(0, result);
})))
.then(Commands.literal("meteor").executes(context -> {
var source = context.getSource(); requireAge(source);
int result = PortMeteorProvider.pulseForTesting(source.getPlayerOrException());
if (result > 0) source.sendSuccess(() -> Component.literal("Meteor descent scheduled; impact in 30 ticks. Saved score/plan unchanged."), false);
else source.sendFailure(Component.literal("No meteor scheduled: check cooldown (200 ticks), active flight, safe loaded terrain and Age/config/provider policy."));
return Math.max(0, result);
}))
.then(Commands.literal("hazard").then(Commands.argument("type", StringArgumentType.word())
.suggests((context, builder) -> {
for (String type : java.util.List.of("lightning", "explosions"))
if (type.startsWith(builder.getRemaining())) builder.suggest(type);
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); var level = requireAge(source);
String type = StringArgumentType.getString(context, "type");
if (!type.equals("lightning") && !type.equals("explosions")) {
source.sendFailure(Component.literal("Choose lightning or explosions")); return 0;
}
int result = PortHazardProviders.pulseForTesting(level, BlockPos.containing(source.getPosition()), type.equals("lightning"));
if (result > 0) source.sendSuccess(() -> Component.literal("Triggered " + type
+ " in a nearby loaded chunk; saved score/plan unchanged"), false);
else source.sendFailure(Component.literal(result < 0 ? "Hazard disabled by config, Age or provider policy"
: "No hazard triggered: cooldown, protected spawn, unloaded terrain or event cancellation"));
return Math.max(0, result);
})))
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
.suggests((context, builder) -> {
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
@@ -9,11 +9,21 @@ public final class PortInstabilityConfig {
private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay;
private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
private static ModConfigSpec.BooleanValue hazards, hazardBlocks, hazardPlayers, hazardFire;
private static ModConfigSpec.IntValue hazardInterval, hazardChunks, hazardMaxBlocks, hazardEntities, hazardDamage, hazardRadius;
private static ModConfigSpec.BooleanValue meteor, meteorBlocks, meteorPlayers, meteorFire;
private static ModConfigSpec.IntValue meteorMaxBlocks, meteorEntities, meteorDamage;
private static ModConfigSpec.BooleanValue scorch, scorchPlayers, erosion;
private static ModConfigSpec.IntValue scorchTargets;
private static ModConfigSpec.IntValue difficulty;
private static ModConfigSpec.ConfigValue<List<? extends String>> materialOverrides;
private PortInstabilityConfig() {}
public static void define(ModConfigSpec.Builder builder) {
builder.push("instability");
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
.define("enabled", true);
difficulty = builder.comment("Legacy instability difficulty: 0=quarter, 1=half, 2=normal, 3=1.75x; saved scores stay unchanged.")
.defineInRange("difficulty", 2, 0, 3);
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
.define("world_mutation", true);
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
@@ -42,7 +52,77 @@ public final class PortInstabilityConfig {
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
.defineInRange("max_attempts_per_call", 4, 1, 6);
builder.pop();
builder.push("instability_hazards");
hazards = builder.comment("Run Legacy lightning and explosion cards in loaded Ages.").define("enabled", true);
hazardBlocks = builder.comment("Allow block destruction; also requires world_mutation and mobGriefing.").define("block_damage", true);
hazardPlayers = builder.comment("Allow damage to Survival/Adventure players.").define("player_damage", true);
hazardFire = builder.comment("Allow ignition where the vanilla fire-spread rule permits it.").define("fire", true);
hazardInterval = builder.defineInRange("minimum_gap_ticks", 20, 20, 12000);
hazardChunks = builder.defineInRange("max_loaded_chunks", 81, 1, 169);
hazardMaxBlocks = builder.defineInRange("max_explosion_blocks", 16, 0, 64);
hazardEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
hazardDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
hazardRadius = builder.comment("Legacy strength 3, bounded to one already-loaded chunk.").defineInRange("explosion_radius", 3, 1, 3);
builder.pop();
builder.push("instability_meteor");
meteor = builder.comment("Run the active Legacy meteor card in loaded Ages.").define("enabled", true);
meteorBlocks = builder.comment("Allow impact block destruction; also requires world_mutation and mobGriefing.")
.define("block_damage", true);
meteorPlayers = builder.comment("Allow impacts to hurt Survival/Adventure players.").define("player_damage", true);
meteorFire = builder.comment("Allow impact fire where the vanilla fire-spread rule permits it.").define("fire", true);
meteorMaxBlocks = builder.defineInRange("max_impact_blocks", 24, 0, 64);
meteorEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
meteorDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
builder.pop();
builder.push("instability_scorching");
scorch = builder.comment("Run Legacy's active burning card on sky-exposed entities.").define("enabled", true);
scorchPlayers = builder.comment("Allow burning on Survival/Adventure players.").define("affect_players", true);
scorchTargets = builder.defineInRange("max_targets_per_pass", 4, 0, 16);
builder.pop();
builder.push("instability_erosion");
erosion = builder.comment("Enable Legacy's dormant erosion effect for testing; no saved card is added.",
"Ages with a score of at least 10000 can spread adjacent source fluid into one eligible block per pass.")
.define("enabled", true);
builder.pop();
builder.push("instability_modded_materials");
materialOverrides = builder.comment("Recognized ores, mineral blocks and source fluids from installed mods are scored automatically.",
"Optional exact modid:block_id=points overrides; zero excludes a block. No content mod is required.")
.defineListAllowEmpty("block_weight_overrides", List.of(), () -> "examplemod:rare_ore=4000", PortInstabilityConfig::validMaterialOverride);
builder.pop();
}
private static boolean validMaterialOverride(Object value) {
if (!(value instanceof String entry)) return false;
int separator = entry.lastIndexOf('=');
if (separator < 1) return false;
Identifier id = Identifier.tryParse(entry.substring(0, separator));
if (id == null || !entry.substring(0, separator).contains(":")
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
try { int weight = Integer.parseInt(entry.substring(separator + 1)); return weight >= 0 && weight <= 100000; }
catch (NumberFormatException ignored) { return false; }
}
public static int instabilityDifficulty() { return difficulty.get(); }
public static List<? extends String> moddedMaterialOverrides() { return materialOverrides.get(); }
public static boolean scorchEnabled() { return scorch.get(); }
public static boolean scorchPlayers() { return scorchPlayers.get(); }
public static int scorchMaxTargets() { return scorchTargets.get(); }
public static boolean erosionEnabled() { return erosion.get(); }
public static boolean meteorEnabled() { return meteor.get(); }
public static boolean meteorBlockDamage() { return meteorBlocks.get(); }
public static boolean meteorPlayerDamage() { return meteorPlayers.get(); }
public static boolean meteorFire() { return meteorFire.get(); }
public static int meteorMaxBlocks() { return meteorMaxBlocks.get(); }
public static int meteorMaxEntities() { return meteorEntities.get(); }
public static int meteorMaxDamage() { return meteorDamage.get(); }
public static boolean hazardsEnabled() { return hazards.get(); }
public static boolean hazardsBlockDamage() { return hazardBlocks.get(); }
public static boolean hazardsPlayerDamage() { return hazardPlayers.get(); }
public static boolean hazardsFire() { return hazardFire.get(); }
public static int hazardsMinInterval() { return hazardInterval.get(); }
public static int hazardsMaxChunks() { return hazardChunks.get(); }
public static int hazardsMaxBlocks() { return hazardMaxBlocks.get(); }
public static int hazardsMaxEntities() { return hazardEntities.get(); }
public static int hazardsMaxDamage() { return hazardDamage.get(); }
public static int hazardsExplosionRadius() { return hazardRadius.get(); }
public static boolean instabilityWorldMutation() { return worldMutation.get(); }
public static boolean decayEnabled() { return decay.get(); }
public static int decayMaxAttempts() { return decayAttempts.get(); }
@@ -47,6 +47,11 @@ public final class PortInstabilityData extends SavedData {
return age != null ? age : new Age(level.getSeed(),
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
}
/** Older neutral Ages stay neutral even if an arrival or enabled flag was later saved. */
public boolean supportsProfiling(ServerLevel level) {
Age state = ages.get(level.dimension().identifier());
return state != null && state.contributions().stream().noneMatch(cost -> cost.reason().equals("predates_instability"));
}
public void enabled(ServerLevel level, boolean enabled) {
Age previous = age(level);
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
@@ -66,6 +71,8 @@ public final class PortInstabilityData extends SavedData {
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
List<Contribution> costs = new ArrayList<>();
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
for (var diagnostic : net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages).diagnostics())
costs.add(new Contribution("writing_" + diagnostic.code(), diagnostic.index(), diagnostic.points()));
boolean authoredBright = false;
Map<Identifier, Integer> structures = new HashMap<>();
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
@@ -20,6 +20,8 @@ import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortInstabilityRuntime {
private static final Map<ServerLevel, CachedPlan> PLANS = new WeakHashMap<>();
private record CachedPlan(int score, long seed, PortInstabilityPlan plan) {}
private static final Map<Identifier, Handler> HANDLERS = new HashMap<>();
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
@@ -35,12 +37,27 @@ public final class PortInstabilityRuntime {
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());
Integer test = testScore(level);
int profile = test == null ? PortBlockInstability.score(level) : 0;
int difficulty = PortInstabilityConfig.instabilityDifficulty();
if (test == null && difficulty == 2 && profile == 0) return age.plan();
int score = test != null ? test : PortBlockInstability.difficultyScore((long) age.score() + profile, difficulty);
var cached = PLANS.get(level);
if (cached == null || cached.score() != score || cached.seed() != age.seed()) {
cached = new CachedPlan(score, age.seed(), PortInstabilityPlan.select(score, age.seed()));
PLANS.put(level, cached);
}
return cached.plan();
}
public static Integer testScore(ServerLevel level) {
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
}
public static int effectiveScore(ServerLevel level) {
Integer test = testScore(level);
if (test != null) return test;
return PortBlockInstability.difficultyScore((long) PortInstabilityData.get(level.getServer()).age(level).score()
+ PortBlockInstability.score(level), PortInstabilityConfig.instabilityDifficulty());
}
public static void testScore(ServerLevel level, Integer score) {
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score);
@@ -69,6 +86,9 @@ public final class PortInstabilityRuntime {
var age = PortInstabilityData.get(server).age(level);
if (!age.enabled()) return;
var providers = plan(level).providers();
PortHazardProviders.tickPlanned(level, age, tick, providers);
PortMeteorProvider.tickPlanned(level, age, tick, providers);
PortEnvironmentalProviders.tickPlanned(level, age, tick, providers);
var chunks = loadedChunks(level);
if (providers.isEmpty() || chunks.isEmpty()) return;
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
@@ -77,7 +97,8 @@ public final class PortInstabilityRuntime {
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++;
if (!PortHazardProviders.isHazard(provider.id()) && !provider.id().equals(PortMeteorProvider.METEORS)
&& !PortEnvironmentalProviders.isEnvironmental(provider.id()) && invoke(context, provider.id(), provider.level())) ran++;
}
}
public static boolean invoke(Context context, Identifier id, int level) {
@@ -101,9 +122,21 @@ public final class PortInstabilityRuntime {
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 (PortHazardProviders.isHazard(id)) {
if (!PortInstabilityConfig.hazardsEnabled()) return "hazards config off";
} else if (id.equals(PortMeteorProvider.METEORS)) {
if (!PortInstabilityConfig.meteorEnabled()) return "meteor config off";
} else if (id.equals(PortEnvironmentalProviders.BURNING)) {
if (!PortInstabilityConfig.scorchEnabled()) return "burning config off";
if (PortInstabilityConfig.scorchMaxTargets() == 0) return "burning target budget zero";
} else if (HANDLERS.get(id).worldMutating()) {
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
if (id.equals(PortEnvironmentalProviders.EROSION)) {
if (!PortInstabilityConfig.erosionEnabled()) return "erosion config off";
if (!PortHazardProviders.canMutate(level)) return "mobGriefing off";
if (effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()) return "erosion threshold not met";
}
} else {
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
@@ -126,7 +159,7 @@ public final class PortInstabilityRuntime {
return List.copyOf(chunks);
}
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear();
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); PLANS.clear();
}
public interface ProviderEffect { void apply(Context context, Identifier id, int level); }
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
@@ -0,0 +1,133 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.level.LevelEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** The 1.21.1 meteor's short particle descent and bounded impact, without a new entity. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortMeteorProvider {
public static final Identifier METEORS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "meteors");
private static final int METEOR_FLIGHT_TICKS = 30;
private static final int MAX_ACTIVE_AGES = 16;
private static final Map<ServerLevel, Meteor> ACTIVE = new WeakHashMap<>();
private PortMeteorProvider() {}
public static void register() {
PortInstabilityRuntime.register(METEORS, false, (context, id, level) -> {
if (PortInstabilityRuntime.isAge(context.level())) scheduleMeteor(context, level, false);
});
}
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
List<PortInstabilityPlan.ProviderLevel> providers) {
var card = providers.stream().filter(provider -> provider.id().equals(METEORS)).findFirst();
if (card.isEmpty() || !PortInstabilityConfig.meteorEnabled()) return;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
if (!chunks.isEmpty()) PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks,
tick, new PortInstabilityRuntime.Budget(1)), METEORS, card.get().level());
}
/** Skip the chance roll only; retain per-Age policy, loading, active-flight and cooldown checks. */
public static int pulseForTesting(ServerPlayer player) {
ServerLevel level = player.level();
if (!PortInstabilityRuntime.isAge(level)) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|| !age.enabled() || !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) return 0;
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16);
if (chunks.isEmpty()) return 0;
return scheduleMeteor(new PortInstabilityRuntime.Context(level, age, chunks,
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)), 1, true) ? 1 : 0;
}
private static boolean scheduleMeteor(PortInstabilityRuntime.Context context, int effectiveLevel, boolean force) {
if (!PortInstabilityConfig.meteorEnabled() || ACTIVE.containsKey(context.level())
|| ACTIVE.size() >= MAX_ACTIVE_AGES) return false;
PortHazardSavedData data = PortHazardSavedData.get(context.level());
long gameTime = context.level().getGameTime();
if (!data.meteorReady(gameTime, 200)) return false;
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L
^ METEORS.hashCode());
if (!force && random.nextDouble() >= PortHazardProviders.legacyChance(context.loadedChunks().size(),
PortInstabilityConfig.instabilityCooldown(), effectiveLevel, 50000)) return false;
int start = random.nextInt(context.loadedChunks().size());
for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) {
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
int x = chunk.getMinBlockX() + 6 + random.nextInt(4);
int z = chunk.getMinBlockZ() + 6 + random.nextInt(4);
int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
BlockPos impact = new BlockPos(x, surfaceY, z);
if (surfaceY >= context.level().getMaxY() - 8
|| !PortHazardProviders.safeTarget(context, impact, 8)) continue;
if (!context.budget().claim()) return false;
int startY = Math.min(context.level().getMaxY() - 2, surfaceY + 48);
data.recordMeteor(gameTime);
ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS));
context.level().playSound(null, impact, Rivencraft.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F);
context.budget().recordApplied();
return true;
}
return false;
}
@SubscribeEvent
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level)) return;
Meteor meteor = ACTIVE.get(level);
if (meteor == null) return;
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|| !PortInstabilityRuntime.isAge(level)
|| level.players().isEmpty()
|| level.getChunkSource().getChunkNow(meteor.chunk().x(), meteor.chunk().z()) == null) {
ACTIVE.remove(level);
return;
}
var age = PortInstabilityData.get(level.getServer()).age(level);
if (!age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) {
ACTIVE.remove(level);
return;
}
int remaining = meteor.remaining() - 1;
if (remaining <= 0) {
ACTIVE.remove(level); // clear before impact so an exception cannot replay it
PortHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(),
PortInstabilityConfig.meteorBlockDamage(), PortInstabilityConfig.meteorPlayerDamage(),
PortInstabilityConfig.meteorFire(), PortInstabilityConfig.meteorMaxBlocks(),
PortInstabilityConfig.meteorMaxEntities(), PortInstabilityConfig.meteorMaxDamage(), 3);
return;
}
double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS;
double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress;
double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0;
double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0;
level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01);
level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02);
ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining));
}
@SubscribeEvent
public static void onServerStopping(ServerStoppingEvent event) {
ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart
}
@SubscribeEvent
public static void onLevelUnload(LevelEvent.Unload event) {
if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level);
}
private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {}
}
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class LinkModifierBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public LinkModifierBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
BlockState> builder) { builder.add(FACING); }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LinkModifierBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
Component.translatable("container.rivencraft.link_modifier")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.SUCCESS;
}
}
@@ -34,7 +34,8 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
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)
var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target, !book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(),
net.dnorberg.rivencraft.item.LinkProperties.NONE).has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM))
: BlockPos.containing(destination.x(), destination.y(), destination.z());
if (pos != null) {
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
@@ -0,0 +1,69 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** One persisted book slot; edits are performed by the server menu. */
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.LINK_MODIFIER_BLOCK_ENTITY.get(), pos, state);
}
@Override public int getContainerSize() { return 1; }
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return slot == 0 && (stack.is(Rivencraft.LINKING_BOOK.get())
|| stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()));
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level != null && !level.isClientSide()) {
net.minecraft.world.Containers.dropContents(level, pos, this);
clearContent();
}
super.preRemoveSideEffects(pos, oldState);
}
@Override public void clearContent() {
items = NonNullList.withSize(1, ItemStack.EMPTY);
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(1, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
}
}
@@ -0,0 +1,120 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Existing Legacy single-slot layout, using the 26.3 extraction/input APIs. */
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
private EditBox titleInput;
private ItemStack lastBook = ItemStack.EMPTY;
private boolean refreshing;
private final Button[] options = new Button[6];
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 166);
inventoryLabelY = 72;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
Component.translatable("screen.rivencraft.link_modifier.title"));
titleInput.setMaxLength(32);
titleInput.setResponder(value -> {
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
});
addRenderableWidget(titleInput);
for (int index = 0; index < options.length; index++) {
final int option = index;
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
topPos + 10 + (index % 3) * 20, 18, 18, label(index), button -> {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, IDS[option]);
}, java.util.function.Supplier::get) {
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = getX(), y = getY();
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
graphics.fill(x + 2, y + 2, x + 17, y + 17, !active ? 0xFF555555
: selected(option, menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE))
? 0xFFBBBBBB : 0xFF777777);
}
});
options[index].setTooltip(Tooltip.create(label(index)));
}
refreshControls();
}
@Override protected void containerTick() { refreshControls(); }
private void refreshControls() {
ItemStack book = menu.book();
// Changing book kinds refreshes immediately; otherwise avoid replacing
// focused text with an earlier server echo while the player is typing.
if (!ItemStack.isSameItem(book, lastBook) || !titleInput.isFocused()) {
if (!ItemStack.isSameItemSameComponents(book, lastBook)) {
Component name = book.get(DataComponents.CUSTOM_NAME);
refreshing = true;
titleInput.setValue(name == null ? "" : name.getString());
refreshing = false;
lastBook = book.copy();
}
}
titleInput.setEditable(!book.isEmpty());
for (int i = 0; i < options.length; i++) if (options[i] != null) {
options[i].active = available(i);
Component label = label(i);
if (!options[i].getMessage().equals(label)) {
options[i].setMessage(label);
options[i].setTooltip(Tooltip.create(label));
}
}
}
private boolean available(int index) {
return !menu.book().isEmpty() && ((index != 1 && index != 2) || menu.book().is(Rivencraft.LINKING_BOOK.get()));
}
private Component label(int index) {
Component name = Component.translatable("item.rivencraft.link_property." + NAMES[index]);
return name.copy().append(selected(index,
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
}
@Override public boolean keyPressed(KeyEvent event) {
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
titleInput.keyPressed(event);
return true; // Character input must never activate inventory/hotbar shortcuts.
}
return super.keyPressed(event);
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
}
private boolean selected(int index, LinkProperties properties) {
if (index != 5) return properties.has(FLAGS[index]);
return menu.book().is(Rivencraft.LINKING_BOOK.get()) ? properties.has(LinkProperties.GENERATE_PLATFORM)
: !properties.has(LinkProperties.DISABLE_PLATFORM);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
}
@@ -43,6 +43,7 @@ public final class PortBookBinderClientEvents {
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
event.register(Rivencraft.LINK_MODIFIER_MENU.get(), LinkModifierScreen::new);
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
}
@@ -36,5 +36,6 @@ public final class DescriptiveBookItem extends Item {
Consumer<Component> tooltip, TooltipFlag flag) {
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook().appendTooltip(tooltip);
}
}
@@ -5,12 +5,17 @@ import io.netty.buffer.ByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** The three Ink Mixer modifiers currently supported by the 26.3 port. */
/** Link Panel flags shared by the mixer, books and Link Modifier station. */
public record LinkProperties(int flags) {
public static final int DISARM = 1;
public static final int GENERATE_PLATFORM = 2;
public static final int INTRA_LINKING_ONLY = 16;
public static final int RELATIVE_LINK = 32;
public static final int DISABLE_PLATFORM = 64;
public static final int MAINTAIN_MOMENTUM = 4;
public static final int INTRA_LINKING = 8;
public static final int SUPPORTED = DISARM | MAINTAIN_MOMENTUM | INTRA_LINKING;
public static final int SUPPORTED = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING
| INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM;
public static final LinkProperties NONE = new LinkProperties(0);
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
public static final StreamCodec<ByteBuf, LinkProperties> STREAM_CODEC = StreamCodec.of(
@@ -23,6 +28,18 @@ public record LinkProperties(int flags) {
}
}
public boolean allowsTravel(boolean sameDimension) {
return sameDimension ? has(INTRA_LINKING) || has(INTRA_LINKING_ONLY) : !has(INTRA_LINKING_ONLY);
}
public LinkProperties forDescriptiveBook() {
return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY));
}
public void appendTooltip(java.util.function.Consumer<net.minecraft.network.chat.Component> tooltip) {
int[] flags = {DISARM, MAINTAIN_MOMENTUM, INTRA_LINKING, INTRA_LINKING_ONLY, RELATIVE_LINK, GENERATE_PLATFORM, DISABLE_PLATFORM};
String[] names = {"disarm", "maintain_momentum", "intra_linking", "intra_linking_only", "relative_link", "generate_platform", "disable_platform"};
for (int i = 0; i < flags.length; i++) if (has(flags[i]))
tooltip.accept(net.minecraft.network.chat.Component.translatable("item.rivencraft.link_property." + names[i]));
}
public boolean has(int flag) {
return (flags & flag) != 0;
}
@@ -48,7 +48,7 @@ public final class LinkingBookItem extends Item {
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
if (!destination.valid()) return "Invalid Linking Book destination";
if (player.level().dimension().identifier().equals(destination.dimension())
&& !properties.has(LinkProperties.INTRA_LINKING)) {
&& !properties.allowsTravel(true)) {
return "Same-dimension linking requires an Intra-Linking page";
}
ServerLevel target = player.level().getServer().getLevel(
@@ -57,11 +57,13 @@ public final class LinkingBookItem extends Item {
if (!AgeCatalogSync.ready(player, destination.dimension())) {
return "Destination catalog not acknowledged by client";
}
if (!properties.allowsTravel(target == player.level())) return "Intra-Linking Only cannot travel between dimensions";
destination = PortLinkDestinations.resolve(player.level(), target, player.blockPosition(), destination, properties);
if (!destination.valid()) return "Invalid relative destination";
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) {
return "Destination is outside the world bounds";
}
|| !target.getWorldBorder().isWithinBounds(feet)) return "Destination is outside the world bounds";
target.getChunkAt(feet);
// Test the player's actual arrival volume: checking whole block shapes
// would incorrectly reject links bound while standing on a slab.
if (!target.noCollision(player, player.getBoundingBox().move(
@@ -74,7 +76,10 @@ public final class LinkingBookItem extends Item {
Vec3 incoming = player.getDeltaMovement();
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
Set.of(), destination.yaw(), destination.pitch(), true);
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
if (moved) {
PortLinkDestinations.generatePlatform(target, player.position(), properties);
applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
}
return moved ? null : "Teleportation failed";
}
@@ -133,11 +138,6 @@ public final class LinkingBookItem extends Item {
destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ()));
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
properties.appendTooltip(tooltip);
}
}
@@ -96,15 +96,7 @@ public final class PageItem extends Item {
return;
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
if (properties.has(LinkProperties.INTRA_LINKING))
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
tooltip.accept(Component.translatable(isLinkPanel(stack)
? "item.rivencraft.page.link_panel.port_status"
: "item.rivencraft.page.blank.port_status"));
properties.appendTooltip(tooltip);
}
private static boolean isCorrupted(Identifier id) {
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.Vec3;
/** 1.21.1 Relative Link and optional Linking Book arrival-floor rules. */
public final class PortLinkDestinations {
private PortLinkDestinations() {}
public static BlockPos relativeOrigin(BlockPos sourceSpawn, BlockPos destinationSpawn, BlockPos departure) {
return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(), departure.getY() - sourceSpawn.getY(),
departure.getZ() - sourceSpawn.getZ());
}
public static BlockPos relativeOrigin(ServerLevel source, ServerLevel target, BlockPos departure) {
return relativeOrigin(source.getRespawnData().pos(), target.getRespawnData().pos(), departure);
}
public static LinkBookTarget resolve(ServerLevel source, ServerLevel target, BlockPos departure,
LinkBookTarget written, LinkProperties properties) {
if (!properties.has(LinkProperties.RELATIVE_LINK)) return written;
BlockPos pos = relativeOrigin(source, target, departure);
return new LinkBookTarget(written.dimension(), pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5, written.yaw(), written.pitch());
}
public static void generatePlatform(ServerLevel level, Vec3 arrival, LinkProperties properties) {
if (!properties.has(LinkProperties.GENERATE_PLATFORM)) return;
BlockPos feet = BlockPos.containing(arrival), floor = feet.below();
if (level.isEmptyBlock(floor) && level.isEmptyBlock(feet.above(2)))
level.setBlock(floor, Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
/** 1.21.1's persistent single-book editing station, adapted to 26.3. */
public final class LinkModifierMenu extends AbstractContainerMenu {
public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3,
TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7;
private final LinkModifierBlockEntity modifier;
private final Container books;
private final Player owner;
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof LinkModifierBlockEntity found ? found : null);
}
public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) {
super(Rivencraft.LINK_MODIFIER_MENU.get(), id);
this.modifier = modifier;
books = modifier == null ? new SimpleContainer(1) : modifier;
owner = inventory.player;
addSlot(new Slot(books, 0, 80, 35) {
@Override public boolean mayPlace(ItemStack stack) { return isBook(stack); }
@Override public int getMaxStackSize() { return 1; }
});
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
for (int col = 0; col < 9; col++) addSlot(new Slot(inventory, col, 8 + col * 18, 142));
}
private static boolean isBook(ItemStack stack) {
return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
}
public ItemStack book() { return books.getItem(0); }
public boolean setTitle(Player player, String title) {
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())
|| title == null || title.length() > 32) return false;
String clean = title.strip();
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
books.setChanged();
broadcastChanges();
return true;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false;
int flag = switch (id) {
case TOGGLE_DISARM -> LinkProperties.DISARM;
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY;
case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK;
case TOGGLE_PLATFORM -> book().is(Rivencraft.LINKING_BOOK.get())
? LinkProperties.GENERATE_PLATFORM : LinkProperties.DISABLE_PLATFORM;
default -> 0;
};
// Descriptive Books travel to an Age; Intra Linking is a Linking Book property.
if (flag == 0 || (flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY) && book().is(Rivencraft.DESCRIPTIVE_BOOK.get())) return false;
int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
flags |= LinkProperties.INTRA_LINKING;
if (flag == LinkProperties.INTRA_LINKING && (flags & LinkProperties.INTRA_LINKING) != 0)
flags &= ~LinkProperties.INTRA_LINKING_ONLY;
book().set(Rivencraft.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag));
books.setChanged();
broadcastChanges();
return true;
}
@Override public boolean stillValid(Player player) {
return player == owner && modifier != null && modifier.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem(), copy = source.copy();
if (index == 0) {
if (!moveItemStackTo(source, 1, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY;
if (source.isEmpty()) slot.set(ItemStack.EMPTY);
else slot.setChanged();
return copy;
}
}
@@ -33,6 +33,12 @@ public final class PortBookNetworking {
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
&& menu.containerId == payload.containerId()) menu.setTitle(player, payload.title());
});
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
(payload, context) -> {
@@ -117,6 +123,8 @@ public final class PortBookNetworking {
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
long seed = player.getRandom().nextLong();
var recovery = net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages);
List<Identifier> activePages = recovery.activePages();
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
PortAgeTerrain terrain;
List<Identifier> generationPages;
@@ -148,7 +156,7 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain);
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain);
var randomFeatures = pages.isEmpty()
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
@@ -210,7 +218,8 @@ public final class PortBookNetworking {
Vec3 departure = player.position();
Vec3 incoming = player.getDeltaMovement();
java.util.function.Supplier<String> travel = () -> {
String result = PortAgeTravel.enter(player, id);
String result = PortAgeTravel.enter(player, id,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook());
if (result == null) LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot(), portal);
@@ -0,0 +1,18 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record SetLinkModifierTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetLinkModifierTitlePayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierTitlePayload::containerId,
ByteBufCodecs.stringUtf8(32), SetLinkModifierTitlePayload::title,
SetLinkModifierTitlePayload::new);
@Override public Type<SetLinkModifierTitlePayload> type() { return TYPE; }
}
@@ -43,14 +43,17 @@ public final class PortalBookTravel {
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);
var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook());
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;
if (target == null || !entity.canTeleport(source, target) || !descriptive && !properties.allowsTravel(target == source)) return;
if (!descriptive) destination = net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(source, target,
entity.blockPosition(), destination, properties);
if (!destination.valid()) 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;
@@ -68,6 +71,7 @@ public final class PortalBookTravel {
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
if (arrived == null) return;
if (!descriptive) net.dnorberg.rivencraft.item.PortLinkDestinations.generatePlatform(target, arrival, properties);
if (properties.has(LinkProperties.DISARM)) {
LinkDisarmService.disarmAt(arrived, source, departure);
}
@@ -33,6 +33,7 @@ public final class ModCreativeTabs {
output.accept(Rivencraft.INK_VIAL.get());
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
output.accept(Rivencraft.INK_MIXER_ITEM.get());
output.accept(Rivencraft.LINK_MODIFIER_ITEM.get());
output.accept(Rivencraft.WRITING_DESK_ITEM.get());
output.accept(Rivencraft.NOTEBOOK.get());
output.accept(Rivencraft.COLLATION_FOLDER.get());
@@ -17,6 +17,20 @@ public final class PortAuthoredTerrainSelection {
}
public static Selection select(MinecraftServer server, List<Identifier> pages, long seed) {
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages))
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
for (var page : pages) {
var entry = PortSymbolPages.find(page);
if (entry != null && entry.role().equals("biome")) {
var biome = PortRegistryPages.biomeId(page);
if (biome == null || server.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME)
.get(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.BIOME, biome)).isEmpty())
throw new IllegalArgumentException("Unknown biome page: " + page);
}
}
var recovered = PortWritingRecovery.recover(pages);
if (!recovered.implemented()) throw new IllegalArgumentException("Unimplemented symbol page in this book");
pages = recovered.activePages();
var explicit = PortAuthoredWriting.explicitTerrain(pages);
if (explicit != null) return compile(server, pages, seed, explicit);
var preferred = PortRandomAgeSelection.terrain(seed);
@@ -72,20 +72,25 @@ public final class PortAuthoredWriting {
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
}
/** A page never disappears into an Age unless this port can apply its effect. */
/** Recover known category defects; unimplemented pages and unsupported runtime combinations still block binding. */
public static boolean supports(List<Identifier> pages) {
if (pages.stream().noneMatch(PortAuthoredWriting::isTerrain))
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
var recovered = PortWritingRecovery.recover(pages);
if (!recovered.implemented()) return false;
List<Identifier> active = recovered.activePages();
if (active.stream().noneMatch(PortAuthoredWriting::isTerrain))
return net.dnorberg.rivencraft.age.PortRandomAgeSelection.terrains().stream()
.anyMatch(terrain -> supportsOnTerrain(pages, terrain, 0));
try {
return supportsExplicit(PortMaterialCompletion.complete(pages, 0));
} catch (IllegalArgumentException exception) { return false; }
.anyMatch(terrain -> supportsOnTerrain(active, terrain, 0));
try { return supportsExplicit(PortMaterialCompletion.complete(active, 0)); }
catch (IllegalArgumentException exception) { return false; }
}
static boolean supportsOnTerrain(List<Identifier> pages, PortAgeTerrain terrain, long seed) {
try {
return supportsExplicit(PortMaterialCompletion.complete(withTerrain(pages, terrain), seed));
} catch (IllegalArgumentException exception) { return false; }
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
var recovered = PortWritingRecovery.recover(pages);
if (!recovered.implemented()) return false;
try { return supportsExplicit(PortMaterialCompletion.complete(withTerrain(recovered.activePages(), terrain), seed)); }
catch (IllegalArgumentException exception) { return false; }
}
static List<Identifier> withTerrain(List<Identifier> pages, PortAgeTerrain terrain) {
@@ -104,7 +109,6 @@ public final class PortAuthoredWriting {
}
private static boolean supportsExplicit(List<Identifier> pages) {
if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false;
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
boolean terrain = false;
int biomes = 0;
@@ -101,7 +101,7 @@ public final class PortRegistryPages {
|| path.equals("deep_dark") || path.endsWith("_caves");
}
private static String materialCategory(Block block) {
public static String materialCategory(Block block) {
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|| block == Rivencraft.CLOUD_PLATFORM.get()
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
@@ -17,7 +17,13 @@ import net.dnorberg.rivencraft.item.PageItem;
/** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */
public final class PortSymbolPages {
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {}
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem, String role) {
public Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {
this(id, category, nameKey, descriptionKey, poem,
category.equals("rivencraft:biome") ? "biome"
: category.equals("rivencraft:block") || category.equals("rivencraft:fluid") ? "block" : "unknown");
}
}
private static final Map<Identifier, Entry> ENTRIES = load();
@@ -100,7 +106,9 @@ public final class PortSymbolPages {
category = "rivencraft:biome_generation";
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
definition.get("description").getAsString(),
definition.has("poem") ? definition.get("poem").getAsString() : ""));
definition.has("poem") ? definition.get("poem").getAsString() : "",
definition.has("semantics") ? definition.getAsJsonObject("semantics").get("role").getAsString()
: id.getPath().equals("lighting/ambient_color") ? "ambient_color" : "unknown"));
});
return Collections.unmodifiableMap(entries);
} catch (java.io.IOException exception) {
@@ -0,0 +1,99 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.minecraft.resources.Identifier;
/** 1.21.1 grammar diagnostics and singular-controller recovery; preserve ordered modifier chains. */
public final class PortWritingRecovery {
private static final Set<String> SINGULAR = Set.of("terrain", "biome_controller", "weather", "season", "lighting", "sky");
private static final Set<String> MODIFIERS = Set.of("period_modifier", "angle_modifier", "phase_modifier", "size_modifier",
"celestial_color", "direction_modifier");
public record Diagnostic(String code, int index, int points) {}
public record Result(List<Identifier> activePages, List<Diagnostic> diagnostics, boolean implemented) {
public Result { activePages = List.copyOf(activePages); diagnostics = List.copyOf(diagnostics); }
}
private PortWritingRecovery() {}
public static Result recover(List<Identifier> pages) {
Map<String, Integer> last = new HashMap<>();
List<Diagnostic> diagnostics = new ArrayList<>();
boolean implemented = pages.size() <= NotebookContents.MAX_PAGES;
int previousStage = -1, biomes = 0;
Map<String, Integer> pendingModifiers = new java.util.LinkedHashMap<>();
Integer pendingNumber = null;
for (int index = 0; index < pages.size(); index++) {
Identifier page = pages.get(index);
var entry = PortSymbolPages.find(page);
if (entry == null || !PortAuthoredWriting.isPageImplemented(page)) { implemented = false; continue; }
String role = entry.role();
if (pendingNumber != null && !numberTarget(role)) {
diagnostics.add(new Diagnostic("misplaced_modifier", pendingNumber, 100));
pendingNumber = null;
}
if (role.equals("color_number")) pendingNumber = index;
else if (numberTarget(role)) pendingNumber = null;
if (MODIFIERS.contains(role)) pendingModifiers.put(role, index);
else if (role.equals("celestial")) pendingModifiers.clear();
if (SINGULAR.contains(role)) {
Integer prior = last.put(role, index);
if (prior != null) {
diagnostics.add(new Diagnostic(pages.get(prior).equals(page) ? "duplicate" : "conflict", index, 500));
// Match WritingGrammar: a repeated singular choice does not also earn an order penalty.
continue;
}
}
if (role.equals("biome")) biomes++;
int stage = stage(role);
if (stage < previousStage && !MODIFIERS.contains(role) && !role.equals("color") && !role.equals("color_number"))
diagnostics.add(new Diagnostic("out_of_order", index, 100));
else if (!role.equals("color") && !role.equals("color_number")) previousStage = stage;
}
if (pendingNumber != null) diagnostics.add(new Diagnostic("dangling_modifier", pendingNumber, 100));
for (int index : pendingModifiers.values())
diagnostics.add(new Diagnostic("dangling_modifier", index, 100));
Integer controller = last.get("biome_controller");
boolean nativeBiomes = controller != null && pages.get(controller).getPath().equals("biome/native");
boolean single = controller != null && pages.get(controller).getPath().equals("biome/single");
if (nativeBiomes && biomes > 0 || single && biomes > 1)
diagnostics.add(new Diagnostic("conflict", controller, 500));
List<Identifier> active = new ArrayList<>();
boolean retainedBiome = false;
for (int index = 0; index < pages.size(); index++) {
var page = pages.get(index);
var entry = PortSymbolPages.find(page);
if (entry != null) {
String role = entry.role();
if (SINGULAR.contains(role) && last.getOrDefault(role, -1) != index) continue;
if (role.equals("biome")) {
if (nativeBiomes || single && retainedBiome) continue;
retainedBiome = true;
}
}
active.add(page);
}
// Keep source order for material pairing and per-celestial modifier chains.
// The port's category compilers select by role, so no physical resort is necessary.
return new Result(active, diagnostics, implemented);
}
private static boolean numberTarget(String role) {
return role.equals("color") || role.equals("period_modifier") || role.equals("angle_modifier")
|| role.equals("phase_modifier") || role.equals("size_modifier");
}
private static int stage(String role) {
return switch (role) {
case "terrain" -> 0;
case "biome" -> 1;
case "biome_controller" -> 2;
case "weather" -> 3;
case "season" -> 4;
case "lighting" -> 5;
case "sky", "sky_color", "ambient_color", "color", "color_number" -> 6;
case "period_modifier", "angle_modifier", "phase_modifier", "size_modifier", "celestial_color", "direction_modifier" -> 7;
case "celestial" -> 8;
default -> 9;
};
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/link_modifier"}}