Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9097967b9 | ||
|
|
32c52e635f |
No files matched your search
@@ -20,13 +20,14 @@ feature completion, and random structure selection. Nether/End noise-biome
|
|||||||
materials now follow their native rules. Books now have Legacy-style spreads,
|
materials now follow their native rules. Books now have Legacy-style spreads,
|
||||||
animated fallback panels and saved destination labels; lectern stands, open-book
|
animated fallback panels and saved destination labels; lectern stands, open-book
|
||||||
drops, page collections, stations and basic progression are also ported. The latest
|
drops, page collections, stations and basic progression are also ported. The latest
|
||||||
beta adds Age libraries, crystal portals with animated fallback surfaces, saved
|
beta includes libraries, fallback crystal portals, live instability profiling and
|
||||||
instability scoring/provider selection, potion providers and four-color decay.
|
hazards, ordered writing recovery, and the Link Modifier with property, seed and
|
||||||
Full writing recovery, live material profiling and other hazards remain pending.
|
operator retirement controls. JEI, TerraBlender/Biomes O' Plenty, Serene Seasons
|
||||||
The beta does not yet provide full
|
and Ruins integrations are now available as optional additions. Live destination
|
||||||
1.21.1 gameplay or optional-integration parity.
|
renderers, remaining discovery/configuration parity and the final multiplayer
|
||||||
|
regression remain unfinished.
|
||||||
See the [26.3 beta guide](ports/mc263/README.md) and
|
See the [26.3 beta guide](ports/mc263/README.md) and
|
||||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md).
|
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md).
|
||||||
Keep the builds and their saves separate, and back up worlds before updating.
|
Keep the builds and their saves separate, and back up worlds before updating.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ The user wants both Minecraft lines maintained after the port, not a one-time
|
|||||||
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
|
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
|
||||||
tracks issues #97–#103 under parent #96.
|
tracks issues #97–#103 under parent #96.
|
||||||
|
|
||||||
|
## Published status — 2026-11-11
|
||||||
|
|
||||||
|
[1.0.1.136-beta.1](../ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md) collects
|
||||||
|
player-validated .130–.136: Link Modifier seed editing and operator retirement,
|
||||||
|
creation rollback, and optional JEI, TerraBlender/BOP, Serene Seasons/GlitchCore
|
||||||
|
and Ruins. Both Minecraft builds pass. The latest production client log records
|
||||||
|
all these optional integrations loaded together. Issue #99's remaining creation
|
||||||
|
rollback requirement is complete. #100 still requires the current dedicated
|
||||||
|
two-client/normal-book matrix; #102 retains the live-renderer/alternative review;
|
||||||
|
#103/#104 and parent #96 remain open. Shared dual-version follow-ups remain open
|
||||||
|
where one or both implementations are pending. The versioning change requested
|
||||||
|
in #103's comment remains part of that next release-workflow task; this release
|
||||||
|
retains the version already built and player-tested. Entries below are historical
|
||||||
|
engineering records and may describe limitations addressed in later slices.
|
||||||
|
|
||||||
## Version boundary
|
## Version boundary
|
||||||
|
|
||||||
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block,
|
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block,
|
||||||
@@ -2326,3 +2341,533 @@ The user passed .120 and authorized commit, push and publication of the accumula
|
|||||||
implemented libraries, fallback crystal portals and partial instability/decay from
|
implemented libraries, fallback crystal portals and partial instability/decay from
|
||||||
remaining gameplay and optional-integration parity. Tracking issues #98/#102/#104
|
remaining gameplay and optional-integration parity. Tracking issues #98/#102/#104
|
||||||
remain open; #114 still requires the matching 1.21.1 fix.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
## .130 Link Modifier seed editing
|
||||||
|
|
||||||
|
Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a
|
||||||
|
bound Descriptive Book whose Age is still indexed, including an unloaded Age.
|
||||||
|
The complete signed 64-bit seed synchronizes through four 16-bit menu data words;
|
||||||
|
zero and negative seeds are not treated as missing data. Linking Books and unbound
|
||||||
|
or retired references do not expose an editable seed.
|
||||||
|
|
||||||
|
Enter/keypad Enter or clicking outside the field applies the edit; Escape closes
|
||||||
|
without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts.
|
||||||
|
Modern InputConstants are used rather than legacy numeric key values. The bounded
|
||||||
|
payload validates the open menu ID and expected Age ID, plus menu owner, station
|
||||||
|
identity and distance. Invalid/overflowing input and occupied Ages are refused.
|
||||||
|
This matches 1.21.1's ordinary creative-acquired station editing permissions.
|
||||||
|
|
||||||
|
Successful edits use RuntimeAgeBackend.reseed, not a display-only book component.
|
||||||
|
Existing chunks and book identity remain intact, while newly generated chunks use
|
||||||
|
the changed seed. Existing backend unload/save/catalog synchronization is reused;
|
||||||
|
frozen written choices and instability history are not rerolled. Retirement UI
|
||||||
|
remains a later port slice.
|
||||||
|
|
||||||
|
Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/
|
||||||
|
backend checks plus two restart checks: bound/unbound visibility, malformed/overflow
|
||||||
|
input, stale expected Age, occupied and remote refusal, retained old terrain,
|
||||||
|
changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained
|
||||||
|
book identity and full restart persistence. Future-chunk height fingerprints from
|
||||||
|
the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed
|
||||||
|
control (new=-1541880620; retained origin=924831745). The initial flat-world fixture
|
||||||
|
could not distinguish terrain seeds; the terrain comparison used a normal-world
|
||||||
|
fixture. All temporary test worlds and fixture sources were removed and the server
|
||||||
|
stopped. The production JAR was rebuilt without fixtures. Client field input,
|
||||||
|
feedback and real travel validation are delegated to the player. No commit, push
|
||||||
|
or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .131 Link Modifier Age retirement
|
||||||
|
|
||||||
|
The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement
|
||||||
|
control through the existing 26.3 lifecycle backend. A bound, active Descriptive
|
||||||
|
Book exposes the control only to players with modern Gamemaster (OP level 2)
|
||||||
|
permission. The first native button arms retirement; a separate red confirmation
|
||||||
|
button performs it. The arm and confirm actions have distinct IDs so repeating the
|
||||||
|
initial click cannot silently confirm. Normal book editing permissions are unchanged.
|
||||||
|
|
||||||
|
Confirmation is server-owned and tied to the exact Age and book components.
|
||||||
|
Replacing/removing the book, renaming, changing properties or applying a seed edit
|
||||||
|
clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and
|
||||||
|
remote actions cannot retire an Age. The target must be empty of players, including
|
||||||
|
the operator. A failed attempt clears confirmation and reports refusal.
|
||||||
|
|
||||||
|
Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target,
|
||||||
|
persist its tombstone, archive only its directory beside the active world and
|
||||||
|
synchronize the runtime catalog. Other Ages are retained. The inserted book keeps
|
||||||
|
its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or
|
||||||
|
retirement controls. Storage failures reuse backend rollback and display failure.
|
||||||
|
This follows 1.21.1 archival behavior rather than permanently erasing the files.
|
||||||
|
|
||||||
|
Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four
|
||||||
|
restart checks passed: permission boundary, arm/confirm order, edits and book swaps,
|
||||||
|
occupied/remote refusal, successful retirement, exact archive and surviving control
|
||||||
|
Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone,
|
||||||
|
and refusal to reload/recreate after restart. Test servers were stopped and all
|
||||||
|
worlds, archives and fixture sources removed. Production JAR rebuilt without the
|
||||||
|
fixture. Client button visibility, tooltip/feedback, occupied refusal and travel
|
||||||
|
through an old book are delegated to the player. No commit, push or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .132 failed Age-creation rollback (#99)
|
||||||
|
|
||||||
|
The player passed .131 retirement. Wrapped new runtime Age setup in one rollback
|
||||||
|
boundary: library writing, level construction/load event, weather and Accelerated
|
||||||
|
settings, index/initial display name, instability record and initial SavedData save.
|
||||||
|
The catalog is published only after those steps succeed. Book creation now supplies
|
||||||
|
its initial name to this operation and no longer configures acceleration/name after
|
||||||
|
commit. Operator/default callers retain the prior overloads.
|
||||||
|
|
||||||
|
A failed attempt closes the level stores, dispatches unload cleanup, removes its
|
||||||
|
new index/environment entries without leaving a retirement tombstone, restores the
|
||||||
|
prior library/instability metadata and saves that restoration. It removes only the
|
||||||
|
exact, newly allocated Age directory with a non-following tree walk. Existing Age
|
||||||
|
storage, live levels, retired IDs and unsafe/symlink paths are refused before any
|
||||||
|
setup. Pre-existing directories are never reused or erased. Cleanup failures are
|
||||||
|
attached to the original exception; if stores cannot close, files are retained to
|
||||||
|
avoid racing an active writer. Book failures are logged/reported and do not move
|
||||||
|
the caller. This covers recoverable runtime exceptions, not arbitrary process death
|
||||||
|
or a guarantee against persistent filesystem/I/O failure.
|
||||||
|
|
||||||
|
Index entry cleanup is shared with retirement without removing the retained display
|
||||||
|
name needed by retirement rollback. No existing Age scores, generation or settings
|
||||||
|
are migrated. No production fault-injection command/hook was introduced.
|
||||||
|
|
||||||
|
Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus
|
||||||
|
seven restart checks. Injected failures during LevelEvent.Load, instability creation
|
||||||
|
and late display-name validation left no live/indexed Age, restored prior writing,
|
||||||
|
dispatched one unload and removed only the new directory. Clean retries of the same
|
||||||
|
IDs succeeded. An existing directory marker survived refusal. A failed book-creation
|
||||||
|
call returned no Age and left its caller in the original world. Restart confirmed
|
||||||
|
failed metadata stayed absent while committed names/Accelerated settings persisted.
|
||||||
|
Fixture source, worlds and servers were removed/stopped; production JAR rebuilt
|
||||||
|
without the fixture. Client validation: create two Ages with distinct names, travel
|
||||||
|
and restart, including a book with Accelerated pages, and confirm their names,
|
||||||
|
settings and destinations remain independent. No commit, push or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .133 optional JEI integration (#102)
|
||||||
|
|
||||||
|
The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and
|
||||||
|
render extraction path. At the player's request, the target is the newest published
|
||||||
|
26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official
|
||||||
|
Maven metadata and Modrinth release API. This supersedes the initially inspected
|
||||||
|
installed 31.17.0.94. The latest release channel differs from the latest published
|
||||||
|
build; this slice deliberately targets the newer 31.17.0.109 build.
|
||||||
|
|
||||||
|
Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants
|
||||||
|
and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid
|
||||||
|
pages use the same pools as Creative, including installed registry content. Runtime
|
||||||
|
registration fills any missing world-dependent variants; Blank Pages and the empty
|
||||||
|
base portfolio are removed from JEI's ingredient list. Future pages entering the
|
||||||
|
shared category pools participate automatically. Shared link-panel variant creation
|
||||||
|
now supplies both Creative and JEI.
|
||||||
|
|
||||||
|
Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides,
|
||||||
|
and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer
|
||||||
|
text correctly states one paper plus one ink per panel, 16 units per vial and reset
|
||||||
|
of additives on output pickup. Added Link Modifier information, without a recipe.
|
||||||
|
Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height.
|
||||||
|
These are explanatory guides; no auto-transfer handler or fictitious Blank Page
|
||||||
|
crafting recipe is introduced.
|
||||||
|
|
||||||
|
JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled
|
||||||
|
or a required runtime dependency; no common class loads its plugin. The developer
|
||||||
|
`-PwithJei` option adds JEI locally, including its Maven dependencies. The current
|
||||||
|
109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's
|
||||||
|
0.6.9 must be updated alongside JEI as required by the mod's distribution metadata.
|
||||||
|
Maven repository filters include JEI and MezzConfig groups only.
|
||||||
|
|
||||||
|
Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started
|
||||||
|
without JEI or any optional mods and stopped normally; its world was removed. Client
|
||||||
|
plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category
|
||||||
|
navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other
|
||||||
|
optional integrations remain unported; #102 is not complete. No commit, push or
|
||||||
|
publication, and no user's instance files were replaced.
|
||||||
|
|
||||||
|
|
||||||
|
## .134 optional TerraBlender regions (#102)
|
||||||
|
|
||||||
|
The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter
|
||||||
|
to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3
|
||||||
|
build checked on Modrinth). The core boundary never exposes TerraBlender types.
|
||||||
|
Absent/broken optional API falls back to existing tags/native pools, with one
|
||||||
|
warning for API failures. No content mod is required or bundled. A developer
|
||||||
|
`-PwithTerraBlender` runtime option is available.
|
||||||
|
|
||||||
|
Enumerates registered non-vanilla biome keys from Overworld and Nether regions,
|
||||||
|
excluding TerraBlender's deferred placeholder. Overworld additions extend random
|
||||||
|
and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership
|
||||||
|
extends Cave-compatible selection and Cave Page classification even without an
|
||||||
|
is_nether tag. Registry-derived Creative and JEI page pools share that result.
|
||||||
|
Runtime Age construction primes membership before biome validation/generation;
|
||||||
|
Nether material-rule conditions also include recognized TerraBlender Nether keys.
|
||||||
|
|
||||||
|
Region scans are cached per registry access with weak keys; hot generation queries
|
||||||
|
use weak-holder membership rather than rescanning all regions. Tags updates refresh
|
||||||
|
the relevant registry entry, and a lowest-priority server-about-to-start callback
|
||||||
|
refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools
|
||||||
|
are not rerolled or migrated. No mod-specific biome names are hardcoded.
|
||||||
|
|
||||||
|
Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type.
|
||||||
|
This distinction and the need for cave tags/metadata were posted and verified in
|
||||||
|
#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags,
|
||||||
|
explicit authoring, and the planned Native Nether/End pages. This slice retains
|
||||||
|
current Native Cave eligibility: excluding Nether from its default pool and adding
|
||||||
|
those new pages remain #111, for both version tracks. No 1.21.1 code was changed.
|
||||||
|
|
||||||
|
Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender
|
||||||
|
starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and
|
||||||
|
GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether
|
||||||
|
additions, placeholder exclusion, completion pools, Cave Page placement, an injected
|
||||||
|
Nether region referencing an untagged biome, cache removal after region removal,
|
||||||
|
and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or
|
||||||
|
client rendering was claimed. Test servers stopped, worlds and fixture sources
|
||||||
|
removed; production JAR rebuilt without fixtures. Client Creative/JEI placement,
|
||||||
|
actual BOP surface/Cave terrain materials and decoration remain player checks.
|
||||||
|
No commit, push or publication. Other #102 integrations remain incomplete.
|
||||||
|
|
||||||
|
|
||||||
|
## .135 optional Serene Seasons per-Age policies (#102)
|
||||||
|
|
||||||
|
The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons,
|
||||||
|
Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build
|
||||||
|
26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test
|
||||||
|
instance. Both APIs are compile-only behind a guarded adapter, never bundled or
|
||||||
|
required. The developer -PwithSereneSeasons flag adds them to the local runtime.
|
||||||
|
|
||||||
|
New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted
|
||||||
|
choices randomize only when Serene is installed, matching root Legacy ranks:
|
||||||
|
Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables
|
||||||
|
seasons. Existing Ages without a stored policy retain legacy cycling behavior
|
||||||
|
when integration/auto-whitelisting is enabled; they are not randomly reassigned.
|
||||||
|
No Seasons is usable without the optional mod; other seasonal pages require it.
|
||||||
|
Singular page recovery keeps the last seasonal controller with existing penalties.
|
||||||
|
|
||||||
|
The guarded adapter writes only the target Age's whitelist entry and persists it
|
||||||
|
through Serene/GlitchCore's config API. Connected-client updates and login replay
|
||||||
|
apply the same entries without a restart. Existing unrelated entries are retained.
|
||||||
|
Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding
|
||||||
|
per-tick dirty data and leaving the Overworld calendar unchanged. Server config
|
||||||
|
serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies
|
||||||
|
still apply when disabled, while existing whitelist entries are not removed merely
|
||||||
|
by disabling automatic behavior.
|
||||||
|
|
||||||
|
Policy selection participates in creation rollback before catalog publication;
|
||||||
|
optional whitelist changes happen only after successful creation. Reload/reseed
|
||||||
|
reuse the frozen policy. Retirement removes its whitelist entry only after backend
|
||||||
|
retirement succeeds, while policy history can remain in global metadata. Client
|
||||||
|
messages are limited to Rivencraft Age IDs. Compatibility failures log once rather
|
||||||
|
than flood every level tick. No Ecliptic Seasons support is added.
|
||||||
|
|
||||||
|
Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional
|
||||||
|
mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart:
|
||||||
|
all six saved modes/whitelist policies, each eternal mid-season and recovery from a
|
||||||
|
changed cycle value, unchanged Overworld season data, unrelated whitelist entries,
|
||||||
|
deterministic omitted choices, no random choice when the mod is absent, and Cave
|
||||||
|
suppression. The initial restart assertion was corrected to allow the deliberate
|
||||||
|
mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and
|
||||||
|
servers stopped; production JAR rebuilt without fixtures. Client visual effects,
|
||||||
|
reconnect/live whitelist synchronization, normal season progression, authoring and
|
||||||
|
retirement with a real connected client remain player validation. No commit, push
|
||||||
|
or publication; other #102 integrations remain open.
|
||||||
|
|
||||||
|
|
||||||
|
### Optional Ruins template pages — 1.0.1.136-beta.1
|
||||||
|
|
||||||
|
The preceding Serene Seasons slice passed the player's in-game check. Ruins now
|
||||||
|
uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename
|
||||||
|
gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool.
|
||||||
|
Names preserve case and Unicode through reversible IDs. Discovery reads Ruins'
|
||||||
|
active `config/ruins_config` directory, with its bundled archive as the first-launch
|
||||||
|
fallback. Duplicate basenames disable the bridge because Ruins' event cannot
|
||||||
|
identify which directory supplied the template.
|
||||||
|
|
||||||
|
Authored template choices and all duplicate counts are frozen in the Age's terrain
|
||||||
|
feature plan and restored before its load event. Only selected templates pass the
|
||||||
|
Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins'
|
||||||
|
biome/surface/distance requirements remain applicable. Copies multiply template
|
||||||
|
weight and increase attempts by one per extra page, capped at 32 attempts without
|
||||||
|
lowering an explicitly higher Ruins configuration. The first three copies are free;
|
||||||
|
each later copy adds 100 instability, including copies beyond the attempt cap.
|
||||||
|
Material pages do not modify Ruins structures. Unloading clears bridge state and
|
||||||
|
reloading restores it; optional Ruins classes are not loaded when the mod is absent.
|
||||||
|
|
||||||
|
The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119);
|
||||||
|
the installed Ruins 26.3.2NF was used for integration validation. For development,
|
||||||
|
`-PwithRuins` adds the baseline runtime, or combine it with
|
||||||
|
`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build.
|
||||||
|
Neither dependency is bundled or required to run Rivencraft.
|
||||||
|
|
||||||
|
Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF,
|
||||||
|
21 checks passed on creation and 10 after restart, covering 25 discovered templates,
|
||||||
|
creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip,
|
||||||
|
50 retained copies, attempt limits, 4,700 excess-page instability, exact template
|
||||||
|
filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement
|
||||||
|
(149 non-air blocks counted in the checked volume). Restart, unload, and reload
|
||||||
|
retained the counts. Temporary fixtures and test worlds were removed; test servers
|
||||||
|
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
|
||||||
|
remain player validation. No commit, push, or publication; #102 remains open.
|
||||||
+662
-3
@@ -9,9 +9,13 @@ biome-distribution pages, selected material/vegetation/structure features, and
|
|||||||
independent Age weather and lighting, multiple authored or seeded celestial
|
independent Age weather and lighting, multiple authored or seeded celestial
|
||||||
bodies and eclipses, biome/material completion, and random features/structures.
|
bodies and eclipses, biome/material completion, and random features/structures.
|
||||||
Crystal portals and libraries are ported. Instability scoring/provider selection,
|
Crystal portals and libraries are ported. Instability scoring/provider selection,
|
||||||
status effects and colored decay are partially ported; remaining hazards, live material profiling,
|
status effects, colored decay, lightning, explosions, meteors, burning and erosion,
|
||||||
remaining discovery/stations, writing recovery and optional integrations remain unfinished. The latest published beta is
|
ordered writing recovery, live material profiling and all six Link Modifier controls are now ported.
|
||||||
[1.0.1.120-beta.1](RELEASE_NOTES_1.0.1.120-beta.1.md).
|
Seed editing, operator-confirmed Age retirement and creation rollback are included.
|
||||||
|
JEI, TerraBlender/Biomes O' Plenty, Serene Seasons/GlitchCore and Ruins are optional
|
||||||
|
integrations. Live previews, remaining discovery/configuration parity and the final
|
||||||
|
multiplayer regression remain unfinished. The latest published beta is
|
||||||
|
[1.0.1.136-beta.1](RELEASE_NOTES_1.0.1.136-beta.1.md).
|
||||||
Use a test world and keep a backup; do not
|
Use a test world and keep a backup; do not
|
||||||
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
|
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
|
||||||
|
|
||||||
@@ -2142,3 +2146,658 @@ No permanent test fixtures, commit, push or publication.
|
|||||||
The player passed every slice, including .120 colored decay, and authorized commit,
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
## .130 Link Modifier seed editing
|
||||||
|
|
||||||
|
Ported the 1.21.1 station's seed field into the Legacy GUI. It is visible for a
|
||||||
|
bound Descriptive Book whose Age is still indexed, including an unloaded Age.
|
||||||
|
The complete signed 64-bit seed synchronizes through four 16-bit menu data words;
|
||||||
|
zero and negative seeds are not treated as missing data. Linking Books and unbound
|
||||||
|
or retired references do not expose an editable seed.
|
||||||
|
|
||||||
|
Enter/keypad Enter or clicking outside the field applies the edit; Escape closes
|
||||||
|
without applying unfinished text. Focused typing consumes inventory/hotbar shortcuts.
|
||||||
|
Modern InputConstants are used rather than legacy numeric key values. The bounded
|
||||||
|
payload validates the open menu ID and expected Age ID, plus menu owner, station
|
||||||
|
identity and distance. Invalid/overflowing input and occupied Ages are refused.
|
||||||
|
This matches 1.21.1's ordinary creative-acquired station editing permissions.
|
||||||
|
|
||||||
|
Successful edits use RuntimeAgeBackend.reseed, not a display-only book component.
|
||||||
|
Existing chunks and book identity remain intact, while newly generated chunks use
|
||||||
|
the changed seed. Existing backend unload/save/catalog synchronization is reused;
|
||||||
|
frozen written choices and instability history are not rerolled. Retirement UI
|
||||||
|
remains a later port slice.
|
||||||
|
|
||||||
|
Version 1.0.1.130-beta.1 builds. A temporary dedicated-server fixture passed 16 menu/
|
||||||
|
backend checks plus two restart checks: bound/unbound visibility, malformed/overflow
|
||||||
|
input, stale expected Age, occupied and remote refusal, retained old terrain,
|
||||||
|
changed index/level seed, unloaded reseeding including Long.MIN_VALUE, retained
|
||||||
|
book identity and full restart persistence. Future-chunk height fingerprints from
|
||||||
|
the menu-reseeded Age matched a fresh new-seed control and differed from an old-seed
|
||||||
|
control (new=-1541880620; retained origin=924831745). The initial flat-world fixture
|
||||||
|
could not distinguish terrain seeds; the terrain comparison used a normal-world
|
||||||
|
fixture. All temporary test worlds and fixture sources were removed and the server
|
||||||
|
stopped. The production JAR was rebuilt without fixtures. Client field input,
|
||||||
|
feedback and real travel validation are delegated to the player. No commit, push
|
||||||
|
or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .131 Link Modifier Age retirement
|
||||||
|
|
||||||
|
The player passed .130 seed editing. Ported the remaining 1.21.1 station retirement
|
||||||
|
control through the existing 26.3 lifecycle backend. A bound, active Descriptive
|
||||||
|
Book exposes the control only to players with modern Gamemaster (OP level 2)
|
||||||
|
permission. The first native button arms retirement; a separate red confirmation
|
||||||
|
button performs it. The arm and confirm actions have distinct IDs so repeating the
|
||||||
|
initial click cannot silently confirm. Normal book editing permissions are unchanged.
|
||||||
|
|
||||||
|
Confirmation is server-owned and tied to the exact Age and book components.
|
||||||
|
Replacing/removing the book, renaming, changing properties or applying a seed edit
|
||||||
|
clears confirmation; reopening the menu starts unarmed. Invalid, stale, non-OP and
|
||||||
|
remote actions cannot retire an Age. The target must be empty of players, including
|
||||||
|
the operator. A failed attempt clears confirmation and reports refusal.
|
||||||
|
|
||||||
|
Successful retirement uses RuntimeAgeBackend.retire: flush/unload the target,
|
||||||
|
persist its tombstone, archive only its directory beside the active world and
|
||||||
|
synchronize the runtime catalog. Other Ages are retained. The inserted book keeps
|
||||||
|
its now-dead identity, cannot reopen the retired Age, and no longer exposes seed or
|
||||||
|
retirement controls. Storage failures reuse backend rollback and display failure.
|
||||||
|
This follows 1.21.1 archival behavior rather than permanently erasing the files.
|
||||||
|
|
||||||
|
Version 1.0.1.131-beta.1 builds. Eighteen temporary dedicated-server checks plus four
|
||||||
|
restart checks passed: permission boundary, arm/confirm order, edits and book swaps,
|
||||||
|
occupied/remote refusal, successful retirement, exact archive and surviving control
|
||||||
|
Age, retained dead-book identity, repeated confirmation refusal, persisted tombstone,
|
||||||
|
and refusal to reload/recreate after restart. Test servers were stopped and all
|
||||||
|
worlds, archives and fixture sources removed. Production JAR rebuilt without the
|
||||||
|
fixture. Client button visibility, tooltip/feedback, occupied refusal and travel
|
||||||
|
through an old book are delegated to the player. No commit, push or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .132 failed Age-creation rollback (#99)
|
||||||
|
|
||||||
|
The player passed .131 retirement. Wrapped new runtime Age setup in one rollback
|
||||||
|
boundary: library writing, level construction/load event, weather and Accelerated
|
||||||
|
settings, index/initial display name, instability record and initial SavedData save.
|
||||||
|
The catalog is published only after those steps succeed. Book creation now supplies
|
||||||
|
its initial name to this operation and no longer configures acceleration/name after
|
||||||
|
commit. Operator/default callers retain the prior overloads.
|
||||||
|
|
||||||
|
A failed attempt closes the level stores, dispatches unload cleanup, removes its
|
||||||
|
new index/environment entries without leaving a retirement tombstone, restores the
|
||||||
|
prior library/instability metadata and saves that restoration. It removes only the
|
||||||
|
exact, newly allocated Age directory with a non-following tree walk. Existing Age
|
||||||
|
storage, live levels, retired IDs and unsafe/symlink paths are refused before any
|
||||||
|
setup. Pre-existing directories are never reused or erased. Cleanup failures are
|
||||||
|
attached to the original exception; if stores cannot close, files are retained to
|
||||||
|
avoid racing an active writer. Book failures are logged/reported and do not move
|
||||||
|
the caller. This covers recoverable runtime exceptions, not arbitrary process death
|
||||||
|
or a guarantee against persistent filesystem/I/O failure.
|
||||||
|
|
||||||
|
Index entry cleanup is shared with retirement without removing the retained display
|
||||||
|
name needed by retirement rollback. No existing Age scores, generation or settings
|
||||||
|
are migrated. No production fault-injection command/hook was introduced.
|
||||||
|
|
||||||
|
Version 1.0.1.132-beta.1 builds. A temporary dedicated fixture passed 36 checks plus
|
||||||
|
seven restart checks. Injected failures during LevelEvent.Load, instability creation
|
||||||
|
and late display-name validation left no live/indexed Age, restored prior writing,
|
||||||
|
dispatched one unload and removed only the new directory. Clean retries of the same
|
||||||
|
IDs succeeded. An existing directory marker survived refusal. A failed book-creation
|
||||||
|
call returned no Age and left its caller in the original world. Restart confirmed
|
||||||
|
failed metadata stayed absent while committed names/Accelerated settings persisted.
|
||||||
|
Fixture source, worlds and servers were removed/stopped; production JAR rebuilt
|
||||||
|
without the fixture. Client validation: create two Ages with distinct names, travel
|
||||||
|
and restart, including a book with Accelerated pages, and confirm their names,
|
||||||
|
settings and destinations remain independent. No commit, push or publication.
|
||||||
|
|
||||||
|
|
||||||
|
## .133 optional JEI integration (#102)
|
||||||
|
|
||||||
|
The player passed .132. Adapted the 1.21.1 JEI plugin to the current 26.3 API and
|
||||||
|
render extraction path. At the player's request, the target is the newest published
|
||||||
|
26.3 NeoForge build, 31.17.0.109 (JEI labels it beta), verified against its official
|
||||||
|
Maven metadata and Modrinth release API. This supersedes the initially inspected
|
||||||
|
installed 31.17.0.94. The latest release channel differs from the latest published
|
||||||
|
build; this slice deliberately targets the newer 31.17.0.109 build.
|
||||||
|
|
||||||
|
Added searchable component-aware Symbol Pages, all eight mixer Link Panel variants
|
||||||
|
and category/volume Symbol Portfolios. Registered biome and eligible Block/Fluid
|
||||||
|
pages use the same pools as Creative, including installed registry content. Runtime
|
||||||
|
registration fills any missing world-dependent variants; Blank Pages and the empty
|
||||||
|
base portfolio are removed from JEI's ingredient list. Future pages entering the
|
||||||
|
shared category pools participate automatically. Shared link-panel variant creation
|
||||||
|
now supplies both Creative and JEI.
|
||||||
|
|
||||||
|
Reused the Writing and Ink Mixer recipe-guide categories, additive chance guides,
|
||||||
|
and Ink Vial/Writing Desk/Book Binder/Ink Mixer catalysts. The port-specific mixer
|
||||||
|
text correctly states one paper plus one ink per panel, 16 units per vial and reset
|
||||||
|
of additives on output pickup. Added Link Modifier information, without a recipe.
|
||||||
|
Wrapped guide text uses opaque, shadow-free rendering and extra Mixer guide height.
|
||||||
|
These are explanatory guides; no auto-transfer handler or fictitious Blank Page
|
||||||
|
crafting recipe is introduced.
|
||||||
|
|
||||||
|
JEI is compile-only and isolated in its annotated scanner plugin. It is not bundled
|
||||||
|
or a required runtime dependency; no common class loads its plugin. The developer
|
||||||
|
`-PwithJei` option adds JEI locally, including its Maven dependencies. The current
|
||||||
|
109 POM requires MezzConfig 0.6.10 (and its GUI module); the existing test instance's
|
||||||
|
0.6.9 must be updated alongside JEI as required by the mod's distribution metadata.
|
||||||
|
Maven repository filters include JEI and MezzConfig groups only.
|
||||||
|
|
||||||
|
Version 1.0.1.133-beta.1 builds against the 109 API. A fresh dedicated server started
|
||||||
|
without JEI or any optional mods and stopped normally; its world was removed. Client
|
||||||
|
plugin discovery, ingredient counts/subtype uniqueness, guide rendering and category
|
||||||
|
navigation await the player's check with JEI 31.17.0.109 and its dependencies. Other
|
||||||
|
optional integrations remain unported; #102 is not complete. No commit, push or
|
||||||
|
publication, and no user's instance files were replaced.
|
||||||
|
|
||||||
|
|
||||||
|
## .134 optional TerraBlender regions (#102)
|
||||||
|
|
||||||
|
The player passed .133 JEI. Ported the 1.21.1 optional TerraBlender region adapter
|
||||||
|
to the current 26.3 API, compile-only at 26.3.0.0.9 (the latest published 26.3
|
||||||
|
build checked on Modrinth). The core boundary never exposes TerraBlender types.
|
||||||
|
Absent/broken optional API falls back to existing tags/native pools, with one
|
||||||
|
warning for API failures. No content mod is required or bundled. A developer
|
||||||
|
`-PwithTerraBlender` runtime option is available.
|
||||||
|
|
||||||
|
Enumerates registered non-vanilla biome keys from Overworld and Nether regions,
|
||||||
|
excluding TerraBlender's deferred placeholder. Overworld additions extend random
|
||||||
|
and seed-completed Standard/Amplified/Island/Flat pools. Nether-region membership
|
||||||
|
extends Cave-compatible selection and Cave Page classification even without an
|
||||||
|
is_nether tag. Registry-derived Creative and JEI page pools share that result.
|
||||||
|
Runtime Age construction primes membership before biome validation/generation;
|
||||||
|
Nether material-rule conditions also include recognized TerraBlender Nether keys.
|
||||||
|
|
||||||
|
Region scans are cached per registry access with weak keys; hot generation queries
|
||||||
|
use weak-holder membership rather than rescanning all regions. Tags updates refresh
|
||||||
|
the relevant registry entry, and a lowest-priority server-about-to-start callback
|
||||||
|
refreshes after TerraBlender loads data-driven regions. Frozen saved biome pools
|
||||||
|
are not rerolled or migrated. No mod-specific biome names are hardcoded.
|
||||||
|
|
||||||
|
Both 1.21.1 and 26.3 APIs expose OVERWORLD/NETHER, but no CAVE or END region type.
|
||||||
|
This distinction and the need for cave tags/metadata were posted and verified in
|
||||||
|
#111 (comment 605), including Nether precedence for overlapping Cave/Nether tags,
|
||||||
|
explicit authoring, and the planned Native Nether/End pages. This slice retains
|
||||||
|
current Native Cave eligibility: excluding Nether from its default pool and adding
|
||||||
|
those new pages remain #111, for both version tracks. No 1.21.1 code was changed.
|
||||||
|
|
||||||
|
Version 1.0.1.134-beta.1 builds. A clean dedicated server without TerraBlender
|
||||||
|
starts normally. A fresh server with TerraBlender 26.3.0.0.9, BOP 26.3.0.0.16 and
|
||||||
|
GlitchCore 26.3.0.0.5 passed nine focused checks: 59 Overworld additions, five Nether
|
||||||
|
additions, placeholder exclusion, completion pools, Cave Page placement, an injected
|
||||||
|
Nether region referencing an untagged biome, cache removal after region removal,
|
||||||
|
and Cave Age acceptance/reload with a modded Nether biome. No bulk generation or
|
||||||
|
client rendering was claimed. Test servers stopped, worlds and fixture sources
|
||||||
|
removed; production JAR rebuilt without fixtures. Client Creative/JEI placement,
|
||||||
|
actual BOP surface/Cave terrain materials and decoration remain player checks.
|
||||||
|
No commit, push or publication. Other #102 integrations remain incomplete.
|
||||||
|
|
||||||
|
|
||||||
|
## .135 optional Serene Seasons per-Age policies (#102)
|
||||||
|
|
||||||
|
The player passed .134. Ported 1.21.1's six modes: No Seasons, cycling Seasons,
|
||||||
|
Eternal Winter/Summer/Fall/Spring. Uses the latest published 26.3 Serene build
|
||||||
|
26.1.2.0.7 (QnvW5HPm) and GlitchCore 26.3.0.0.5 (pQgy3bfh), matching the test
|
||||||
|
instance. Both APIs are compile-only behind a guarded adapter, never bundled or
|
||||||
|
required. The developer -PwithSereneSeasons flag adds them to the local runtime.
|
||||||
|
|
||||||
|
New Ages freeze an authored or seed-selected policy in separate SavedData. Omitted
|
||||||
|
choices randomize only when Serene is installed, matching root Legacy ranks:
|
||||||
|
Seasons weight 6, No Seasons 5 and each Eternal 1. Cave terrain always disables
|
||||||
|
seasons. Existing Ages without a stored policy retain legacy cycling behavior
|
||||||
|
when integration/auto-whitelisting is enabled; they are not randomly reassigned.
|
||||||
|
No Seasons is usable without the optional mod; other seasonal pages require it.
|
||||||
|
Singular page recovery keeps the last seasonal controller with existing penalties.
|
||||||
|
|
||||||
|
The guarded adapter writes only the target Age's whitelist entry and persists it
|
||||||
|
through Serene/GlitchCore's config API. Connected-client updates and login replay
|
||||||
|
apply the same entries without a restart. Existing unrelated entries are retained.
|
||||||
|
Eternal policies hold the middle sub-season in a bounded mid-season window, avoiding
|
||||||
|
per-tick dirty data and leaving the Overworld calendar unchanged. Server config
|
||||||
|
serene_seasons.auto_whitelist_ages defaults true, matching 1.21.1: explicit policies
|
||||||
|
still apply when disabled, while existing whitelist entries are not removed merely
|
||||||
|
by disabling automatic behavior.
|
||||||
|
|
||||||
|
Policy selection participates in creation rollback before catalog publication;
|
||||||
|
optional whitelist changes happen only after successful creation. Reload/reseed
|
||||||
|
reuse the frozen policy. Retirement removes its whitelist entry only after backend
|
||||||
|
retirement succeeds, while policy history can remain in global metadata. Client
|
||||||
|
messages are limited to Rivencraft Age IDs. Compatibility failures log once rather
|
||||||
|
than flood every level tick. No Ecliptic Seasons support is added.
|
||||||
|
|
||||||
|
Version 1.0.1.135-beta.1 builds. A clean dedicated server starts without optional
|
||||||
|
mods. With Serene/GlitchCore, 25 checks passed on creation and 22 after full restart:
|
||||||
|
all six saved modes/whitelist policies, each eternal mid-season and recovery from a
|
||||||
|
changed cycle value, unchanged Overworld season data, unrelated whitelist entries,
|
||||||
|
deterministic omitted choices, no random choice when the mod is absent, and Cave
|
||||||
|
suppression. The initial restart assertion was corrected to allow the deliberate
|
||||||
|
mid-season hold window inherited from 1.21.1. Test worlds/fixtures were removed and
|
||||||
|
servers stopped; production JAR rebuilt without fixtures. Client visual effects,
|
||||||
|
reconnect/live whitelist synchronization, normal season progression, authoring and
|
||||||
|
retirement with a real connected client remain player validation. No commit, push
|
||||||
|
or publication; other #102 integrations remain open.
|
||||||
|
|
||||||
|
|
||||||
|
### Optional Ruins template pages — 1.0.1.136-beta.1
|
||||||
|
|
||||||
|
The preceding Serene Seasons slice passed the player's in-game check. Ruins now
|
||||||
|
uses the 1.21.1 exact-template bridge: each unambiguous installed `.tml` filename
|
||||||
|
gets a dynamic glyph page in a Ruins creative tab, portfolios, and JEI's page pool.
|
||||||
|
Names preserve case and Unicode through reversible IDs. Discovery reads Ruins'
|
||||||
|
active `config/ruins_config` directory, with its bundled archive as the first-launch
|
||||||
|
fallback. Duplicate basenames disable the bridge because Ruins' event cannot
|
||||||
|
identify which directory supplied the template.
|
||||||
|
|
||||||
|
Authored template choices and all duplicate counts are frozen in the Age's terrain
|
||||||
|
feature plan and restored before its load event. Only selected templates pass the
|
||||||
|
Age dimension and pre-spawn gates; other worlds retain Ruins' own behavior. Ruins'
|
||||||
|
biome/surface/distance requirements remain applicable. Copies multiply template
|
||||||
|
weight and increase attempts by one per extra page, capped at 32 attempts without
|
||||||
|
lowering an explicitly higher Ruins configuration. The first three copies are free;
|
||||||
|
each later copy adds 100 instability, including copies beyond the attempt cap.
|
||||||
|
Material pages do not modify Ruins structures. Unloading clears bridge state and
|
||||||
|
reloading restores it; optional Ruins classes are not loaded when the mod is absent.
|
||||||
|
|
||||||
|
The reproducible compile-only baseline is Ruins 26.3.1NF (CurseForge file 8962119);
|
||||||
|
the installed Ruins 26.3.2NF was used for integration validation. For development,
|
||||||
|
`-PwithRuins` adds the baseline runtime, or combine it with
|
||||||
|
`-PruinsJar=/absolute/path/to/ruins-26.3.2NF.jar` to run a local current build.
|
||||||
|
Neither dependency is bundled or required to run Rivencraft.
|
||||||
|
|
||||||
|
Build and clean dedicated-server startup without Ruins passed. With Ruins 26.3.2NF,
|
||||||
|
21 checks passed on creation and 10 after restart, covering 25 discovered templates,
|
||||||
|
creative/JEI pool parity, readable/case-sensitive IDs, authoring, codec roundtrip,
|
||||||
|
50 retained copies, attempt limits, 4,700 excess-page instability, exact template
|
||||||
|
filtering, both dimension gates, spawn-event veto, and a real Tiki Head placement
|
||||||
|
(149 non-air blocks counted in the checked volume). Restart, unload, and reload
|
||||||
|
retained the counts. Temporary fixtures and test worlds were removed; test servers
|
||||||
|
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
|
||||||
|
remain player validation. No commit, push, or publication; #102 remains open.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Rivencraft 26.3 beta 1.0.1.129-beta.1
|
||||||
|
|
||||||
|
Collects the player-validated .121–.129 port slices since 1.0.1.120-beta.1.
|
||||||
|
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent Minecraft
|
||||||
|
1.21.1 release is unchanged. Full parity and optional integrations remain in progress.
|
||||||
|
|
||||||
|
## Instability hazards and live profiling
|
||||||
|
|
||||||
|
- Ported lightning, explosions, falling meteor effects, burning and erosion with
|
||||||
|
bounded loaded-chunk scans, spawn protection, claim events and configurable
|
||||||
|
world/entity damage. Meteor flight uses the existing sound and particle effects.
|
||||||
|
- Natural hazards choose randomized locations; operator test commands provide
|
||||||
|
targeted checks with independent cooldowns. Erosion retains its requested testing
|
||||||
|
default of enabled above 10,000 effective instability, outside the Legacy deck.
|
||||||
|
- Ported persistent live material profiling, including native ores, valuable blocks,
|
||||||
|
recognized installed-mod materials and configurable modded block weight overrides.
|
||||||
|
No content mod is required. Profiling starts scoring after 401 Age and 500
|
||||||
|
Overworld baseline samples, with bounded gradual background sampling while an
|
||||||
|
eligible Age is occupied. A chunk snapshot is counted once.
|
||||||
|
- Effective instability and deterministic provider selection now update as material
|
||||||
|
abundance is measured; the original writing contribution remains frozen. Added
|
||||||
|
difficulty scaling and operator-only sampling/score diagnostics. Older neutral
|
||||||
|
Ages remain neutral. Normal book/Binder screens do not reveal scores.
|
||||||
|
|
||||||
|
## Ordered writing recovery
|
||||||
|
|
||||||
|
- Repeated singular terrain, distribution, weather, lighting and sky controllers
|
||||||
|
use the last choice, adding 500 instability per duplicate/conflict. Category-order
|
||||||
|
mistakes add 100. Native distribution ignores supplied biomes; Single retains the
|
||||||
|
first biome when too many are supplied, with conflict penalties.
|
||||||
|
- Repeated celestial modifiers use the final pending value of each kind. Dangling
|
||||||
|
modifiers and misplaced/trailing numbers allow binding and add 100 instability.
|
||||||
|
Numbers only attach to the immediately following supported target.
|
||||||
|
- Preserved original book/library pages, ordered color mixing and independent
|
||||||
|
celestial chains. A trailing modifier cannot change an earlier sun or moon.
|
||||||
|
Unported pages, unsupported combinations and body limits remain enforced.
|
||||||
|
|
||||||
|
## Link Modifier station
|
||||||
|
|
||||||
|
- Added the creative-only Link Modifier with its existing model, textures and
|
||||||
|
Legacy single-book GUI. There is no crafting recipe.
|
||||||
|
- Books can be renamed and edited through all six controls: Disarm, Intra-Linking,
|
||||||
|
Intra-Linking Only, Maintain Momentum, Relative Link and Generate Platform.
|
||||||
|
- Intra-Linking Only enables Intra-Linking and prevents cross-dimension Linking
|
||||||
|
Book travel. Both intra controls are unavailable for Descriptive Books.
|
||||||
|
- Relative Link preserves the traveller's offset from the source dimension spawn
|
||||||
|
at the destination spawn. Linking Book platform generation defaults off;
|
||||||
|
Descriptive Book fallback platforms default on and can be disabled.
|
||||||
|
- Player and non-player portal paths share the new properties. Book contents and
|
||||||
|
flags persist in the station. Removal releases its stored book.
|
||||||
|
- Fixed the controls ignoring real clicks: 26.3 left-click is button 1, not 0.
|
||||||
|
Registered native button widgets now handle input, focus, sound and state tooltips.
|
||||||
|
|
||||||
|
## Validation and remaining work
|
||||||
|
|
||||||
|
The player passed every included slice, including the six controls and click fix.
|
||||||
|
Both Minecraft builds pass (no standalone Gradle test sources). Targeted temporary
|
||||||
|
checks covered grammar/celestial recovery, component serialization, station actions,
|
||||||
|
platform rules and dedicated-server save/restart behavior. Live profiling was checked
|
||||||
|
with real snapshots plus synthetic threshold records; natural gameplay was validated
|
||||||
|
by the player. Temporary servers were stopped and their test worlds removed.
|
||||||
|
|
||||||
|
Link Modifier seed editing/retirement, remaining discovery/config policy and optional
|
||||||
|
integrations are not complete. Live previews remain unavailable; books and portals
|
||||||
|
use the animated fallback. Crumble has no active 1.21.1 handler and is not invented
|
||||||
|
in this port. The dual-version follow-up issues and full multiplayer/parity gate
|
||||||
|
remain open. Use matching client/server beta builds and keep Minecraft version
|
||||||
|
tracks and their saves separate.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Rivencraft 26.3 beta 1.0.1.136-beta.1
|
||||||
|
|
||||||
|
Collects the player-validated .130–.136 port slices since 1.0.1.129-beta.1.
|
||||||
|
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The Minecraft 1.21.1
|
||||||
|
release and its Dynamic Dimensions backend are unchanged. This remains an
|
||||||
|
incomplete beta port; use matching client/server builds and separate saves.
|
||||||
|
|
||||||
|
## Link Modifier and Age creation
|
||||||
|
|
||||||
|
- Edit a Descriptive Book Age's full signed 64-bit seed. Existing chunks remain;
|
||||||
|
newly generated terrain uses the new seed. Occupied Ages and stale/remote menu
|
||||||
|
requests are refused. Seed input supports Enter or focus loss without game keys
|
||||||
|
taking over while typing.
|
||||||
|
- Operators can retire an unoccupied Age through a separate confirmation action.
|
||||||
|
Its exact dimension directory is archived under `world-retired-ages`; the Age
|
||||||
|
is tombstoned and cannot reload after restart. Other Age data remains intact.
|
||||||
|
- Failed Age creation now rolls back the new level, index, initial name, library
|
||||||
|
writing, instability and season policy before publishing the catalog. Cleanup
|
||||||
|
removes only the newly created directory; pre-existing storage is protected.
|
||||||
|
Persistent filesystem failures or process crashes are not transactional guarantees.
|
||||||
|
|
||||||
|
## Optional integrations
|
||||||
|
|
||||||
|
All integrations remain optional and are never bundled.
|
||||||
|
|
||||||
|
- **JEI:** searchable symbol pages, Link Panel variants and portfolios, with Ink
|
||||||
|
Mixer/Writing Desk/Book Binder guidance. Internal Blank Pages and empty portfolios
|
||||||
|
remain hidden. Tested with JEI 31.17.0.109 and MezzConfig 0.7.1.
|
||||||
|
- **TerraBlender:** recognizes registered Overworld and Nether region biomes from
|
||||||
|
installed biome mods, including Biomes O' Plenty. Nether-region biomes join the
|
||||||
|
Cave-compatible authoring pool and use Nether material rules. Tested with
|
||||||
|
TerraBlender 26.3.0.0.9 and Biomes O' Plenty 26.3.0.0.16. The requested Native Cave
|
||||||
|
pool changes and Native Nether/End pages remain separate work in #111.
|
||||||
|
- **Serene Seasons:** No Seasons, Seasons and all four Eternal modes operate per
|
||||||
|
Age. Missing policies are seed-selected only when Serene is installed; Cave Ages
|
||||||
|
disable seasons. Dynamic whitelist updates synchronize without restarting, while
|
||||||
|
eternal policies leave the Overworld calendar unchanged. Tested with Serene
|
||||||
|
Seasons 26.1.2.0.7 and GlitchCore 26.3.0.0.5.
|
||||||
|
- **Ruins:** exact-template pages appear in creative and JEI. Authored Ages permit
|
||||||
|
only their selected templates; Ruins' surface, biome and distance checks remain.
|
||||||
|
Duplicate pages increase selection weight and attempts (32-attempt cap, without
|
||||||
|
lowering higher Ruins settings). The first three copies are free; each later
|
||||||
|
copy adds 100 instability, even beyond the attempt cap. Tested with Ruins
|
||||||
|
26.3.2NF. Duplicate template basenames disable the bridge to prevent ambiguity.
|
||||||
|
|
||||||
|
## Validation and remaining work
|
||||||
|
|
||||||
|
Both Minecraft builds pass. Every included slice passed player in-game checks;
|
||||||
|
the latest client log confirms JEI, TerraBlender/BOP, Serene/GlitchCore and Ruins
|
||||||
|
loaded together. Disposable dedicated-server checks covered seed changes with
|
||||||
|
unchanged old chunks, retirement/archive isolation, injected creation failures,
|
||||||
|
optional-mod absence, region discovery, season policies, Ruins template placement
|
||||||
|
and restart/unload/reload persistence. Test fixtures/worlds were removed and test
|
||||||
|
servers stopped. No permanent game tests were added.
|
||||||
|
|
||||||
|
Books and crystal portals still use animated fallback previews. Live destination
|
||||||
|
renderers, remaining discovery/configuration parity and random-Age glyph descriptions
|
||||||
|
remain unfinished. The current expanded-catalog dedicated two-client regression
|
||||||
|
and full parity/release gates remain open. Shared 1.21.1/26.3 follow-up issues are
|
||||||
|
not complete merely because one version's implementation works.
|
||||||
@@ -3,6 +3,39 @@ plugins {
|
|||||||
id 'net.neoforged.moddev' version '2.0.148'
|
id 'net.neoforged.moddev' version '2.0.148'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
maven { url = 'https://www.cursemaven.com'; content { includeGroup 'curse.maven' } }
|
||||||
|
maven { url = 'https://api.modrinth.com/maven'; content { includeGroup 'maven.modrinth' } }
|
||||||
|
maven {
|
||||||
|
name = 'TerraBlenderMaven'
|
||||||
|
url = 'https://maven.minecraftforge.net/releases/'
|
||||||
|
content { includeGroup 'com.github.glitchfiend' }
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
name = 'JaredsMaven'
|
||||||
|
url = 'https://maven.blamejared.com'
|
||||||
|
content { includeGroup 'mezz.jei'; includeGroup 'net.mezzdev.config' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
// Compile against the 26.3 baseline API; a local current Ruins JAR can override it.
|
||||||
|
def ruinsDependency = project.hasProperty('ruinsJar') ? files(project.property('ruinsJar'))
|
||||||
|
: 'curse.maven:ruins-227873:8962119'
|
||||||
|
compileOnly ruinsDependency
|
||||||
|
if (project.hasProperty('withRuins')) localRuntime ruinsDependency
|
||||||
|
compileOnly "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
|
||||||
|
compileOnly "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
|
||||||
|
if (project.hasProperty('withSereneSeasons')) {
|
||||||
|
localRuntime "maven.modrinth:e0bNACJD:${serene_seasons_version_id}"
|
||||||
|
localRuntime "maven.modrinth:s3dmwKy5:${glitchcore_version_id}"
|
||||||
|
}
|
||||||
|
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||||
|
if (project.hasProperty('withTerraBlender')) localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||||
|
// Plugin-only API dependency: JEI is never bundled or required at runtime.
|
||||||
|
compileOnly "mezz.jei:jei-${minecraft_version}-neoforge-api:${jei_version}"
|
||||||
|
if (project.hasProperty('withJei')) localRuntime "mezz.jei:jei-${minecraft_version}-neoforge:${jei_version}"
|
||||||
|
}
|
||||||
|
|
||||||
group = 'net.dnorberg.rivencraft'
|
group = 'net.dnorberg.rivencraft'
|
||||||
version = mod_version
|
version = mod_version
|
||||||
|
|
||||||
@@ -244,6 +277,12 @@ tasks.named('processResources', ProcessResources).configure {
|
|||||||
include 'assets/rivencraft/textures/block/book_binder.png'
|
include 'assets/rivencraft/textures/block/book_binder.png'
|
||||||
include 'assets/rivencraft/textures/block/book_binder_press.png'
|
include 'assets/rivencraft/textures/block/book_binder_press.png'
|
||||||
include 'assets/rivencraft/textures/gui/book_binder_legacy.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/gui/ink_mixer_legacy.png'
|
||||||
include 'assets/rivencraft/textures/block/inkwell_base.png'
|
include 'assets/rivencraft/textures/block/inkwell_base.png'
|
||||||
include 'assets/rivencraft/textures/block/inkwell_cup.png'
|
include 'assets/rivencraft/textures/block/inkwell_cup.png'
|
||||||
|
|||||||
@@ -7,4 +7,11 @@ neo_version=26.3.0.99
|
|||||||
mod_id=rivencraft
|
mod_id=rivencraft
|
||||||
mod_name=Rivencraft
|
mod_name=Rivencraft
|
||||||
mod_license=LGPL-3.0-or-later
|
mod_license=LGPL-3.0-or-later
|
||||||
mod_version=1.0.1.120-beta.1
|
mod_version=1.0.1.136-beta.1
|
||||||
|
|
||||||
|
jei_version=31.17.0.109
|
||||||
|
|
||||||
|
terrablender_version=26.3.0.0.9
|
||||||
|
|
||||||
|
serene_seasons_version_id=QnvW5HPm
|
||||||
|
glitchcore_version_id=pQgy3bfh
|
||||||
@@ -106,5 +106,9 @@
|
|||||||
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
|
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
|
||||||
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
|
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
|
||||||
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
|
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
|
||||||
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
|
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes.",
|
||||||
|
"jei.rivencraft.ink_mixer.usage": "Paper + ink makes a Link Panel. Each panel uses one paper and one ink. A vial supplies 16 ink and returns a glass bottle; additives reset when a panel is taken.",
|
||||||
|
"jei.rivencraft.link_modifier": "Creative-only tool; no crafting recipe. Insert a book to rename it and edit link properties. Bound Descriptive Books also allow seed editing while their Age is empty. Age retirement requires OP permission and a separate confirmation; its data is archived.",
|
||||||
|
"symbol.rivencraft.ruins.description": "Generates this Ruins template. Repeated pages increase its selection weight and spawn attempts.",
|
||||||
|
"itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages"
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ public final class PortConfig {
|
|||||||
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||||
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
|
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
|
||||||
public static final ModConfigSpec SPEC;
|
public static final ModConfigSpec SPEC;
|
||||||
|
public static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||||
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||||
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||||
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||||
@@ -16,6 +17,10 @@ public final class PortConfig {
|
|||||||
|
|
||||||
static {
|
static {
|
||||||
var builder = new ModConfigSpec.Builder();
|
var builder = new ModConfigSpec.Builder();
|
||||||
|
builder.push("serene_seasons");
|
||||||
|
AUTO_WHITELIST_SERENE_AGES = builder.comment("Automatically whitelist Ages with seed-selected/legacy cycling seasons. Explicit pages still apply; existing entries are preserved when disabled.")
|
||||||
|
.define("auto_whitelist_ages", true);
|
||||||
|
builder.pop();
|
||||||
builder.push("descriptive_books");
|
builder.push("descriptive_books");
|
||||||
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
|
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
|
||||||
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
|
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ public final class Rivencraft {
|
|||||||
"book_binder", PortBookBinderBlock::new,
|
"book_binder", PortBookBinderBlock::new,
|
||||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||||
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
|
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(
|
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
|
||||||
"ink_mixer", InkMixerBlock::new,
|
"ink_mixer", InkMixerBlock::new,
|
||||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||||
@@ -239,6 +248,7 @@ public final class Rivencraft {
|
|||||||
MENUS.register(modEventBus);
|
MENUS.register(modEventBus);
|
||||||
COMPONENTS.register(modEventBus);
|
COMPONENTS.register(modEventBus);
|
||||||
RECIPE_SERIALIZERS.register(modEventBus);
|
RECIPE_SERIALIZERS.register(modEventBus);
|
||||||
|
net.dnorberg.rivencraft.compat.RuinsCompatibility.register();
|
||||||
ModCreativeTabs.register(modEventBus);
|
ModCreativeTabs.register(modEventBus);
|
||||||
PortBiomeSources.register(modEventBus);
|
PortBiomeSources.register(modEventBus);
|
||||||
PortChunkGenerators.register(modEventBus);
|
PortChunkGenerators.register(modEventBus);
|
||||||
@@ -246,14 +256,23 @@ public final class Rivencraft {
|
|||||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
||||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
||||||
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
||||||
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onTagsUpdated);
|
||||||
|
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes::onServerAboutToStart);
|
||||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
||||||
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelLoad);
|
||||||
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLogin);
|
||||||
|
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility::onLevelTickPost);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
||||||
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
||||||
modEventBus.addListener(PortBookNetworking::registerPayloads);
|
modEventBus.addListener(PortBookNetworking::registerPayloads);
|
||||||
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
|
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
|
||||||
net.dnorberg.rivencraft.age.PortDecayProviders.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)");
|
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,8 +182,15 @@ final class AgeLevelIndex extends SavedData {
|
|||||||
setDirty();
|
setDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void retire(Identifier id) {
|
/** Failed creation is not retirement: release the new identity for a clean retry. */
|
||||||
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
|
void discardCreation(Identifier id) {
|
||||||
|
if (retired.contains(id)) throw new IllegalArgumentException("Cannot discard a retired Age: " + id);
|
||||||
|
removeActive(id);
|
||||||
|
displayNames.remove(id);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
|
private void removeActive(Identifier id) {
|
||||||
|
ages.remove(id);
|
||||||
biomeOverrides.remove(id);
|
biomeOverrides.remove(id);
|
||||||
biomePlans.remove(id);
|
biomePlans.remove(id);
|
||||||
terrain.remove(id);
|
terrain.remove(id);
|
||||||
@@ -196,6 +203,10 @@ final class AgeLevelIndex extends SavedData {
|
|||||||
sky.remove(id);
|
sky.remove(id);
|
||||||
colors.remove(id);
|
colors.remove(id);
|
||||||
dayCycles.remove(id);
|
dayCycles.remove(id);
|
||||||
|
}
|
||||||
|
void retire(Identifier id) {
|
||||||
|
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||||
|
removeActive(id);
|
||||||
retired.add(id);
|
retired.add(id);
|
||||||
setDirty();
|
setDirty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ public record PortAgeColors(int sky, int ambient, int celestial) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static PortAgeColors compile(List<Identifier> pages, long seed) {
|
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;
|
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
|
||||||
boolean written = false;
|
boolean written = false;
|
||||||
for (int i = 0; i < pages.size(); i++) {
|
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, 2, 0).ticks(12000) != 6000
|
||||||
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|
||||||
|| 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(symbol.apply("period/half")))
|
||||||
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|
|| !PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|
||||||
throw new IllegalStateException("Day cycle fixture failed");
|
throw new IllegalStateException("Day cycle fixture failed");
|
||||||
var server = context.getSource().getServer();
|
var server = context.getSource().getServer();
|
||||||
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
|
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
|
|| 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, PortDayCycle.NORMAL_TIME))
|
||||||
|| !PortDayCycle.validWriting(java.util.List.of(target, target))
|
|| !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");
|
throw new IllegalStateException("Single-body modifier/order invariant failed");
|
||||||
}
|
}
|
||||||
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
|
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ final class PortAgeLevel extends ServerLevel {
|
|||||||
return ageWeather == null ? super.getWeatherData() : ageWeather;
|
return ageWeather == null ? super.getWeatherData() : ageWeather;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean caveTerrain() { return cave; }
|
||||||
|
|
||||||
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
|
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
|
||||||
|
|
||||||
void configureWeather(PortWeatherMode mode) {
|
void configureWeather(PortWeatherMode mode) {
|
||||||
|
|||||||
@@ -18,33 +18,45 @@ public final class PortAgeTravel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String enter(ServerPlayer player, Identifier id) {
|
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));
|
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||||
if (target == null) return "Age not loaded: " + id;
|
if (target == null) return "Age not loaded: " + id;
|
||||||
if (!AgeCatalogSync.ready(player, id)) {
|
if (!AgeCatalogSync.ready(player, id)) {
|
||||||
return "Age catalog not acknowledged by client; remaining in source dimension";
|
return "Age catalog not acknowledged by client; remaining in source dimension";
|
||||||
}
|
}
|
||||||
BlockPos landing = resolveLanding(target);
|
BlockPos landing = resolveLanding(target, player, properties);
|
||||||
if (landing == null) return "No safe landing available; remaining in source dimension";
|
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,
|
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||||
return moved ? null : "Teleportation failed; remaining in source dimension";
|
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
|
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
|
||||||
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
|
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
|
||||||
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
|
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
|
||||||
if (landing != null) landing = isCaveTerrain(target)
|
if (landing != null) landing = isCaveTerrain(target)
|
||||||
? findFootedDestination(target, landing).orElse(null)
|
? findFootedDestination(target, landing).orElse(null)
|
||||||
: findSurfaceLanding(target, landing.getX(), landing.getZ());
|
: findSurfaceLanding(target, landing.getX(), landing.getZ());
|
||||||
if (landing == null) landing = findLanding(target);
|
if (landing == null) landing = findLanding(target, generatePlatform);
|
||||||
if (landing == null) return null;
|
if (landing == null) return null;
|
||||||
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
|
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
|
||||||
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
|
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
|
||||||
return 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)) {
|
if (isCaveTerrain(target)) {
|
||||||
// The cave roof is not a valid arrival surface. Search down from
|
// The cave roof is not a valid arrival surface. Search down from
|
||||||
// Legacy's Y=64 start, then construct the normal safety platform
|
// 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);
|
BlockPos surface = findSurfaceLanding(target, 0, 0);
|
||||||
if (surface != null) return surface;
|
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.
|
// beneath it. Void Ages retain the usual platform floor at Y=63.
|
||||||
BlockPos obstruction = highestObstruction(target, 0, 0);
|
BlockPos obstruction = highestObstruction(target, 0, 0);
|
||||||
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
|
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) {
|
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ final class PortBiomeMaterialRules {
|
|||||||
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
|
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
|
||||||
var biomes = registries.lookupOrThrow(Registries.BIOME);
|
var biomes = registries.lookupOrThrow(Registries.BIOME);
|
||||||
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
|
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
|
||||||
|
var nether = java.util.stream.Stream.concat(java.util.stream.Stream.of(Biomes.NETHER_WASTES,
|
||||||
|
Biomes.SOUL_SAND_VALLEY, Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries).stream()
|
||||||
|
.map(id -> net.minecraft.resources.ResourceKey.create(Registries.BIOME, id)))
|
||||||
|
.distinct().toList();
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
net.minecraft.resources.ResourceKey<net.minecraft.world.level.biome.Biome>[] netherKeys = nether.toArray(net.minecraft.resources.ResourceKey[]::new);
|
||||||
var materials = MaterialRules.sequence(
|
var materials = MaterialRules.sequence(
|
||||||
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
|
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
|
||||||
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
|
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
|
||||||
MaterialRules.getRule(rules, EndMaterialRules.END)),
|
MaterialRules.getRule(rules, EndMaterialRules.END)),
|
||||||
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY,
|
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, netherKeys),
|
||||||
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
|
|
||||||
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
|
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
|
||||||
base.materialRule().value());
|
base.materialRule().value());
|
||||||
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
|
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
|
||||||
|
|||||||
@@ -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) {
|
public static boolean validWriting(List<Identifier> pages) {
|
||||||
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
|
int suns = 0, moons = 0;
|
||||||
for (Identifier page : pages) {
|
for (Identifier page : pages) if (isController(page)) {
|
||||||
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|
if (!page.getPath().equals("celestial/moon")) suns++;
|
||||||
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|
if (!page.getPath().equals("celestial/sun")) moons++;
|
||||||
|| isSize(page) && ++sizes > 1) return false;
|
if (suns > 10 || moons > 10) 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
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. */
|
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
|
||||||
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
|
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
|
||||||
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
|
if (!validWriting(pages))
|
||||||
throw new IllegalArgumentException("Invalid celestial modifier order");
|
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);
|
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
|
||||||
// Match root AgeSkyProfile.random's count draw positions: sky style,
|
// Match root AgeSkyProfile.random's count draw positions: sky style,
|
||||||
// mixed sky color, then independent 0..3 sun and moon counts.
|
// 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) {
|
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)
|
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
|
||||||
return compileOne(pages, seed, nativeTicks, 0);
|
return compileOne(pages, seed, nativeTicks, 0);
|
||||||
var bodies = new java.util.ArrayList<PortCelestialBody>();
|
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) {
|
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
|
||||||
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
|
int controllerIndex = -1;
|
||||||
if (controller == null) return NATIVE;
|
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
|
int mode = controller.getPath().equals("celestial/sun") ? SUN
|
||||||
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
|
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
|
||||||
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
|
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
|
||||||
|
|||||||
+178
@@ -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()
|
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
|
||||||
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
|
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
|
||||||
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
|
+ ", 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(
|
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
|
||||||
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
|
" " + 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())
|
for (var provider : PortInstabilityRuntime.plan(level).providers())
|
||||||
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
|
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
|
||||||
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
|
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
|
||||||
@@ -56,7 +65,7 @@ public final class PortInstabilityCommands {
|
|||||||
.then(Commands.literal("clear_test_score").executes(context -> {
|
.then(Commands.literal("clear_test_score").executes(context -> {
|
||||||
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
|
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
|
||||||
PortStatusEffectProviders.clearAge(level);
|
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())
|
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
|
||||||
.suggests((context, builder) -> {
|
.suggests((context, builder) -> {
|
||||||
@@ -66,6 +75,48 @@ public final class PortInstabilityCommands {
|
|||||||
}).executes(context -> decay(context, 1))
|
}).executes(context -> decay(context, 1))
|
||||||
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
|
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
|
||||||
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
|
.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())
|
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
|
||||||
.suggests((context, builder) -> {
|
.suggests((context, builder) -> {
|
||||||
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
|
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.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.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
|
||||||
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
|
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() {}
|
private PortInstabilityConfig() {}
|
||||||
public static void define(ModConfigSpec.Builder builder) {
|
public static void define(ModConfigSpec.Builder builder) {
|
||||||
builder.push("instability");
|
builder.push("instability");
|
||||||
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
|
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
|
||||||
.define("enabled", true);
|
.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.")
|
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
|
||||||
.define("world_mutation", true);
|
.define("world_mutation", true);
|
||||||
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
|
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.")
|
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
|
||||||
.defineInRange("max_attempts_per_call", 4, 1, 6);
|
.defineInRange("max_attempts_per_call", 4, 1, 6);
|
||||||
builder.pop();
|
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 instabilityWorldMutation() { return worldMutation.get(); }
|
||||||
public static boolean decayEnabled() { return decay.get(); }
|
public static boolean decayEnabled() { return decay.get(); }
|
||||||
public static int decayMaxAttempts() { return decayAttempts.get(); }
|
public static int decayMaxAttempts() { return decayAttempts.get(); }
|
||||||
|
|||||||
@@ -42,11 +42,21 @@ public final class PortInstabilityData extends SavedData {
|
|||||||
private final Map<Identifier, Age> ages = new HashMap<>();
|
private final Map<Identifier, Age> ages = new HashMap<>();
|
||||||
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
|
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
|
||||||
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||||
|
Age snapshot(Identifier id) { return ages.get(id); }
|
||||||
|
void restore(Identifier id, Age snapshot) {
|
||||||
|
if (snapshot == null) ages.remove(id); else ages.put(id, snapshot);
|
||||||
|
setDirty();
|
||||||
|
}
|
||||||
public Age age(ServerLevel level) {
|
public Age age(ServerLevel level) {
|
||||||
Age age = ages.get(level.dimension().identifier());
|
Age age = ages.get(level.dimension().identifier());
|
||||||
return age != null ? age : new Age(level.getSeed(),
|
return age != null ? age : new Age(level.getSeed(),
|
||||||
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
|
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) {
|
public void enabled(ServerLevel level, boolean enabled) {
|
||||||
Age previous = age(level);
|
Age previous = age(level);
|
||||||
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
|
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
|
||||||
@@ -66,6 +76,8 @@ public final class PortInstabilityData extends SavedData {
|
|||||||
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
|
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
|
||||||
List<Contribution> costs = new ArrayList<>();
|
List<Contribution> costs = new ArrayList<>();
|
||||||
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
|
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;
|
boolean authoredBright = false;
|
||||||
Map<Identifier, Integer> structures = new HashMap<>();
|
Map<Identifier, Integer> structures = new HashMap<>();
|
||||||
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
|
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
|
||||||
@@ -83,6 +95,10 @@ public final class PortInstabilityData extends SavedData {
|
|||||||
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|
||||||
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
|
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
|
||||||
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
|
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
|
||||||
|
if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(page)) {
|
||||||
|
if (structures.merge(page, 1, Integer::sum) > 3)
|
||||||
|
costs.add(new Contribution("excess_" + path, i, 100));
|
||||||
|
}
|
||||||
if (PortStructurePages.supports(page)) {
|
if (PortStructurePages.supports(page)) {
|
||||||
int count = structures.merge(page, 1, Integer::sum);
|
int count = structures.merge(page, 1, Integer::sum);
|
||||||
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
|
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
|
||||||
|
|||||||
@@ -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. */
|
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
|
||||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||||
public final class PortInstabilityRuntime {
|
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<Identifier, Handler> HANDLERS = new HashMap<>();
|
||||||
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
|
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
|
||||||
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
|
private static final Map<MinecraftServer, 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 boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; }
|
||||||
public static PortInstabilityPlan plan(ServerLevel level) {
|
public static PortInstabilityPlan plan(ServerLevel level) {
|
||||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||||
Integer test = TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
|
Integer test = testScore(level);
|
||||||
return test == null ? age.plan() : PortInstabilityPlan.select(test, age.seed());
|
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) {
|
public static Integer testScore(ServerLevel level) {
|
||||||
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
|
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) {
|
public static void testScore(ServerLevel level, Integer score) {
|
||||||
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
|
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);
|
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);
|
var age = PortInstabilityData.get(server).age(level);
|
||||||
if (!age.enabled()) return;
|
if (!age.enabled()) return;
|
||||||
var providers = plan(level).providers();
|
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);
|
var chunks = loadedChunks(level);
|
||||||
if (providers.isEmpty() || chunks.isEmpty()) return;
|
if (providers.isEmpty() || chunks.isEmpty()) return;
|
||||||
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
|
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()
|
for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders()
|
||||||
&& context.budget().remaining() > 0; offset++) {
|
&& context.budget().remaining() > 0; offset++) {
|
||||||
var provider = providers.get((first + offset) % providers.size());
|
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) {
|
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 (!PortInstabilityConfig.instabilityEnabled()) return "global config off";
|
||||||
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
|
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
|
||||||
if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off";
|
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 (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
|
||||||
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config 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 {
|
} else {
|
||||||
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
|
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
|
||||||
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
|
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
|
||||||
@@ -126,7 +159,7 @@ public final class PortInstabilityRuntime {
|
|||||||
return List.copyOf(chunks);
|
return List.copyOf(chunks);
|
||||||
}
|
}
|
||||||
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
|
@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 interface ProviderEffect { void apply(Context context, Identifier id, int level); }
|
||||||
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
|
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) {}
|
||||||
|
}
|
||||||
@@ -69,11 +69,16 @@ public final class PortRandomAgeSelection {
|
|||||||
}
|
}
|
||||||
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
|
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
|
||||||
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
|
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
|
||||||
.map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList();
|
.map(key -> key.identifier()).filter(id -> !id.equals(PortRegistryPages.DEFERRED_PLACEHOLDER))
|
||||||
|
.distinct().sorted(Comparator.naturalOrder()).toList();
|
||||||
|
surface = java.util.stream.Stream.concat(surface.stream(),
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.overworld(server.registryAccess()).stream())
|
||||||
|
.distinct().sorted().toList();
|
||||||
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
|
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
|
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||||
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
|
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
|
||||||
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
|
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
|
||||||
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
|
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ public final class PortRandomFeatureSelection {
|
|||||||
var plan = base.terrain();
|
var plan = base.terrain();
|
||||||
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
|
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
|
||||||
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
|
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
|
||||||
structures.structures(), plan.carvers(), true);
|
structures.structures(), plan.carvers(), true, plan.ruins());
|
||||||
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
|
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package net.dnorberg.rivencraft.age;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||||
|
|
||||||
|
/** Frozen per-Age season choice, separate from the backend's legacy index codec. */
|
||||||
|
public final class PortSeasonPolicies extends SavedData {
|
||||||
|
public enum Mode {
|
||||||
|
NO_SEASONS, SEASONS, ETERNAL_WINTER, ETERNAL_SUMMER, ETERNAL_FALL, ETERNAL_SPRING;
|
||||||
|
public boolean eternal() { return this != NO_SEASONS && this != SEASONS; }
|
||||||
|
}
|
||||||
|
public record Policy(Mode mode, boolean authored) {
|
||||||
|
static final Codec<Policy> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||||
|
Codec.STRING.xmap(Mode::valueOf, Mode::name).fieldOf("mode").forGetter(Policy::mode),
|
||||||
|
Codec.BOOL.fieldOf("authored").forGetter(Policy::authored)).apply(i, Policy::new));
|
||||||
|
}
|
||||||
|
private static final SavedDataType<PortSeasonPolicies> TYPE = new SavedDataType<>(
|
||||||
|
Identifier.fromNamespaceAndPath("rivencraft", "season_policies"), () -> new PortSeasonPolicies(Map.of()),
|
||||||
|
Codec.unboundedMap(Identifier.CODEC, Policy.CODEC).fieldOf("ages").codec()
|
||||||
|
.xmap(PortSeasonPolicies::new, data -> Map.copyOf(data.policies)));
|
||||||
|
private final Map<Identifier, Policy> policies = new HashMap<>();
|
||||||
|
private PortSeasonPolicies(Map<Identifier, Policy> values) { policies.putAll(values); }
|
||||||
|
public static PortSeasonPolicies get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||||
|
Policy snapshot(Identifier id) { return policies.get(id); }
|
||||||
|
void put(Identifier id, Policy policy) { if (policy == null) policies.remove(id); else policies.put(id, policy); setDirty(); }
|
||||||
|
public Policy forLevel(ServerLevel level) {
|
||||||
|
if (level instanceof PortAgeLevel age && age.caveTerrain()) return new Policy(Mode.NO_SEASONS, true);
|
||||||
|
// Existing Ages retain cycling behavior when integration is installed later.
|
||||||
|
return policies.getOrDefault(level.dimension().identifier(), new Policy(Mode.SEASONS, false));
|
||||||
|
}
|
||||||
|
public static Mode forPage(Identifier id) {
|
||||||
|
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("season/")) return null;
|
||||||
|
try { return Mode.valueOf(id.getPath().substring(7).toUpperCase(Locale.ROOT)); }
|
||||||
|
catch (IllegalArgumentException e) { return null; }
|
||||||
|
}
|
||||||
|
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed) {
|
||||||
|
if (terrain == PortAgeTerrain.CAVE) return new Policy(Mode.NO_SEASONS, true);
|
||||||
|
Mode written = null;
|
||||||
|
for (var page : pages) if (forPage(page) != null) written = forPage(page);
|
||||||
|
if (written != null) {
|
||||||
|
if (!installed && written != Mode.NO_SEASONS) throw new IllegalArgumentException("Seasonal pages require Serene Seasons");
|
||||||
|
return new Policy(written, true);
|
||||||
|
}
|
||||||
|
if (!installed) return null;
|
||||||
|
// 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3.
|
||||||
|
int ticket = new Random(seed ^ 0x5E450A5E5EED21L).nextInt(15);
|
||||||
|
Mode mode = ticket < 6 ? Mode.SEASONS : ticket < 11 ? Mode.NO_SEASONS
|
||||||
|
: List.of(Mode.ETERNAL_WINTER, Mode.ETERNAL_SUMMER, Mode.ETERNAL_FALL, Mode.ETERNAL_SPRING).get(ticket - 11);
|
||||||
|
return new Policy(mode, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import net.minecraft.resources.Identifier;
|
|||||||
/** Frozen terrain filters and ordered solid/surface features for an Age. */
|
/** Frozen terrain filters and ordered solid/surface features for an Age. */
|
||||||
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||||
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
|
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
|
||||||
boolean structureControl) {
|
boolean structureControl, List<Identifier> ruins) {
|
||||||
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
|
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
|
||||||
Rivencraft.MOD_ID, "feature/floating_islands");
|
Rivencraft.MOD_ID, "feature/floating_islands");
|
||||||
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
|
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
|
||||||
@@ -32,7 +32,8 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
|||||||
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
|
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
|
||||||
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
|
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
|
||||||
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
|
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
|
||||||
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
|
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl),
|
||||||
|
Identifier.CODEC.listOf().optionalFieldOf("ruins", List.of()).forGetter(PortTerrainFeaturePlan::ruins)
|
||||||
).apply(instance, PortTerrainFeaturePlan::new));
|
).apply(instance, PortTerrainFeaturePlan::new));
|
||||||
|
|
||||||
public record Choice(Identifier page, Identifier material) {
|
public record Choice(Identifier page, Identifier material) {
|
||||||
@@ -50,6 +51,9 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
public PortTerrainFeaturePlan {
|
public PortTerrainFeaturePlan {
|
||||||
|
ruins = List.copyOf(ruins);
|
||||||
|
if (ruins.size() > 252 || ruins.stream().anyMatch(id -> !net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id)))
|
||||||
|
throw new IllegalArgumentException("Invalid Ruins template pages");
|
||||||
choices = List.copyOf(choices);
|
choices = List.copyOf(choices);
|
||||||
structures = List.copyOf(structures);
|
structures = List.copyOf(structures);
|
||||||
carvers = List.copyOf(carvers);
|
carvers = List.copyOf(carvers);
|
||||||
@@ -57,7 +61,7 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
|||||||
PortCarverPages.validateCounts(carvers);
|
PortCarverPages.validateCounts(carvers);
|
||||||
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
|
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
|
||||||
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
|
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
|
||||||
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl)
|
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl && ruins.isEmpty())
|
||||||
throw new IllegalArgumentException("Terrain feature plan must not be empty");
|
throw new IllegalArgumentException("Terrain feature plan must not be empty");
|
||||||
var counts = new HashMap<Identifier, Integer>();
|
var counts = new HashMap<Identifier, Integer>();
|
||||||
for (Choice choice : choices) {
|
for (Choice choice : choices) {
|
||||||
@@ -66,6 +70,12 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||||
|
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
|
||||||
|
boolean structureControl) {
|
||||||
|
this(choices, skylands, denseOres, starFissures, structures, carvers, structureControl, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
|
||||||
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
|
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
|
||||||
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
|
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
|
||||||
|
|||||||
@@ -159,24 +159,110 @@ public final class RuntimeAgeBackend {
|
|||||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||||
List<Identifier> librarySymbols) {
|
List<Identifier> librarySymbols) {
|
||||||
|
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
|
||||||
|
weather, lighting, sky, colors, dayCycle, librarySymbols, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||||
|
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||||
|
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||||
|
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||||
|
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||||
|
List<Identifier> librarySymbols, String displayName) {
|
||||||
checkServerThread(server);
|
checkServerThread(server);
|
||||||
ageName(id);
|
String name = ageName(id);
|
||||||
AgeLevelIndex index = index(server);
|
AgeLevelIndex index = index(server);
|
||||||
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
|
if (index.contains(id) || server.getLevel(dimensionKey(id)) != null)
|
||||||
PortLibraryWriting.get(server).put(id, librarySymbols);
|
throw new IllegalArgumentException("Age already exists: " + id);
|
||||||
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
Path directory = archivePaths(server, name).live();
|
||||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS))
|
||||||
((PortAgeLevel) level).configureWeather(weather);
|
throw new IllegalArgumentException("Age storage already exists: " + id);
|
||||||
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
PortLibraryWriting writing = PortLibraryWriting.get(server);
|
||||||
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle);
|
List<Identifier> priorWriting = writing.symbols(id);
|
||||||
// A newly bound book may be used immediately. Persist its Age identity
|
PortInstabilityData instability = PortInstabilityData.get(server);
|
||||||
// before publishing the catalog so a later crash cannot leave that
|
PortInstabilityData.Age priorInstability = instability.snapshot(id);
|
||||||
// book pointing at a world omitted from the on-disk index.
|
PortSeasonPolicies seasons = PortSeasonPolicies.get(server);
|
||||||
server.overworld().getDataStorage().saveAndJoin();
|
PortSeasonPolicies.Policy priorSeason = seasons.snapshot(id);
|
||||||
|
ServerLevel level = null;
|
||||||
|
try {
|
||||||
|
seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain,
|
||||||
|
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available()));
|
||||||
|
writing.put(id, librarySymbols);
|
||||||
|
level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||||
|
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||||
|
((PortAgeLevel) level).configureWeather(weather);
|
||||||
|
int accelerated = PortAcceleratedTicks.pageCount(librarySymbols);
|
||||||
|
if (accelerated > 0) PortAcceleratedTicks.configure(level, accelerated);
|
||||||
|
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
||||||
|
instability.create(level, librarySymbols, terrain, lighting, dayCycle);
|
||||||
|
if (displayName != null) index.setDisplayName(id, displayName);
|
||||||
|
// Commit all initial metadata before announcing an enterable Age.
|
||||||
|
level.getDataStorage().saveAndJoin();
|
||||||
|
server.overworld().getDataStorage().saveAndJoin();
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
boolean closed = !(failure instanceof FailedAgeCleanupException);
|
||||||
|
if (level != null) {
|
||||||
|
try { closeFailedCreation(server, level); }
|
||||||
|
catch (RuntimeException cleanup) { closed = false; failure.addSuppressed(cleanup); }
|
||||||
|
}
|
||||||
|
index.discardCreation(id);
|
||||||
|
writing.put(id, priorWriting);
|
||||||
|
instability.restore(id, priorInstability);
|
||||||
|
seasons.put(id, priorSeason);
|
||||||
|
try { server.overworld().getDataStorage().saveAndJoin(); }
|
||||||
|
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||||
|
// Only the exact, previously absent directory belongs to this attempt.
|
||||||
|
// If closing failed, leave its files in place rather than race a writer.
|
||||||
|
if (closed) {
|
||||||
|
try { removeFailedCreationDirectory(server, name, directory); }
|
||||||
|
catch (IOException | RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||||
|
}
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.apply(level);
|
||||||
AgeCatalogSync.onCatalogChanged(server);
|
AgeCatalogSync.onCatalogChanged(server);
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final class FailedAgeCleanupException extends IllegalStateException {
|
||||||
|
FailedAgeCleanupException(RuntimeException cause) { super("Failed Age stores could not close; files retained", cause); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void closeFailedCreation(MinecraftServer server, ServerLevel level) {
|
||||||
|
if (!level.players().isEmpty()) throw new IllegalStateException("Failed Age contains players: " + level.dimension().identifier());
|
||||||
|
RuntimeException error = null;
|
||||||
|
try { level.getChunkSource().deactivateTicketsOnClosing(); }
|
||||||
|
catch (RuntimeException failure) { error = failure; }
|
||||||
|
try { level.close(); }
|
||||||
|
catch (IOException | RuntimeException failure) {
|
||||||
|
if (error == null) error = new IllegalStateException("Could not close failed Age", failure);
|
||||||
|
else error.addSuppressed(failure);
|
||||||
|
} finally {
|
||||||
|
server.levels.remove(level.dimension(), level);
|
||||||
|
try { NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level)); }
|
||||||
|
catch (RuntimeException failure) {
|
||||||
|
if (error == null) error = failure; else error.addSuppressed(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error != null) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeFailedCreationDirectory(MinecraftServer server, String name, Path directory) throws IOException {
|
||||||
|
if (!archivePaths(server, name).live().equals(directory)) throw new IOException("Age directory changed during rollback");
|
||||||
|
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||||
|
Files.walkFileTree(directory, new java.nio.file.SimpleFileVisitor<Path>() {
|
||||||
|
@Override public java.nio.file.FileVisitResult visitFile(Path file, java.nio.file.attribute.BasicFileAttributes attributes) throws IOException {
|
||||||
|
Files.delete(file);
|
||||||
|
return java.nio.file.FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
@Override public java.nio.file.FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException {
|
||||||
|
if (error != null) throw error;
|
||||||
|
Files.delete(dir);
|
||||||
|
return java.nio.file.FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
|
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
|
||||||
public static void unload(MinecraftServer server, Identifier id) {
|
public static void unload(MinecraftServer server, Identifier id) {
|
||||||
checkServerThread(server);
|
checkServerThread(server);
|
||||||
@@ -278,6 +364,7 @@ public final class RuntimeAgeBackend {
|
|||||||
}
|
}
|
||||||
throw new IllegalStateException("Could not archive Age " + id, exception);
|
throw new IllegalStateException("Could not archive Age " + id, exception);
|
||||||
}
|
}
|
||||||
|
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.retired(server, id);
|
||||||
AgeCatalogSync.onCatalogChanged(server);
|
AgeCatalogSync.onCatalogChanged(server);
|
||||||
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
||||||
}
|
}
|
||||||
@@ -420,6 +507,7 @@ public final class RuntimeAgeBackend {
|
|||||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||||
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
|
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
|
||||||
PortAgeColors colors, PortDayCycle dayCycle) {
|
PortAgeColors colors, PortDayCycle dayCycle) {
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(server.registryAccess());
|
||||||
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
|
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
|
||||||
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
|
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
|
||||||
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
|
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
|
||||||
@@ -506,6 +594,7 @@ public final class RuntimeAgeBackend {
|
|||||||
// Publish only after the constructor succeeds. If the load event fails,
|
// Publish only after the constructor succeeds. If the load event fails,
|
||||||
// roll back the map and close the newly opened chunk/entity stores.
|
// roll back the map and close the newly opened chunk/entity stores.
|
||||||
try {
|
try {
|
||||||
|
net.dnorberg.rivencraft.compat.RuinsCompatibility.registerAge(id, terrainFeatures);
|
||||||
server.levels.put(key, level);
|
server.levels.put(key, level);
|
||||||
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
|
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
|
||||||
server.getPlayerList().addWorldborderListener(level);
|
server.getPlayerList().addWorldborderListener(level);
|
||||||
@@ -518,11 +607,10 @@ public final class RuntimeAgeBackend {
|
|||||||
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
|
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
|
||||||
return level;
|
return level;
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
server.levels.remove(key);
|
try { closeFailedCreation(server, level); }
|
||||||
try {
|
catch (RuntimeException cleanup) {
|
||||||
level.close();
|
exception.addSuppressed(cleanup);
|
||||||
} catch (IOException closeException) {
|
throw new FailedAgeCleanupException(exception);
|
||||||
exception.addSuppressed(closeException);
|
|
||||||
}
|
}
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -34,7 +34,8 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||||
net.minecraft.core.registries.Registries.DIMENSION, id));
|
net.minecraft.core.registries.Registries.DIMENSION, id));
|
||||||
if (target != null) {
|
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());
|
: BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
|
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
|
||||||
|
|||||||
+69
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ public final class AgeCatalogClient {
|
|||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void register(RegisterClientPayloadHandlersEvent event) {
|
public static void register(RegisterClientPayloadHandlersEvent event) {
|
||||||
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
|
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
|
||||||
|
event.register(net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload.TYPE, (payload, context) ->
|
||||||
|
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.acceptClient(payload.dimensionId(), payload.enabled()));
|
||||||
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
|
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
|
||||||
if (!(context.listener() instanceof ClientPacketListener listener)) return;
|
if (!(context.listener() instanceof ClientPacketListener listener)) return;
|
||||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
|
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||||
|
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||||
|
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||||
|
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
|
||||||
|
import com.mojang.blaze3d.platform.InputConstants;
|
||||||
|
import net.minecraft.client.input.MouseButtonEvent;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
|
import net.minecraft.client.input.KeyEvent;
|
||||||
|
import net.minecraft.client.gui.components.Button;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
|
import net.minecraft.client.renderer.RenderPipelines;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||||
|
|
||||||
|
/** Existing Legacy single-slot layout, using the 26.3 extraction/input APIs. */
|
||||||
|
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
|
||||||
|
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
|
||||||
|
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
|
||||||
|
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
|
||||||
|
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
|
||||||
|
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
|
||||||
|
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
|
||||||
|
private EditBox titleInput;
|
||||||
|
private EditBox seedInput;
|
||||||
|
private Identifier seedAge;
|
||||||
|
private long lastSeed = Long.MIN_VALUE;
|
||||||
|
private ItemStack lastBook = ItemStack.EMPTY;
|
||||||
|
private boolean refreshing;
|
||||||
|
private Button retireButton;
|
||||||
|
private Button confirmRetireButton;
|
||||||
|
private final Button[] options = new Button[6];
|
||||||
|
|
||||||
|
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
|
||||||
|
super(menu, inventory, title, 176, 166);
|
||||||
|
inventoryLabelY = 72;
|
||||||
|
}
|
||||||
|
@Override public boolean isPauseScreen() { return false; }
|
||||||
|
@Override protected void init() {
|
||||||
|
super.init();
|
||||||
|
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
|
||||||
|
Component.translatable("screen.rivencraft.link_modifier.title"));
|
||||||
|
titleInput.setMaxLength(32);
|
||||||
|
titleInput.setResponder(value -> {
|
||||||
|
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
|
||||||
|
});
|
||||||
|
addRenderableWidget(titleInput);
|
||||||
|
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
|
||||||
|
Component.translatable("screen.rivencraft.link_modifier.seed"));
|
||||||
|
seedInput.setMaxLength(21);
|
||||||
|
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
|
||||||
|
addRenderableWidget(seedInput);
|
||||||
|
for (int index = 0; index < options.length; index++) {
|
||||||
|
final int option = index;
|
||||||
|
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
|
||||||
|
topPos + 10 + (index % 3) * 20, 18, 18, label(index), button -> {
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, IDS[option]);
|
||||||
|
}, java.util.function.Supplier::get) {
|
||||||
|
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
int x = getX(), y = getY();
|
||||||
|
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
|
||||||
|
graphics.fill(x + 2, y + 2, x + 17, y + 17, !active ? 0xFF555555
|
||||||
|
: selected(option, menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE))
|
||||||
|
? 0xFFBBBBBB : 0xFF777777);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
options[index].setTooltip(Tooltip.create(label(index)));
|
||||||
|
}
|
||||||
|
retireButton = addRenderableWidget(new RetirementButton(120, LinkModifierMenu.RETIRE_AGE, "retire", false));
|
||||||
|
confirmRetireButton = addRenderableWidget(new RetirementButton(140, LinkModifierMenu.CONFIRM_RETIRE_AGE, "confirm", true));
|
||||||
|
refreshControls();
|
||||||
|
}
|
||||||
|
private final class RetirementButton extends Button {
|
||||||
|
private final boolean danger;
|
||||||
|
RetirementButton(int x, int action, String name, boolean danger) {
|
||||||
|
super(leftPos + x, topPos + 32, 18, 18,
|
||||||
|
Component.translatable("screen.rivencraft.link_modifier." + name), button -> {
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, action);
|
||||||
|
}, DEFAULT_NARRATION);
|
||||||
|
this.danger = danger;
|
||||||
|
setTooltip(Tooltip.create(getMessage()));
|
||||||
|
}
|
||||||
|
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
int x = getX(), y = getY();
|
||||||
|
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
|
||||||
|
graphics.fill(x + 2, y + 2, x + 17, y + 17, danger ? 0xFFAA5555
|
||||||
|
: menu.awaitingRetirementConfirmation() ? 0xFFBBBBBB : 0xFF777777);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Override protected void containerTick() { refreshControls(); }
|
||||||
|
private void refreshControls() {
|
||||||
|
ItemStack book = menu.book();
|
||||||
|
// Changing book kinds refreshes immediately; otherwise avoid replacing
|
||||||
|
// focused text with an earlier server echo while the player is typing.
|
||||||
|
if (!ItemStack.isSameItem(book, lastBook) || !titleInput.isFocused()) {
|
||||||
|
if (!ItemStack.isSameItemSameComponents(book, lastBook)) {
|
||||||
|
Component name = book.get(DataComponents.CUSTOM_NAME);
|
||||||
|
refreshing = true;
|
||||||
|
titleInput.setValue(name == null ? "" : name.getString());
|
||||||
|
refreshing = false;
|
||||||
|
lastBook = book.copy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
titleInput.setEditable(!book.isEmpty());
|
||||||
|
boolean canRetire = minecraft != null && minecraft.player != null && menu.canRetire(minecraft.player);
|
||||||
|
if (retireButton != null) {
|
||||||
|
retireButton.visible = canRetire;
|
||||||
|
retireButton.active = canRetire && !menu.awaitingRetirementConfirmation();
|
||||||
|
confirmRetireButton.visible = canRetire && menu.awaitingRetirementConfirmation();
|
||||||
|
confirmRetireButton.active = confirmRetireButton.visible;
|
||||||
|
}
|
||||||
|
Identifier age = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||||
|
if (!java.util.Objects.equals(seedAge, age)) {
|
||||||
|
seedInput.setFocused(false);
|
||||||
|
seedAge = age;
|
||||||
|
seedInput.setValue(Long.toString(menu.seed()));
|
||||||
|
lastSeed = menu.seed();
|
||||||
|
}
|
||||||
|
seedInput.visible = menu.hasSeed();
|
||||||
|
seedInput.setEditable(seedInput.visible);
|
||||||
|
if (!seedInput.visible) seedInput.setFocused(false);
|
||||||
|
else if (!seedInput.isFocused() && (lastSeed != menu.seed()
|
||||||
|
|| !seedInput.getValue().equals(Long.toString(menu.seed())))) {
|
||||||
|
lastSeed = menu.seed();
|
||||||
|
seedInput.setValue(Long.toString(lastSeed));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < options.length; i++) if (options[i] != null) {
|
||||||
|
options[i].active = available(i);
|
||||||
|
Component label = label(i);
|
||||||
|
if (!options[i].getMessage().equals(label)) {
|
||||||
|
options[i].setMessage(label);
|
||||||
|
options[i].setTooltip(Tooltip.create(label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private boolean available(int index) {
|
||||||
|
return !menu.book().isEmpty() && ((index != 1 && index != 2) || menu.book().is(Rivencraft.LINKING_BOOK.get()));
|
||||||
|
}
|
||||||
|
private Component label(int index) {
|
||||||
|
Component name = Component.translatable("item.rivencraft.link_property." + NAMES[index]);
|
||||||
|
return name.copy().append(selected(index,
|
||||||
|
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
|
||||||
|
}
|
||||||
|
private void submitSeed() {
|
||||||
|
if (seedInput.visible && seedAge != null && !seedInput.getValue().equals(Long.toString(menu.seed())))
|
||||||
|
ClientPacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedAge, seedInput.getValue()));
|
||||||
|
}
|
||||||
|
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||||
|
if (seedInput.isFocused() && !seedInput.isMouseOver(event.x(), event.y())) {
|
||||||
|
submitSeed();
|
||||||
|
seedInput.setFocused(false);
|
||||||
|
}
|
||||||
|
return super.mouseClicked(event, doubleClick);
|
||||||
|
}
|
||||||
|
@Override public boolean keyPressed(KeyEvent event) {
|
||||||
|
if (seedInput != null && seedInput.isFocused()) {
|
||||||
|
if (event.key() == InputConstants.KEY_RETURN || event.key() == InputConstants.KEY_NUMPADENTER) {
|
||||||
|
submitSeed();
|
||||||
|
seedInput.setFocused(false);
|
||||||
|
setFocused(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!event.isEscape()) {
|
||||||
|
seedInput.keyPressed(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
|
||||||
|
titleInput.keyPressed(event);
|
||||||
|
return true; // Character input must never activate inventory/hotbar shortcuts.
|
||||||
|
}
|
||||||
|
return super.keyPressed(event);
|
||||||
|
}
|
||||||
|
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.extractBackground(graphics, mouseX, mouseY, partialTick);
|
||||||
|
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean selected(int index, LinkProperties properties) {
|
||||||
|
if (index != 5) return properties.has(FLAGS[index]);
|
||||||
|
return menu.book().is(Rivencraft.LINKING_BOOK.get()) ? properties.has(LinkProperties.GENERATE_PLATFORM)
|
||||||
|
: !properties.has(LinkProperties.DISABLE_PLATFORM);
|
||||||
|
}
|
||||||
|
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||||
|
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -43,6 +43,7 @@ public final class PortBookBinderClientEvents {
|
|||||||
public static void registerScreens(RegisterMenuScreensEvent event) {
|
public static void registerScreens(RegisterMenuScreensEvent event) {
|
||||||
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
|
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
|
||||||
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::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.NOTEBOOK_MENU.get(), NotebookScreen::new);
|
||||||
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
|
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
|
||||||
|
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
|
||||||
|
public final class RuinsCompatibility {
|
||||||
|
private static final Set<Identifier> KNOWN_AGES = ConcurrentHashMap.newKeySet();
|
||||||
|
private static final Map<Identifier, Map<Identifier, Integer>> AUTHORED_TEMPLATES =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private RuinsCompatibility() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
|
||||||
|
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(RuinsCompatibility::onLevelUnload);
|
||||||
|
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
|
||||||
|
(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) -> RuinsTemplatePages.refresh());
|
||||||
|
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(
|
||||||
|
(net.neoforged.neoforge.event.server.ServerStoppedEvent event) -> onServerStopping());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void registerAge(Identifier id, PortTerrainFeaturePlan plan) {
|
||||||
|
if (!ModList.get().isLoaded("ruins")) return;
|
||||||
|
Map<Identifier, Integer> selected = templateCounts(plan == null ? List.of() : plan.ruins());
|
||||||
|
if (selected.isEmpty()) AUTHORED_TEMPLATES.remove(id);
|
||||||
|
else AUTHORED_TEMPLATES.put(id, selected);
|
||||||
|
KNOWN_AGES.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onLevelUnload(net.neoforged.neoforge.event.level.LevelEvent.Unload event) {
|
||||||
|
if (event.getLevel() instanceof net.minecraft.server.level.ServerLevel level) {
|
||||||
|
var id = level.dimension().identifier();
|
||||||
|
KNOWN_AGES.remove(id);
|
||||||
|
AUTHORED_TEMPLATES.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called only by the optional Ruins FileHandler mixin. */
|
||||||
|
public static boolean allowsAge(Identifier dimension) {
|
||||||
|
return AUTHORED_TEMPLATES.containsKey(dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isKnownAge(Identifier dimension) {
|
||||||
|
return KNOWN_AGES.contains(dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean permits(Identifier dimension, Identifier template) {
|
||||||
|
return templateCount(dimension, template) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int templateCount(Identifier dimension, Identifier template) {
|
||||||
|
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra pages increase attempts, without making distinct template choices denser. */
|
||||||
|
public static int attemptsPerChunk(Identifier dimension, int configuredAttempts) {
|
||||||
|
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int attemptsPerChunk(int configuredAttempts, Map<Identifier, Integer> counts) {
|
||||||
|
if (counts == null || counts.isEmpty()) return configuredAttempts;
|
||||||
|
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
|
||||||
|
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<Identifier, Integer> templateCounts(List<Identifier> choices) {
|
||||||
|
Map<Identifier, Integer> counts = new java.util.HashMap<>();
|
||||||
|
for (Identifier choice : choices) {
|
||||||
|
if (RuinsTemplatePages.contains(choice)) counts.merge(choice, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
return Map.copyOf(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onServerStopping() {
|
||||||
|
KNOWN_AGES.clear();
|
||||||
|
AUTHORED_TEMPLATES.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
|
||||||
|
import atomicstryker.ruins.common.RuinTemplate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.util.RandomSource;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
|
||||||
|
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
|
||||||
|
public final class RuinsEventAdapter {
|
||||||
|
private RuinsEventAdapter() {}
|
||||||
|
|
||||||
|
static void register() {
|
||||||
|
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
|
||||||
|
if (!event.isPrePhase || event.testingRuin
|
||||||
|
|| !(event.getLevel() instanceof ServerLevel level)
|
||||||
|
|| !RuinsCompatibility.isKnownAge(level.dimension().identifier())) return;
|
||||||
|
|
||||||
|
var template = RuinsTemplatePages.featureId(event.template.getName());
|
||||||
|
if (!RuinsTemplatePages.contains(template)
|
||||||
|
|| !RuinsCompatibility.permits(level.dimension().identifier(), template)) event.setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve Ruins' weights and multiply each by its authored page count. */
|
||||||
|
public static Object chooseTemplate(net.minecraft.resources.Identifier dimension,
|
||||||
|
Collection<?> pool, RandomSource random) {
|
||||||
|
if (pool == null || pool.isEmpty()) return null;
|
||||||
|
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
|
||||||
|
.map(RuinTemplate.class::cast)
|
||||||
|
.filter(template -> RuinsCompatibility.permits(dimension,
|
||||||
|
RuinsTemplatePages.featureId(template.getName())))
|
||||||
|
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
|
||||||
|
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
|
||||||
|
if (total <= 0.0) return null;
|
||||||
|
double roll = random.nextDouble() * total;
|
||||||
|
for (RuinTemplate candidate : candidates) {
|
||||||
|
roll -= authoredWeight(dimension, candidate);
|
||||||
|
if (roll < 0.0) return candidate;
|
||||||
|
}
|
||||||
|
return candidates.getLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double authoredWeight(net.minecraft.resources.Identifier dimension,
|
||||||
|
RuinTemplate template) {
|
||||||
|
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
|
||||||
|
RuinsTemplatePages.featureId(template.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
|
||||||
|
/** Discovers Ruins' template filenames without making its jar mandatory. */
|
||||||
|
public final class RuinsTemplatePages {
|
||||||
|
public static final Identifier CATEGORY = Identifier.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "ruins");
|
||||||
|
private static final String PREFIX = "ruins/";
|
||||||
|
private static volatile Map<Identifier, String> names = Map.of();
|
||||||
|
private static volatile boolean initialized;
|
||||||
|
|
||||||
|
private RuinsTemplatePages() {}
|
||||||
|
|
||||||
|
public static void refresh() {
|
||||||
|
if (!ModList.get().isLoaded("ruins")) {
|
||||||
|
names = Map.of();
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Identifier, String> discovered = new HashMap<>();
|
||||||
|
Map<String, String> sources = new HashMap<>();
|
||||||
|
Set<String> duplicateNames = new HashSet<>();
|
||||||
|
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
|
||||||
|
// overrideable CONFIGDIR; mirror its active folder exactly.
|
||||||
|
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
|
||||||
|
if (Files.isDirectory(configured)) {
|
||||||
|
try (var paths = Files.walk(configured)) {
|
||||||
|
paths.filter(Files::isRegularFile)
|
||||||
|
.filter(path -> configured.relativize(path).getNameCount() == 2)
|
||||||
|
.filter(path -> isTemplate(path.getFileName().toString()))
|
||||||
|
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
|
||||||
|
path.getFileName().toString(), configured.relativize(path).toString()));
|
||||||
|
} catch (IOException exception) {
|
||||||
|
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ruins extracts this archive on first launch. It also supplies the creative
|
||||||
|
// pages before extraction has completed on a fresh installation.
|
||||||
|
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
|
||||||
|
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
|
||||||
|
.getResourceAsStream("assets/ruins_config.zip")) {
|
||||||
|
if (stream != null) {
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(stream)) {
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zip.getNextEntry()) != null) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
String name = entry.getName();
|
||||||
|
name = name.substring(name.lastIndexOf('/') + 1);
|
||||||
|
if (isTemplate(name)) add(discovered, sources, duplicateNames,
|
||||||
|
name, entry.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ruins' spawn event exposes only the basename. If two directories use
|
||||||
|
// that basename, Rivencraft cannot identify which structure was chosen.
|
||||||
|
// Disable the bridge instead of advertising pages with ambiguous effects.
|
||||||
|
if (!duplicateNames.isEmpty()) {
|
||||||
|
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
|
||||||
|
+ "Give every .tml file a unique basename.", duplicateNames);
|
||||||
|
names = Map.of();
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
names = Map.copyOf(discovered);
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> templates() {
|
||||||
|
if (!initialized) refresh();
|
||||||
|
List<String> templates = new ArrayList<>(names.values());
|
||||||
|
templates.sort(Comparator.comparing(name -> name.toLowerCase(Locale.ROOT)));
|
||||||
|
return List.copyOf(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isSymbol(Identifier id) {
|
||||||
|
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean contains(Identifier id) {
|
||||||
|
if (!initialized) refresh();
|
||||||
|
return isSymbol(id) && names.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Identifier symbolId(String templateName) {
|
||||||
|
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
|
||||||
|
// Hex is reversible and collision-free even for case-sensitive or Unicode
|
||||||
|
// filenames; normalizing to lowercase would merge distinct templates.
|
||||||
|
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Identifier featureId(String templateName) {
|
||||||
|
return symbolId(templateName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String fallbackName(Identifier symbolId) {
|
||||||
|
String encoded = symbolId.getPath().substring(PREFIX.length());
|
||||||
|
if (!encoded.startsWith("t_")) return encoded + " Ruin";
|
||||||
|
String name;
|
||||||
|
try {
|
||||||
|
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
name = encoded;
|
||||||
|
}
|
||||||
|
return stripExtension(name).replace('_', ' ') + " Ruin";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PortSymbolPages.Entry definition(String templateName) {
|
||||||
|
Identifier id = featureId(templateName);
|
||||||
|
return new PortSymbolPages.Entry(id, CATEGORY.toString(),
|
||||||
|
"symbol.rivencraft.ruins.template." + id.getPath().substring(PREFIX.length()),
|
||||||
|
"symbol.rivencraft.ruins.description", "", "feature");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PortSymbolPages.Entry find(Identifier id) {
|
||||||
|
if (!initialized) refresh();
|
||||||
|
String name = names.get(id);
|
||||||
|
return name == null ? null : definition(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTemplate(String name) {
|
||||||
|
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripExtension(String name) {
|
||||||
|
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(Map<Identifier, String> templates, Map<String, String> sources,
|
||||||
|
Set<String> duplicateNames, String name, String source) {
|
||||||
|
String previousSource = sources.putIfAbsent(name, source);
|
||||||
|
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
|
||||||
|
Identifier id = symbolId(name);
|
||||||
|
String previous = templates.putIfAbsent(id, name);
|
||||||
|
if (previous != null && !previous.equals(name)) {
|
||||||
|
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
|
||||||
|
previous, name, id, previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import sereneseasons.api.season.Season;
|
||||||
|
import sereneseasons.config.SeasonsConfig;
|
||||||
|
import sereneseasons.init.ModConfig;
|
||||||
|
import sereneseasons.season.SeasonHandler;
|
||||||
|
import sereneseasons.season.SeasonSavedData;
|
||||||
|
import sereneseasons.season.SeasonTime;
|
||||||
|
|
||||||
|
/** Isolates references to optional Serene Seasons and GlitchCore classes. */
|
||||||
|
final class SereneSeasonsAdapter {
|
||||||
|
private static final String WHITELIST_KEY = "dimension_settings.whitelisted_dimensions";
|
||||||
|
|
||||||
|
private SereneSeasonsAdapter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean setServerWhitelist(Identifier dimensionId, boolean enabled) {
|
||||||
|
SeasonsConfig config = ModConfig.seasons;
|
||||||
|
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return false;
|
||||||
|
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
|
||||||
|
if (enabled) dimensions.add(dimensionId.toString());
|
||||||
|
else dimensions.removeIf(dimensionId.toString()::equals);
|
||||||
|
// GlitchCore rereads this config from disk before synchronizing new clients.
|
||||||
|
config.set(WHITELIST_KEY, dimensions);
|
||||||
|
config.write();
|
||||||
|
config.whitelistedDimensions = dimensions;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setClientWhitelist(Identifier dimensionId, boolean enabled) {
|
||||||
|
SeasonsConfig config = ModConfig.seasons;
|
||||||
|
if (config == null || config.whitelistedDimensions.contains(dimensionId.toString()) == enabled) return;
|
||||||
|
List<String> dimensions = new ArrayList<>(config.whitelistedDimensions);
|
||||||
|
if (enabled) dimensions.add(dimensionId.toString());
|
||||||
|
else dimensions.removeIf(dimensionId.toString()::equals);
|
||||||
|
config.whitelistedDimensions = dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void holdSeason(ServerLevel level, Mode mode) {
|
||||||
|
SeasonSavedData data = SeasonHandler.getSeasonSavedData(level);
|
||||||
|
Season.SubSeason target = switch (mode) {
|
||||||
|
case ETERNAL_SPRING -> Season.SubSeason.MID_SPRING;
|
||||||
|
case ETERNAL_SUMMER -> Season.SubSeason.MID_SUMMER;
|
||||||
|
case ETERNAL_FALL -> Season.SubSeason.MID_AUTUMN;
|
||||||
|
case ETERNAL_WINTER -> Season.SubSeason.MID_WINTER;
|
||||||
|
default -> throw new IllegalArgumentException("Not an eternal season: " + mode);
|
||||||
|
};
|
||||||
|
int duration = SeasonTime.ZERO.getSubSeasonDuration();
|
||||||
|
int midpoint = target.ordinal() * duration + duration / 2;
|
||||||
|
// Reset well before the end of MID_* so the client never observes a
|
||||||
|
// different sub-season; avoid marking saved data dirty every tick.
|
||||||
|
int current = data.seasonCycleTicks;
|
||||||
|
if (current >= midpoint && current < midpoint + duration / 4) return;
|
||||||
|
data.seasonCycleTicks = midpoint;
|
||||||
|
data.setDirty();
|
||||||
|
SeasonHandler.sendSeasonUpdate(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.PortConfig;
|
||||||
|
import net.dnorberg.rivencraft.age.PortSeasonPolicies;
|
||||||
|
import net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode;
|
||||||
|
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||||
|
import net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
|
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||||
|
|
||||||
|
/** Common optional boundary: only its guarded adapter loads Serene/GlitchCore APIs. */
|
||||||
|
public final class SereneSeasonsCompatibility {
|
||||||
|
private static final java.util.concurrent.atomic.AtomicBoolean WARNED = new java.util.concurrent.atomic.AtomicBoolean();
|
||||||
|
private SereneSeasonsCompatibility() {}
|
||||||
|
public static boolean available() { return ModList.get().isLoaded("sereneseasons"); }
|
||||||
|
private static boolean isAge(Identifier id) { return id.getNamespace().equals("rivencraft") && id.getPath().startsWith("age/"); }
|
||||||
|
private static Boolean enabled(ServerLevel level) {
|
||||||
|
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
|
||||||
|
if (policy.mode() != Mode.NO_SEASONS && !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return null;
|
||||||
|
return policy.mode() != Mode.NO_SEASONS;
|
||||||
|
}
|
||||||
|
public static void apply(ServerLevel level) {
|
||||||
|
if (!available() || !isAge(level.dimension().identifier())) return;
|
||||||
|
try {
|
||||||
|
Boolean enabled = enabled(level);
|
||||||
|
if (enabled == null) return;
|
||||||
|
var id = level.dimension().identifier();
|
||||||
|
SereneSeasonsAdapter.setServerWhitelist(id, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
var mode = PortSeasonPolicies.get(level.getServer()).forLevel(level).mode();
|
||||||
|
if (mode.eternal()) SereneSeasonsAdapter.holdSeason(level, mode);
|
||||||
|
}
|
||||||
|
for (var player : level.getServer().getPlayerList().getPlayers()) send(player, id, enabled);
|
||||||
|
} catch (RuntimeException | LinkageError e) { warn(e); }
|
||||||
|
}
|
||||||
|
public static void onLevelLoad(LevelEvent.Load event) {
|
||||||
|
if (event.getLevel() instanceof ServerLevel level && isAge(level.dimension().identifier())
|
||||||
|
&& RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier())) apply(level);
|
||||||
|
}
|
||||||
|
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||||
|
if (!available() || !(event.getEntity() instanceof ServerPlayer player)) return;
|
||||||
|
for (var level : player.level().getServer().getAllLevels()) if (isAge(level.dimension().identifier())) {
|
||||||
|
Boolean enabled = enabled(level);
|
||||||
|
if (enabled != null) send(player, level.dimension().identifier(), enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||||
|
if (!available() || !(event.getLevel() instanceof ServerLevel level) || !isAge(level.dimension().identifier())) return;
|
||||||
|
var policy = PortSeasonPolicies.get(level.getServer()).forLevel(level);
|
||||||
|
if (!policy.mode().eternal() || !policy.authored() && !PortConfig.AUTO_WHITELIST_SERENE_AGES.get()) return;
|
||||||
|
try { SereneSeasonsAdapter.holdSeason(level, policy.mode()); }
|
||||||
|
catch (RuntimeException | LinkageError e) { warn(e); }
|
||||||
|
}
|
||||||
|
public static void retired(MinecraftServer server, Identifier id) {
|
||||||
|
if (!available()) return;
|
||||||
|
try {
|
||||||
|
SereneSeasonsAdapter.setServerWhitelist(id, false);
|
||||||
|
for (var player : server.getPlayerList().getPlayers()) send(player, id, false);
|
||||||
|
} catch (RuntimeException | LinkageError e) { warn(e); }
|
||||||
|
}
|
||||||
|
private static void send(ServerPlayer player, Identifier id, boolean enabled) {
|
||||||
|
PacketDistributor.sendToPlayer(player, new SereneAgeWhitelistPayload(id, enabled));
|
||||||
|
}
|
||||||
|
public static void acceptClient(Identifier id, boolean enabled) {
|
||||||
|
if (!available() || !isAge(id)) return;
|
||||||
|
try { SereneSeasonsAdapter.setClientWhitelist(id, enabled); }
|
||||||
|
catch (RuntimeException | LinkageError e) { warn(e); }
|
||||||
|
}
|
||||||
|
private static void warn(Throwable e) {
|
||||||
|
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn("Could not apply optional Serene Seasons policy", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.core.Holder;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.RegistryAccess;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.level.biome.Biome;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
import net.neoforged.neoforge.event.TagsUpdatedEvent;
|
||||||
|
|
||||||
|
/** Optional API boundary with registry-scoped pools and cheap worldgen membership queries. */
|
||||||
|
public final class TerraBlenderBiomes {
|
||||||
|
private record Pools(List<Identifier> overworld, List<Identifier> nether) {}
|
||||||
|
private static final Pools EMPTY = new Pools(List.of(), List.of());
|
||||||
|
private static final Map<RegistryAccess, Pools> POOLS = new WeakHashMap<>();
|
||||||
|
private static final Map<Holder<Biome>, Boolean> NETHER_MEMBERSHIP = new WeakHashMap<>();
|
||||||
|
private static final AtomicBoolean WARNED = new AtomicBoolean();
|
||||||
|
private TerraBlenderBiomes() {}
|
||||||
|
public static List<Identifier> overworld(HolderLookup.Provider registries) { return prepare(registries).overworld(); }
|
||||||
|
public static List<Identifier> nether(HolderLookup.Provider registries) { return prepare(registries).nether(); }
|
||||||
|
public static synchronized boolean isNether(Holder<Biome> biome) {
|
||||||
|
return Boolean.TRUE.equals(NETHER_MEMBERSHIP.get(biome));
|
||||||
|
}
|
||||||
|
public static void onServerAboutToStart(net.neoforged.neoforge.event.server.ServerAboutToStartEvent event) {
|
||||||
|
// TerraBlender loads data-driven regions at normal priority in this event.
|
||||||
|
// Refresh after that, even if tags were seen before those regions existed.
|
||||||
|
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getServer().registryAccess()); }
|
||||||
|
prepare(event.getServer().registryAccess());
|
||||||
|
}
|
||||||
|
public static void onTagsUpdated(TagsUpdatedEvent event) {
|
||||||
|
// Server/client registry objects have independent cache entries.
|
||||||
|
synchronized (TerraBlenderBiomes.class) { POOLS.remove(event.getRegistries()); }
|
||||||
|
prepare(event.getRegistries());
|
||||||
|
}
|
||||||
|
private static synchronized Pools prepare(HolderLookup.Provider provider) {
|
||||||
|
if (!ModList.get().isLoaded("terrablender") || !(provider instanceof RegistryAccess registries)) return EMPTY;
|
||||||
|
Pools cached = POOLS.get(registries);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
Pools pools = EMPTY;
|
||||||
|
try {
|
||||||
|
pools = new Pools(TerraBlenderRegionAdapter.collect(registries, false),
|
||||||
|
TerraBlenderRegionAdapter.collect(registries, true));
|
||||||
|
} catch (RuntimeException | LinkageError exception) {
|
||||||
|
if (WARNED.compareAndSet(false, true)) Rivencraft.LOGGER.warn(
|
||||||
|
"TerraBlender biome regions could not be read; using registry tags and native biome pools", exception);
|
||||||
|
}
|
||||||
|
POOLS.put(registries, pools);
|
||||||
|
var nether = java.util.Set.copyOf(pools.nether());
|
||||||
|
registries.lookupOrThrow(Registries.BIOME).listElements().forEach(holder ->
|
||||||
|
NETHER_MEMBERSHIP.put(holder, nether.contains(holder.key().identifier())));
|
||||||
|
return pools;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.minecraft.core.RegistryAccess;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.level.biome.Biome;
|
||||||
|
import terrablender.api.Region;
|
||||||
|
import terrablender.api.RegionType;
|
||||||
|
import terrablender.api.Regions;
|
||||||
|
|
||||||
|
/** Loaded only after the optional TerraBlender mod is confirmed present. */
|
||||||
|
final class TerraBlenderRegionAdapter {
|
||||||
|
private TerraBlenderRegionAdapter() {}
|
||||||
|
|
||||||
|
static List<Identifier> collect(RegistryAccess registries, boolean nether) {
|
||||||
|
var biomes = registries.lookupOrThrow(Registries.BIOME);
|
||||||
|
RegionType type = nether ? RegionType.NETHER : RegionType.OVERWORLD;
|
||||||
|
Set<Identifier> ids = new LinkedHashSet<>();
|
||||||
|
for (Region region : Regions.get(type)) {
|
||||||
|
region.addBiomes(biomes, mapping -> {
|
||||||
|
ResourceKey<Biome> key = mapping.getSecond();
|
||||||
|
Identifier id = key.identifier();
|
||||||
|
// Vanilla's default region is always present. Only additions from
|
||||||
|
// other mods extend an Age's existing deterministic biome pool.
|
||||||
|
if (!id.getNamespace().equals("minecraft") && !id.equals(
|
||||||
|
net.dnorberg.rivencraft.writing.PortRegistryPages.DEFERRED_PLACEHOLDER) && biomes.containsKey(id)) ids.add(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids.stream().sorted().toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat.jei;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import mezz.jei.api.IModPlugin;
|
||||||
|
import mezz.jei.api.JeiPlugin;
|
||||||
|
import mezz.jei.api.constants.VanillaTypes;
|
||||||
|
import mezz.jei.api.gui.builder.IRecipeLayoutBuilder;
|
||||||
|
import mezz.jei.api.gui.ingredient.IRecipeSlotsView;
|
||||||
|
import mezz.jei.api.helpers.IGuiHelper;
|
||||||
|
import mezz.jei.api.recipe.IFocusGroup;
|
||||||
|
import mezz.jei.api.recipe.types.IRecipeType;
|
||||||
|
import mezz.jei.api.recipe.category.AbstractRecipeCategory;
|
||||||
|
import mezz.jei.api.registration.IRecipeCatalystRegistration;
|
||||||
|
import mezz.jei.api.registration.IRecipeCategoryRegistration;
|
||||||
|
import mezz.jei.api.registration.IRecipeRegistration;
|
||||||
|
import mezz.jei.api.registration.ISubtypeRegistration;
|
||||||
|
import mezz.jei.api.registration.IExtraIngredientRegistration;
|
||||||
|
import mezz.jei.api.ingredients.subtypes.ISubtypeInterpreter;
|
||||||
|
import mezz.jei.api.runtime.IJeiRuntime;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||||
|
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||||
|
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||||
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.network.chat.MutableComponent;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
|
||||||
|
/** Loaded by JEI's plugin scanner only when JEI is installed. No common mod class references this type. */
|
||||||
|
@JeiPlugin
|
||||||
|
public final class RivencraftJeiPlugin implements IModPlugin {
|
||||||
|
private static final IRecipeType<WritingGuide> WRITING =
|
||||||
|
IRecipeType.create(Rivencraft.MOD_ID, "writing", WritingGuide.class);
|
||||||
|
private static final IRecipeType<InkMixerGuide> INK_MIXER =
|
||||||
|
IRecipeType.create(Rivencraft.MOD_ID, "ink_mixer", InkMixerGuide.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Identifier getPluginUid() {
|
||||||
|
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "jei_writing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerItemSubtypes(ISubtypeRegistration registration) {
|
||||||
|
registration.registerSubtypeInterpreter(Rivencraft.PAGE.get(),
|
||||||
|
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||||
|
PageContent content = PageItem.content(stack);
|
||||||
|
return switch (content.kind()) {
|
||||||
|
case BLANK -> "blank";
|
||||||
|
case LINK_PANEL -> "link_panel:" + stack.getOrDefault(
|
||||||
|
Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
|
||||||
|
case SYMBOL -> "symbol:" + content.symbol().orElseThrow().id();
|
||||||
|
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
registration.registerSubtypeInterpreter(Rivencraft.SYMBOL_PORTFOLIO.get(),
|
||||||
|
(ISubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||||
|
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||||
|
return name == null ? "empty" : name.getString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerExtraIngredients(IExtraIngredientRegistration registration) {
|
||||||
|
List<ItemStack> pages = new ArrayList<>();
|
||||||
|
pages.addAll(PageItem.creativeLinkPanelPages());
|
||||||
|
var connection = Minecraft.getInstance().getConnection();
|
||||||
|
if (connection != null) {
|
||||||
|
pages.addAll(symbolPages(connection.registryAccess()));
|
||||||
|
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
|
||||||
|
} else {
|
||||||
|
PortSymbolPages.sealedCandidateIds().stream().map(PageItem::createSymbolPage).forEach(pages::add);
|
||||||
|
}
|
||||||
|
registration.addExtraItemStacks(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRuntimeAvailable(IJeiRuntime runtime) {
|
||||||
|
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
||||||
|
runtime.getIngredientManager().removeIngredientsAtRuntime(VanillaTypes.ITEM_STACK,
|
||||||
|
List.of(new ItemStack(Rivencraft.PAGE.get()), new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get())));
|
||||||
|
var connection = Minecraft.getInstance().getConnection();
|
||||||
|
if (connection == null) return;
|
||||||
|
Set<Identifier> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||||
|
.filter(stack -> stack.is(Rivencraft.PAGE.get()))
|
||||||
|
.map(PageItem::content)
|
||||||
|
.filter(content -> content.kind() == PageContent.Kind.SYMBOL)
|
||||||
|
.map(content -> content.symbol().orElseThrow().id())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
List<ItemStack> missing = symbolPages(connection.registryAccess()).stream()
|
||||||
|
.filter(stack -> !known.contains(PageItem.content(stack).symbol().orElseThrow().id()))
|
||||||
|
.toList();
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
||||||
|
}
|
||||||
|
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||||
|
.filter(stack -> stack.is(Rivencraft.SYMBOL_PORTFOLIO.get()))
|
||||||
|
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.map(Component::getString)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
|
||||||
|
.stream()
|
||||||
|
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
|
||||||
|
.toList();
|
||||||
|
if (!missingPortfolios.isEmpty()) {
|
||||||
|
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ItemStack> symbolPages(net.minecraft.core.HolderLookup.Provider registries) {
|
||||||
|
return PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, registries).stream()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerCategories(IRecipeCategoryRegistration registration) {
|
||||||
|
IGuiHelper guiHelper = registration.getJeiHelpers().getGuiHelper();
|
||||||
|
registration.addRecipeCategories(new WritingCategory(guiHelper), new InkMixerCategory(guiHelper));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerRecipes(IRecipeRegistration registration) {
|
||||||
|
registration.addItemStackInfo(new ItemStack(Rivencraft.LINK_MODIFIER_ITEM.get()),
|
||||||
|
Component.translatable("jei.rivencraft.link_modifier"));
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
ItemStack symbolPage = PageItem.createSymbolPage(new SymbolReference(
|
||||||
|
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/standard")));
|
||||||
|
registration.addRecipes(WRITING, List.of(
|
||||||
|
new WritingGuide(List.of(new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
|
||||||
|
panel, "jei.rivencraft.writing.link_panel_hand"),
|
||||||
|
new WritingGuide(List.of(symbolPage, new ItemStack(Items.PAPER), new ItemStack(Rivencraft.INK_VIAL.get())),
|
||||||
|
symbolPage, "jei.rivencraft.writing.copy"),
|
||||||
|
new WritingGuide(List.of(new ItemStack(Items.LEATHER), panel),
|
||||||
|
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), "jei.rivencraft.writing.bind"),
|
||||||
|
new WritingGuide(List.of(panel, new ItemStack(Items.LEATHER)),
|
||||||
|
new ItemStack(Rivencraft.LINKING_BOOK.get()), "jei.rivencraft.writing.linking_book")
|
||||||
|
));
|
||||||
|
List<InkMixerGuide> mixerGuides = new ArrayList<>();
|
||||||
|
mixerGuides.add(new InkMixerGuide(panel, null));
|
||||||
|
InkModifierIngredients.builtIn().forEach(ingredient ->
|
||||||
|
mixerGuides.add(new InkMixerGuide(panel, ingredient)));
|
||||||
|
registration.addRecipes(INK_MIXER, mixerGuides);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerRecipeCatalysts(IRecipeCatalystRegistration registration) {
|
||||||
|
registration.addCraftingStation(WRITING, Rivencraft.INK_VIAL.get(),
|
||||||
|
Rivencraft.WRITING_DESK_ITEM.get(), Rivencraft.BOOK_BINDER_ITEM.get());
|
||||||
|
registration.addCraftingStation(INK_MIXER, Rivencraft.INK_MIXER_ITEM.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record WritingGuide(List<ItemStack> inputs, ItemStack output, String instructionKey) {}
|
||||||
|
private record InkMixerGuide(ItemStack panel, InkModifierIngredients.Ingredient additive) {}
|
||||||
|
|
||||||
|
private static final class InkMixerCategory extends AbstractRecipeCategory<InkMixerGuide> {
|
||||||
|
InkMixerCategory(IGuiHelper guiHelper) {
|
||||||
|
super(INK_MIXER, Component.translatable("jei.rivencraft.ink_mixer"),
|
||||||
|
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||||
|
new ItemStack(Rivencraft.INK_MIXER_ITEM.get())), 138, 110);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setRecipe(IRecipeLayoutBuilder builder, InkMixerGuide recipe, IFocusGroup focuses) {
|
||||||
|
builder.addInputSlot(2, 3).setStandardSlotBackground().add(new ItemStack(Items.PAPER));
|
||||||
|
builder.addInputSlot(23, 3).setStandardSlotBackground()
|
||||||
|
.add(new ItemStack(Rivencraft.INK_VIAL.get()));
|
||||||
|
if (recipe.additive() != null)
|
||||||
|
builder.addInputSlot(44, 3).setStandardSlotBackground()
|
||||||
|
.add(recipe.additive().item());
|
||||||
|
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.panel());
|
||||||
|
builder.addOutputSlot(112, 46).setStandardSlotBackground()
|
||||||
|
.add(new ItemStack(Items.GLASS_BOTTLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(InkMixerGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
|
||||||
|
double mouseX, double mouseY) {
|
||||||
|
Component instructions = recipe.additive() == null
|
||||||
|
? Component.translatable("jei.rivencraft.ink_mixer.usage")
|
||||||
|
: additiveInstructions(recipe.additive());
|
||||||
|
graphics.textWithWordWrap(Minecraft.getInstance().font, instructions, 2, 27, 104, 0xFF404040, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component additiveInstructions(InkModifierIngredients.Ingredient additive) {
|
||||||
|
MutableComponent effects = Component.empty();
|
||||||
|
for (InkModifierIngredients.Effect effect : additive.effects()) {
|
||||||
|
if (!effects.getSiblings().isEmpty()) effects.append(", ");
|
||||||
|
effects.append(Component.translatable("jei.rivencraft.ink_mixer.effect",
|
||||||
|
Component.translatable("item.rivencraft.link_property." + switch (effect.flag()) {
|
||||||
|
case LinkProperties.DISARM -> "disarm";
|
||||||
|
case LinkProperties.MAINTAIN_MOMENTUM -> "maintain_momentum";
|
||||||
|
case LinkProperties.INTRA_LINKING -> "intra_linking";
|
||||||
|
default -> throw new IllegalArgumentException("Unknown mixer modifier");
|
||||||
|
}), Math.round(effect.chance() * 100)));
|
||||||
|
}
|
||||||
|
return Component.translatable("jei.rivencraft.ink_mixer.additive", effects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class WritingCategory extends AbstractRecipeCategory<WritingGuide> {
|
||||||
|
WritingCategory(IGuiHelper guiHelper) {
|
||||||
|
super(WRITING, Component.translatable("jei.rivencraft.writing"),
|
||||||
|
guiHelper.createDrawableIngredient(VanillaTypes.ITEM_STACK,
|
||||||
|
new ItemStack(Rivencraft.WRITING_DESK_ITEM.get())), 138, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setRecipe(IRecipeLayoutBuilder builder, WritingGuide recipe, IFocusGroup focuses) {
|
||||||
|
for (int index = 0; index < recipe.inputs().size(); index++) {
|
||||||
|
builder.addInputSlot(2 + index * 21, 3).setStandardSlotBackground()
|
||||||
|
.add(recipe.inputs().get(index));
|
||||||
|
}
|
||||||
|
builder.addOutputSlot(112, 3).setOutputSlotBackground().add(recipe.output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(WritingGuide recipe, IRecipeSlotsView slots, GuiGraphicsExtractor graphics,
|
||||||
|
double mouseX, double mouseY) {
|
||||||
|
graphics.textWithWordWrap(Minecraft.getInstance().font,
|
||||||
|
Component.translatable(recipe.instructionKey()), 2, 27, 134, 0xFF404040, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,5 +36,6 @@ public final class DescriptiveBookItem extends Item {
|
|||||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||||
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
|
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
|
||||||
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
|
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.ByteBufCodecs;
|
||||||
import net.minecraft.network.codec.StreamCodec;
|
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 record LinkProperties(int flags) {
|
||||||
public static final int DISARM = 1;
|
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 MAINTAIN_MOMENTUM = 4;
|
||||||
public static final int INTRA_LINKING = 8;
|
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 LinkProperties NONE = new LinkProperties(0);
|
||||||
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
|
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
|
||||||
public static final StreamCodec<ByteBuf, LinkProperties> STREAM_CODEC = StreamCodec.of(
|
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) {
|
public boolean has(int flag) {
|
||||||
return (flags & flag) != 0;
|
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 == null) return "Unbound Linking Book (26.3 port preview)";
|
||||||
if (!destination.valid()) return "Invalid Linking Book destination";
|
if (!destination.valid()) return "Invalid Linking Book destination";
|
||||||
if (player.level().dimension().identifier().equals(destination.dimension())
|
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";
|
return "Same-dimension linking requires an Intra-Linking page";
|
||||||
}
|
}
|
||||||
ServerLevel target = player.level().getServer().getLevel(
|
ServerLevel target = player.level().getServer().getLevel(
|
||||||
@@ -57,11 +57,13 @@ public final class LinkingBookItem extends Item {
|
|||||||
if (!AgeCatalogSync.ready(player, destination.dimension())) {
|
if (!AgeCatalogSync.ready(player, destination.dimension())) {
|
||||||
return "Destination catalog not acknowledged by client";
|
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());
|
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||||
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|
||||||
|| !target.getWorldBorder().isWithinBounds(feet)) {
|
|| !target.getWorldBorder().isWithinBounds(feet)) return "Destination is outside the world bounds";
|
||||||
return "Destination is outside the world bounds";
|
target.getChunkAt(feet);
|
||||||
}
|
|
||||||
// Test the player's actual arrival volume: checking whole block shapes
|
// Test the player's actual arrival volume: checking whole block shapes
|
||||||
// would incorrectly reject links bound while standing on a slab.
|
// would incorrectly reject links bound while standing on a slab.
|
||||||
if (!target.noCollision(player, player.getBoundingBox().move(
|
if (!target.noCollision(player, player.getBoundingBox().move(
|
||||||
@@ -74,7 +76,10 @@ public final class LinkingBookItem extends Item {
|
|||||||
Vec3 incoming = player.getDeltaMovement();
|
Vec3 incoming = player.getDeltaMovement();
|
||||||
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
|
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
|
||||||
Set.of(), destination.yaw(), destination.pitch(), true);
|
Set.of(), destination.yaw(), destination.pitch(), true);
|
||||||
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
|
if (moved) {
|
||||||
|
PortLinkDestinations.generatePlatform(target, player.position(), properties);
|
||||||
|
applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
|
||||||
|
}
|
||||||
return moved ? null : "Teleportation failed";
|
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()));
|
destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ()));
|
||||||
}
|
}
|
||||||
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||||
if (properties.has(LinkProperties.DISARM))
|
properties.appendTooltip(tooltip);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,17 @@ public final class PageItem extends Item {
|
|||||||
return stack;
|
return stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All eight mixer-produced variants, shared by Creative and optional JEI. */
|
||||||
|
public static java.util.List<ItemStack> creativeLinkPanelPages() {
|
||||||
|
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
|
||||||
|
var panels = new java.util.ArrayList<ItemStack>();
|
||||||
|
for (int combination = 0; combination < 8; combination++) {
|
||||||
|
int selected = 0;
|
||||||
|
for (int bit = 0; bit < flags.length; bit++) if ((combination & (1 << bit)) != 0) selected |= flags[bit];
|
||||||
|
panels.add(createLinkPanelPage(selected));
|
||||||
|
}
|
||||||
|
return java.util.List.copyOf(panels);
|
||||||
|
}
|
||||||
public static boolean isLinkPanel(ItemStack stack) {
|
public static boolean isLinkPanel(ItemStack stack) {
|
||||||
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
|
return stack.is(Rivencraft.PAGE.get()) && stack.getOrDefault(Rivencraft.LINK_PANEL_PAGE.get(), false);
|
||||||
}
|
}
|
||||||
@@ -96,15 +107,7 @@ public final class PageItem extends Item {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||||
if (properties.has(LinkProperties.DISARM))
|
properties.appendTooltip(tooltip);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isCorrupted(Identifier id) {
|
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,205 @@
|
|||||||
|
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.inventory.DataSlot;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||||
|
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,
|
||||||
|
RETIRE_AGE = 4, CONFIRM_RETIRE_AGE = 8;
|
||||||
|
private final LinkModifierBlockEntity modifier;
|
||||||
|
private final Container books;
|
||||||
|
private final Player owner;
|
||||||
|
private final int[] seedWords = new int[4];
|
||||||
|
private boolean clientHasSeed;
|
||||||
|
private boolean clientRetirementArmed;
|
||||||
|
private Identifier pendingAge;
|
||||||
|
private ItemStack pendingBook = ItemStack.EMPTY;
|
||||||
|
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;
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return awaitingRetirementConfirmation() ? 1 : 0; }
|
||||||
|
@Override public void set(int value) { clientRetirementArmed = value != 0; }
|
||||||
|
});
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return hasSeed() ? 1 : 0; }
|
||||||
|
@Override public void set(int value) { clientHasSeed = value != 0; }
|
||||||
|
});
|
||||||
|
for (int i = 0; i < seedWords.length; i++) {
|
||||||
|
final int word = i;
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return (int) ((seed() >>> (word * 16)) & 0xffffL); }
|
||||||
|
@Override public void set(int value) { seedWords[word] = value & 0xffff; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addSlot(new Slot(books, 0, 80, 35) {
|
||||||
|
@Override public boolean mayPlace(ItemStack stack) { return isBook(stack); }
|
||||||
|
@Override public int getMaxStackSize() { return 1; }
|
||||||
|
@Override public void setChanged() { super.setChanged(); resetRetirement(); }
|
||||||
|
});
|
||||||
|
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 hasSeed() {
|
||||||
|
if (!book().is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !book().has(Rivencraft.AGE_BOOK_ID.get())) return false;
|
||||||
|
return owner.level().isClientSide() ? clientHasSeed
|
||||||
|
: RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer())
|
||||||
|
.containsKey(book().get(Rivencraft.AGE_BOOK_ID.get()));
|
||||||
|
}
|
||||||
|
public long seed() {
|
||||||
|
if (owner.level().isClientSide()) {
|
||||||
|
long value = 0;
|
||||||
|
for (int i = 0; i < seedWords.length; i++) value |= (seedWords[i] & 0xffffL) << (i * 16);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return hasSeed() ? RuntimeAgeBackend.savedAges(((ServerPlayer) owner).level().getServer())
|
||||||
|
.get(book().get(Rivencraft.AGE_BOOK_ID.get())) : 0;
|
||||||
|
}
|
||||||
|
public boolean canRetire(Player player) {
|
||||||
|
return net.minecraft.commands.Commands.LEVEL_GAMEMASTERS.check(player.permissions()) && hasSeed();
|
||||||
|
}
|
||||||
|
public boolean awaitingRetirementConfirmation() {
|
||||||
|
return owner.level().isClientSide() ? clientRetirementArmed : pendingAge != null;
|
||||||
|
}
|
||||||
|
private void resetRetirement() { pendingAge = null; pendingBook = ItemStack.EMPTY; }
|
||||||
|
private boolean retireAge(Player player, boolean confirm) {
|
||||||
|
if (!(player instanceof ServerPlayer serverPlayer) || !canRetire(player)) { resetRetirement(); return false; }
|
||||||
|
Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get());
|
||||||
|
if (!confirm) {
|
||||||
|
pendingAge = age;
|
||||||
|
pendingBook = book().copy();
|
||||||
|
broadcastChanges();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!age.equals(pendingAge) || !ItemStack.matches(book(), pendingBook)) {
|
||||||
|
resetRetirement();
|
||||||
|
broadcastChanges();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resetRetirement();
|
||||||
|
var server = serverPlayer.level().getServer();
|
||||||
|
var target = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||||
|
net.minecraft.core.registries.Registries.DIMENSION, age));
|
||||||
|
String outcome;
|
||||||
|
boolean retired = false;
|
||||||
|
if (target != null && !target.players().isEmpty()) outcome = "occupied";
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
RuntimeAgeBackend.retire(server, age);
|
||||||
|
outcome = "retired";
|
||||||
|
retired = true;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
outcome = "storage_failed";
|
||||||
|
Rivencraft.LOGGER.warn("Could not retire Age {} from Link Modifier", age, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendSystemMessage(Component.translatable("message.rivencraft.link_modifier.delete." + outcome));
|
||||||
|
broadcastChanges();
|
||||||
|
return retired;
|
||||||
|
}
|
||||||
|
/** The expected Age prevents an in-flight edit from targeting a replacement book. */
|
||||||
|
public boolean setSeed(Player player, Identifier expectedAge, String text) {
|
||||||
|
if (player.level().isClientSide() || !stillValid(player) || !hasSeed()
|
||||||
|
|| !(player instanceof ServerPlayer serverPlayer)) return false;
|
||||||
|
Identifier age = book().get(Rivencraft.AGE_BOOK_ID.get());
|
||||||
|
if (!age.equals(expectedAge)) return false;
|
||||||
|
resetRetirement();
|
||||||
|
boolean changed = false;
|
||||||
|
try {
|
||||||
|
if (text == null || text.length() > 21) throw new IllegalArgumentException("Invalid seed text");
|
||||||
|
long requested = Long.parseLong(text.strip());
|
||||||
|
var server = serverPlayer.level().getServer();
|
||||||
|
var level = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||||
|
net.minecraft.core.registries.Registries.DIMENSION, age));
|
||||||
|
if (level != null && !level.players().isEmpty()) throw new IllegalArgumentException("Age occupied");
|
||||||
|
RuntimeAgeBackend.reseed(server, age, requested);
|
||||||
|
changed = true;
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
|
// Invalid/occupied/missing Ages leave the existing book and seed intact.
|
||||||
|
}
|
||||||
|
player.sendSystemMessage(Component.translatable(changed ? "message.rivencraft.link_modifier.seed_changed"
|
||||||
|
: "message.rivencraft.link_modifier.seed_failed"));
|
||||||
|
broadcastChanges();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
public boolean setTitle(Player player, String title) {
|
||||||
|
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())
|
||||||
|
|| title == null || title.length() > 32) return false;
|
||||||
|
resetRetirement();
|
||||||
|
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;
|
||||||
|
if (id == RETIRE_AGE || id == CONFIRM_RETIRE_AGE) return retireAge(player, id == CONFIRM_RETIRE_AGE);
|
||||||
|
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;
|
||||||
|
resetRetirement();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
|
||||||
|
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
|
||||||
|
public abstract class RuinsAttemptMixin {
|
||||||
|
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
|
||||||
|
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
|
||||||
|
private int rivencraft$normalAttempts(int configured, Level level) {
|
||||||
|
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
|
||||||
|
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
|
||||||
|
private int rivencraft$netherAttempts(int configured, Level level) {
|
||||||
|
return RuinsCompatibility.attemptsPerChunk(level.dimension().identifier(), configured);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.util.RandomSource;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
|
||||||
|
public abstract class RuinsDimensionMixin {
|
||||||
|
@Shadow @Final private Identifier dimension;
|
||||||
|
@Shadow @Final private HashMap<String, HashSet<?>> templates;
|
||||||
|
|
||||||
|
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
|
||||||
|
CallbackInfoReturnable<Object> result) {
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
|
||||||
|
public abstract class RuinsTemplateDimensionMixin {
|
||||||
|
@Shadow @Final private String name;
|
||||||
|
|
||||||
|
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
|
||||||
|
Identifier dimension = Identifier.fromNamespaceAndPath("rivencraft", dimensionPath);
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,19 @@ public final class PortBookNetworking {
|
|||||||
|
|
||||||
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
||||||
var registrar = event.registrar("1");
|
var registrar = event.registrar("1");
|
||||||
|
registrar.playToServer(SetLinkModifierSeedPayload.TYPE, SetLinkModifierSeedPayload.STREAM_CODEC,
|
||||||
|
(payload, context) -> {
|
||||||
|
if (context.player() instanceof ServerPlayer player
|
||||||
|
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
|
||||||
|
&& menu.containerId == payload.containerId()) menu.setSeed(player, payload.age(), payload.seed());
|
||||||
|
});
|
||||||
|
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(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC);
|
||||||
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
|
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
|
||||||
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
|
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
|
||||||
(payload, context) -> {
|
(payload, context) -> {
|
||||||
@@ -117,6 +130,8 @@ public final class PortBookNetworking {
|
|||||||
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
|
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
|
||||||
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
|
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
|
||||||
long seed = player.getRandom().nextLong();
|
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;
|
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
|
||||||
PortAgeTerrain terrain;
|
PortAgeTerrain terrain;
|
||||||
List<Identifier> generationPages;
|
List<Identifier> generationPages;
|
||||||
@@ -148,10 +163,10 @@ public final class PortBookNetworking {
|
|||||||
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
|
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
|
||||||
}
|
}
|
||||||
try {
|
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()
|
var randomFeatures = pages.isEmpty()
|
||||||
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
|
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
|
||||||
var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
|
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
|
||||||
randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
|
randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
|
||||||
PortAuthoredWriting.vegetationPlan(generationPages),
|
PortAuthoredWriting.vegetationPlan(generationPages),
|
||||||
randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(),
|
randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(),
|
||||||
@@ -160,12 +175,11 @@ public final class PortBookNetworking {
|
|||||||
environment.weather(), environment.lighting(), environment.sky(),
|
environment.weather(), environment.lighting(), environment.sky(),
|
||||||
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
|
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
|
||||||
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
|
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
|
||||||
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages);
|
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages, title);
|
||||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level,
|
} catch (RuntimeException exception) {
|
||||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages));
|
Rivencraft.LOGGER.warn("Age creation failed for {}", id, exception);
|
||||||
RuntimeAgeBackend.setAgeDisplayName(server, id, title);
|
player.sendOverlayMessage(Component.literal("Age creation failed: "
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
+ java.util.Objects.toString(exception.getMessage(), "internal error")));
|
||||||
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
@@ -210,7 +224,8 @@ public final class PortBookNetworking {
|
|||||||
Vec3 departure = player.position();
|
Vec3 departure = player.position();
|
||||||
Vec3 incoming = player.getDeltaMovement();
|
Vec3 incoming = player.getDeltaMovement();
|
||||||
java.util.function.Supplier<String> travel = () -> {
|
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,
|
if (result == null) LinkingBookItem.applySuccessfulLink(player,
|
||||||
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
|
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
|
||||||
source, departure, incoming, player.getYRot(), portal);
|
source, departure, incoming, player.getYRot(), portal);
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/** Updates an already connected client's Serene whitelist for one Age. */
|
||||||
|
public record SereneAgeWhitelistPayload(Identifier dimensionId, boolean enabled) implements CustomPacketPayload {
|
||||||
|
public static final Type<SereneAgeWhitelistPayload> TYPE = new Type<>(
|
||||||
|
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "serene_age_whitelist"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, SereneAgeWhitelistPayload> STREAM_CODEC =
|
||||||
|
StreamCodec.of(
|
||||||
|
(buffer, payload) -> {
|
||||||
|
buffer.writeIdentifier(payload.dimensionId());
|
||||||
|
buffer.writeBoolean(payload.enabled());
|
||||||
|
},
|
||||||
|
buffer -> new SereneAgeWhitelistPayload(buffer.readIdentifier(), buffer.readBoolean()));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<? extends CustomPacketPayload> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
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 SetLinkModifierSeedPayload(int containerId, Identifier age, String seed) implements CustomPacketPayload {
|
||||||
|
public static final Type<SetLinkModifierSeedPayload> TYPE = new Type<>(
|
||||||
|
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_seed"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierSeedPayload> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierSeedPayload::containerId,
|
||||||
|
Identifier.STREAM_CODEC, SetLinkModifierSeedPayload::age,
|
||||||
|
ByteBufCodecs.stringUtf8(21), SetLinkModifierSeedPayload::seed,
|
||||||
|
SetLinkModifierSeedPayload::new);
|
||||||
|
@Override public Type<SetLinkModifierSeedPayload> type() { return TYPE; }
|
||||||
|
}
|
||||||
+18
@@ -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());
|
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||||
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||||
if (level == null) return;
|
if (level == null) return;
|
||||||
var landing = PortAgeTravel.resolveLanding(level);
|
var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook());
|
||||||
if (landing == null) return;
|
if (landing == null) return;
|
||||||
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||||
entity.getYRot(), entity.getXRot());
|
entity.getYRot(), entity.getXRot());
|
||||||
}
|
}
|
||||||
if (destination == null || !destination.valid()) return;
|
if (destination == null || !destination.valid()) return;
|
||||||
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
|
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());
|
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||||
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|
||||||
|| !target.getWorldBorder().isWithinBounds(feet)) return;
|
|| !target.getWorldBorder().isWithinBounds(feet)) return;
|
||||||
@@ -68,6 +71,7 @@ public final class PortalBookTravel {
|
|||||||
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
|
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
|
||||||
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
|
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
|
||||||
if (arrived == null) return;
|
if (arrived == null) return;
|
||||||
|
if (!descriptive) net.dnorberg.rivencraft.item.PortLinkDestinations.generatePlatform(target, arrival, properties);
|
||||||
if (properties.has(LinkProperties.DISARM)) {
|
if (properties.has(LinkProperties.DISARM)) {
|
||||||
LinkDisarmService.disarmAt(arrived, source, departure);
|
LinkDisarmService.disarmAt(arrived, source, departure);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package net.dnorberg.rivencraft.registry;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
|
||||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
@@ -33,6 +32,7 @@ public final class ModCreativeTabs {
|
|||||||
output.accept(Rivencraft.INK_VIAL.get());
|
output.accept(Rivencraft.INK_VIAL.get());
|
||||||
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
|
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
|
||||||
output.accept(Rivencraft.INK_MIXER_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.WRITING_DESK_ITEM.get());
|
||||||
output.accept(Rivencraft.NOTEBOOK.get());
|
output.accept(Rivencraft.NOTEBOOK.get());
|
||||||
output.accept(Rivencraft.COLLATION_FOLDER.get());
|
output.accept(Rivencraft.COLLATION_FOLDER.get());
|
||||||
@@ -44,14 +44,7 @@ public final class ModCreativeTabs {
|
|||||||
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
|
.title(Component.translatable("itemGroup.rivencraft.pages.link"))
|
||||||
.icon(PageItem::createLinkPanelPage)
|
.icon(PageItem::createLinkPanelPage)
|
||||||
.displayItems((parameters, output) -> {
|
.displayItems((parameters, output) -> {
|
||||||
int[] flags = {LinkProperties.DISARM, LinkProperties.MAINTAIN_MOMENTUM,
|
PageItem.creativeLinkPanelPages().forEach(output::accept);
|
||||||
LinkProperties.INTRA_LINKING};
|
|
||||||
for (int combination = 0; combination < 8; combination++) {
|
|
||||||
int selected = 0;
|
|
||||||
for (int bit = 0; bit < flags.length; bit++)
|
|
||||||
if ((combination & (1 << bit)) != 0) selected |= flags[bit];
|
|
||||||
output.accept(PageItem.createLinkPanelPage(selected));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.build());
|
.build());
|
||||||
for (String category : PortSymbolPages.categories()) {
|
for (String category : PortSymbolPages.categories()) {
|
||||||
|
|||||||
+14
@@ -17,6 +17,20 @@ public final class PortAuthoredTerrainSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Selection select(MinecraftServer server, List<Identifier> pages, long seed) {
|
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);
|
var explicit = PortAuthoredWriting.explicitTerrain(pages);
|
||||||
if (explicit != null) return compile(server, pages, seed, explicit);
|
if (explicit != null) return compile(server, pages, seed, explicit);
|
||||||
var preferred = PortRandomAgeSelection.terrain(seed);
|
var preferred = PortRandomAgeSelection.terrain(seed);
|
||||||
|
|||||||
+29
-14
@@ -55,6 +55,9 @@ public final class PortAuthoredWriting {
|
|||||||
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
||||||
var entry = PortSymbolPages.find(page);
|
var entry = PortSymbolPages.find(page);
|
||||||
if (entry == null) return false;
|
if (entry == null) return false;
|
||||||
|
var season = net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page);
|
||||||
|
if (season != null) return season == net.dnorberg.rivencraft.age.PortSeasonPolicies.Mode.NO_SEASONS
|
||||||
|
|| net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available();
|
||||||
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|
||||||
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
|
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
|
||||||
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
|
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
|
||||||
@@ -69,23 +72,29 @@ public final class PortAuthoredWriting {
|
|||||||
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
||||||
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|
||||||
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|
||||||
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
|
|| PortStructurePages.supports(page) || PortCarverPages.supports(page)
|
||||||
|
|| net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(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) {
|
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()
|
return net.dnorberg.rivencraft.age.PortRandomAgeSelection.terrains().stream()
|
||||||
.anyMatch(terrain -> supportsOnTerrain(pages, terrain, 0));
|
.anyMatch(terrain -> supportsOnTerrain(active, terrain, 0));
|
||||||
try {
|
try { return supportsExplicit(PortMaterialCompletion.complete(active, 0)); }
|
||||||
return supportsExplicit(PortMaterialCompletion.complete(pages, 0));
|
catch (IllegalArgumentException exception) { return false; }
|
||||||
} catch (IllegalArgumentException exception) { return false; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean supportsOnTerrain(List<Identifier> pages, PortAgeTerrain terrain, long seed) {
|
static boolean supportsOnTerrain(List<Identifier> pages, PortAgeTerrain terrain, long seed) {
|
||||||
try {
|
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
|
||||||
return supportsExplicit(PortMaterialCompletion.complete(withTerrain(pages, terrain), seed));
|
var recovered = PortWritingRecovery.recover(pages);
|
||||||
} catch (IllegalArgumentException exception) { return false; }
|
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) {
|
static List<Identifier> withTerrain(List<Identifier> pages, PortAgeTerrain terrain) {
|
||||||
@@ -104,7 +113,6 @@ public final class PortAuthoredWriting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean supportsExplicit(List<Identifier> pages) {
|
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;
|
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
|
||||||
boolean terrain = false;
|
boolean terrain = false;
|
||||||
int biomes = 0;
|
int biomes = 0;
|
||||||
@@ -117,7 +125,7 @@ public final class PortAuthoredWriting {
|
|||||||
int skylands = 0;
|
int skylands = 0;
|
||||||
int denseOres = 0;
|
int denseOres = 0;
|
||||||
int starFissures = 0;
|
int starFissures = 0;
|
||||||
int weather = 0;
|
int weather = 0, season = 0;
|
||||||
int lighting = 0;
|
int lighting = 0;
|
||||||
int sky = 0;
|
int sky = 0;
|
||||||
Map<Identifier, Integer> terrainFeatures = new HashMap<>();
|
Map<Identifier, Integer> terrainFeatures = new HashMap<>();
|
||||||
@@ -136,6 +144,8 @@ public final class PortAuthoredWriting {
|
|||||||
// Every Accelerated page adds random-tick attempts; it is not a controller.
|
// Every Accelerated page adds random-tick attempts; it is not a controller.
|
||||||
} else if (isTerrain(page) && !terrain) {
|
} else if (isTerrain(page) && !terrain) {
|
||||||
terrain = true;
|
terrain = true;
|
||||||
|
} else if (net.dnorberg.rivencraft.age.PortSeasonPolicies.forPage(page) != null && season++ == 0) {
|
||||||
|
// Per-Age optional season policy, independent of ordinary weather.
|
||||||
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
|
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
|
||||||
// Sky presentation, independent of terrain and illumination.
|
// Sky presentation, independent of terrain and illumination.
|
||||||
} else if (PortLighting.forPage(page) != null && lighting++ == 0) {
|
} else if (PortLighting.forPage(page) != null && lighting++ == 0) {
|
||||||
@@ -174,6 +184,8 @@ public final class PortAuthoredWriting {
|
|||||||
} else if (PortStructurePages.supports(page)
|
} else if (PortStructurePages.supports(page)
|
||||||
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
|
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
|
||||||
structures.merge(page, 1, Integer::sum);
|
structures.merge(page, 1, Integer::sum);
|
||||||
|
} else if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page)) {
|
||||||
|
// Each exact template page is retained, including copies beyond the attempt cap.
|
||||||
} else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
|
} else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
|
||||||
vegetation++;
|
vegetation++;
|
||||||
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
|
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
|
||||||
@@ -277,8 +289,10 @@ public final class PortAuthoredWriting {
|
|||||||
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
|
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
|
||||||
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
|
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
|
||||||
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
|
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
|
||||||
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null
|
List<Identifier> ruins = pages.stream().filter(net.dnorberg.rivencraft.compat.RuinsTemplatePages::contains).toList();
|
||||||
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers);
|
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty()
|
||||||
|
&& carvers.isEmpty() && ruins.isEmpty() ? null
|
||||||
|
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers, false, ruins);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
|
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
|
||||||
@@ -369,6 +383,7 @@ public final class PortAuthoredWriting {
|
|||||||
PortStructurePages.validateRegistry(pages, registries);
|
PortStructurePages.validateRegistry(pages, registries);
|
||||||
PortCarverPages.validateRegistry(pages, registries);
|
PortCarverPages.validateRegistry(pages, registries);
|
||||||
PortBiomePlan.Distribution distribution = null;
|
PortBiomePlan.Distribution distribution = null;
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||||
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
|
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
|
||||||
boolean hasSurfaceBiome = false;
|
boolean hasSurfaceBiome = false;
|
||||||
boolean hasOreBiome = false;
|
boolean hasOreBiome = false;
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ public final class PortMaterialCompletion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isFeature(Identifier page) {
|
private static boolean isFeature(Identifier page) {
|
||||||
return PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page)
|
return net.dnorberg.rivencraft.compat.RuinsTemplatePages.contains(page)
|
||||||
|
|| PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page)
|
||||||
|| PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page)
|
|| PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page)
|
||||||
|| PortCarverPages.supports(page) || page.equals(id("feature/rocks"))
|
|| PortCarverPages.supports(page) || page.equals(id("feature/rocks"))
|
||||||
|| page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
|| page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import net.minecraft.world.level.block.EntityBlock;
|
|||||||
public final class PortRegistryPages {
|
public final class PortRegistryPages {
|
||||||
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
|
private static final TagKey<net.minecraft.world.level.biome.Biome> CAVE_BIOMES = TagKey.create(
|
||||||
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes"));
|
Registries.BIOME, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biomes"));
|
||||||
private static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
|
public static final Identifier DEFERRED_PLACEHOLDER = Identifier.fromNamespaceAndPath(
|
||||||
"terrablender", "deferred_placeholder");
|
"terrablender", "deferred_placeholder");
|
||||||
|
|
||||||
private PortRegistryPages() {}
|
private PortRegistryPages() {}
|
||||||
@@ -65,6 +65,7 @@ public final class PortRegistryPages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
|
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
|
||||||
|
net.dnorberg.rivencraft.compat.TerraBlenderBiomes.nether(registries);
|
||||||
return registries.lookupOrThrow(Registries.BIOME).listElements()
|
return registries.lookupOrThrow(Registries.BIOME).listElements()
|
||||||
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
|
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
|
||||||
.filter(holder -> isCaveBiome(holder) == caves)
|
.filter(holder -> isCaveBiome(holder) == caves)
|
||||||
@@ -98,10 +99,11 @@ public final class PortRegistryPages {
|
|||||||
public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) {
|
public static boolean isCaveBiome(net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> holder) {
|
||||||
String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse("");
|
String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse("");
|
||||||
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|
||||||
|
|| net.dnorberg.rivencraft.compat.TerraBlenderBiomes.isNether(holder)
|
||||||
|| path.equals("deep_dark") || path.endsWith("_caves");
|
|| 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
|
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|
||||||
|| block == Rivencraft.CLOUD_PLATFORM.get()
|
|| block == Rivencraft.CLOUD_PLATFORM.get()
|
||||||
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
|
|| 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. */
|
/** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */
|
||||||
public final class PortSymbolPages {
|
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();
|
private static final Map<Identifier, Entry> ENTRIES = load();
|
||||||
|
|
||||||
@@ -25,7 +31,9 @@ public final class PortSymbolPages {
|
|||||||
|
|
||||||
public static Entry find(Identifier id) {
|
public static Entry find(Identifier id) {
|
||||||
Entry bundled = ENTRIES.get(id);
|
Entry bundled = ENTRIES.get(id);
|
||||||
return bundled != null ? bundled : PortRegistryPages.find(id);
|
if (bundled != null) return bundled;
|
||||||
|
var ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.find(id);
|
||||||
|
return ruin != null ? ruin : PortRegistryPages.find(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<String> categories() {
|
public static List<String> categories() {
|
||||||
@@ -34,6 +42,7 @@ public final class PortSymbolPages {
|
|||||||
categories.add("rivencraft:cave_biome");
|
categories.add("rivencraft:cave_biome");
|
||||||
categories.add("rivencraft:block");
|
categories.add("rivencraft:block");
|
||||||
categories.add("rivencraft:fluid");
|
categories.add("rivencraft:fluid");
|
||||||
|
if (!net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().isEmpty()) categories.add("rivencraft:ruins");
|
||||||
return List.copyOf(categories);
|
return List.copyOf(categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +61,9 @@ public final class PortSymbolPages {
|
|||||||
.filter(entry -> !category.equals("rivencraft:biome")
|
.filter(entry -> !category.equals("rivencraft:biome")
|
||||||
|| !entry.id().getPath().startsWith("biome/"))
|
|| !entry.id().getPath().startsWith("biome/"))
|
||||||
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id())));
|
.forEach(entry -> pages.add(PageItem.createSymbolPage(entry.id())));
|
||||||
|
if (category.equals("rivencraft:ruins")) net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates()
|
||||||
|
.forEach(name -> pages.add(PageItem.createSymbolPage(
|
||||||
|
net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name))));
|
||||||
if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
|
if (category.equals("rivencraft:biome")) pages.addAll(PortRegistryPages.biomePages(registries, false));
|
||||||
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
|
if (category.equals("rivencraft:cave_biome")) pages.addAll(PortRegistryPages.biomePages(registries, true));
|
||||||
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
|
if (category.equals("rivencraft:block")) pages.addAll(PortRegistryPages.materialPages(false));
|
||||||
@@ -60,6 +72,10 @@ public final class PortSymbolPages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ItemStack icon(String category) {
|
public static ItemStack icon(String category) {
|
||||||
|
if (category.equals("rivencraft:ruins")) return net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates()
|
||||||
|
.stream().findFirst().map(name -> PageItem.createSymbolPage(
|
||||||
|
net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId(name)))
|
||||||
|
.orElseGet(PageItem::createLinkPanelPage);
|
||||||
if (category.equals("rivencraft:cave_biome"))
|
if (category.equals("rivencraft:cave_biome"))
|
||||||
return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
|
return PageItem.createSymbolPage(PortRegistryPages.biomePageId(
|
||||||
Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
|
Identifier.fromNamespaceAndPath("minecraft", "lush_caves")));
|
||||||
@@ -73,6 +89,8 @@ public final class PortSymbolPages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String fallbackName(Identifier id) {
|
public static String fallbackName(Identifier id) {
|
||||||
|
if (net.dnorberg.rivencraft.compat.RuinsTemplatePages.isSymbol(id))
|
||||||
|
return net.dnorberg.rivencraft.compat.RuinsTemplatePages.fallbackName(id);
|
||||||
if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
|
if (id.getPath().startsWith("biome/") || id.getPath().startsWith("block/"))
|
||||||
return PortRegistryPages.fallbackName(id);
|
return PortRegistryPages.fallbackName(id);
|
||||||
String path = id.getPath();
|
String path = id.getPath();
|
||||||
@@ -100,7 +118,9 @@ public final class PortSymbolPages {
|
|||||||
category = "rivencraft:biome_generation";
|
category = "rivencraft:biome_generation";
|
||||||
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
|
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
|
||||||
definition.get("description").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);
|
return Collections.unmodifiableMap(entries);
|
||||||
} catch (java.io.IOException exception) {
|
} 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"}}
|
||||||
@@ -3,7 +3,22 @@
|
|||||||
"minVersion": "0.8",
|
"minVersion": "0.8",
|
||||||
"package": "net.dnorberg.rivencraft.mixin",
|
"package": "net.dnorberg.rivencraft.mixin",
|
||||||
"compatibilityLevel": "JAVA_25",
|
"compatibilityLevel": "JAVA_25",
|
||||||
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin", "AgeAmbientLightMixin", "AgeColorAttributesMixin", "AgeDayTimelineMixin"],
|
"mixins": [
|
||||||
"client": ["StaticSkyStateMixin", "StaticSkyRendererMixin"],
|
"StructurePlacementAccessor",
|
||||||
"injectors": {"defaultRequire": 1}
|
"AgeStructureBiomeMixin",
|
||||||
|
"AgeCarversMixin",
|
||||||
|
"AgeAmbientLightMixin",
|
||||||
|
"AgeColorAttributesMixin",
|
||||||
|
"AgeDayTimelineMixin",
|
||||||
|
"RuinsDimensionMixin",
|
||||||
|
"RuinsTemplateDimensionMixin",
|
||||||
|
"RuinsAttemptMixin"
|
||||||
|
],
|
||||||
|
"client": [
|
||||||
|
"StaticSkyStateMixin",
|
||||||
|
"StaticSkyRendererMixin"
|
||||||
|
],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user