Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32c52e635f | ||
|
|
d2904eaa8d | ||
|
|
35980338e1 |
No files matched your search
@@ -17,10 +17,16 @@ supports authored terrain/biomes, structure and vegetation pages, independent
|
||||
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
|
||||
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
|
||||
feature completion, and random structure selection. Nether/End noise-biome
|
||||
materials now follow their native rules. The beta does not yet provide full
|
||||
materials now follow their native rules. Books now have Legacy-style spreads,
|
||||
animated fallback panels and saved destination labels; lectern stands, open-book
|
||||
drops, page collections, stations and basic progression are also ported. The latest
|
||||
beta adds Age libraries, crystal portals with animated fallback surfaces, saved
|
||||
instability scoring/provider selection, potion providers and four-color decay.
|
||||
Full writing recovery, live material profiling and other hazards remain pending.
|
||||
The beta does not yet provide full
|
||||
1.21.1 gameplay or optional-integration parity.
|
||||
See the [26.3 beta guide](ports/mc263/README.md) and
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.106-beta.1.md).
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md).
|
||||
Keep the builds and their saves separate, and back up worlds before updating.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -2021,3 +2021,570 @@ passed again after the 26.3 registry slice.
|
||||
On 2026-11-01, the default 1.21.1 GameTest server was rerun and all 93
|
||||
required tests passed, including the desk test. Issue #97 was then closed;
|
||||
remaining 26.3 parity work stays under #98–#104.
|
||||
|
||||
## Progression advancement port
|
||||
|
||||
`1.0.1.107-beta.1` reuses seven root progression JSON files and existing
|
||||
translations. The Open Portal node remains excluded because its Receptacle icon
|
||||
and action are not yet ported. Added server-owned post-commit hooks for Mixer
|
||||
normal/shift output, Desk normal/shift copies, Binder assembly and successful
|
||||
handheld travel whose destination is a saved runtime Age. Inventory and root
|
||||
criteria remain vanilla. Existing saves retain vanilla player advancement data;
|
||||
no action history is reconstructed and no protocol/schema change is required.
|
||||
|
||||
The build and disposable-server fixtures passed loaded data, preview/empty Mixer
|
||||
rejection, both Panel pickup paths, actual Desk copy and Binder assembly awards,
|
||||
and registered-Age entry eligibility. NeoForge FakePlayer uses a no-op advancement
|
||||
tracker; the fixture player overrides it with vanilla PlayerAdvancements rather
|
||||
than changing production awards to accommodate a test double. No client joined;
|
||||
GUI tree/toasts, inventory triggers, actual travel and reconnect persistence are
|
||||
the user validation. The test server was stopped and its world removed.
|
||||
|
||||
## Recipe discovery and advancement background
|
||||
|
||||
`1.0.1.109-beta.1` uses the existing Crystal Block texture for the root
|
||||
advancement background via the modern asset ID. The .108 stone-path correction
|
||||
passed the player's in-game test. Added adapted 1.21.1 discovery advancements
|
||||
for Ink Vial, Ink Mixer, Book Binder and Crystal Block, retaining all original
|
||||
materials, OR requirements and recipe rewards. The 26.3 recipe-unlocked trigger
|
||||
requires `conditions.recipes`, a list. Root 1.21.1 data remains unchanged.
|
||||
|
||||
The build and disposable-server `recipe_discovery_probe` passed loaded
|
||||
recipe/advancement availability, wrong-material negatives, actual matching-material
|
||||
inventory triggers, per-player isolation and direct recipe grants for all six
|
||||
crafting unlocks (including Notebook and Writing Desk). No client joined this
|
||||
fixture. The test server was stopped and its world removed; crystal background
|
||||
and client recipe-book presentation remain manual checks.
|
||||
|
||||
## Lectern book stands
|
||||
|
||||
`1.0.1.110-beta.1` adapts the root stand block/entity/conversion and final
|
||||
renderer transforms to 26.3 extraction/submission. It uses the shared lectern
|
||||
blockstate and lectern-drop loot table; no new art or stand item. Storage sync
|
||||
and persistence use ValueInput/ValueOutput. preRemoveSideEffects receives the
|
||||
old state and handles one stored-item drop; emptying before conversion back to
|
||||
vanilla prevents duplicate recovery.
|
||||
|
||||
The existing book screens accept a stand source. A separate validated packet
|
||||
checks dimension, eight-block distance, loaded chunk, stand identity and exact
|
||||
book components before travel. Descriptive travel now shares the same helper
|
||||
for handheld/stand routes; only handheld books are detached/dropped. Linking
|
||||
travel uses the existing item service. Stand travel retains effects, Return Link
|
||||
configuration and Age-entry awards. Live views and exploration remain unported;
|
||||
unbound books require removal for handheld binding in this slice.
|
||||
|
||||
Build and isolated server checks passed all facing/hand/book/page combinations,
|
||||
occupied/unrelated-item rejection, ownership on retrieval/break, stale/distant/
|
||||
wrong-dimension requests, serialization and full restart persistence. The test
|
||||
server was stopped and world removed. GUI/rendering/actual stand travel and
|
||||
modifier effects require the player's in-game check. Root 1.21.1 is unchanged.
|
||||
|
||||
## Descriptive Book authored-page GUI
|
||||
|
||||
`1.0.1.111-beta.1` adapts the root Legacy cover/front/full-right-page composition
|
||||
and page-index rules to GuiGraphicsExtractor. Glyphs use the existing glyph-only
|
||||
special item model in a later stratum at 128×128, avoiding the dynamic-blit and
|
||||
nested item-outline issues previously fixed in the Binder. Navigation uses SDL
|
||||
mouse constants and current KeyEvent helpers, corner hotspots and page-turn sound.
|
||||
The destination packet remains confined to the front spread; stand source
|
||||
validation is unchanged. Naming widgets stay hidden off the front spread even
|
||||
after resize. Screens do not pause the world.
|
||||
|
||||
Added the root `book_author` component with persistent string and network codec.
|
||||
Binder and handheld descriptive binding assign the authenticated player's profile
|
||||
name; the client never supplies author text. Existing books without it use the
|
||||
unknown-author translation. Existing book_symbols retain written order, duplicates
|
||||
and omitted-controller choices exactly; no Age data or generation behavior changes.
|
||||
Random-Age glyph descriptions/live destination rendering remain unported.
|
||||
|
||||
Build and disposable server checks passed real assembly, authorship, first/last
|
||||
symbol mapping, repeated-symbol and legacy-empty fallbacks, navigation bounds and
|
||||
full-restart book author/page persistence. Shared full-page texture was verified
|
||||
in the JAR. The temporary server was stopped and world removed. Client visuals,
|
||||
turning/input/sound, lectern opening and panel-click travel remain manual checks.
|
||||
|
||||
## Animated link-panel fallback
|
||||
|
||||
`1.0.1.112-beta.1` reuses the root LegacyDestinationPreviewRenderer spiral/noise
|
||||
algorithm in a shared 26.3 GUI extractor helper, removing placeholder labels from
|
||||
both book panels. Shared dimensions/hit-testing retain the existing 132×83 panel
|
||||
and validated hand/stand travel routes. GUI strata enforce cover -> animation ->
|
||||
Legacy frame ordering. Descriptive symbol pages do not expose an active panel.
|
||||
No world/network changes, dependencies, remote audio or chunk loads are introduced.
|
||||
Default teal is retained until destination metadata/tint is ported; this is a
|
||||
fallback, not live destination rendering. Build passed; client visual/click,
|
||||
lectern, scale and page-turn checks remain manual. No server run was required.
|
||||
|
||||
## Linking Book spreads and artwork consolidation
|
||||
|
||||
`1.0.1.113-beta.1` adapts LinkingBookScreen's two spreads, counter, wrapped
|
||||
left-page details, Return Link title suppression and input/page-turn behavior.
|
||||
The front retains validated hand/stand travel and the animated fallback; the
|
||||
final spread has no active destination panel. Vanilla dimension names translate;
|
||||
Age display-name metadata remains pending and other dimensions fall back to IDs.
|
||||
Block coordinates use floor-based BlockPos conversion, including negative values.
|
||||
|
||||
Both screens use PortLegacyBookArtwork adapted directly from the root helper,
|
||||
with shared text/counter/corner routines. The descriptive screen retains its
|
||||
accepted glyph/author behavior. No networking, persistence or server changes.
|
||||
Build and diff checks passed; GUI presentation, controls, long names, lecterns
|
||||
and descriptive-page regression are manual checks. No server was started.
|
||||
|
||||
## Saved destination names
|
||||
|
||||
`1.0.1.114-beta.1` adds optional server-owned Age display metadata and the root
|
||||
persistent/networked `link_destination_name` item component. Successful book-driven
|
||||
Age creation saves the entered title; Linking Book binding snapshots its source
|
||||
Age's title, and automatic Return Links snapshot the departure label. Item tooltip
|
||||
and GUI use the same component/vanilla/ID fallback. Negative tooltip coordinates
|
||||
now floor correctly rather than truncating toward zero.
|
||||
|
||||
The index name map defaults empty for old saves and does not participate in seed,
|
||||
folder or dimension identity selection. Names are visible only for active IDs;
|
||||
retirement retains metadata for identity/rollback without exposing it as an active
|
||||
Age. No catalog packet change, remote chunk load, automatic old-label migration
|
||||
or terrain change is introduced.
|
||||
|
||||
Build and isolated server checks passed actual Binder name assignment, metadata
|
||||
with case/punctuation, named Linking/automatic Return Link components, legacy
|
||||
vanilla fallback, unload/reload and full-restart index/book persistence. Temporary
|
||||
config restored; server stopped and world removed. Client GUI/tooltip/lectern
|
||||
presentation remains manual. New names require a newly created Age and newly
|
||||
bound book; old Ages retain their existing fallback label.
|
||||
|
||||
## Ordinary dropped books
|
||||
|
||||
`1.0.1.115-beta.1` implements the 26.3 half of #114. A server-only queued join
|
||||
conversion creates the custom entity before discarding an ordinary book drop,
|
||||
preserving toss state and item data through native restoreFrom. It gives the
|
||||
replacement a new UUID (the original is still registered), adopts open-book
|
||||
lifetime/decay rules and skips already-custom entities. Canceled replacements
|
||||
preserve the ordinary source. No force-loading, world scanning or per-tick retry.
|
||||
Loaded ordinary book entities are also eligible.
|
||||
|
||||
Build and isolated source/state checks passed, including cancellation recovery.
|
||||
The no-player fixture required one loaded entity chunk for inspection. The user
|
||||
requested to perform further checks; no additional automated restart test was run.
|
||||
Server stopped and temporary world removed. Manual checks: Q/inventory toss,
|
||||
death/Disarm, broken stand, full-inventory delivery/retrieval, open-book visual
|
||||
orientation, motion, ownership/pickup, component preservation and restart. Root
|
||||
1.21.1 remains unchanged and #114 stays open until both tracks are complete.
|
||||
|
||||
## Handheld Link Panel resource policy
|
||||
|
||||
`1.0.1.116-beta.1` adapts root WritingInkService and the configurable
|
||||
`writing.link_panel_ink_cost` (2 default, 1–16 allowed). The item delegates the
|
||||
server transaction to the service; Writing Desk cost reads also use its shared
|
||||
getter. Mixer cost remains independently fixed at one ink per Link Panel.
|
||||
Unsynchronized LOCAL config is not consulted for client affordability prediction;
|
||||
the server validates both inputs and constructs outputs before mutation.
|
||||
|
||||
Build passed. No additional server fixtures were added; the user offered manual
|
||||
validation. Check default/custom cost, insufficient ink, final bottle, both hands,
|
||||
stacked/full-inventory inputs and dedicated-server cost differing from the client.
|
||||
Root 1.21.1 is unchanged; no commit/push/publication was performed.
|
||||
|
||||
## Library discovery port
|
||||
|
||||
`1.0.1.117-beta.1` ports MystcraftLibraryFeature from the 1.21.1 implementation,
|
||||
using the shared schematic/loot table and final five-stand orientation. Natural
|
||||
placement runs after all vanilla/authored decoration via the Age feature wrapper,
|
||||
with the same bounded footprint, candidate RNG/spacing, slope/footing and obstruction
|
||||
rules. The wrapper now applies to every runtime Age; Cave terrain disables libraries
|
||||
as in root. Existing chunks are not rewritten and vanilla Overworld generation is
|
||||
not modified.
|
||||
|
||||
A separate saved writing map holds the original symbol list for AGE_ONLY loot,
|
||||
avoiding expansion of the already-large backend index record. Creation stores it
|
||||
before constructing/publishing the level; the wrapper captures an immutable list
|
||||
and serializes library flags/list in its codec. Reload reconstructs the same pool.
|
||||
LEGACY is the default; AGE_ONLY uses known distinct written IDs. Full random-page
|
||||
policy integration remains pending. The snapshot list does not alter generation
|
||||
traits or the book's original contents.
|
||||
|
||||
Added operator placement, session-only spacing override, cheap candidate reporting,
|
||||
current-chunk probe/loot inspection and a locator restricted to loaded nearby chunks.
|
||||
The locator checks a clear, dry, footed aisle; it never performs a mass remote
|
||||
chunk-generation search. Explicit test placement checks empty space and entities.
|
||||
|
||||
Build passed; shared schematic/Sealed Notebook loot packaging verified. Dedicated
|
||||
server startup passed. The user offered manual testing, so generation/loot checks
|
||||
were handed over before running them. Temporary server stopped and world removed.
|
||||
No new test fixture, commit or publication. This is a substantial discovery-system
|
||||
slice; instability/recovery, crystal portals and remaining optional integrations
|
||||
remain the other large parity gaps.
|
||||
|
||||
|
||||
## .118 crystal portal gameplay port
|
||||
|
||||
The player confirmed .117 library generation and retracted the missing-library report.
|
||||
Ported the 1.21.1 Book Receptacle, bounded crystal flood/tension network, portal
|
||||
surface ownership and orientation, activation protection checks and rollback, removal
|
||||
cleanup, queued transport and cooldown. Reused existing crystal, portal and receptacle
|
||||
assets, recipe and progression node. The book renderer retains all final six mounting
|
||||
orientations and explicitly clears its extracted presence when the book is removed.
|
||||
|
||||
Player traversal uses existing Linking/Descriptive Book paths, with portal audio
|
||||
precedence. Descriptive arrival resolution is now shared; no separate portal offset
|
||||
or generated exit position is introduced. Existing Intra-Linking, Disarm, momentum,
|
||||
client Age acknowledgement, Return Link configuration and progression remain in force.
|
||||
Non-player traversal uses modern TeleportTransition, collision/permission checks,
|
||||
and the same landing resolver. A shared Disarm helper supports container/horse/mob
|
||||
inventory and equipment without requiring the old protected-method mixin.
|
||||
|
||||
Book and owner pointers use modern ValueInput/ValueOutput and update packets. Startup
|
||||
waits for loaded frame chunks; periodic bounded reconciliation retains root's cleanup
|
||||
of disconnected cells, staggered every 200 ticks per holder. The surface remains the
|
||||
animated facing-only fallback, tinted to the saved destination sky. Live portal views
|
||||
are still optional integration work, not advertised as ported.
|
||||
|
||||
Build/resources and dedicated-server startup passed. A command-seeded 3x3 opening
|
||||
verified exact-coordinate entity transport, persistence and traversal after restart,
|
||||
and support-removal teardown/open-book recovery. Player interaction and client visuals
|
||||
are handed over for manual testing. Temporary server stopped/world deleted; no new
|
||||
permanent GameTest, commit, push or publication. Version: 1.0.1.118-beta.1.
|
||||
|
||||
|
||||
## .119 instability foundation and status-effect gameplay
|
||||
|
||||
The player passed .118. Ported the complete five-deck catalog and deterministic
|
||||
provider selection from 1.21.1. Separate modern codec SavedData freezes new Ages'
|
||||
scoring contributions, seed, selected providers/levels and an enabled flag. Older
|
||||
Ages remain neutral. Creation-time scoring currently covers Bright Lighting,
|
||||
Accelerated, all three Corrupted sizes, ignored Cave weather/celestial modifiers,
|
||||
excess/biome-forced structures and additional suns. Original page indices are retained.
|
||||
Corrupted Pages now bind and contribute their Legacy costs; existing redacted rendering
|
||||
is reused. The new Abandoned Camps structure follows the same modern-structure costs.
|
||||
|
||||
Adapted the bounded occupied-Age scheduler and the 18 local/global/general/hostile
|
||||
potion providers, preserving 1.21.1 defaults, effect durations, level-based amplifiers,
|
||||
sky exposure and Survival/Adventure targeting. The owned-instance lease system revokes
|
||||
only Rivencraft's effects on exit/disable/shutdown, preserving other sources. Runtime
|
||||
uses already-loaded chunks and shared per-Age work budgets; failed providers receive
|
||||
a temporary quarantine. The config retains server ownership and can disable providers
|
||||
without rerolling stored plans.
|
||||
|
||||
OP-only `/rivencraft_port instability` supplies status/breakdown, persistent enabled
|
||||
control, session-only score override/reset and one-provider pulses. Pulse output tracks
|
||||
successful application separately from attempted work so event-cancelled effects are
|
||||
not reported as applied. No scoring data is sent to player book/Binder UI. Unimplemented
|
||||
hazards stay selected with an explicit status; they are never replaced or rerolled.
|
||||
|
||||
Build/startup passed. Temporary dedicated-server checks verified neutral creation,
|
||||
provider selection at a test score, Poison application/duration, immediate owned-effect
|
||||
revocation, unrelated stronger-effect retention, local-versus-global roof behavior,
|
||||
enabled-state persistence and test-score reset across restart. Server stopped/world
|
||||
deleted; no new permanent GameTests, commit, push or publication. Manual checks cover
|
||||
authored Corrupted/Accelerated/lighting score contributions, automatic player-occupied
|
||||
scheduling, Survival/Creative policy, exit cleanup and hostile buffs. Version .119.
|
||||
|
||||
Still pending: full writing grammar recovery/penalties, live block abundance, decay,
|
||||
lightning/explosions, meteors, burning and erosion, plus optional integration parity.
|
||||
|
||||
|
||||
## .120 colored decay gameplay
|
||||
|
||||
The player passed .119. Ported the 1.21.1 DecayBlock, four provider handlers and bounded
|
||||
frontier persistence. Reused all four active 16x16 block textures/models and the
|
||||
protected-block tag. Modern adaptation uses BreakBlockEvent, Value/codec SavedData,
|
||||
ChunkPos accessors, loaded-chunk lookup and server-side magic damage for White.
|
||||
No decay item/recipe/drop is added.
|
||||
|
||||
Provider metadata now distinguishes world-mutating handlers, so world_mutation gates
|
||||
decay independently of potion policy. Defaults remain enabled/four neighbor attempts,
|
||||
one successful conversion per call under the shared scheduler budget. Conversion
|
||||
hardness/air/color odds, infrastructure/BE/fluid/unbreakable protection, positioned
|
||||
fake-player claim events and placement rollback are retained from 1.21.1.
|
||||
|
||||
The instability record adds an optional first resolved spawn, preserving old records'
|
||||
scores/plans and enable flags. Shared Descriptive Book landing resolution records it
|
||||
once; decay protects the same +/-4 horizontal and -2..+4 vertical region as root.
|
||||
Older Ages record this on the next book/portal arrival; OP status exposes the point.
|
||||
Frontiers and attempt counters live in each Age, capped at 1,024 positions per color.
|
||||
|
||||
Added OP-only `/rivencraft_port instability decay <color> [1..32 attempts]`, reporting
|
||||
actual changes and nearby tracked positions. Existing generic pulses now report block
|
||||
as well as entity actions. Invalid input/out-of-Age commands use proper command errors.
|
||||
White block contact damage retains root's decay/world_mutation policy; disabling an
|
||||
Age scheduler stops spread but does not remove existing decay or alter its block form.
|
||||
|
||||
Build/startup passed. All four colors seeded/spread through bounded command pulses.
|
||||
A full restart retained each frontier and advanced attempts from 32 to 40 while Red
|
||||
continued growing. Bedrock and water generated empty frontiers, and the saved Age
|
||||
disable blocked further provider changes. Textures verified 16x16. A stationary NoAI
|
||||
sheep did not exercise the stepping hook, so White stepping damage remains a player
|
||||
manual check, alongside visuals, arrival protection, normal scheduler/claim integration.
|
||||
Server stopped/test world removed; no permanent tests, commit, push or publication.
|
||||
Version: 1.0.1.120-beta.1. Other hazards, full writing recovery and live abundance
|
||||
profiling remain pending.
|
||||
|
||||
|
||||
## .120 release sign-off
|
||||
|
||||
The user passed .120 and authorized commit, push and publication of the accumulated
|
||||
.116–.120 work. Both target builds passed. Consolidated release notes distinguish
|
||||
implemented libraries, fallback crystal portals and partial instability/decay from
|
||||
remaining gameplay and optional-integration parity. Tracking issues #98/#102/#104
|
||||
remain open; #114 still requires the matching 1.21.1 fix.
|
||||
|
||||
|
||||
## .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.
|
||||
+868
-4
@@ -2,15 +2,18 @@
|
||||
|
||||
This is an **experimental beta port** for issues #97–#104, not gameplay parity
|
||||
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
|
||||
their seeds. Books, the Book Binder, Ink Mixer, Writing Desk and page collections
|
||||
their seeds. Books, lectern book stands, the Book Binder, Ink Mixer, Writing Desk and page collections
|
||||
are ported, and the Binder can
|
||||
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
|
||||
biome-distribution pages, selected material/vegetation/structure features, and
|
||||
independent Age weather and lighting, multiple authored or seeded celestial
|
||||
bodies and eclipses, biome/material completion, and random features/structures.
|
||||
Instability, crystal portals, remaining discovery/stations, writing recovery and optional
|
||||
integration parity remain unfinished. The latest published beta is
|
||||
[1.0.1.106-beta.1](RELEASE_NOTES_1.0.1.106-beta.1.md).
|
||||
Crystal portals and libraries are ported. Instability scoring/provider selection,
|
||||
status effects, colored decay, lightning, explosions, meteors, burning and erosion,
|
||||
ordered writing recovery, live material profiling and all six Link Modifier controls are now ported.
|
||||
Link Modifier seed editing/retirement, remaining discovery/configuration and optional
|
||||
integrations remain unfinished. The latest published beta is
|
||||
[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md).
|
||||
Use a test world and keep a backup; do not
|
||||
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
|
||||
|
||||
@@ -1646,6 +1649,34 @@ Recipe/advancement data loaded; shared models and atlases were verified in
|
||||
the JAR. No client joined these fixtures: visuals, mouse and live catalog
|
||||
updates remain the in-game check. The server was stopped and world removed.
|
||||
|
||||
### Progression advancements
|
||||
|
||||
The local `1.0.1.107-beta.1` slice reuses the 1.21.1 progression tree and
|
||||
translations. The root, Page and Linking Book inventory milestones load along
|
||||
with action milestones for taking a Link Panel from the Ink Mixer, copying a
|
||||
Page at the Writing Desk, binding an Age in the Book Binder and successfully
|
||||
travelling by book into a registered Age. Both normal and shift output pickup
|
||||
paths award after the server consumes the inputs. Opening a menu, viewing a
|
||||
preview or rejected travel does not earn an action milestone. Awards remain
|
||||
per-player and use vanilla advancement persistence. No retroactive action
|
||||
awards are inferred from existing books. The portal advancement is deferred
|
||||
until the Receptacle/portal feature and its icon item are ported.
|
||||
|
||||
The build and disposable-server checks passed advancement decoding, empty and
|
||||
preview Mixer negative checks, normal/shift Panel pickup, actual Desk copying,
|
||||
Binder assembly and registered-Age entry eligibility. Fixtures use vanilla
|
||||
PlayerAdvancements because NeoForge FakePlayer deliberately discards awards.
|
||||
The temporary server was stopped and its world deleted. Client tree layout,
|
||||
toasts, inventory milestones and actual book-travel awards remain manual checks.
|
||||
|
||||
For the in-game check, open Advancements and find the Rivencraft tree. Obtain a
|
||||
Page and Linking Book; then take a Panel from the Mixer, copy a Page at the Desk,
|
||||
bind a Descriptive Book and enter its Age. Check that previews alone do not award
|
||||
progress, then reconnect/restart to confirm earned progress remains. If these
|
||||
milestones were already earned, revoke the Rivencraft tree with the vanilla
|
||||
operator advancement command before retesting. Include shift-click output pickup.
|
||||
Nothing has been committed, pushed or published for this slice.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
@@ -1667,3 +1698,836 @@ The dedicated development server uses `build/run-server`, with its own world
|
||||
and properties. Set a nonconflicting `server-port` there before running if
|
||||
another Minecraft server is active. The prototype restores all indexed Ages
|
||||
during the Overworld load, before initial chunk preparation completes.
|
||||
|
||||
The `1.0.1.108-beta.1` visual correction adapts the advancement root background
|
||||
to 26.3's ClientAsset resource identifier (`minecraft:gui/advancements/backgrounds/stone`).
|
||||
The older full texture path decoded but resolved to a missing texture. The shared
|
||||
1.21.1 root keeps its original path. Check the stone background in the client.
|
||||
|
||||
### Crystal advancement background and recipe discovery
|
||||
|
||||
The local `1.0.1.109-beta.1` slice switches the progression background to the
|
||||
existing blue Crystal Block texture (`rivencraft:block/crystal_block`) using
|
||||
the 26.3 ClientAsset identifier format. No new artwork is generated.
|
||||
|
||||
The Ink Vial, Ink Mixer, Book Binder and Crystal Block now receive their 1.21.1
|
||||
recipe-book discovery advancements. Black Dye unlocks the vial, a Glass Bottle
|
||||
unlocks the mixer, an Iron Ingot unlocks the binder, and a Crystal Shard unlocks
|
||||
the block. As with the already-ported Notebook and Writing Desk, 26.3's
|
||||
`recipe_unlocked` condition uses a `recipes` list instead of the older `recipe`
|
||||
field. Recipe ingredients and outputs are unchanged; no Blank Page recipe is added.
|
||||
|
||||
Build and disposable-server checks passed all six loaded crafting unlocks:
|
||||
wrong material stays locked, obtaining the matching material unlocks the recipe,
|
||||
progress remains per-player, and granting the recipe directly completes its
|
||||
advancement. The test server was stopped and its temporary world removed.
|
||||
The player passed the preceding progression/background correction; this crystal
|
||||
background and client recipe-book discovery still need an in-game check. Use
|
||||
Survival and a fresh recipe book (or revoke the relevant recipe/advancement)
|
||||
when testing the four newly ported unlocks. Nothing is committed or published.
|
||||
|
||||
### Lectern book stands
|
||||
|
||||
The local `1.0.1.110-beta.1` slice ports the 1.21.1 lectern conversion and
|
||||
book/page display. Place a Rivencraft book or Page on an empty vanilla lectern;
|
||||
the stand keeps its facing and consumes one item in Survival (Creative retains
|
||||
its held stack). Vanilla occupied lecterns are not replaced. Shift-right-click
|
||||
with an empty hand retrieves the stored item and restores a vanilla lectern.
|
||||
Breaking drops one lectern and its stored item. There is no separate stand item
|
||||
or new crafting recipe.
|
||||
|
||||
The extracted renderer reuses vanilla lectern geometry, existing book textures
|
||||
and the final 1.21.1 Page orientation, including east/west yaw corrections. Books
|
||||
show their name above the stand; Pages have no hover label. Empty-hand use opens
|
||||
the current port book GUI. Clicking the destination panel travels while leaving
|
||||
the book on its stand. Disarm/Maintain Momentum, same-dimension Linking Book
|
||||
restrictions, successful-Age advancements and configured Return Links use the
|
||||
existing travel logic. Handheld travel still drops the active book at departure.
|
||||
Unbound books can be displayed; retrieve and bind them in hand for this slice.
|
||||
Live previews and exploration remain later port work.
|
||||
|
||||
The stand packet includes the source dimension, position and displayed ItemStack.
|
||||
The server checks distance, loaded source chunk, stand identity and exact current
|
||||
book contents before travel, rejecting removed/replaced books or stale screens.
|
||||
No chunk is loaded just to inspect a remote stand. Book storage uses 26.3
|
||||
ValueInput/ValueOutput, update tags and packets. The modern block-entity removal
|
||||
hook receives the old state, so the stored item is dropped there once; retrieval
|
||||
empties it first to prevent duplicate drops.
|
||||
|
||||
Build and disposable-server checks passed conversion/retrieval for all four
|
||||
facings, both hands and all three item kinds; occupied-lectern/unrelated-item
|
||||
rejection; distant/wrong-dimension/replaced/removed-source rejection; exact break
|
||||
drops; component serialization; and named-book persistence across full restart.
|
||||
The server was stopped and its world removed. Client checks: display/orientation
|
||||
in all facings, names above books only, empty-hand GUI opening, panel-click travel
|
||||
for both bound book types, retention at the departure stand, modifier effects,
|
||||
Shift retrieval and break drops. Also restart with a loaded stand. Nothing has
|
||||
been committed, pushed or published for this slice.
|
||||
|
||||
### Descriptive Book author and written pages
|
||||
|
||||
The local `1.0.1.111-beta.1` slice brings the authored-book layout closer to
|
||||
1.21.1: Legacy gilded cover, front name/author on the left and destination panel
|
||||
on the right, followed by one authored glyph page at a time on the right. It
|
||||
reuses `bookui_rpage_full.png` and the existing runtime glyph item pipeline;
|
||||
no new artwork or per-symbol PNGs are generated. Bottom corners and left/right
|
||||
arrow keys turn pages with the vanilla page-turn sound. Only the front page's
|
||||
panel activates travel, including when opened from a stand. Escape still closes
|
||||
the GUI, and focused naming fields retain text input behavior.
|
||||
|
||||
The server records the player's profile name in the persistent/networked
|
||||
`book_author` component when binding through the Binder or the temporary handheld
|
||||
binding flow. Existing books retain their saved ordered `book_symbols`; older
|
||||
books with no author show the shared unknown-author text. Page zero is the front
|
||||
spread, followed by each saved symbol in order (including repeated IDs). Books
|
||||
with no symbols retain the Legacy blank final spread. Inferred generation pages
|
||||
are not inserted into authored contents. The current destination placeholder
|
||||
remains; live snapshots and the pageless random-Age glyph description remain
|
||||
future port work.
|
||||
|
||||
Build and disposable-server checks passed real Binder author assignment, ordered
|
||||
symbol indexing, repeated-symbol presentation, navigation bounds, older/panel-only
|
||||
fallback and book author/page persistence across full restart. The server was
|
||||
stopped and temporary world deleted. Client checks: bind a new book with several
|
||||
distinct pages and verify your name, cover/layout, glyph size/no extra item outline,
|
||||
corner/arrow turns, sound, Escape, GUI resize and travel only from the front panel.
|
||||
Repeat opening the book from a lectern. No commit, push or publication was made.
|
||||
|
||||
### Built-in animated link panels
|
||||
|
||||
The local `1.0.1.112-beta.1` slice ports the 1.21.1 Legacy fallback animation to
|
||||
both book GUIs. Two noisy teal spirals expand into the panel using the shared
|
||||
GUI renderer, replacing the static placeholder and instructional text. Rendering
|
||||
and clicking share the same panel bounds. Separate GUI strata keep the cover,
|
||||
animation and Legacy frame in order. Descriptive Books display the animation only
|
||||
on their front page; clicking written pages does not travel. The animation keeps
|
||||
running while the book is open and does not pause the world, load destination
|
||||
chunks or render/audio-tick a remote world. Lectern books use the same animation.
|
||||
|
||||
This is the existing mod-independent fallback, not a live snapshot or a new shader.
|
||||
It currently uses the fallback's default teal tint; destination-specific tint and
|
||||
live renderer integrations remain unported. The GUI API and outline method were
|
||||
adapted; the root spiral/noise math, timing and reveal are unchanged.
|
||||
|
||||
The build passed. No server behavior or packet schema changes were made and no
|
||||
test server was needed. Check animation/reveal/frame in both books, at different
|
||||
GUI scales and on lecterns; panel clicks should still travel, Escape should close,
|
||||
and descriptive page turning should keep working. Nothing committed or published.
|
||||
|
||||
### Linking Book final spread and shared artwork
|
||||
|
||||
The local `1.0.1.113-beta.1` slice adapts the remaining Linking Book layout
|
||||
from 1.21.1. Its front spread uses the plain Legacy cover, animated right panel,
|
||||
and wrapped/shadow-free text on the left. It shows the bound title, destination
|
||||
dimension and floored block coordinates. Vanilla dimensions use translated names;
|
||||
other dimensions currently use their registry IDs until saved Age display-name
|
||||
metadata is ported. Automatic Return Links suppress the redundant title by its
|
||||
translation identity, matching root behavior.
|
||||
|
||||
The blank final spread, 0/1 counter, bottom-corner and arrow navigation and
|
||||
page-turn sound are now present. Binding widgets hide on the final spread and
|
||||
remain hidden after resizing. Escape closes even with naming focused; focused
|
||||
text inputs consume inventory/hotbar keys. Only the front panel can travel,
|
||||
including books opened from a stand.
|
||||
|
||||
Both book screens now share an extractor adaptation of root LegacyBookArtwork,
|
||||
plus wrapped-text, counter and corner-hit helpers. The descriptive gilded cover,
|
||||
author and symbol pages are retained. This removes duplicated atlas composition
|
||||
without changing packets, saved data, Age generation or destination coordinates.
|
||||
The build passed; no server run was needed for this client-only change. Check
|
||||
long titles, Overworld/Nether/End destination text and negative coordinates,
|
||||
front/final turning/sound, resize/Escape/naming, lectern opening and front-only
|
||||
travel. Recheck descriptive glyph pages after the shared-artwork refactor.
|
||||
Nothing committed, pushed or published.
|
||||
|
||||
### Saved Age and Linking Book destination names
|
||||
|
||||
The local `1.0.1.114-beta.1` slice ports the root destination-name snapshot.
|
||||
New Ages save the entered title, preserving case/spaces/punctuation, separately
|
||||
from their existing dimension ID, folder and seed. New Linking Books bound inside
|
||||
those Ages store that title in `link_destination_name`; the front GUI and tooltip
|
||||
share the same name resolution. Vanilla dimensions keep their familiar names.
|
||||
Automatic Return Links capture the departure dimension's saved name when available.
|
||||
|
||||
The optional display-name map defaults empty for older indexes. Older books/Ages
|
||||
without metadata retain translated vanilla names or raw dimension IDs; no names
|
||||
are inferred from inventory books or folder names. This does not rename folders,
|
||||
rewrite old book labels, change collision suffixes, or reroll any generation.
|
||||
Labels on existing bound books remain snapshots as in root. Names retained with
|
||||
retired identities remain unavailable through active-Age lookup and survive a
|
||||
failed-retirement rollback. Tooltip coordinates now use floor-based BlockPos
|
||||
conversion, matching the GUI for negative values.
|
||||
|
||||
Build and disposable-server checks passed real Binder creation/name assignment,
|
||||
case/punctuation preservation, Linking/automatic Return Link labels, older vanilla
|
||||
fallback, Age unload/reload and full-restart persistence of Age metadata and book
|
||||
components. The temporary Return Link test config was restored, and the server
|
||||
was stopped/world removed. Client check: create a new named Age, enter it, bind
|
||||
a Linking Book there, and inspect its destination in the GUI and tooltip (including
|
||||
on a stand). Recheck after restart; optional Return Links should show the departure
|
||||
name when enabled. No commit, push or publication was made.
|
||||
|
||||
### Universal open-book drops (26.3 half of issue #114)
|
||||
|
||||
The local `1.0.1.115-beta.1` slice converts ordinary dropped Linking and
|
||||
Descriptive Books into the existing open-book entity, including unwritten
|
||||
variants. A server-only join listener queues one conversion after entity/chunk
|
||||
registration; it does not scan worlds or force-load chunks. Existing custom
|
||||
book entities and non-book items are skipped. The source must actually be
|
||||
registered and still present before conversion, and is discarded only after
|
||||
replacement succeeds. Rejected replacements leave the ordinary drop intact.
|
||||
|
||||
The factory uses native entity-state copying and preserves ItemStack components,
|
||||
position, motion, facing, pickup delay, owner/thrower and ordinary entity metadata.
|
||||
It does not apply the handheld travel constructor's Y offset or zero velocity.
|
||||
The replacement receives a new UUID so both can coexist during the transaction;
|
||||
its lifetime switches to the existing open-book decay/weather rules. Existing
|
||||
ordinary books loaded from disk also use the join hook. No per-tick retry is used
|
||||
if another mod cancels conversion; those drops are eligible on a later reload.
|
||||
|
||||
Build and disposable-server checks passed state/component/owner copying and
|
||||
ordinary item, player, container and stand-drop conversion, custom-entity recursion
|
||||
avoidance, non-book exclusion and cancellation recovery. A no-player fixture's
|
||||
entity chunk became unavailable to the first query; temporarily loading that
|
||||
single fixture chunk made the checks pass. No storage stall occurred. The player
|
||||
offered to perform the remaining validation, so no further automated restart run
|
||||
was started. Server stopped and world removed. Shared issue #114 remains open:
|
||||
1.21.1 implementation and manual client validation are pending. Nothing committed
|
||||
or published.
|
||||
|
||||
### Configurable handheld Link Panel ink cost
|
||||
|
||||
The local `1.0.1.116-beta.1` slice ports root WritingInkService and its
|
||||
`writing.link_panel_ink_cost` option in `config/rivencraft-local.toml`.
|
||||
The allowed range is 1–16, default 2. Preparing a baseline Link Panel by hand
|
||||
consumes one Paper and that many ink units; insufficient ink changes neither
|
||||
input. A drained vial returns a vanilla Glass Bottle. Stacked inputs and
|
||||
inventory-full give/drop behavior retain the root transaction rules. The
|
||||
Ink Mixer still consumes exactly one ink unit and one Paper per panel, separately
|
||||
from this setting. Writing Desk symbol-copy cost uses the shared service getter.
|
||||
|
||||
Handheld client prediction checks only eligible item types and a valid nonempty
|
||||
vial; the server uses its own configured cost to accept/refuse and consume inputs.
|
||||
This avoids relying on unsynchronized LOCAL config for client affordability.
|
||||
No new recipe, packet, Page variant or progression award is introduced.
|
||||
|
||||
The build passed. Following the player's offer to do testing, no test server
|
||||
or new fixtures were started for this small resource-policy port. In game, check
|
||||
two ink per panel by default; then set `link_panel_ink_cost = 4` under `[writing]`
|
||||
and restart to check four ink per panel, insufficient-ink refusal, last-use bottle,
|
||||
both hands and stacked/full-inventory cases. Confirm the Mixer still costs one
|
||||
ink. On a dedicated server, change the server's value; the client need not match.
|
||||
Nothing committed, pushed or published.
|
||||
|
||||
### Age library discovery
|
||||
|
||||
The local `1.0.1.117-beta.1` slice ports the 1.21.1 library discovery system:
|
||||
the existing 11×11×11 schematic, five stocked lecterns, chest loot, deterministic
|
||||
32-chunk-region spread and the terrain/obstruction checks. Libraries generate
|
||||
at the end of decoration in newly generated, non-Cave Age chunks. The footprint
|
||||
stays inside its candidate chunk. Trees/foliage can be cleared, while fluids,
|
||||
block entities, solid obstructions, excessive slopes and absent sturdy footing
|
||||
reject placement. Vanilla Overworld generation is unchanged. Old chunks are
|
||||
not revisited; existing Ages gain libraries only in eligible new chunks.
|
||||
|
||||
`[libraries] loot_mode` in `config/rivencraft-local.toml` defaults to `LEGACY`:
|
||||
writing supplies and possible Sealed Notebooks in chests, with random symbol
|
||||
Pages on five lecterns. `AGE_ONLY` limits new library chest/lectern Pages to
|
||||
known symbols written into that Age, using distinct IDs. Page-free Ages then
|
||||
have no symbol loot and the stands display ordinary Books, matching root.
|
||||
The original written list is saved independently and copied into the generator
|
||||
before generation, then restored on reload. It does not change the book's writing,
|
||||
seed, folder or other trait completion. Older Ages without this saved list have
|
||||
an empty Age-only pool. The broader random-page eligibility policy remains a
|
||||
separate unported system. The Legacy pool may include currently unsupported red
|
||||
pages, like the existing Sealed Notebook pool.
|
||||
|
||||
Operator tools under `/rivencraft_port library`:
|
||||
- `test_place`: place a raised library in clear space ahead, without overwriting
|
||||
existing blocks/entities. A console source may supply position via `execute`.
|
||||
- `nearest_loaded`: teleport into the nearest marked library aisle in loaded
|
||||
chunks within 16 chunks. It checks footing/fluids/collision and does not scan
|
||||
or generate remote chunks.
|
||||
- `test_spacing 1` through `32`: session-only increased candidate density in an
|
||||
Age, for new chunks only. `32` restores normal spacing; restart clears overrides.
|
||||
- `candidate`: report the closest deterministic candidate without loading it;
|
||||
eligibility is still required and a library is not guaranteed.
|
||||
- `probe`: identify a marked library or explain current-chunk placement rejection.
|
||||
- `inspect_loot`: materialize the current chunk's library chest as opening it would,
|
||||
then print contents and the number of stands in that chunk.
|
||||
|
||||
Build passed and the schematic/loot resources were verified in the JAR. A local
|
||||
dedicated server started with the data; at the player's request it was stopped
|
||||
before generation/loot tests. Its world was deleted. The player will check actual
|
||||
natural placement, five lectern pages and orientation/retrieval, chest loot/Sealed
|
||||
Notebook opening, both loot modes, locator/test tools and persistence after restart.
|
||||
No new GameTest fixtures were added. No commit, push or publication was made.
|
||||
|
||||
|
||||
## Beta .118 — crystal portals and Book Receptacles
|
||||
|
||||
The player confirmed natural library generation in .117; the earlier missing-library
|
||||
report was withdrawn. .118 adapts the complete fallback crystal network from 1.21.1:
|
||||
|
||||
- Book Receptacles mount on any of the six faces of Crystal Blocks, with the final
|
||||
1.21.1 model/book orientations. Eight Crystal Blocks surrounding an empty crafting
|
||||
square craft one receptacle; its recipe unlocks on obtaining a Crystal Block.
|
||||
- Insert a bound Linking or Descriptive Book into a valid connected crystal frame.
|
||||
The Legacy tension/flood algorithm accepts supported irregular openings, using
|
||||
the same 16-block radius, 2,048-crystal and 1,024-surface limits. Placement respects
|
||||
NeoForge protection events and rolls back rejected activation.
|
||||
- Empty-hand interaction removes the book and closes the portal. Breaking its
|
||||
support or holder closes it and drops the book as the recoverable open-book entity.
|
||||
- Player travel delegates to the existing book transport, catalog acknowledgement,
|
||||
safe landing, Link Panel effects and configured Return Link handling. Descriptive
|
||||
Books share the exact same landing resolver with portals. Linking Books retain
|
||||
their exact coordinates, facing and same-dimension Intra-Linking requirement.
|
||||
- Non-player transport uses the same target rules, collision checks and momentum
|
||||
rotation. Disarm refuses loose items and leaves container contents/eligible mob
|
||||
equipment behind. The shared Disarm helper also replaces the player-only loop.
|
||||
- Queued collision travel waits until the entering movement packet is complete.
|
||||
A 40-tick session-only cooldown prevents repeated traversal. Portal owner pointers,
|
||||
the one stored book, and destination sky tint persist and synchronize to clients.
|
||||
Startup waits for required frame chunks before repair. Loaded networks receive a
|
||||
staggered bounded maintenance pass every 200 ticks, without generating chunks.
|
||||
- The animated fallback only renders its two facing surfaces. Its tint follows the
|
||||
destination sky, including authored colors and the 1.21.1 Nether/End palette.
|
||||
Optional live destination views remain part of the integration port; installing
|
||||
another portal mod alone does not hide this fallback.
|
||||
- Successful activation awards the restored Open Portal advancement.
|
||||
|
||||
Validation: build and dedicated-server startup passed. A small command-seeded
|
||||
crystal ring verified persisted book/owner data, entity collision travel to exact
|
||||
saved coordinates before and after a full server restart, and removal/drop cleanup
|
||||
when the support was broken. This was not a substitute for player-driven activation:
|
||||
the player should test book insertion/removal, the six model orientations, both book
|
||||
types and their Link Panel effects, survival/creative item handling, frame edits,
|
||||
reopening after restart, and travel with destination chunks initially unloaded.
|
||||
The temporary server was stopped and its world deleted. No new permanent GameTests,
|
||||
commit, push or publication.
|
||||
|
||||
|
||||
## Beta .119 — saved instability and status-effect providers
|
||||
|
||||
The player passed .118 crystal portal testing. This slice begins the major instability
|
||||
port using the 1.21.1 scoring rules, complete Legacy card catalog, deterministic deck
|
||||
selection, bounded scheduler and potion-provider implementation.
|
||||
|
||||
New Ages save creation-time contributions and selected provider IDs/levels in separate
|
||||
Overworld SavedData (`rivencraft/instability`), alongside an independent enabled flag.
|
||||
Creation saves this with the Age identity before catalog publication. Reload and
|
||||
configuration changes do not reroll the frozen plan. Older Ages remain neutral rather
|
||||
than being rescored. No score or provider detail is added to player books or Binder UI.
|
||||
|
||||
The first scoring set includes Bright Lighting (+500, also when seed-completed),
|
||||
Accelerated (+1,000 each), Corrupted Medium (+1,000), Small (+2,000) and Large (+0),
|
||||
Cave-ineffective weather/celestial modifiers (+100 each), structure occurrences after
|
||||
the first three (+100 each), authored structures forced into non-native biomes (+100
|
||||
per occurrence), and growing costs for the third and later suns (+1,000, +2,000, ...).
|
||||
The same structure policy includes the new 26.3 Abandoned Camps page. Corrupted Pages
|
||||
are now implemented in authoring/creative availability; they retain animated redacted
|
||||
text and supply instability rather than a terrain decoration.
|
||||
|
||||
All five Legacy decks and card costs/copy counts are retained. Scoring rounds down to
|
||||
500 before selecting/shuffling from the Age seed. Unported hazard cards remain selected
|
||||
and are reported as unavailable; no replacement reroll biases the deck toward potions.
|
||||
Writing-recovery penalties, live valuable-material profiling, decay, lightning,
|
||||
explosions, meteors, burning and erosion remain later slices. This is not yet full
|
||||
instability parity, and the existing authoring validation rules still apply.
|
||||
|
||||
Implemented providers: local/global Blindness, Mining Fatigue, Hunger, Nausea, Poison,
|
||||
Slowness, Weakness and Wither, plus global hostile Regeneration and Resistance. Local
|
||||
variants require visible sky; global variants work under cover. Harmful general effects
|
||||
can affect Survival/Adventure players and non-player living entities; Creative and
|
||||
Spectator are exempt. Hostile buffs target monsters. Duration follows the 1.21.1 values:
|
||||
60 ticks Blindness/Nausea, 80 Fatigue/Hunger/Poison/Slowness/Weakness, 30 Wither, and 200
|
||||
hostile buffs. Provider level raises amplifier, not duration; default effective cap is 3.
|
||||
|
||||
The scheduler runs only in occupied runtime Ages, selecting already-loaded nearby
|
||||
chunks. Defaults match 1.21.1: 20-tick interval, four Ages, two providers and one action
|
||||
per run, at most 16 chunks. Status leases cap at 32 per Age, eight candidates per attempt,
|
||||
and 200 ticks per effect. A failed handler is quarantined temporarily. Owned effects
|
||||
are revoked on departure/logout/disable/shutdown without deleting replacements or
|
||||
stronger/extended effects from another source. Config entries are under `[instability]`
|
||||
and `[instability_status_effects]` in the server's `rivencraft-local.toml`; provider
|
||||
exclusions accept `poison` or `rivencraft:poison`.
|
||||
|
||||
Operator commands, used inside a runtime Age:
|
||||
|
||||
- `/rivencraft_port instability status`: frozen score/contributions, saved or test
|
||||
provider levels, enabled/availability information.
|
||||
- `/rivencraft_port instability enabled false` (or `true`): persistently pause/resume
|
||||
this Age; pausing immediately clears owned effects without changing its saved plan.
|
||||
- `/rivencraft_port instability test_score 5000`: session-only deterministic provider
|
||||
selection at a test score, leaving creation-time contributions unchanged.
|
||||
- `/rivencraft_port instability clear_test_score`: restore saved selection. Restart
|
||||
also clears the test override.
|
||||
- `/rivencraft_port instability pulse poison_global`: attempt one implemented level-1
|
||||
effect on an eligible living entity in the current already-loaded chunk. The command
|
||||
respects config, enabled state, target policy and effect ownership; it reports when
|
||||
no effect was applied. Existing effects are not replaced. Other catalog providers
|
||||
are suggested too, with unavailable handlers explicitly reported.
|
||||
|
||||
Validation: build and fresh dedicated-server startup passed. A small temporary Age
|
||||
confirmed neutral scoring, deterministic test deck selection, exact Poison duration,
|
||||
owned-effect cleanup on disable, retention of a stronger command-supplied Weakness,
|
||||
local shelter versus global effects, saved enabled state after restart and session
|
||||
score reset. The server was stopped and its world deleted. No permanent test fixtures
|
||||
were added. Player authoring/scoring, automatic occupied-Age scheduling, Survival versus
|
||||
Creative behavior, departure cleanup, hostile buffs and multiplayer fairness remain
|
||||
manual checks. No commit, push or publication.
|
||||
|
||||
|
||||
## Beta .120 — colored decay
|
||||
|
||||
The player passed .119. This slice ports the four active 1.21.1 colored decay cards,
|
||||
the block and all four existing 16x16 textures, per-Age frontier persistence, surface
|
||||
seeding and six-neighbor conversion. Decay is selected by the existing saved Legacy
|
||||
decks; older saved provider selections are not rerolled.
|
||||
|
||||
Blue, Red and Purple use the same hardness-dependent conversion odds as 1.21.1.
|
||||
White converts eligible solid blocks without a hardness roll. Air conversion remains
|
||||
rarer (Blue/Red 1-in-20, Purple 1-in-8, White 1-in-50). Other decay colors can be
|
||||
converted; White wins that conversion immediately and others use a 1-in-5 roll.
|
||||
White also deals one point of magic damage on stepping contact, using the modern
|
||||
server damage API. All decay variants are solid, hardness 5/resistance 10, have no
|
||||
item form or drops, and do not spread through autonomous/random block ticks.
|
||||
|
||||
A provider call samples at most four six-neighbor targets by default and changes at
|
||||
most one block, sharing the existing scheduler's Age/provider/action budgets. It only
|
||||
examines supplied already-loaded player-nearby chunks. Per-Age SavedData retains a
|
||||
monotonic attempt sequence and at most 1,024 frontier coordinates per color; no chunk
|
||||
or entity references are persisted. Destroyed/recolored stale sources are discarded.
|
||||
|
||||
Protection matches 1.21.1: fluids, unbreakable/critical blocks, block entities,
|
||||
portals and Rivencraft infrastructure resist conversion. Data packs can add entries
|
||||
to `rivencraft:decay_protected`. A positioned fake player supplies modern NeoForge
|
||||
BreakBlockEvent and placement events, with rollback for cancelled placement.
|
||||
|
||||
The first resolved Descriptive Book landing is saved as a protected spawn without
|
||||
changing scoring or provider selection. It protects +/-4 blocks horizontally and
|
||||
Y-2 through Y+4. Portals and hand-held books share that resolver. For an Age entered
|
||||
before .120, re-enter it once to record this point; OP status reports whether it is
|
||||
recorded. Cloud Platforms and Rivencraft infrastructure remain protected independently.
|
||||
|
||||
`[instability] world_mutation` and `[instability_decay] enabled` default to true,
|
||||
matching 1.21.1. `max_attempts_per_call` defaults to 4 (range 1–6). Per-Age enable,
|
||||
global enable and provider exclusions also gate seeding/spread. Disabling does not
|
||||
remove existing decay. As in 1.21.1, existing White block contact damage follows the
|
||||
decay/world-mutation toggles rather than the Age provider scheduler toggle.
|
||||
|
||||
Operator testing, inside an Age and preferably away from its protected arrival:
|
||||
|
||||
- `/rivencraft_port instability decay blue 32` makes up to 32 bounded provider
|
||||
attempts in your current loaded chunk. Use `purple`, `red` or `white` too. It prints
|
||||
the number of actual changes and up to four nearby tracked coordinates, without
|
||||
changing score/selection. A failed roll or protected location can yield no change.
|
||||
- The existing `/rivencraft_port instability pulse decayblue` (and other colors)
|
||||
attempts one call. Its output now describes successful actions for either blocks
|
||||
or living entities.
|
||||
- `/rivencraft_port instability test_score 50000` allows natural deck selection for
|
||||
observation; inspect `status` for the selected colors, then use `clear_test_score`
|
||||
to restore the saved plan. Merely placing decay does not register an active card.
|
||||
|
||||
Validation: build and fresh dedicated-server startup passed. Bounded pulses seeded
|
||||
and spread all four colors. On restart, the saved attempt sequence advanced from
|
||||
32 to 40 and the original frontiers persisted, with further Red growth. Bedrock and
|
||||
water seeding produced no conversions/frontier entries; disabling the Age blocked
|
||||
further changes. All packaged textures are 16x16. The temporary server was stopped
|
||||
and world deleted. Manual checks remain for visual variants, White stepping damage,
|
||||
protected arrival, normal occupied-Age spreading, claim-mod cancellation and controls.
|
||||
No permanent test fixtures, commit, push or publication.
|
||||
|
||||
|
||||
## .116–.120 release validation
|
||||
|
||||
The player passed every slice, including .120 colored decay, and authorized commit,
|
||||
push and publication as 1.0.1.120-beta.1. Both Minecraft targets build successfully.
|
||||
See the consolidated release notes for the published scope and remaining parity gaps.
|
||||
|
||||
|
||||
## Beta .121 — lightning and explosions
|
||||
|
||||
The player passed .120. This slice ports the 1.21.1 lightning/explosion providers,
|
||||
their separate scheduling lane, persisted per-Age attempt sequence/cooldowns,
|
||||
loaded-chunk candidate selection and existing damage/protection settings.
|
||||
|
||||
Each selected hazard receives a roll on each admitted Age scheduler pass, independent
|
||||
of rotating potion/decay providers. Legacy per-chunk/per-tick odds are aggregated as
|
||||
`1 - (1 - 1/denominator)^(loadedChunks * interval * effectiveCopies)`. Denominators
|
||||
remain 100,000 for clear-weather lightning, 5,000 during rain plus thunder, and 1,000
|
||||
for explosions. Candidate chunks are already-loaded rings around Age players, capped
|
||||
at 81 by default; no chunks are generated for hazards. Lightning uses the motion-blocking
|
||||
surface; natural explosions choose a random Y across the dimension's modern height.
|
||||
|
||||
Defaults match 1.21.1: hazards/block damage/player damage/fire enabled, 20-tick minimum
|
||||
gap per hazard type, strength-3 blasts, at most 16 distinct destructible blocks and
|
||||
eight damaged entities, and at most 12 damage per entity (lightning itself caps at 5).
|
||||
Creative/Spectator are excluded. Spawn columns within eight blocks horizontally are
|
||||
excluded as targets; protected infrastructure, block entities, TNT, portals and
|
||||
unbreakable blocks resist blasts. Break and placement cancellation checks remain.
|
||||
World mutation and mobGriefing gate block damage; entity damage and visual/audio
|
||||
feedback can remain active when block mutation is disabled.
|
||||
|
||||
The modern API no longer returns an explosion/block list. The damage calculator
|
||||
retains its bounded approved positions for post-blast ignition, only where the block
|
||||
is now air and fire placement is allowed. Cancelled explosion startup does not count
|
||||
as dispatch or generate follow-up fire. NeoForge's renamed BreakBlockEvent and 26.3's
|
||||
`canSpreadFireAround` replace the old break event and doFireTick query respectively.
|
||||
Vanilla explosion networking and lightning entities provide existing visuals/sounds.
|
||||
The `instability_hazards` config section retains the 1.21.1 setting names/defaults.
|
||||
|
||||
Operator checks inside an Age:
|
||||
|
||||
- `/rivencraft_port instability hazard lightning`
|
||||
- `/rivencraft_port instability hazard explosions`
|
||||
|
||||
These skip random probability only, retaining loaded-chunk, cooldown, Age/config/provider
|
||||
and protected-spawn checks. They target a nearby loaded chunk center, using its surface
|
||||
for lightning and the command's Y for explosions, rather than the player's exact feet.
|
||||
Move away from the arrival area and allow at least one second between repeats of the
|
||||
same hazard. The command reports no dispatch when blocked/cancelled. It also works with
|
||||
an explicitly positioned console source. Generic `pulse lightning`/`pulse explosions`
|
||||
retain their natural probability roll; `test_score`/`status` exercise normal selection.
|
||||
The saved score/plan and catalog protocol are unchanged.
|
||||
|
||||
Build and dedicated-server startup passed. The user offered to validate damage and
|
||||
block destruction manually, so the server was stopped before effect/terrain fixtures
|
||||
were run and its temporary world was removed. Manual checks include lightning/thunder,
|
||||
explosion visuals/sound, damage caps and Creative immunity, block/protected-block limits,
|
||||
spawn exclusion, world-mutation/mobGriefing/fire settings, cooldown/restart persistence,
|
||||
and natural rates in occupied Ages. No permanent tests, commit, push or publication.
|
||||
|
||||
|
||||
## Beta .122 — meteor flight and impacts
|
||||
|
||||
The player passed .121 lightning/explosion testing. Meteors now reuse the 1.21.1
|
||||
particle descent, roar sound, selected Legacy provider card, independent scheduling
|
||||
lane and bounded blast helper. They are not a new falling-block/entity model: a
|
||||
flame/smoke trail descends for 30 ticks (1.5 seconds) from up to 48 blocks above a
|
||||
captured surface target, followed by the impact. The existing meteor roar is bundled.
|
||||
|
||||
Natural scheduling aggregates the Legacy 1-in-50,000 per-chunk/per-tick odds across
|
||||
loaded chunks, scheduler interval and effective card copies, as in 1.21.1. Targets
|
||||
are randomized among already-loaded nearby candidate chunks; X/Z vary within the
|
||||
safe central area of each chunk. Up to 16 candidate chunks are considered per
|
||||
schedule attempt. Target selection rejects protected spawn columns and surfaces
|
||||
without headroom. No terrain is loaded or generated by the provider.
|
||||
|
||||
The shared hazard SavedData preserves the meteor cooldown (200 game ticks) and random
|
||||
attempt sequence. At most one meteor can descend per Age and 16 Ages can have active
|
||||
flights. A pending descent is session-only: server stop, Age unload, no players,
|
||||
unloaded target chunk, or disabled Age/global/provider/meteor policy cancels it.
|
||||
Interrupted flights never replay after restart; the saved cooldown remains. The
|
||||
flight is removed before impact dispatch so a failed impact cannot repeat.
|
||||
|
||||
The independent `[instability_meteor]` settings match 1.21.1: enabled/block damage/
|
||||
player damage/fire default true, maximum 24 impact blocks, eight damaged entities,
|
||||
and 12 damage per entity, strength 3. Impact reuses protected block/BE/infrastructure,
|
||||
claim events, Creative/Spectator immunity, world-mutation/mobGriefing and modern fire
|
||||
permission checks. Disabling block mutation still permits descent, sound and eligible
|
||||
entity damage. Generic instability status reports meteor availability separately
|
||||
from potion policy; saved scores/decks and catalog protocol are unchanged.
|
||||
|
||||
Test inside an Age, away from its arrival area:
|
||||
|
||||
`/rivencraft_port instability meteor`
|
||||
|
||||
This skips the natural probability roll, retaining cooldown, loaded target, active
|
||||
flight and all policy checks. It uses randomized nearby impact locations, unlike
|
||||
the deliberately fixed chunk-center lightning/explosion test commands. Wait at
|
||||
least ten seconds before repeating. `/rivencraft_port instability pulse meteors`
|
||||
keeps the natural chance roll; test-score/status tools exercise saved deck selection.
|
||||
|
||||
The build passed and JAR sound/class packaging was verified. Following the player's
|
||||
manual validation preference, particle/audio presentation, impacts/damage/protection,
|
||||
repeat cooldown, disabling/leaving during flight and restart cancellation are handed
|
||||
over for in-game testing. No server or new world was launched for this slice. No new
|
||||
permanent tests, commit, push or publication.
|
||||
|
||||
|
||||
## Beta .123 — burning and erosion
|
||||
|
||||
The player passed .122 meteor testing. This slice adapts the remaining burning and
|
||||
erosion portions of 1.21.1 AgeEnvironmentalProviders, using the existing hazard
|
||||
candidate selection, saved attempt sequence, protection helpers and independent
|
||||
provider scheduling. No new textures, entities, sounds or optional mods are required.
|
||||
|
||||
Burning remains an active Legacy deck card. Sky-exposed eligible living entities
|
||||
outside protected spawn can ignite; Survival/Adventure players are included by
|
||||
default and Creative/Spectator are excluded. Natural processing inspects at most
|
||||
eight loaded chunks/eight eligible candidates per chunk, ignites at most one per
|
||||
inspected chunk and four entities per pass by default. The old 1-in-10 per-tick gate
|
||||
is aggregated over the scheduler interval. Fire lasts four seconds per effective
|
||||
provider level, capped at twelve. Vanilla water/rain extinguishing and fire immunity
|
||||
remain in force. Disabling the provider stops new ignitions; existing fire follows
|
||||
vanilla behavior rather than the potion lease cleanup mechanism.
|
||||
|
||||
Erosion remains outside Legacy's saved card deck. As requested for testing in 1.21.1,
|
||||
its config defaults enabled and natural attempts run in admitted occupied Ages at a
|
||||
current score of at least 10,000. Current score uses the frozen creation total or an
|
||||
explicit session test-score override; live material profiling remains pending.
|
||||
At most eight random positions are sampled in already-loaded candidate chunks per
|
||||
pass, with at most one conversion. An eligible block next to a source fluid above or
|
||||
on a horizontal side is replaced with that fluid's source block; fluid below does
|
||||
not grow upward. Normal fluid ticks can subsequently spread it.
|
||||
|
||||
Erosion requires world_mutation and mobGriefing, and reuses decay's protection against
|
||||
spawn/infrastructure, block entities, fluids, critical/unbreakable blocks and protected
|
||||
tags. Modern break/place claim events and rollback remain. Its `instability_erosion`
|
||||
enabled toggle does not add a new saved card or alter the Age score. Burning uses
|
||||
`instability_scorching`: enabled/affect_players default true and max_targets_per_pass
|
||||
is 4 (range 0–16), matching 1.21.1.
|
||||
|
||||
Operator manual checks inside an Age:
|
||||
|
||||
- On dry ground under open sky, outside arrival protection and in Survival, run
|
||||
`/rivencraft_port instability environment burning`. It targets the player directly
|
||||
for four seconds, skips the chance roll, and explicitly reports water/rain that
|
||||
would immediately extinguish the fire.
|
||||
- Place ordinary Stone beside or below source water/lava, outside protected arrival.
|
||||
Use `/rivencraft_port instability test_score 10000`, look at the Stone within six
|
||||
blocks, and run `/rivencraft_port instability environment erosion`. The aimed
|
||||
block should become that adjacent source fluid. Existing policies still apply.
|
||||
End with `/rivencraft_port instability clear_test_score` to restore the saved plan.
|
||||
- Automatic burning needs a selected burning card; natural erosion uses the enabled
|
||||
test policy/threshold. `status` exposes both config/threshold information when
|
||||
inspecting the respective provider, without adding scores to ordinary books.
|
||||
|
||||
Build passed. The player is performing visual/effect testing, so no server/world or
|
||||
new permanent fixtures were created. Manual validation covers burning/fire damage,
|
||||
Creative/shelter/spawn exclusion, wet extinguishing feedback, source-fluid conversion,
|
||||
protected blocks, mobGriefing/world-mutation/config switches and natural scheduling.
|
||||
No commit, push or publication. Crumble, full writing recovery and live abundance
|
||||
profiling remain major instability work.
|
||||
|
||||
|
||||
## Beta .124 — live material-abundance instability
|
||||
|
||||
The player passed .123. This slice ports 1.21.1's Legacy-style generated-block
|
||||
profiling and live effective provider selection. Crumble remains a catalog entry
|
||||
without a registered handler in 1.21.1; no new Crumble behavior is invented here.
|
||||
|
||||
Overworld and runtime-Age chunk loads enqueue bounded sampling work, rather than
|
||||
accessing SavedData during chunk-storage callbacks. Four snapshots are processed
|
||||
per server tick, with a queue cap of 2,048. Paletted section counts record watched
|
||||
block IDs efficiently. Each chunk is sampled once, up to 1,024 per dimension, and
|
||||
samples/totals persist in per-dimension `rivencraft/block_profile` SavedData. Subsequent
|
||||
player edits or chunk reloads do not replace or double-count a stored snapshot.
|
||||
|
||||
Live points remain zero until at least **401 Age samples and 500 Overworld baseline
|
||||
samples** exist, matching 1.21.1. For each watched material, positive excess blocks
|
||||
per Age chunk above 1.05 times the Overworld average are multiplied by that material's
|
||||
weight. The total rounds to an integer and caps at 100,000. The watched list retains
|
||||
Legacy ores/quartz/glowstone/crystal and the existing modern/storage extensions,
|
||||
including Diamond Blocks, Ancient Debris and Netherite Blocks.
|
||||
|
||||
Recognized ores, mineral storage blocks and source-fluid block types from installed
|
||||
mods participate automatically via current NeoForge common tags, names and the same
|
||||
full-block/fluid material eligibility used for Pages. No content mod/API is required.
|
||||
Unknown ore/storage forms use the 1.21.1 fallback weights; known minerals retain its
|
||||
named weights. `[instability_modded_materials] block_weight_overrides` accepts exact
|
||||
`modid:block_id=points` entries, including zero to exclude a modded block. This is
|
||||
installation-based optional support, not a required mod or per-mod opt-in switch.
|
||||
|
||||
While a profiling-enabled Age is occupied, missing spawn-area samples are requested
|
||||
gradually: at most one chunk request per second, one in flight across the server,
|
||||
round-robin between active Ages and the Overworld baseline. Existing samples are
|
||||
skipped. The modern getChunkFuture API still blocks when invoked on the server
|
||||
thread, so requests use its off-thread scheduling path and completion returns to
|
||||
the server thread. Closed levels and stopped-server callbacks are discarded. No
|
||||
permanent force-load tickets or generation while no Age is occupied are introduced.
|
||||
Natural exploration also fills the sample pool and can reach readiness sooner.
|
||||
|
||||
Writing contributions and the original saved provider plan remain frozen. The live
|
||||
score is added only at runtime, and the effective deck is reselected deterministically
|
||||
from the same saved seed as the total changes. Providers can therefore appear or
|
||||
change as abundance is measured. Unchanged totals/seeds reuse a cached plan rather
|
||||
than reshuffling on each query. Older Ages predating instability records remain
|
||||
neutral; saved neutral arrivals/enable flags do not opt them into new scoring.
|
||||
|
||||
`[instability] difficulty` matches 1.21.1: 0=quarter, 1=half, 2=normal (default),
|
||||
3=1.75x. It scales writing plus live points without rewriting saved contributions.
|
||||
A session `test_score` remains an explicit final-score override and is cleared on
|
||||
restart; clearing it now restores the live effective plan. Erosion threshold checks
|
||||
use this same effective score.
|
||||
|
||||
Use `/rivencraft_port instability status` inside an Age to inspect frozen/live/effective
|
||||
scores, difficulty, profile eligibility and both sample counts. Normal book/Binder
|
||||
screens receive no score/profiler data. Zero live points before both thresholds are
|
||||
ready are expected. To validate, create/explore an Age with Diamond Block Rocks or
|
||||
another abundant watched material, watch progress, then confirm live points and
|
||||
providers change while the frozen writing contribution remains unchanged. Reopen the
|
||||
world to check sample persistence; repeated reloads should not duplicate counts.
|
||||
|
||||
Build and temporary dedicated-server checks passed. Real loaded snapshots saved 25
|
||||
samples per test dimension and captured one deliberately placed fixture Diamond Block.
|
||||
A restart with synthetic profile records exercised scoring/decoding without generating
|
||||
hundreds of chunks: resumed real chunk samples produced 423 Age and 522 baseline
|
||||
samples, with the expected 2,987 live points, frozen score zero and effective providers.
|
||||
Clearing a session override restored the same live result. This verifies save/scoring
|
||||
plumbing, not natural bulk generation. The player will check background progress,
|
||||
actual generated material distribution, modded recognition and difficulty/config
|
||||
changes. Test server stopped/world deleted; no permanent tests, commit or publication.
|
||||
|
||||
|
||||
## .125 ordered controller recovery
|
||||
|
||||
The player passed .124. New authored Ages now recover repeated singular terrain,
|
||||
biome-distribution, weather, lighting and sky choices: the last choice wins, with
|
||||
500 instability per duplicate or conflict. Supported category-order mistakes add
|
||||
100 points instead of blocking binding. Native distribution discards authored
|
||||
biome choices with a 500-point conflict; Single retains the first authored biome
|
||||
with a 500-point conflict when extras exist. These diagnostics use the 1.21.1
|
||||
writing rules. Season pages remain unsupported until their integration is ported.
|
||||
|
||||
The original pages remain in the book and library source list. Generation receives
|
||||
recovered choices without rearranging material/feature or per-celestial modifier
|
||||
chains. Existing Ages are not rescored. OP instability status shows the writing
|
||||
contributions; normal book/Binder screens do not expose the score or new warnings.
|
||||
Unknown/unported pages and other unsupported combinations still prevent binding.
|
||||
Celestial, number and dangling-modifier recovery is a later slice: their existing
|
||||
validation still applies to the original page sequence, before controller recovery.
|
||||
|
||||
Version 1.0.1.125-beta.1 builds. Thirteen temporary focused checks passed for
|
||||
last-choice selection, duplicate/conflict/order costs, Native/Single recovery,
|
||||
unimplemented pages, raw numeric guards, celestial-chain preservation, intentional
|
||||
feature repeats and Ambient Color ordering. No server/world was launched. In-game
|
||||
validation: bind books with conflicting terrain/weather choices and confirm the
|
||||
last choice takes effect; test Native with biome pages and Single with two biomes;
|
||||
check that all original pages remain and OP status reports the writing costs.
|
||||
No commit, push or publication.
|
||||
|
||||
|
||||
## .126 ordered celestial and number recovery
|
||||
|
||||
The player passed .125. Ported the 1.21.1 grammar treatment of pending celestial
|
||||
modifiers and numeric adjacency. Repeated Length/Phase/Angle/Size/Direction pages
|
||||
before a celestial use the final value of that kind without a duplicate penalty.
|
||||
Only the final unconsumed modifier of each kind adds 100 dangling-modifier points.
|
||||
A number without an immediately following supported Color/Length/Phase/Angle/Size
|
||||
page adds 100 points (misplaced, or dangling at the end). Consecutive numbers leave
|
||||
only the last available for the next target. Colors still mix in written order.
|
||||
|
||||
These defects no longer block binding. Numbers and celestial compilation retain
|
||||
the original page sequence and seeded color indices; controller recovery never
|
||||
moves a number next to a new target. A trailing modifier cannot rewrite an earlier
|
||||
body, including the optimized single-body compilation path. Missing celestial
|
||||
branches retain their existing seeded completion; dangling controls do not apply
|
||||
to those generated bodies. All original book/library pages remain. Unsupported
|
||||
pages and the ten-sun/ten-moon limit still block binding; existing Ages are unchanged.
|
||||
|
||||
Version 1.0.1.126-beta.1 builds. Seventeen temporary focused checks passed, covering
|
||||
last-value selection, dangling costs/indices, numeric adjacency, per-body isolation,
|
||||
seeded color stability, valid numbered writing, body limits, unsupported pages and
|
||||
cycle codec round-trip. No server/world or permanent test fixture was created.
|
||||
Manual validation: bind a book with Half Length, Double Length, Sun and verify the
|
||||
last length; bind Sun followed by Large Size and confirm it does not resize that
|
||||
sun. Test a trailing Number page and inspect the added writing contribution with
|
||||
`/rivencraft_port instability status`. Original pages should remain visible.
|
||||
No commit, push or publication.
|
||||
|
||||
|
||||
## .127 Link Modifier station: title and core link properties
|
||||
|
||||
The player passed .126. Started the missing 1.21.1 Link Modifier station port,
|
||||
reusing its block model, textures, horizontal facing variants and Legacy 176x166
|
||||
single-book GUI. The item is available in the main creative tab or via
|
||||
`/give @s rivencraft:link_modifier`; it has no crafting recipe. As in 1.21.1,
|
||||
creative acquisition does not impose a new permission restriction on ordinary
|
||||
book edits once the station has been placed.
|
||||
|
||||
One persistent slot accepts Linking and Descriptive Books, including unbound
|
||||
books, with normal and shift-click transfers. The server menu handles renaming
|
||||
and Disarm, Maintain Momentum and Intra Linking toggles. Descriptive Books cannot
|
||||
toggle Intra Linking, which only governs same-dimension Linking Book travel.
|
||||
Existing target, author, symbols and Age identity are retained. Renaming changes
|
||||
the item's custom title, not the Age's saved folder/registry identity. Removing
|
||||
the title restores the item's normal name. Text editing consumes inventory/hotbar
|
||||
shortcuts while focused; Escape closes the screen. Programmatic text refreshes do
|
||||
not send rename packets. Menu identity, owner, distance and live station checks
|
||||
protect edits; unknown button IDs and non-book inputs are rejected.
|
||||
|
||||
The book is saved with modern ValueInput/ValueOutput container serialization.
|
||||
Station removal uses block-entity preRemoveSideEffects to release the stored book
|
||||
once, including non-player removal. No tick handler or renderer is needed.
|
||||
|
||||
This is a station slice, not full Link Modifier parity. Seed editing, Age retirement,
|
||||
Relative Link, Intra-Linking Only and platform overrides remain unported and are
|
||||
not presented as working controls. Existing core flag behavior is reused unchanged.
|
||||
Manual validation: place the station in multiple orientations, insert/shift-click
|
||||
both book types, rename using text containing E, toggle each available property,
|
||||
remove the book and use it to check travel effects. Confirm Descriptive Book Intra
|
||||
Linking is unavailable, restart with a book left in the station, and break a loaded
|
||||
station to recover one book. Build and dedicated persistence results are recorded
|
||||
below. No commit, push or publication.
|
||||
|
||||
Version 1.0.1.127-beta.1 builds. Packaged model, GUI, texture, item definition,
|
||||
loot table and localization checks passed; no station recipe is included. A
|
||||
fresh dedicated server placed the station and serialized a named Linking Book
|
||||
with all three core flags. Restart retained the same book/name/flags and station
|
||||
removal completed normally. The first harness run expected the wrong wording
|
||||
for vanilla force-load cleanup; the corrected harness completed successfully.
|
||||
Both temporary worlds were deleted and servers stopped. No permanent tests were
|
||||
added. UI interaction, transfer counts and modifier travel effects await the
|
||||
player's in-game check.
|
||||
|
||||
|
||||
## .128 six Link Modifier controls
|
||||
|
||||
The player reported that .127 exposed only three of 1.21.1's six controls. Added
|
||||
the remaining Intra-Linking Only, Relative Link and Generate Platform controls in
|
||||
the existing two-column, three-row Legacy layout, with matching bit values and
|
||||
server-owned menu actions. Enabling Intra-Linking Only enables Intra Linking too;
|
||||
disabling Intra Linking clears Intra-Linking Only. Both intra controls are disabled
|
||||
for Descriptive Books. Unknown actions and out-of-range menu edits remain rejected.
|
||||
|
||||
Linking Book Intra-Linking Only blocks cross-dimension travel. Relative Link uses
|
||||
the traveller's block offset from the source world's spawn, applied to destination
|
||||
spawn, preserving destination yaw/pitch. Normal links retain their precise bound
|
||||
coordinates. Player and non-player portal travel share the new flags and relative
|
||||
coordinate helper. Descriptive relative arrivals still require proper footing.
|
||||
|
||||
Generate Platform is off by default for Linking Books. When enabled, a successful
|
||||
link fills one missing floor block with cloud platform only if the block two above
|
||||
the arrival is air, matching the 1.21.1 helper. Existing floors are retained.
|
||||
Descriptive Books instead default to enabled and use Disable Platform internally;
|
||||
turning it off prevents fallback platform creation and refuses travel if no footed
|
||||
landing is available. Receptacle tint preparation respects that setting too.
|
||||
New flags serialize in the shared component and appear in book/panel tooltips.
|
||||
Existing three-flag books remain valid. Seed editing and Age retirement remain
|
||||
separate, unfinished station work.
|
||||
|
||||
Version 1.0.1.128-beta.1 builds. A temporary dedicated-server fixture passed 19
|
||||
checks: menu actions/dependencies, descriptive intra rejection, numeric flag codec,
|
||||
relative offsets, platform-off/default behavior, platform placement/preservation,
|
||||
invalid input and remote edit protection. It did not perform real client travel.
|
||||
The test server stopped and its world/fixture sources were removed. The production
|
||||
JAR is rebuilt without that fixture. Manual checks: all six buttons and tooltips,
|
||||
same/cross-dimension Intra-Linking Only, Relative Link offsets, and platform on/off
|
||||
for both book types (including a new Void Age without footing). No commit, push
|
||||
or publication.
|
||||
|
||||
|
||||
## .129 Link Modifier mouse input repair
|
||||
|
||||
The player reported that none of the six controls responded. Confirmed the
|
||||
26.3 input constants in the local Minecraft artifact: left/middle/right are
|
||||
1/2/3, while the custom Link Modifier handler checked the old left-button value
|
||||
0. Its server actions therefore passed the prior direct-menu fixture while
|
||||
real mouse clicks never sent an action.
|
||||
|
||||
Replaced the manually drawn/clicked areas with six registered Minecraft Button
|
||||
widgets at the same Legacy positions. Their callbacks send the existing validated
|
||||
menu action IDs, and their custom rendering retains the square controls. Native
|
||||
input provides correct mouse mapping, focus, click sound and keyboard activation.
|
||||
All six tooltips expose their On/Off state; unavailable Descriptive Book intra
|
||||
controls remain disabled. Tooltip objects update only when state changes so their
|
||||
hover delay is not reset every tick. Book mutations/travel code is unchanged.
|
||||
|
||||
Version 1.0.1.129-beta.1 builds. The native widget's left-button mapping and callback
|
||||
dispatch were verified in the local Minecraft bytecode. No client click test or
|
||||
new server run was performed; the previous 19 checks covered server actions, not
|
||||
client input. Please click each option with an inserted Linking Book and verify
|
||||
its selected shading and tooltip/book properties change, then repeat supported
|
||||
options with a Descriptive Book. No commit, push or publication.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.115-beta.1
|
||||
|
||||
Collects the player-validated port slices .107–.115 since 1.0.1.106-beta.1.
|
||||
Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. This remains a beta with
|
||||
incomplete Minecraft 1.21.1 gameplay and optional-integration parity.
|
||||
|
||||
## Book stands and dropped books
|
||||
|
||||
- Place a Linking Book, Descriptive Book or Page on an empty vanilla lectern
|
||||
to convert it into a Rivencraft book stand, preserving facing. Occupied
|
||||
vanilla lecterns are left alone.
|
||||
- Books use the existing open-book model and textures and show their name
|
||||
above the stand. Pages use their item artwork without a hover label,
|
||||
retaining the 1.21.1 orientation corrections.
|
||||
- Open a bound book with an empty hand, then click its destination panel to
|
||||
travel while leaving the book on its stand. Existing link modifiers,
|
||||
same-dimension rules, Return Link configuration and Age-entry awards apply.
|
||||
- Shift-right-click with an empty hand retrieves the stored item and restores
|
||||
a vanilla lectern. Breaking returns one lectern and its contents. Unwritten
|
||||
books can be displayed; retrieve them to bind in hand in this beta.
|
||||
- Ordinary dropped Linking/Descriptive Books now become recoverable open-book
|
||||
entities, including unwritten books, inventory/player drops, container and
|
||||
stand drops, death/Disarm drops and ordinary book entities loaded from disk.
|
||||
- Conversion preserves the stack and components, position, motion, facing,
|
||||
pickup delay and ownership/thrower metadata. It uses the existing open-book
|
||||
lifetime, slow decay and water/rain behavior. Already-custom books and
|
||||
non-book items are skipped.
|
||||
- Conversion runs after registration without world scans or force-loading.
|
||||
The original is removed only after replacement succeeds; a canceled
|
||||
replacement leaves the ordinary item intact and eligible on a later reload.
|
||||
- Stand travel validates source dimension, distance, loaded chunk and the exact
|
||||
currently stored book, rejecting stale screens after removal/replacement.
|
||||
|
||||
## Book GUIs and names
|
||||
|
||||
- Descriptive Books reuse the Legacy gilded cover, name/author front spread,
|
||||
and one authored symbol at a time on following right-hand pages. Saved page
|
||||
order and repetitions are retained, with runtime glyphs and no extra item outline.
|
||||
- New Descriptive Books record their author server-side. Older books without
|
||||
that metadata use the unknown-author label.
|
||||
- Linking Books use the plain Legacy cover, wrapped title, destination name
|
||||
and block coordinates, plus the blank final spread. Return Links suppress
|
||||
the redundant title. Bottom corners or arrow keys turn pages with sound.
|
||||
- Both GUIs share the Legacy artwork/text/counter/corner helpers. Naming
|
||||
controls hide on later pages and remain correct after resizing. Escape closes
|
||||
even with naming focused; normal text input does not trigger inventory keys.
|
||||
- Ported the existing 1.21.1 fallback's animated teal spiral panel to both GUIs,
|
||||
including lecterns. Removed placeholder labels. The Legacy frame draws over
|
||||
the animation; travel is active only on the front panel.
|
||||
- The animated fallback is not a live destination view. Destination tint,
|
||||
snapshot renderer integrations and random-Age glyph descriptions remain pending.
|
||||
- New Ages save their entered display name independently of their ID, folder
|
||||
and seed. Newly bound Linking Books and automatic Return Links capture their
|
||||
destination/departure label for both GUI and tooltip. Older Ages/books retain
|
||||
vanilla-name or dimension-ID fallbacks; existing labels are not rewritten.
|
||||
- Corrected negative tooltip coordinates to floor consistently with the GUI.
|
||||
|
||||
## Progression and recipe discovery
|
||||
|
||||
- Ported the Rivencraft advancement tree: root, obtaining a Page/Linking Book,
|
||||
taking a Link Panel from the Ink Mixer, copying a Page, binding an Age and
|
||||
successfully travelling into an Age. Action awards occur after committed
|
||||
server actions, including normal and shift pickup; previews earn nothing.
|
||||
- The advancement background uses the existing blue Crystal Block texture,
|
||||
with the corrected 26.3 resource-path format. The portal node waits for the
|
||||
Receptacle/portal port.
|
||||
- Added recipe-book discovery for Ink Vials (Black Dye), Ink Mixers (Glass
|
||||
Bottle), Book Binders (Iron Ingot) and Crystal Blocks (Crystal Shard), alongside
|
||||
the Notebook and Writing Desk unlocks. Three-by-three recipes appear in a
|
||||
Crafting Table's recipe book, not the inventory's two-by-two book.
|
||||
- Ingredients and costs are unchanged. Blank Pages remain uncraftable.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
- Player in-game checks passed each slice and the final dropped-book changes.
|
||||
- The 26.3 and independent 1.21.1 builds passed. Gradle test tasks have no
|
||||
standalone test sources; targeted disposable-server fixtures covered real
|
||||
station actions, advancements/recipe unlocks, stand ownership/drops and
|
||||
persistence, book author/page/name persistence, and drop conversion/state.
|
||||
- Temporary test servers were stopped and their worlds deleted. A no-player
|
||||
entity fixture required one loaded fixture chunk for inspection; further
|
||||
visual/restart validation was handed to the player.
|
||||
- 1.21.1 gameplay code is unchanged. Shared issue #114 remains open for its
|
||||
matching universal dropped-book fix; Writing Desk model placement stays in #113.
|
||||
- Instability, full writing recovery, discovery/library loot, crystal portals,
|
||||
remaining stations/progression and optional integrations still need port work.
|
||||
|
||||
Keep 26.3 and 1.21.1 instances/saves separate and back up beta worlds before updating.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.120-beta.1
|
||||
|
||||
Collects the player-validated .116–.120 port slices since 1.0.1.115-beta.1.
|
||||
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent 1.21.1
|
||||
release is unchanged; full gameplay and optional-integration parity remain incomplete.
|
||||
|
||||
## Libraries and writing
|
||||
|
||||
- Ported Legacy library generation, shared schematic, chest loot and five correctly
|
||||
oriented book/page stands. Libraries generate after decoration in eligible new
|
||||
non-Cave Age chunks, using the 1.21.1 spacing, terrain and clearance checks.
|
||||
- Library loot defaults to `LEGACY`; optional `AGE_ONLY` limits new library Pages
|
||||
to known symbols originally written into that Age. Original writing is saved
|
||||
separately and restored on reload. Older Ages without it have an empty Age-only
|
||||
pool. Ordinary Books replace stand Pages when that pool is empty.
|
||||
- Added operator library placement, candidate/probe/loot diagnostics, session-only
|
||||
test spacing and a locator limited to nearby already-loaded chunks.
|
||||
- Hand-prepared Link Panel ink cost is server-configurable (default two). The
|
||||
Ink Mixer still consumes one ink unit and one paper per panel. Writing Desk
|
||||
ink handling now uses the shared service.
|
||||
|
||||
## Crystal portals
|
||||
|
||||
- Ported craftable Book Receptacles with all six mounting orientations and the
|
||||
existing 1.21.1 model/book transforms. A bound Linking or Descriptive Book
|
||||
activates a valid connected crystal frame, including supported irregular frames.
|
||||
- Reused the bounded crystal flood/tension algorithm, placement protection events,
|
||||
rollback, owner persistence, restart repair, frame cleanup and traversal cooldown.
|
||||
- Empty-hand removal closes the portal and returns the book. Breaking the holder
|
||||
or its crystal support closes it and drops a recoverable open-book entity.
|
||||
- Player and non-player travel reuse book destinations, safe arrival, Intra-Linking,
|
||||
Disarm and momentum rules. Descriptive Books share the same landing resolver
|
||||
with portals; no separate portal exit offset is added. Player Return Link policy
|
||||
and progression remain in force.
|
||||
- The animated fallback surface renders its facing sides and uses destination sky
|
||||
tint. The Open Portal advancement and receptacle recipe unlock are restored.
|
||||
Live destination views are still pending optional integration work.
|
||||
|
||||
## Instability and colored decay
|
||||
|
||||
- New Ages save operator-only creation-time score contributions and deterministic
|
||||
provider selections using all five Legacy decks. Existing Ages remain neutral;
|
||||
reloads/config changes do not reroll stored plans.
|
||||
- Initial scoring covers Bright Lighting, Accelerated, Corrupted Pages, ineffective
|
||||
Cave sky/weather modifiers, excess and biome-forced structures, and extra suns.
|
||||
Corrupted Pages now bind successfully and retain their animated redacted text.
|
||||
- Ported 18 potion providers: local/global Blindness, Fatigue, Hunger, Nausea,
|
||||
Poison, Slowness, Weakness and Wither, plus hostile Regeneration/Resistance.
|
||||
General effects affect eligible Survival/Adventure players and living entities;
|
||||
Creative/Spectator remain exempt. Local variants require sky exposure.
|
||||
- Added a bounded occupied-Age scheduler, owned-effect cleanup, provider controls
|
||||
and operator score/status, enable/disable, session test-score and pulse commands.
|
||||
Effects supplied or extended by another source are preserved.
|
||||
- Ported Blue, Purple, Red and White decay with the existing 16x16 textures,
|
||||
hardness/air conversion odds, saved per-color frontiers and attempt sequence.
|
||||
White retains stepping-contact magic damage. Decay has no item form or drops.
|
||||
- Spread uses only already-loaded candidate chunks, bounded attempts and action
|
||||
budgets. Fluids, block entities, critical/unbreakable blocks, infrastructure,
|
||||
protected tags and claim events prevent conversion.
|
||||
- The first resolved book/portal arrival is saved for spawn protection. Re-enter
|
||||
an older Age once to record it. Global/Age/provider and world-mutation controls
|
||||
gate spread; existing decay blocks remain when spreading is disabled.
|
||||
- Test a color with `/rivencraft_port instability decay blue 32` (also `purple`,
|
||||
`red`, `white`); output includes actual changes and nearby tracked coordinates.
|
||||
Scores are not added to ordinary book or Binder displays.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
Player in-game checks passed .116–.120. Both the 26.3 and 1.21.1 builds passed;
|
||||
Gradle has no standalone test sources. Targeted disposable dedicated-server checks
|
||||
covered library placement, portal entity travel and restart ownership/cleanup,
|
||||
potion application/cleanup and persistence, all four decay colors, saved spread
|
||||
continuation, and bedrock/fluid protection. Test servers were stopped and their
|
||||
worlds removed.
|
||||
|
||||
Full writing recovery/penalties, live material-abundance profiling, other instability
|
||||
hazards, remaining discovery/stations and optional integrations still need port work.
|
||||
Unported hazard cards remain saved and are reported as unavailable, rather than
|
||||
being replaced with different cards. Book previews remain the animated fallback.
|
||||
Machine acceleration (#112), Writing Desk model placement (#113), and the matching
|
||||
1.21.1 universal dropped-book fix (#114) remain tracked separately.
|
||||
|
||||
Use matching client/server beta builds (catalog protocol 10). Keep Minecraft tracks
|
||||
and saves separate, back up beta worlds, and do not downgrade saves containing new
|
||||
blocks or data to earlier betas.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.129-beta.1
|
||||
|
||||
Collects the player-validated .121–.129 port slices since 1.0.1.120-beta.1.
|
||||
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent Minecraft
|
||||
1.21.1 release is unchanged. Full parity and optional integrations remain in progress.
|
||||
|
||||
## Instability hazards and live profiling
|
||||
|
||||
- Ported lightning, explosions, falling meteor effects, burning and erosion with
|
||||
bounded loaded-chunk scans, spawn protection, claim events and configurable
|
||||
world/entity damage. Meteor flight uses the existing sound and particle effects.
|
||||
- Natural hazards choose randomized locations; operator test commands provide
|
||||
targeted checks with independent cooldowns. Erosion retains its requested testing
|
||||
default of enabled above 10,000 effective instability, outside the Legacy deck.
|
||||
- Ported persistent live material profiling, including native ores, valuable blocks,
|
||||
recognized installed-mod materials and configurable modded block weight overrides.
|
||||
No content mod is required. Profiling starts scoring after 401 Age and 500
|
||||
Overworld baseline samples, with bounded gradual background sampling while an
|
||||
eligible Age is occupied. A chunk snapshot is counted once.
|
||||
- Effective instability and deterministic provider selection now update as material
|
||||
abundance is measured; the original writing contribution remains frozen. Added
|
||||
difficulty scaling and operator-only sampling/score diagnostics. Older neutral
|
||||
Ages remain neutral. Normal book/Binder screens do not reveal scores.
|
||||
|
||||
## Ordered writing recovery
|
||||
|
||||
- Repeated singular terrain, distribution, weather, lighting and sky controllers
|
||||
use the last choice, adding 500 instability per duplicate/conflict. Category-order
|
||||
mistakes add 100. Native distribution ignores supplied biomes; Single retains the
|
||||
first biome when too many are supplied, with conflict penalties.
|
||||
- Repeated celestial modifiers use the final pending value of each kind. Dangling
|
||||
modifiers and misplaced/trailing numbers allow binding and add 100 instability.
|
||||
Numbers only attach to the immediately following supported target.
|
||||
- Preserved original book/library pages, ordered color mixing and independent
|
||||
celestial chains. A trailing modifier cannot change an earlier sun or moon.
|
||||
Unported pages, unsupported combinations and body limits remain enforced.
|
||||
|
||||
## Link Modifier station
|
||||
|
||||
- Added the creative-only Link Modifier with its existing model, textures and
|
||||
Legacy single-book GUI. There is no crafting recipe.
|
||||
- Books can be renamed and edited through all six controls: Disarm, Intra-Linking,
|
||||
Intra-Linking Only, Maintain Momentum, Relative Link and Generate Platform.
|
||||
- Intra-Linking Only enables Intra-Linking and prevents cross-dimension Linking
|
||||
Book travel. Both intra controls are unavailable for Descriptive Books.
|
||||
- Relative Link preserves the traveller's offset from the source dimension spawn
|
||||
at the destination spawn. Linking Book platform generation defaults off;
|
||||
Descriptive Book fallback platforms default on and can be disabled.
|
||||
- Player and non-player portal paths share the new properties. Book contents and
|
||||
flags persist in the station. Removal releases its stored book.
|
||||
- Fixed the controls ignoring real clicks: 26.3 left-click is button 1, not 0.
|
||||
Registered native button widgets now handle input, focus, sound and state tooltips.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
The player passed every included slice, including the six controls and click fix.
|
||||
Both Minecraft builds pass (no standalone Gradle test sources). Targeted temporary
|
||||
checks covered grammar/celestial recovery, component serialization, station actions,
|
||||
platform rules and dedicated-server save/restart behavior. Live profiling was checked
|
||||
with real snapshots plus synthetic threshold records; natural gameplay was validated
|
||||
by the player. Temporary servers were stopped and their test worlds removed.
|
||||
|
||||
Link Modifier seed editing/retirement, remaining discovery/config policy and optional
|
||||
integrations are not complete. Live previews remain unavailable; books and portals
|
||||
use the animated fallback. Crumble has no active 1.21.1 handler and is not invented
|
||||
in this port. The dual-version follow-up issues and full multiplayer/parity gate
|
||||
remain open. Use matching client/server beta builds and keep Minecraft version
|
||||
tracks and their saves separate.
|
||||
@@ -193,6 +193,10 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'data/rivencraft/worldgen/structure_set/bastions.json'
|
||||
include 'assets/rivencraft/models/block/star_fissure.json'
|
||||
include 'assets/rivencraft/blockstates/star_fissure.json'
|
||||
include 'assets/rivencraft/blockstates/decay.json'
|
||||
include 'assets/rivencraft/models/block/decay_*.json'
|
||||
include 'assets/rivencraft/textures/block/decay_*.png'
|
||||
include 'data/rivencraft/tags/block/decay_protected.json'
|
||||
include 'assets/rivencraft/sounds.json'
|
||||
include 'assets/rivencraft/sounds/**/*.ogg'
|
||||
include 'assets/rivencraft/textures/item/crystal_shard.png'
|
||||
@@ -220,6 +224,8 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/models/block/writing_desk_extension.json'
|
||||
include 'assets/rivencraft/blockstates/writing_desk.json'
|
||||
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
|
||||
include 'data/rivencraft/advancement/progression/*.json'
|
||||
exclude 'data/rivencraft/advancement/progression/root.json'
|
||||
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
|
||||
include 'assets/rivencraft/models/item/ink_vial.json'
|
||||
include 'assets/rivencraft/models/item/crystal_shard.json'
|
||||
@@ -231,12 +237,19 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/textures/gui/bookui_cover.png'
|
||||
include 'assets/rivencraft/textures/gui/notebook_pages.png'
|
||||
include 'assets/rivencraft/textures/gui/bookui_pager.png'
|
||||
include 'assets/rivencraft/textures/gui/bookui_rpage_full.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_block.png'
|
||||
include 'assets/rivencraft/textures/entity/linking_book.png'
|
||||
include 'assets/rivencraft/textures/entity/descriptive_book.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder_press.png'
|
||||
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
|
||||
include 'assets/rivencraft/textures/gui/link_modifier_legacy.png'
|
||||
include 'assets/rivencraft/textures/block/link_modifier_*.png'
|
||||
include 'assets/rivencraft/models/block/link_modifier.json'
|
||||
include 'assets/rivencraft/models/item/link_modifier.json'
|
||||
include 'assets/rivencraft/blockstates/link_modifier.json'
|
||||
include 'data/rivencraft/loot_table/blocks/link_modifier.json'
|
||||
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
|
||||
include 'assets/rivencraft/textures/block/inkwell_base.png'
|
||||
include 'assets/rivencraft/textures/block/inkwell_cup.png'
|
||||
@@ -249,6 +262,19 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/models/block/book_binder.json'
|
||||
include 'assets/rivencraft/models/item/book_binder.json'
|
||||
include 'assets/rivencraft/blockstates/book_binder.json'
|
||||
include 'assets/rivencraft/blockstates/book_receptacle.json'
|
||||
include 'assets/rivencraft/blockstates/crystal_portal.json'
|
||||
include 'assets/rivencraft/models/block/book_receptacle.json'
|
||||
include 'assets/rivencraft/models/block/crystal_portal.json'
|
||||
include 'assets/rivencraft/models/item/book_receptacle.json'
|
||||
include 'assets/rivencraft/textures/block/book_receptacle.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_portal.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_portal.png.mcmeta'
|
||||
include 'data/rivencraft/loot_table/blocks/book_receptacle.json'
|
||||
include 'assets/rivencraft/blockstates/book_stand.json'
|
||||
include 'data/rivencraft/loot_table/blocks/book_stand.json'
|
||||
include 'data/rivencraft/loot_table/chests/mystcraft_library.json'
|
||||
include 'data/rivencraft/structure/mystcraft_library.nbt'
|
||||
include 'data/rivencraft/loot_table/blocks/book_binder.json'
|
||||
include 'assets/rivencraft/models/block/crystal_block.json'
|
||||
include 'assets/rivencraft/blockstates/crystal_block.json'
|
||||
|
||||
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
|
||||
mod_id=rivencraft
|
||||
mod_name=Rivencraft
|
||||
mod_license=LGPL-3.0-or-later
|
||||
mod_version=1.0.1.106-beta.1
|
||||
mod_version=1.0.1.129-beta.1
|
||||
@@ -4,9 +4,12 @@ import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** Server-owned gameplay policies adapted from 1.21.1. */
|
||||
public final class PortConfig {
|
||||
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
|
||||
public static final ModConfigSpec SPEC;
|
||||
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
|
||||
@@ -18,9 +21,16 @@ public final class PortConfig {
|
||||
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
|
||||
builder.pop();
|
||||
builder.push("libraries");
|
||||
LIBRARY_LOOT_MODE = builder.comment("LEGACY uses writing supplies/Sealed Notebooks and random lectern pages; AGE_ONLY uses only pages written into this Age.")
|
||||
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
|
||||
builder.pop();
|
||||
builder.push("writing");
|
||||
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
|
||||
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline Link Panel by hand with paper.",
|
||||
"The Ink Mixer uses one ink unit per panel independently of this setting.")
|
||||
.defineInRange("link_panel_ink_cost", 2, 1, 16);
|
||||
builder.pop();
|
||||
builder.push("accelerated_age");
|
||||
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
|
||||
@@ -28,6 +38,7 @@ public final class PortConfig {
|
||||
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
|
||||
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
|
||||
builder.pop();
|
||||
net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder);
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ public final class Rivencraft {
|
||||
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
|
||||
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
|
||||
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
|
||||
public static final Supplier<DataComponentType<String>> LINK_DESTINATION_NAME = COMPONENTS.registerComponentType(
|
||||
"link_destination_name", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
|
||||
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
|
||||
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
|
||||
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
|
||||
@@ -106,6 +108,8 @@ public final class Rivencraft {
|
||||
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
|
||||
.networkSynchronized(Identifier.STREAM_CODEC));
|
||||
/** Ordered authored page IDs remain on the finished book for later port slices. */
|
||||
public static final Supplier<DataComponentType<String>> BOOK_AUTHOR = COMPONENTS.registerComponentType(
|
||||
"book_author", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
|
||||
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
|
||||
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
|
||||
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
|
||||
@@ -127,16 +131,48 @@ public final class Rivencraft {
|
||||
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
|
||||
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookReceptacleBlock> BOOK_RECEPTACLE =
|
||||
BLOCKS.registerBlock("book_receptacle", net.dnorberg.rivencraft.block.BookReceptacleBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).noOcclusion().mapColor(MapColor.COLOR_BLUE));
|
||||
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE_ITEM = ITEMS.registerSimpleBlockItem(BOOK_RECEPTACLE);
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.CrystalPortalBlock> CRYSTAL_PORTAL =
|
||||
BLOCKS.registerBlock("crystal_portal", net.dnorberg.rivencraft.block.CrystalPortalBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
|
||||
.noCollision().noOcclusion().lightLevel(state -> 11).noLootTable());
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity>> BOOK_RECEPTACLE_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("book_receptacle", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity::new, BOOK_RECEPTACLE.get()));
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity>> CRYSTAL_PORTAL_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("crystal_portal", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity::new, CRYSTAL_PORTAL.get()));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.DecayBlock> DECAY =
|
||||
BLOCKS.registerBlock("decay", net.dnorberg.rivencraft.block.DecayBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).strength(5.0F, 10.0F).noLootTable());
|
||||
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
|
||||
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
|
||||
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
|
||||
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
|
||||
StarFissureBlockEntity::new, STAR_FISSURE.get()));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookStandBlock> BOOK_STAND =
|
||||
BLOCKS.registerBlock("book_stand", net.dnorberg.rivencraft.block.BookStandBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN));
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookStandBlockEntity>> BOOK_STAND_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("book_stand", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.BookStandBlockEntity::new, BOOK_STAND.get()));
|
||||
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
|
||||
"book_binder", PortBookBinderBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.LinkModifierBlock> LINK_MODIFIER = BLOCKS.registerBlock(
|
||||
"link_modifier", net.dnorberg.rivencraft.block.LinkModifierBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).noOcclusion());
|
||||
public static final DeferredItem<BlockItem> LINK_MODIFIER_ITEM = ITEMS.registerSimpleBlockItem(LINK_MODIFIER);
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity>> LINK_MODIFIER_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("link_modifier", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity::new, LINK_MODIFIER.get()));
|
||||
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.LinkModifierMenu>> LINK_MODIFIER_MENU =
|
||||
MENUS.register("link_modifier", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.LinkModifierMenu::new));
|
||||
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
|
||||
"ink_mixer", InkMixerBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
@@ -219,11 +255,17 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
||||
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
||||
modEventBus.addListener(PortBookNetworking::registerPayloads);
|
||||
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
|
||||
net.dnorberg.rivencraft.age.PortDecayProviders.register();
|
||||
net.dnorberg.rivencraft.age.PortHazardProviders.register();
|
||||
net.dnorberg.rivencraft.age.PortMeteorProvider.register();
|
||||
net.dnorberg.rivencraft.age.PortEnvironmentalProviders.register();
|
||||
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package net.dnorberg.rivencraft.advancement;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Awards action-only progression after the server has committed the action. */
|
||||
public final class ProgressionAdvancements {
|
||||
public static final String OPEN_PORTAL = "open_portal";
|
||||
public static final String LINK_PANEL = "link_panel";
|
||||
public static final String COPY_PAGE = "copy_page";
|
||||
public static final String BIND_AGE = "bind_age";
|
||||
public static final String ENTER_AGE = "enter_age";
|
||||
|
||||
private ProgressionAdvancements() {}
|
||||
|
||||
public static void award(ServerPlayer player, String path) {
|
||||
var advancement = player.level().getServer().getAdvancements().get(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||
if (advancement != null) player.getAdvancements().award(advancement, "completed");
|
||||
}
|
||||
|
||||
public static void awardAgeTravel(ServerPlayer player) {
|
||||
if (RuntimeAgeBackend.savedAges(player.level().getServer()).containsKey(player.level().dimension().identifier()))
|
||||
award(player, ENTER_AGE);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ final class AgeLevelIndex extends SavedData {
|
||||
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
|
||||
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
|
||||
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
|
||||
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles),
|
||||
Codec.unboundedMap(Identifier.CODEC, Codec.STRING)
|
||||
.optionalFieldOf("display_names", Map.of()).forGetter(index -> index.displayNames)
|
||||
).apply(instance, AgeLevelIndex::new));
|
||||
|
||||
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
|
||||
@@ -65,6 +67,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
private final Map<Identifier, PortSkyStyle> sky;
|
||||
private final Map<Identifier, PortAgeColors> colors;
|
||||
private final Map<Identifier, PortDayCycle> dayCycles;
|
||||
private final Map<Identifier, String> displayNames;
|
||||
|
||||
private AgeLevelIndex() {
|
||||
this.ages = new LinkedHashMap<>();
|
||||
@@ -81,6 +84,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
this.sky = new LinkedHashMap<>();
|
||||
this.colors = new LinkedHashMap<>();
|
||||
this.dayCycles = new LinkedHashMap<>();
|
||||
this.displayNames = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
|
||||
@@ -94,7 +98,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
|
||||
Map<Identifier, PortLighting> lighting,
|
||||
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
|
||||
Map<Identifier, PortDayCycle> dayCycles) {
|
||||
Map<Identifier, PortDayCycle> dayCycles, Map<Identifier, String> displayNames) {
|
||||
this.ages = new LinkedHashMap<>(ages);
|
||||
this.retired = new LinkedHashSet<>(retired);
|
||||
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
|
||||
@@ -109,6 +113,16 @@ final class AgeLevelIndex extends SavedData {
|
||||
this.sky = new LinkedHashMap<>(sky);
|
||||
this.colors = new LinkedHashMap<>(colors);
|
||||
this.dayCycles = new LinkedHashMap<>(dayCycles);
|
||||
this.displayNames = new LinkedHashMap<>(displayNames);
|
||||
}
|
||||
|
||||
String displayName(Identifier id) { return ages.containsKey(id) ? displayNames.get(id) : null; }
|
||||
|
||||
void setDisplayName(Identifier id, String name) {
|
||||
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
if (name == null || name.isBlank() || name.length() > 32) throw new IllegalArgumentException("Invalid Age display name");
|
||||
displayNames.put(id, name.strip());
|
||||
setDirty();
|
||||
}
|
||||
|
||||
Map<Identifier, Long> entries() {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */
|
||||
public final class InstabilityProviderCatalog {
|
||||
public enum Deck {
|
||||
BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000);
|
||||
|
||||
private final int entryCost;
|
||||
|
||||
Deck(int entryCost) { this.entryCost = entryCost; }
|
||||
public int entryCost() { return entryCost; }
|
||||
}
|
||||
|
||||
public record Card(Identifier id, int cost) {
|
||||
public Card {
|
||||
if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private static final List<List<Card>> DECKS = createDecks();
|
||||
|
||||
private InstabilityProviderCatalog() {}
|
||||
|
||||
public static List<Card> cards(Deck deck) { return DECKS.get(deck.ordinal()); }
|
||||
|
||||
public static boolean contains(Identifier id) {
|
||||
return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id));
|
||||
}
|
||||
|
||||
private static List<List<Card>> createDecks() {
|
||||
List<List<Card>> decks = new ArrayList<>();
|
||||
for (Deck ignored : Deck.values()) decks.add(new ArrayList<>());
|
||||
add(decks, "blindness", 1000, 0, 0, 0, 1, 0);
|
||||
add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1);
|
||||
add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0);
|
||||
add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0);
|
||||
add(decks, "fatigue", 500, 5, 0, 0, 0, 0);
|
||||
add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "hunger", 500, 8, 2, 0, 0, 0);
|
||||
add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "nausea", 1000, 0, 0, 0, 1, 0);
|
||||
add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1);
|
||||
add(decks, "poison", 500, 9, 3, 0, 0, 0);
|
||||
add(decks, "poison_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "slow", 500, 6, 1, 0, 0, 0);
|
||||
add(decks, "slow_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "weakness", 500, 8, 2, 0, 0, 0);
|
||||
add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "wither", 1000, 0, 1, 1, 2, 0);
|
||||
add(decks, "wither_global", 2000, 0, 0, 1, 1, 1);
|
||||
add(decks, "burning", 500, 0, 1, 0, 0, 0);
|
||||
add(decks, "crumble", 2000, 0, 0, 6, 0, 0);
|
||||
add(decks, "decayblue", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decayred", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3);
|
||||
add(decks, "explosions", 1000, 0, 0, 8, 0, 0);
|
||||
add(decks, "lightning", 1000, 0, 4, 4, 0, 0);
|
||||
add(decks, "meteors", 1000, 0, 0, 4, 0, 0);
|
||||
return decks.stream().map(List::copyOf).toList();
|
||||
}
|
||||
|
||||
private static void add(List<List<Card>> decks, String path, int cost, int... copies) {
|
||||
Card card = new Card(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost);
|
||||
for (Deck deck : Deck.values()) {
|
||||
for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ public record PortAgeColors(int sky, int ambient, int celestial) {
|
||||
}
|
||||
|
||||
public static PortAgeColors compile(List<Identifier> pages, long seed) {
|
||||
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
|
||||
// Numbers only attach to the immediately following target. Misplaced
|
||||
// numbers are diagnosed by PortWritingRecovery, never moved to a later color.
|
||||
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
|
||||
boolean written = false;
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
|
||||
@@ -35,6 +35,12 @@ public final class PortAgeCommands {
|
||||
public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft_port")
|
||||
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
|
||||
.then(Commands.literal("recipe_discovery_probe")
|
||||
.executes(context -> {
|
||||
PortProgressionProbe.recipeDiscovery(context.getSource().getServer().overworld());
|
||||
context.getSource().sendSuccess(() -> Component.literal("Recipe discovery checks passed for all six crafting unlocks"), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("compat")
|
||||
.executes(context -> {
|
||||
StringBuilder mods = new StringBuilder("Optional mods: ");
|
||||
@@ -69,6 +75,44 @@ public final class PortAgeCommands {
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("age")
|
||||
.then(Commands.literal("create_drop_conversion_fixture")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.then(Commands.argument("seed", LongArgumentType.longArg())
|
||||
.executes(context -> {
|
||||
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
|
||||
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
|
||||
PortBookDropConversionProbe.create(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Drop sources queued; run drop_conversion_probe after server tasks complete"), false);
|
||||
return 1;
|
||||
}))))
|
||||
.then(Commands.literal("drop_conversion_probe")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(context -> {
|
||||
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
|
||||
if (level == null) throw new IllegalArgumentException("Age not loaded");
|
||||
PortBookDropConversionProbe.verify(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Universal book drop conversion/ownership checks passed"), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("create_book_stand_fixture")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.then(Commands.argument("seed", LongArgumentType.longArg())
|
||||
.executes(context -> {
|
||||
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
|
||||
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
|
||||
PortBookStandProbe.create(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Book Stand conversion/ownership/source checks passed"), false);
|
||||
return 1;
|
||||
}))))
|
||||
.then(Commands.literal("book_stand_probe")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(context -> {
|
||||
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
|
||||
if (level == null) throw new IllegalArgumentException("Age not loaded");
|
||||
PortBookStandProbe.verify(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Book Stand persistence passed"), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("create_writing_desk_fixture")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.then(Commands.argument("seed", LongArgumentType.longArg())
|
||||
@@ -79,6 +123,15 @@ public final class PortAgeCommands {
|
||||
context.getSource().sendSuccess(() -> Component.literal("Writing Desk transaction/removal checks passed"), false);
|
||||
return 1;
|
||||
}))))
|
||||
.then(Commands.literal("progression_probe")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(context -> {
|
||||
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
|
||||
if (level == null) throw new IllegalArgumentException("Age not loaded");
|
||||
PortProgressionProbe.run(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Progression data/action checks passed"), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("writing_desk_probe")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.executes(context -> {
|
||||
@@ -766,8 +819,8 @@ public final class PortAgeCommands {
|
||||
|| new PortDayCycle(true, 2, 0).ticks(12000) != 6000
|
||||
|| new PortDayCycle(true, 0, 321).ticks(-123456) != 321
|
||||
|| new PortDayCycle(true, 0, 321).ticks(123456) != 321
|
||||
|| PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|
||||
|| PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|
||||
|| !PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half")))
|
||||
|| !PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half"))))
|
||||
throw new IllegalStateException("Day cycle fixture failed");
|
||||
var server = context.getSource().getServer();
|
||||
var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess());
|
||||
@@ -1563,7 +1616,7 @@ public final class PortAgeCommands {
|
||||
|| Math.abs(moving.bodyAngle(origin + 100) + 3F) > 0.0001F
|
||||
|| !PortDayCycle.validWriting(java.util.List.of(target, PortDayCycle.NORMAL_TIME))
|
||||
|| !PortDayCycle.validWriting(java.util.List.of(target, target))
|
||||
|| PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
|
||||
|| !PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large"))))
|
||||
throw new IllegalStateException("Single-body modifier/order invariant failed");
|
||||
}
|
||||
var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
|
||||
|
||||
@@ -18,26 +18,45 @@ public final class PortAgeTravel {
|
||||
}
|
||||
|
||||
public static String enter(ServerPlayer player, Identifier id) {
|
||||
return enter(player, id, net.dnorberg.rivencraft.item.LinkProperties.NONE);
|
||||
}
|
||||
public static String enter(ServerPlayer player, Identifier id, net.dnorberg.rivencraft.item.LinkProperties properties) {
|
||||
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (target == null) return "Age not loaded: " + id;
|
||||
if (!AgeCatalogSync.ready(player, id)) {
|
||||
return "Age catalog not acknowledged by client; remaining in source dimension";
|
||||
}
|
||||
BlockPos landing = resolveLanding(target, player, properties);
|
||||
if (landing == null) return "No safe landing available; remaining in source dimension";
|
||||
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||
return moved ? null : "Teleportation failed; remaining in source dimension";
|
||||
}
|
||||
|
||||
public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); }
|
||||
public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity,
|
||||
net.dnorberg.rivencraft.item.LinkProperties properties) {
|
||||
BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM));
|
||||
if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing;
|
||||
return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin(
|
||||
(ServerLevel) entity.level(), target, entity.blockPosition())).orElse(null);
|
||||
}
|
||||
public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) {
|
||||
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
|
||||
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
|
||||
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
|
||||
if (landing != null) landing = isCaveTerrain(target)
|
||||
? findFootedDestination(target, landing).orElse(null)
|
||||
: findSurfaceLanding(target, landing.getX(), landing.getZ());
|
||||
if (landing == null) landing = findLanding(target);
|
||||
if (landing == null) return "No safe test landing available; remaining in source dimension";
|
||||
if (landing == null) landing = findLanding(target, generatePlatform);
|
||||
if (landing == null) return null;
|
||||
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
|
||||
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||
return moved ? null : "Teleportation failed; remaining in source dimension";
|
||||
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
|
||||
return landing;
|
||||
}
|
||||
|
||||
static BlockPos findLanding(ServerLevel target) {
|
||||
static BlockPos findLanding(ServerLevel target) { return findLanding(target, true); }
|
||||
static BlockPos findLanding(ServerLevel target, boolean generatePlatform) {
|
||||
if (isCaveTerrain(target)) {
|
||||
// The cave roof is not a valid arrival surface. Search down from
|
||||
// Legacy's Y=64 start, then construct the normal safety platform
|
||||
@@ -53,7 +72,7 @@ public final class PortAgeTravel {
|
||||
}
|
||||
}
|
||||
}
|
||||
return makePlatform(target, new BlockPos(0, 64, 0));
|
||||
return generatePlatform ? makePlatform(target, new BlockPos(0, 64, 0)) : null;
|
||||
}
|
||||
BlockPos surface = findSurfaceLanding(target, 0, 0);
|
||||
if (surface != null) return surface;
|
||||
@@ -61,7 +80,7 @@ public final class PortAgeTravel {
|
||||
// beneath it. Void Ages retain the usual platform floor at Y=63.
|
||||
BlockPos obstruction = highestObstruction(target, 0, 0);
|
||||
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
|
||||
return makePlatform(target, new BlockPos(0, y, 0));
|
||||
return generatePlatform ? makePlatform(target, new BlockPos(0, y, 0)) : null;
|
||||
}
|
||||
|
||||
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
|
||||
|
||||
+59
-1
@@ -52,7 +52,7 @@ final class PortBinderCollectionProbe {
|
||||
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
|
||||
throw new IllegalStateException("Invalid collection insertion point accepted");
|
||||
binder.insertPage(0, page("terrain/flat"));
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
|
||||
@@ -81,6 +81,52 @@ final class PortBinderCollectionProbe {
|
||||
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|
||||
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
|
||||
throw new IllegalStateException("Collection-backed Binder assembly regression");
|
||||
if (!PortProgressionProbe.isComplete(player, "bind_age"))
|
||||
throw new IllegalStateException("Missing bind_age award");
|
||||
if (!player.getGameProfile().name().equals(book.get(Rivencraft.BOOK_AUTHOR.get())))
|
||||
throw new IllegalStateException("Binder author was not server assigned");
|
||||
var pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
|
||||
if (pages.lastPage() != EXPECTED.size() || pages.symbolAt(0).isPresent()
|
||||
|| !pages.symbolAt(1).orElseThrow().equals(EXPECTED.getFirst())
|
||||
|| !pages.symbolAt(EXPECTED.size()).orElseThrow().equals(EXPECTED.getLast())
|
||||
|| pages.symbolAt(EXPECTED.size() + 1).isPresent()
|
||||
|| pages.turn(0, -1) != 0 || pages.turn(pages.lastPage(), 1) != pages.lastPage())
|
||||
throw new IllegalStateException("Authored book page indexing failed");
|
||||
var old = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()));
|
||||
if (old.lastPage() != 1 || !old.author().isEmpty() || old.symbolAt(1).isPresent())
|
||||
throw new IllegalStateException("Older panel-only book fallback failed");
|
||||
var repeated = new net.dnorberg.rivencraft.writing.DescriptiveBookPages(
|
||||
List.of(EXPECTED.getFirst(), EXPECTED.getFirst()), "Test");
|
||||
if (repeated.lastPage() != 2 || !repeated.symbolAt(1).equals(repeated.symbolAt(2)))
|
||||
throw new IllegalStateException("Repeated symbol display was collapsed");
|
||||
var ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
var server = level.getServer();
|
||||
if (!RuntimeAgeBackend.savedAgeName(server, ageId).orElseThrow().equals("Collection Fixture104"))
|
||||
throw new IllegalStateException("Created Age lost its display name");
|
||||
var namedLevel = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, ageId));
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel).equals("Collection Fixture104"))
|
||||
throw new IllegalStateException("Link binding name lookup did not use Age title");
|
||||
RuntimeAgeBackend.setAgeDisplayName(server, ageId, "Crystal Garden's Dawn");
|
||||
var target = new net.dnorberg.rivencraft.item.LinkBookTarget(ageId, -0.5, 64, -1.25, 90, 0);
|
||||
var link = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
link.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
|
||||
link.set(Rivencraft.LINK_DESTINATION_NAME.get(), net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel));
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(link, target).getString().equals("Crystal Garden's Dawn"))
|
||||
throw new IllegalStateException("Link destination label ignored saved name");
|
||||
var mode = net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get();
|
||||
player.getInventory().clearContent();
|
||||
try {
|
||||
net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS);
|
||||
PortReturnLinks.afterSuccessfulTravel(player, ageId, target);
|
||||
var returned = java.util.stream.IntStream.range(0, player.getInventory().getContainerSize())
|
||||
.mapToObj(i -> player.getInventory().getItem(i)).filter(i -> i.is(Rivencraft.LINKING_BOOK.get()))
|
||||
.findFirst().orElseThrow();
|
||||
if (!"Crystal Garden's Dawn".equals(returned.get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Automatic Return Link lost departure name");
|
||||
chest.setItem(4, returned.copy());
|
||||
} finally { net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(mode); }
|
||||
chest.setItem(3, link);
|
||||
chest.setItem(2, book.copy());
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
} finally { player.getInventory().clearContent(); }
|
||||
@@ -93,6 +139,18 @@ final class PortBinderCollectionProbe {
|
||||
throw new IllegalStateException("Empty collection covers were not preserved");
|
||||
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
|
||||
throw new IllegalStateException("Imported pages remained in a collection");
|
||||
if (!"RivenBinderProbe".equals(chest.getItem(2).get(Rivencraft.BOOK_AUTHOR.get())))
|
||||
throw new IllegalStateException("Authored book lost its author");
|
||||
var ageId = chest.getItem(2).get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (!RuntimeAgeBackend.savedAgeName(level.getServer(), ageId).orElseThrow().equals("Crystal Garden's Dawn")
|
||||
|| !"Crystal Garden's Dawn".equals(chest.getItem(3).get(Rivencraft.LINK_DESTINATION_NAME.get()))
|
||||
|| !"Crystal Garden's Dawn".equals(chest.getItem(4).get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Age/Linking/Return destination names did not survive persistence");
|
||||
var old = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
var overworld = new net.dnorberg.rivencraft.item.LinkBookTarget(net.minecraft.world.level.Level.OVERWORLD.identifier(), 0, 64, 0, 0, 0);
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(old, overworld).getString()
|
||||
.equals(net.minecraft.network.chat.Component.translatable("dimension.minecraft.overworld").getString()))
|
||||
throw new IllegalStateException("Older book vanilla name fallback changed");
|
||||
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
|
||||
throw new IllegalStateException("Authored book page order was not preserved");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.TickTask;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.ProblemReporter;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.storage.TagValueOutput;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Join conversion runs on queued server tasks: create and inspect on separate commands. */
|
||||
final class PortBookDropConversionProbe {
|
||||
private static final UUID OWNER = UUID.fromString("cc5909e5-aafa-43d6-b841-a917ec9c0115");
|
||||
private static final AABB AREA = new AABB(-16, -64, -16, 32, 320, 32);
|
||||
private static ItemStack book(boolean descriptive, String label) {
|
||||
var stack = new ItemStack(descriptive ? Rivencraft.DESCRIPTIVE_BOOK.get() : Rivencraft.LINKING_BOOK.get());
|
||||
stack.set(DataComponents.CUSTOM_NAME, Component.literal(label));
|
||||
return stack;
|
||||
}
|
||||
static void create(ServerLevel level) {
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) level.setBlock(new BlockPos(x, 63, z), Blocks.STONE.defaultBlockState(), 2);
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
new com.mojang.authlib.GameProfile(UUID.fromString("84f38604-48fe-48d3-a4fb-f858a7200115"), "RivenDropProbe"));
|
||||
player.setPos(8.5, 64, 8.5);
|
||||
// Test exact factory state before physics can advance position or pickup timers.
|
||||
var sample = new ItemEntity(level, 8.5, 66, 8.5, book(false, "state_copy"));
|
||||
sample.getItem().set(Rivencraft.LINK_BOOK_TARGET.get(), new net.dnorberg.rivencraft.item.LinkBookTarget(
|
||||
level.dimension().identifier(), -0.5, 64, -1.25, 135, -20));
|
||||
sample.getItem().set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
|
||||
sample.getItem().set(Rivencraft.LINK_PROPERTIES.get(), new net.dnorberg.rivencraft.item.LinkProperties(net.dnorberg.rivencraft.item.LinkProperties.DISARM
|
||||
| net.dnorberg.rivencraft.item.LinkProperties.MAINTAIN_MOMENTUM));
|
||||
sample.setTarget(OWNER);
|
||||
sample.setThrower(player);
|
||||
sample.setPickUpDelay(80);
|
||||
sample.setDeltaMovement(new Vec3(0.2, 0.1, -0.3));
|
||||
sample.setYRot(37);
|
||||
sample.setXRot(12);
|
||||
sample.setNoGravity(true);
|
||||
var copy = DroppedBookEntity.fromDrop(sample);
|
||||
if (!copy.position().equals(sample.position()) || !copy.getDeltaMovement().equals(sample.getDeltaMovement())
|
||||
|| copy.getYRot() != 37 || copy.getXRot() != 12 || !copy.isNoGravity()
|
||||
|| !ItemStack.matches(copy.getItem(), sample.getItem()) || !OWNER.equals(copy.getTarget())
|
||||
|| copy.getUUID().equals(sample.getUUID()) || copy.getAge() != -32768)
|
||||
throw new IllegalStateException("Drop conversion lost placement, ownership or item components");
|
||||
var originalTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
var copiedTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
sample.saveWithoutId(originalTag); copy.saveWithoutId(copiedTag);
|
||||
for (String key : List.of("PickupDelay", "Owner", "Thrower", "Item"))
|
||||
if (!java.util.Objects.equals(originalTag.buildResult().get(key), copiedTag.buildResult().get(key)))
|
||||
throw new IllegalStateException("Drop conversion lost metadata: " + key);
|
||||
// Four ordinary book variants must all convert through the actual event route.
|
||||
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description")) {
|
||||
var stack = book(name.endsWith("description"), name);
|
||||
if (name.equals("manual_link")) {
|
||||
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), sample.getItem().get(Rivencraft.LINK_BOOK_TARGET.get()));
|
||||
stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
|
||||
}
|
||||
if (name.equals("manual_description")) stack.set(Rivencraft.AGE_BOOK_ID.get(), level.dimension().identifier());
|
||||
var item = new ItemEntity(level, 8.5, 66, 8.5, stack);
|
||||
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); item.setTarget(OWNER);
|
||||
level.addFreshEntity(item);
|
||||
}
|
||||
player.drop(book(false, "player_toss"), false, net.minecraft.util.Prediction.SERVER_ONLY);
|
||||
net.minecraft.world.Containers.dropItemStack(level, 8.5, 65, 8.5, book(true, "container"));
|
||||
var standPos = new BlockPos(10, 64, 8);
|
||||
level.setBlock(standPos, Rivencraft.BOOK_STAND.get().defaultBlockState(), 3);
|
||||
((net.dnorberg.rivencraft.block.entity.BookStandBlockEntity) level.getBlockEntity(standPos)).setBook(book(false, "stand"));
|
||||
level.destroyBlock(standPos, true);
|
||||
var custom = new DroppedBookEntity(level, new Vec3(8.5, 66, 8.5), book(false, "already_custom"));
|
||||
custom.setNoGravity(true);
|
||||
level.addFreshEntity(custom);
|
||||
level.addFreshEntity(new ItemEntity(level, 8.5, 66, 8.5, net.dnorberg.rivencraft.item.PageItem.createLinkPanelPage()));
|
||||
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
|
||||
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity dropped
|
||||
&& dropped.getItem().getHoverName().getString().equals("cancel_preserve")) event.setCanceled(true);
|
||||
};
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
|
||||
try {
|
||||
var item = new ItemEntity(level, 8.5, 66, 8.5, book(false, "cancel_preserve"));
|
||||
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO);
|
||||
level.addFreshEntity(item);
|
||||
} finally {
|
||||
// Queued after that source's conversion task; no fixture listener remains installed.
|
||||
level.getServer().schedule(new TickTask(0, () -> net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel)));
|
||||
}
|
||||
}
|
||||
static void verify(ServerLevel level) {
|
||||
var drops = level.getEntitiesOfClass(ItemEntity.class, AREA);
|
||||
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description",
|
||||
"player_toss", "container", "stand", "already_custom", "cancel_preserve")) {
|
||||
var found = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals(name)).toList();
|
||||
if (found.size() != 1 || !name.equals("cancel_preserve") && !(found.getFirst() instanceof DroppedBookEntity))
|
||||
throw new IllegalStateException("Expected one open book for " + name + ", found " + found.size());
|
||||
}
|
||||
var named = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals("manual_link")).findFirst().orElseThrow();
|
||||
if (!OWNER.equals(named.getTarget()) || !"Named destination".equals(named.getItem().get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Converted book lost owner or destination");
|
||||
if (drops.stream().filter(e -> e.getItem().is(Rivencraft.PAGE.get())).anyMatch(e -> e instanceof DroppedBookEntity))
|
||||
throw new IllegalStateException("Non-book item was converted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.dnorberg.rivencraft.network.UseBookStandPayload;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Isolated conversion, ownership, stale-screen and persistence checks. */
|
||||
final class PortBookStandProbe {
|
||||
private static final BlockPos POS = new BlockPos(0, 64, 0);
|
||||
static void create(ServerLevel level) {
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("a5c323a1-3b58-4dd6-927f-e08000800110"), "RivenStandProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
try {
|
||||
for (Direction facing : Direction.Plane.HORIZONTAL) for (InteractionHand hand : InteractionHand.values())
|
||||
for (var sample : java.util.List.of(new ItemStack(Rivencraft.LINKING_BOOK.get()),
|
||||
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), PageItem.createLinkPanelPage().copyWithCount(3))) {
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.FACING, facing), 3);
|
||||
player.setItemInHand(hand, sample.copy());
|
||||
if (!BookStandConversionHandler.convert(player, hand, POS)) throw new IllegalStateException("Conversion failed");
|
||||
var stand = (BookStandBlockEntity) level.getBlockEntity(POS);
|
||||
var stored = sample.copyWithCount(1);
|
||||
if (!ItemStack.matches(stand.getBook(), stored) || player.getItemInHand(hand).getCount() != sample.getCount() - 1
|
||||
|| stand.getBlockState().getValue(BookStandBlock.FACING) != facing)
|
||||
throw new IllegalStateException("Conversion lost ownership or facing");
|
||||
var packet = new UseBookStandPayload(level.dimension().identifier(), POS, stored.copy());
|
||||
if (PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Valid source rejected");
|
||||
player.setPos(100, 64, 100);
|
||||
if (!PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Distant source accepted");
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "overworld"), POS, stored)).isEmpty())
|
||||
throw new IllegalStateException("Wrong dimension accepted");
|
||||
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(packet.dimension(), POS, new ItemStack(Items.DIRT))).isEmpty())
|
||||
throw new IllegalStateException("Replaced book accepted");
|
||||
var loaded = net.minecraft.world.level.block.entity.BlockEntity.loadStatic(POS, stand.getBlockState(),
|
||||
stand.saveWithFullMetadata(level.registryAccess()), level.registryAccess());
|
||||
if (!(loaded instanceof BookStandBlockEntity copy) || !ItemStack.matches(copy.getBook(), stored))
|
||||
throw new IllegalStateException("Stand serialization lost content");
|
||||
player.getInventory().clearContent();
|
||||
player.setShiftKeyDown(true);
|
||||
stand.getBlockState().useWithoutItem(level, player, new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false));
|
||||
player.setShiftKeyDown(false);
|
||||
if (!level.getBlockState(POS).is(Blocks.LECTERN)
|
||||
|| level.getBlockState(POS).getValue(LecternBlock.FACING) != facing
|
||||
|| player.getInventory().countItem(stored.getItem()) != 1
|
||||
|| !PortBookNetworking.validatedStandBook(player, packet).isEmpty())
|
||||
throw new IllegalStateException("Retrieval did not restore lectern or recover item exactly once");
|
||||
player.getInventory().clearContent();
|
||||
player.setItemInHand(hand, stored.copy());
|
||||
BookStandConversionHandler.convert(player, hand, POS);
|
||||
level.destroyBlock(POS, true);
|
||||
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
|
||||
new net.minecraft.world.phys.AABB(-2, 62, -2, 3, 68, 3));
|
||||
int items = drops.stream().filter(e -> e.getItem().is(stored.getItem())).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
int lecterns = drops.stream().filter(e -> e.getItem().is(Items.LECTERN)).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
if (items != 1 || lecterns != 1) throw new IllegalStateException("Break drops duplicated/lost: " + items + "/" + lecterns);
|
||||
drops.forEach(net.minecraft.world.entity.Entity::discard);
|
||||
}
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.HAS_BOOK, true), 3);
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, PageItem.createLinkPanelPage());
|
||||
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Occupied lectern converted");
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState(), 3);
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIRT));
|
||||
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Unrelated item accepted");
|
||||
var named = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Stand persistence"));
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, named);
|
||||
if (!BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Final stand missing");
|
||||
} finally { player.setShiftKeyDown(false); player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
static void verify(ServerLevel level) {
|
||||
if (!(level.getBlockEntity(POS) instanceof BookStandBlockEntity stand)
|
||||
|| !stand.getBook().is(Rivencraft.LINKING_BOOK.get())
|
||||
|| !stand.getBook().getHoverName().getString().equals("Stand persistence"))
|
||||
throw new IllegalStateException("Stand restart persistence failed");
|
||||
}
|
||||
}
|
||||
@@ -127,19 +127,15 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
}
|
||||
|
||||
public static boolean validWriting(List<Identifier> pages) {
|
||||
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
|
||||
for (Identifier page : pages) {
|
||||
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|
||||
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|
||||
|| isSize(page) && ++sizes > 1) return false;
|
||||
if (isController(page)) {
|
||||
if (!page.getPath().equals("celestial/moon")) suns++;
|
||||
if (!page.getPath().equals("celestial/sun")) moons++;
|
||||
if (suns > 10 || moons > 10) return false;
|
||||
periods = phases = directions = angles = sizes = 0;
|
||||
}
|
||||
int suns = 0, moons = 0;
|
||||
for (Identifier page : pages) if (isController(page)) {
|
||||
if (!page.getPath().equals("celestial/moon")) suns++;
|
||||
if (!page.getPath().equals("celestial/sun")) moons++;
|
||||
if (suns > 10 || moons > 10) return false;
|
||||
}
|
||||
return periods + phases + directions + angles + sizes == 0;
|
||||
// Repeated modifiers overwrite the pending value. Unconsumed modifiers
|
||||
// earn grammar instability, but never affect an earlier body.
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PortDayCycle compile(List<Identifier> pages, long seed) {
|
||||
@@ -148,8 +144,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
|
||||
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
|
||||
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
|
||||
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
|
||||
throw new IllegalArgumentException("Invalid celestial modifier order");
|
||||
if (!validWriting(pages))
|
||||
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
|
||||
// Match root AgeSkyProfile.random's count draw positions: sky style,
|
||||
// mixed sky color, then independent 0..3 sun and moon counts.
|
||||
@@ -167,7 +163,7 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
}
|
||||
|
||||
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
|
||||
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons");
|
||||
if (!validWriting(pages)) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
|
||||
return compileOne(pages, seed, nativeTicks, 0);
|
||||
var bodies = new java.util.ArrayList<PortCelestialBody>();
|
||||
@@ -190,8 +186,13 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
}
|
||||
|
||||
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
|
||||
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
|
||||
if (controller == null) return NATIVE;
|
||||
int controllerIndex = -1;
|
||||
for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) { controllerIndex = i; break; }
|
||||
// Only preceding modifiers belong to this controller. In particular, a
|
||||
// trailing dangling modifier must not rewrite the single-body fast path.
|
||||
if (controllerIndex < 0) return NATIVE;
|
||||
pages = pages.subList(0, controllerIndex + 1);
|
||||
var controller = pages.getLast();
|
||||
int mode = controller.getPath().equals("celestial/sun") ? SUN
|
||||
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
|
||||
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.BlockSnapshot;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
|
||||
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
|
||||
public final class PortDecayProviders {
|
||||
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
|
||||
private static final Map<Identifier, DecayBlock.Color> COLORS = Map.of(
|
||||
id("decayblue"), DecayBlock.Color.BLUE,
|
||||
id("decaypurple"), DecayBlock.Color.PURPLE,
|
||||
id("decayred"), DecayBlock.Color.RED,
|
||||
id("decaywhite"), DecayBlock.Color.WHITE);
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
private PortDecayProviders() {}
|
||||
|
||||
public static void register() {
|
||||
COLORS.keySet().forEach(id -> PortInstabilityRuntime.register(id, true, PortDecayProviders::apply));
|
||||
}
|
||||
|
||||
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
|
||||
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
|
||||
ServerLevel level = player.level();
|
||||
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|
||||
|| !PortInstabilityRuntime.isAge(level)
|
||||
|| level.getChunkSource().getChunkNow(player.chunkPosition().x(), player.chunkPosition().z()) == null) return -1;
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
Identifier id = id("decay" + color.getSerializedName());
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|
||||
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return -1;
|
||||
int changed = 0;
|
||||
for (int i = 0; i < Math.min(count, 32); i++) {
|
||||
PortInstabilityRuntime.Budget budget = new PortInstabilityRuntime.Budget(1);
|
||||
apply(new PortInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
|
||||
level.getServer().getTickCount(), budget), id, 1);
|
||||
changed += budget.applied();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|
||||
|| !PortInstabilityRuntime.isAge(context.level())
|
||||
|| context.loadedChunks().isEmpty()) return;
|
||||
DecayBlock.Color color = COLORS.get(id);
|
||||
if (color == null) return;
|
||||
PortDecaySavedData data = PortDecaySavedData.get(context.level());
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
|
||||
^ id.hashCode());
|
||||
List<BlockPos> frontier = data.frontier(color);
|
||||
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
|
||||
if (source == null) {
|
||||
seed(context, color, data, random);
|
||||
return;
|
||||
}
|
||||
BlockState sourceState = context.level().getBlockState(source);
|
||||
if (!sourceState.is(Rivencraft.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
|
||||
data.remove(color, source);
|
||||
return;
|
||||
}
|
||||
for (int attempt = 0; attempt < PortInstabilityConfig.decayMaxAttempts(); attempt++) {
|
||||
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
|
||||
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|
||||
|| isSpawnProtected(context.age(), target)) continue;
|
||||
BlockState prior = context.level().getBlockState(target);
|
||||
if (!canReplace(context.level(), target, prior, color)) continue;
|
||||
int difficulty = conversionDifficulty(context.level(), target, prior, color);
|
||||
if (random.nextInt(difficulty) != 0) continue;
|
||||
if (replace(context, target, prior, color)) data.add(color, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void seed(PortInstabilityRuntime.Context context, DecayBlock.Color color,
|
||||
PortDecaySavedData data, Random random) {
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
int x = chunk.getMinBlockX() + random.nextInt(16);
|
||||
int z = chunk.getMinBlockZ() + random.nextInt(16);
|
||||
BlockPos pos = new BlockPos(x,
|
||||
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
|
||||
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|
||||
|| isSpawnProtected(context.age(), pos)) return;
|
||||
BlockState prior = context.level().getBlockState(pos);
|
||||
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
|
||||
data.add(color, pos);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
|
||||
List<BlockPos> frontier, Random random) {
|
||||
if (frontier.isEmpty()) return null;
|
||||
int start = random.nextInt(frontier.size());
|
||||
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
|
||||
BlockPos pos = frontier.get((start + i) % frontier.size());
|
||||
if (isLoadedTarget(level, chunks, pos)) return pos;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) != null
|
||||
&& chunks.contains(ChunkPos.containing(pos));
|
||||
}
|
||||
|
||||
static boolean isSpawnProtected(PortInstabilityData.Age age, BlockPos pos) {
|
||||
BlockPos spawn = age.spawn().orElse(null);
|
||||
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
|
||||
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
|
||||
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
|
||||
}
|
||||
|
||||
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
|
||||
if (!PortInstabilityRuntime.isAge(level)
|
||||
|| !level.isInWorldBounds(pos) || level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) == null
|
||||
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|
||||
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|
||||
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|
||||
|| isCritical(prior)) return false;
|
||||
return !prior.is(Rivencraft.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
|
||||
}
|
||||
|
||||
private static boolean isCritical(BlockState prior) {
|
||||
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|
||||
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|
||||
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|
||||
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|
||||
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|
||||
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|
||||
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|
||||
|| prior.getBlock() != Rivencraft.DECAY.get()
|
||||
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
|
||||
.getNamespace().equals(Rivencraft.MOD_ID);
|
||||
}
|
||||
|
||||
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
|
||||
if (state.isAir()) return switch (color) {
|
||||
case BLUE, RED -> 20;
|
||||
case PURPLE -> 8;
|
||||
case WHITE -> 50;
|
||||
};
|
||||
if (state.is(Rivencraft.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
|
||||
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
|
||||
return switch (color) {
|
||||
case BLUE -> Math.max(1, (int) hardness * 2);
|
||||
case RED -> Math.max(1, (int) hardness);
|
||||
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
|
||||
case WHITE -> 1;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean replace(PortInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
|
||||
DecayBlock.Color color) {
|
||||
ServerLevel level = context.level();
|
||||
// A fake player gives claim/protection listeners the Age and exact block position.
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
if (!prior.isAir()) {
|
||||
var breakEvent = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(breakEvent);
|
||||
if (breakEvent.isCanceled()) return false;
|
||||
}
|
||||
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
|
||||
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
|
||||
BlockState decay = Rivencraft.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
|
||||
if (!level.setBlock(pos, decay, 3)) return false;
|
||||
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(placeEvent);
|
||||
if (placeEvent.isCanceled()) {
|
||||
snapshot.restore();
|
||||
return false;
|
||||
}
|
||||
context.budget().recordApplied();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.StringRepresentable;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Persisted per-Age frontier and attempt sequence, bounded to 1,024 positions per color. */
|
||||
public final class PortDecaySavedData extends SavedData {
|
||||
private static final int MAX_FRONTIER_PER_COLOR = 1024;
|
||||
private static final Codec<PortDecaySavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
|
||||
Codec.unboundedMap(StringRepresentable.fromEnum(DecayBlock.Color::values),
|
||||
BlockPos.CODEC.listOf(0, MAX_FRONTIER_PER_COLOR)).fieldOf("frontiers").forGetter(data -> data.frontiers)
|
||||
).apply(instance, PortDecaySavedData::new));
|
||||
private static final SavedDataType<PortDecaySavedData> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "decay"), () -> new PortDecaySavedData(0, Map.of()), CODEC);
|
||||
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
|
||||
private long attempts;
|
||||
private PortDecaySavedData(long attempts, Map<DecayBlock.Color, List<BlockPos>> positions) {
|
||||
this.attempts = attempts;
|
||||
for (var color : DecayBlock.Color.values()) frontiers.put(color,
|
||||
new ArrayList<>(positions.getOrDefault(color, List.of()).stream().distinct().map(BlockPos::immutable).toList()));
|
||||
}
|
||||
public static PortDecaySavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
|
||||
public long nextAttempt() { setDirty(); return attempts++; }
|
||||
public List<BlockPos> frontier(DecayBlock.Color color) { return frontiers.get(color); }
|
||||
public void add(DecayBlock.Color color, BlockPos pos) {
|
||||
var frontier = frontiers.get(color);
|
||||
if (frontier.contains(pos)) return;
|
||||
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.removeFirst();
|
||||
frontier.add(pos.immutable()); setDirty();
|
||||
}
|
||||
public void remove(DecayBlock.Color color, BlockPos pos) { if (frontiers.get(color).remove(pos)) setDirty(); }
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
}
|
||||
+17
-3
@@ -70,10 +70,12 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
.forGetter(generator -> Optional.ofNullable(generator.lakes)),
|
||||
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
|
||||
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
|
||||
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
|
||||
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) ->
|
||||
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
||||
com.mojang.serialization.Codec.BOOL.optionalFieldOf("libraries", false).forGetter(generator -> generator.libraries),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("library_symbols", List.of()).forGetter(generator -> generator.librarySymbols)
|
||||
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed, libraries, symbols) ->
|
||||
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
|
||||
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
|
||||
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed, libraries, symbols)));
|
||||
|
||||
private final ChunkGenerator base;
|
||||
private final PortRockPlan rocks;
|
||||
@@ -82,10 +84,19 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
private final PortLakePlan lakes;
|
||||
private final PortTerrainFeaturePlan terrainFeatures;
|
||||
private final long ageSeed;
|
||||
private final boolean libraries;
|
||||
private final List<Identifier> librarySymbols;
|
||||
|
||||
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
|
||||
this(base, rocks, vegetation, dungeons, lakes, terrainFeatures, ageSeed, false, List.of());
|
||||
}
|
||||
|
||||
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, long ageSeed,
|
||||
boolean libraries, List<Identifier> librarySymbols) {
|
||||
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
|
||||
this.base = base;
|
||||
this.rocks = rocks;
|
||||
@@ -94,6 +105,8 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
this.lakes = lakes;
|
||||
this.terrainFeatures = terrainFeatures;
|
||||
this.ageSeed = ageSeed;
|
||||
this.libraries = libraries;
|
||||
this.librarySymbols = List.copyOf(librarySymbols);
|
||||
}
|
||||
|
||||
public ChunkGenerator base() { return base; }
|
||||
@@ -286,5 +299,6 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
|
||||
}
|
||||
}
|
||||
if (libraries) PortLibraryFeature.generate(level, chunk, ageSeed, librarySymbols);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.LightningBolt;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Explosion;
|
||||
import net.minecraft.world.level.ExplosionDamageCalculator;
|
||||
import net.minecraft.world.level.gamerules.GameRules;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
|
||||
/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */
|
||||
public final class PortHazardProviders {
|
||||
private static final Identifier LIGHTNING = id("lightning");
|
||||
private static final Identifier EXPLOSIONS = id("explosions");
|
||||
|
||||
private PortHazardProviders() {}
|
||||
|
||||
public static void register() {
|
||||
// Visuals and entity damage remain available when block mutation is disabled.
|
||||
PortInstabilityRuntime.register(LIGHTNING, false, PortHazardProviders::apply);
|
||||
PortInstabilityRuntime.register(EXPLOSIONS, false, PortHazardProviders::apply);
|
||||
}
|
||||
|
||||
static boolean isHazard(Identifier id) {
|
||||
return LIGHTNING.equals(id) || EXPLOSIONS.equals(id);
|
||||
}
|
||||
|
||||
/** Roll every selected hazard card each scheduler pass, independently of other providers. */
|
||||
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
|
||||
List<PortInstabilityPlan.ProviderLevel> providers) {
|
||||
if (!PortInstabilityConfig.hazardsEnabled()) return;
|
||||
List<PortInstabilityPlan.ProviderLevel> hazards = providers.stream()
|
||||
.filter(provider -> isHazard(provider.id())).toList();
|
||||
if (hazards.isEmpty()) return;
|
||||
List<ChunkPos> chunks = loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
|
||||
if (chunks.isEmpty()) return;
|
||||
for (PortInstabilityPlan.ProviderLevel hazard : hazards) {
|
||||
var context = new PortInstabilityRuntime.Context(level, age, chunks, tick,
|
||||
new PortInstabilityRuntime.Budget(1));
|
||||
PortInstabilityRuntime.invoke(context, hazard.id(), hazard.level());
|
||||
}
|
||||
}
|
||||
|
||||
static List<ChunkPos> loadedChunksNearPlayers(ServerLevel level, int maximum) {
|
||||
List<ChunkPos> players = level.players().stream()
|
||||
.sorted(Comparator.comparing(Entity::getUUID))
|
||||
.map(ServerPlayer::chunkPosition).toList();
|
||||
return candidateChunks(players, maximum,
|
||||
chunk -> level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null);
|
||||
}
|
||||
|
||||
/** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */
|
||||
static List<ChunkPos> candidateChunks(List<ChunkPos> players, int maximum,
|
||||
Predicate<ChunkPos> loaded) {
|
||||
if (maximum <= 0) return List.of();
|
||||
Set<ChunkPos> chunks = new LinkedHashSet<>();
|
||||
for (int radius = 0; radius <= 4; radius++) {
|
||||
for (int dx = -radius; dx <= radius; dx++) {
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue;
|
||||
for (ChunkPos center : players) {
|
||||
int x = center.x() + dx;
|
||||
int z = center.z() + dz;
|
||||
ChunkPos candidate = new ChunkPos(x, z);
|
||||
if (loaded.test(candidate)) chunks.add(candidate);
|
||||
if (chunks.size() >= maximum) return List.copyOf(chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(chunks);
|
||||
}
|
||||
|
||||
/** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */
|
||||
public static int pulseForTesting(ServerPlayer player, boolean lightning) {
|
||||
return pulseForTesting(player.level(), player.blockPosition(), lightning);
|
||||
}
|
||||
|
||||
public static int pulseForTesting(ServerLevel level, BlockPos origin, boolean lightning) {
|
||||
if (!PortInstabilityConfig.hazardsEnabled()
|
||||
|| !PortInstabilityRuntime.isAge(level)) return -1;
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|
||||
|| !PortInstabilityConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1;
|
||||
PortHazardSavedData data = PortHazardSavedData.get(level);
|
||||
if (!data.ready(lightning, level.getGameTime(), PortInstabilityConfig.hazardsMinInterval())) return 0;
|
||||
ChunkPos center = ChunkPos.containing(origin);
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
ChunkPos chunk = new ChunkPos(center.x() + dx, center.z() + dz);
|
||||
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null) continue;
|
||||
int x = chunk.getMinBlockX() + 8;
|
||||
int z = chunk.getMinBlockZ() + 8;
|
||||
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
|
||||
: origin.getY();
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
var context = new PortInstabilityRuntime.Context(level, age, List.of(chunk),
|
||||
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1));
|
||||
if (!safeTarget(context, pos, 8)) continue;
|
||||
data.nextAttempt();
|
||||
data.record(lightning, level.getGameTime());
|
||||
return (lightning ? strike(level, age, chunk, pos) : explode(level, age, chunk, pos)) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void apply(PortInstabilityRuntime.Context context, Identifier id, int effectiveLevel) {
|
||||
ServerLevel level = context.level();
|
||||
if (!PortInstabilityConfig.hazardsEnabled() || context.loadedChunks().isEmpty()
|
||||
|| !PortInstabilityRuntime.isAge(level)) return;
|
||||
boolean lightning = id.equals(LIGHTNING);
|
||||
if (!lightning && !id.equals(EXPLOSIONS)) return;
|
||||
PortHazardSavedData data = PortHazardSavedData.get(level);
|
||||
long gameTime = level.getGameTime();
|
||||
if (!data.ready(lightning, gameTime, PortInstabilityConfig.hazardsMinInterval())) return;
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
|
||||
^ id.hashCode());
|
||||
int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000;
|
||||
// Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks.
|
||||
double chance = legacyChance(context.loadedChunks().size(), PortInstabilityConfig.instabilityCooldown(),
|
||||
effectiveLevel, legacyDenominator);
|
||||
if (random.nextDouble() >= chance) return;
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
int margin = lightning ? 2 : 6;
|
||||
int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2);
|
||||
int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2);
|
||||
int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)
|
||||
: level.getMinY() + 1 + random.nextInt(level.getHeight() - 2);
|
||||
BlockPos pos = new BlockPos(x, y, z);
|
||||
if (!safeTarget(context, pos, 8) || !context.budget().claim()) return;
|
||||
data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event
|
||||
if (lightning ? strike(level, context.age(), chunk, pos) : explode(level, context.age(), chunk, pos))
|
||||
context.budget().recordApplied();
|
||||
}
|
||||
|
||||
static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) {
|
||||
if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0;
|
||||
return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies);
|
||||
}
|
||||
|
||||
static boolean safeTarget(PortInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) {
|
||||
if (!PortDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false;
|
||||
BlockPos spawn = context.age().spawn().orElse(null);
|
||||
return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin
|
||||
|| Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin;
|
||||
}
|
||||
|
||||
private static boolean strike(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
|
||||
AABB bounds = new AABB(pos).inflate(2);
|
||||
if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return false;
|
||||
LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED);
|
||||
if (bolt == null) return false;
|
||||
bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
|
||||
bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets
|
||||
if (!level.addFreshEntity(bolt)) return false;
|
||||
List<LivingEntity> targets = level.getEntitiesOfClass(LivingEntity.class, bounds,
|
||||
entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity));
|
||||
targets.sort(Comparator.comparing(Entity::getUUID));
|
||||
int count = 0;
|
||||
for (LivingEntity target : targets) {
|
||||
if (count++ >= PortInstabilityConfig.hazardsMaxEntities()) break;
|
||||
if (PortInstabilityConfig.hazardsMaxDamage() > 0)
|
||||
target.hurtServer(level, level.damageSources().lightningBolt(), Math.min(5, PortInstabilityConfig.hazardsMaxDamage()));
|
||||
}
|
||||
// Legacy lightning started fires. Keep it separately gated by block policy and game rule.
|
||||
if (canMutate(level) && PortInstabilityConfig.hazardsFire()
|
||||
&& level.canSpreadFireAround(pos)
|
||||
&& !PortDecayProviders.isSpawnProtected(age, pos)
|
||||
&& PortDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean explode(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) {
|
||||
return explodeBounded(level, age, chunk, pos, PortInstabilityConfig.hazardsBlockDamage(),
|
||||
PortInstabilityConfig.hazardsPlayerDamage(), PortInstabilityConfig.hazardsFire(),
|
||||
PortInstabilityConfig.hazardsMaxBlocks(), PortInstabilityConfig.hazardsMaxEntities(),
|
||||
PortInstabilityConfig.hazardsMaxDamage(), PortInstabilityConfig.hazardsExplosionRadius());
|
||||
}
|
||||
|
||||
/** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */
|
||||
static boolean explodeBounded(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos,
|
||||
boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities,
|
||||
int maxDamage, int radius) {
|
||||
boolean grief = destructiveBlocksAllowed(PortInstabilityConfig.instabilityWorldMutation(),
|
||||
level.getGameRules().get(GameRules.MOB_GRIEFING), blockDamage, maxBlocks);
|
||||
boolean fire = grief && ignite
|
||||
&& level.canSpreadFireAround(pos);
|
||||
BoundedExplosion calculator = new BoundedExplosion(level, age, chunk, grief,
|
||||
playerDamage, maxBlocks, maxEntities, maxDamage);
|
||||
level.explode(null, null, calculator,
|
||||
pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5,
|
||||
radius, false,
|
||||
grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE);
|
||||
if (fire) {
|
||||
int ignited = 0;
|
||||
for (BlockPos affected : calculator.approved) {
|
||||
if (ignited >= 4) break;
|
||||
if (level.canSpreadFireAround(affected) && !PortDecayProviders.isSpawnProtected(age, affected)
|
||||
&& ignite(level, affected)) ignited++;
|
||||
}
|
||||
}
|
||||
return calculator.started;
|
||||
}
|
||||
|
||||
private static boolean ignite(ServerLevel level, BlockPos pos) {
|
||||
if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos))
|
||||
return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5);
|
||||
var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos);
|
||||
BlockState prior = level.getBlockState(pos);
|
||||
if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false;
|
||||
var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(place);
|
||||
if (place.isCanceled()) {
|
||||
snapshot.restore();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean canMutate(ServerLevel level) {
|
||||
return PortInstabilityConfig.instabilityWorldMutation()
|
||||
&& level.getGameRules().get(GameRules.MOB_GRIEFING);
|
||||
}
|
||||
|
||||
static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing,
|
||||
boolean blockDamage, int maxBlocks) {
|
||||
return worldMutation && mobGriefing && blockDamage && maxBlocks > 0;
|
||||
}
|
||||
|
||||
static boolean protectedBlock(ServerLevel level, PortInstabilityData.Age age, BlockPos pos, BlockState state) {
|
||||
return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT)
|
||||
|| state.is(PortDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS)
|
||||
|| PortDecayProviders.isSpawnProtected(age, pos)
|
||||
|| state.getDestroySpeed(level, pos) < 0
|
||||
|| BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID);
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity) {
|
||||
return eligibleEntity(entity, PortInstabilityConfig.hazardsPlayerDamage());
|
||||
}
|
||||
|
||||
private static boolean eligibleEntity(Entity entity, boolean playerDamage) {
|
||||
return !(entity instanceof Player player) || playerDamage
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private static final class BoundedExplosion extends ExplosionDamageCalculator {
|
||||
private final ServerLevel level;
|
||||
private final PortInstabilityData.Age age;
|
||||
private final ChunkPos chunk;
|
||||
private final boolean grief;
|
||||
private final boolean playerDamage;
|
||||
private final int maxBlocks;
|
||||
private final int maxEntities;
|
||||
private final int maxDamage;
|
||||
private final Set<BlockPos> approved = new LinkedHashSet<>();
|
||||
private final Set<UUID> damagedEntities = new HashSet<>();
|
||||
private int damaged;
|
||||
private boolean started;
|
||||
|
||||
private BoundedExplosion(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, boolean grief,
|
||||
boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) {
|
||||
this.level = level;
|
||||
this.age = age;
|
||||
this.chunk = chunk;
|
||||
this.grief = grief;
|
||||
this.playerDamage = playerDamage;
|
||||
this.maxBlocks = maxBlocks;
|
||||
this.maxEntities = maxEntities;
|
||||
this.maxDamage = maxDamage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Float> getBlockExplosionResistance(Explosion explosion, BlockGetter reader,
|
||||
BlockPos pos, BlockState state, FluidState fluid) {
|
||||
started = true;
|
||||
if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE);
|
||||
return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos,
|
||||
BlockState state, float power) {
|
||||
if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false;
|
||||
if (approved.contains(pos)) return true;
|
||||
if (approved.size() >= maxBlocks) return false;
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
var event = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, state, actor);
|
||||
NeoForge.EVENT_BUS.post(event);
|
||||
if (event.isCanceled()) return false;
|
||||
approved.add(pos.immutable());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldDamageEntity(Explosion explosion, Entity entity) {
|
||||
if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage)
|
||||
|| maxDamage == 0) return false;
|
||||
damaged++;
|
||||
damagedEntities.add(entity.getUUID());
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEntityDamageAmount(Explosion explosion, Entity entity, float exposure) {
|
||||
return Math.min(super.getEntityDamageAmount(explosion, entity, exposure), maxDamage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getKnockbackMultiplier(Entity entity) {
|
||||
return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F;
|
||||
}
|
||||
|
||||
private boolean inLoadedChunk(BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && ChunkPos.containing(pos).equals(chunk)
|
||||
&& level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Per-Age random sequence and event cooldowns survive unload and restart. */
|
||||
public final class PortHazardSavedData extends SavedData {
|
||||
private static final Codec<PortHazardSavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
|
||||
Codec.LONG.optionalFieldOf("last_lightning", Long.MIN_VALUE).forGetter(data -> data.lastLightning),
|
||||
Codec.LONG.optionalFieldOf("last_explosion", Long.MIN_VALUE).forGetter(data -> data.lastExplosion),
|
||||
Codec.LONG.optionalFieldOf("last_meteor", Long.MIN_VALUE).forGetter(data -> data.lastMeteor)
|
||||
).apply(instance, PortHazardSavedData::new));
|
||||
private static final SavedDataType<PortHazardSavedData> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "hazards"),
|
||||
() -> new PortHazardSavedData(0, Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE), CODEC);
|
||||
private long attempts, lastLightning, lastExplosion, lastMeteor;
|
||||
private PortHazardSavedData(long attempts, long lightning, long explosion, long meteor) {
|
||||
this.attempts = attempts; lastLightning = lightning; lastExplosion = explosion; lastMeteor = meteor;
|
||||
}
|
||||
public static PortHazardSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
|
||||
public long nextAttempt() { setDirty(); return attempts++; }
|
||||
public boolean ready(boolean lightning, long time, int interval) {
|
||||
long last = lightning ? lastLightning : lastExplosion;
|
||||
return last == Long.MIN_VALUE || time < last || time - last >= interval;
|
||||
}
|
||||
public void record(boolean lightning, long time) { if (lightning) lastLightning = time; else lastExplosion = time; setDirty(); }
|
||||
public boolean meteorReady(long time, int interval) { return lastMeteor == Long.MIN_VALUE || time < lastMeteor || time - lastMeteor >= interval; }
|
||||
public void recordMeteor(long time) { lastMeteor = time; setDirty(); }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
|
||||
/** Scores and test overrides are deliberately operator-only, never book tooltip data. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortInstabilityCommands {
|
||||
private PortInstabilityCommands() {}
|
||||
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft_port")
|
||||
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
|
||||
.then(Commands.literal("instability")
|
||||
.then(Commands.literal("status").executes(context -> {
|
||||
var source = context.getSource(); var level = requireAge(source);
|
||||
var age = PortInstabilityData.get(source.getServer()).age(level);
|
||||
source.sendSuccess(() -> Component.literal("Age " + level.dimension().identifier()
|
||||
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
|
||||
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
|
||||
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
|
||||
source.sendSuccess(() -> Component.literal(" live block score=" + PortBlockInstability.score(level)
|
||||
+ "; effective score=" + PortInstabilityRuntime.effectiveScore(level)
|
||||
+ "; difficulty=" + PortInstabilityConfig.instabilityDifficulty()
|
||||
+ "; samples Age=" + PortBlockInstability.sampledChunks(level) + "/" + PortBlockProfileSavedData.MIN_AGE_SAMPLES
|
||||
+ ", Overworld=" + PortBlockInstability.sampledChunks(source.getServer().overworld()) + "/" + PortBlockProfileSavedData.MIN_BASELINE_SAMPLES
|
||||
+ "; profiling=" + PortInstabilityData.get(source.getServer()).supportsProfiling(level)), false);
|
||||
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
|
||||
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
|
||||
source.sendSuccess(() -> Component.literal(" erosion testing policy (not a saved card): "
|
||||
+ PortInstabilityRuntime.providerStatus(level, PortEnvironmentalProviders.EROSION)
|
||||
+ "; current score=" + PortInstabilityRuntime.effectiveScore(level)), false);
|
||||
for (var provider : PortInstabilityRuntime.plan(level).providers())
|
||||
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
|
||||
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(context -> {
|
||||
var level = requireAge(context.getSource());
|
||||
boolean enabled = BoolArgumentType.getBool(context, "enabled");
|
||||
PortInstabilityData.get(level.getServer()).enabled(level, enabled);
|
||||
if (!enabled) PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Age instability enabled=" + enabled
|
||||
+ "; saved score and provider selection unchanged"), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("test_score").then(Commands.argument("score", IntegerArgumentType.integer(0, 1_000_000))
|
||||
.executes(context -> {
|
||||
var level = requireAge(context.getSource());
|
||||
PortInstabilityRuntime.testScore(level, IntegerArgumentType.getInteger(context, "score"));
|
||||
PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Session-only score override set; frozen score unchanged. Restart or clear_test_score removes it."), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear_test_score").executes(context -> {
|
||||
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
|
||||
PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Cleared test override; restored live effective provider selection"), false); return 1;
|
||||
}))
|
||||
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (var color : net.dnorberg.rivencraft.block.DecayBlock.Color.values())
|
||||
if (color.getSerializedName().startsWith(builder.getRemaining())) builder.suggest(color.getSerializedName());
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> decay(context, 1))
|
||||
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
|
||||
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
|
||||
.then(Commands.literal("environment").then(Commands.argument("effect", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (String effect : java.util.List.of("burning", "erosion"))
|
||||
if (effect.startsWith(builder.getRemaining())) builder.suggest(effect);
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> {
|
||||
var source = context.getSource(); requireAge(source);
|
||||
String effect = StringArgumentType.getString(context, "effect");
|
||||
if (!effect.equals("burning") && !effect.equals("erosion")) {
|
||||
source.sendFailure(Component.literal("Choose burning or erosion")); return 0;
|
||||
}
|
||||
int result = PortEnvironmentalProviders.pulseForTesting(source.getPlayerOrException(), effect);
|
||||
if (result > 0) source.sendSuccess(() -> Component.literal("Applied " + effect + "; saved score/plan unchanged"), false);
|
||||
else source.sendFailure(Component.literal(result == -2 ? "Burning would be extinguished by water or rain"
|
||||
: "No effect applied. Check Age/config/provider policy, spawn protection and sky exposure; burning requires Survival/Adventure, erosion requires score >=10000 and a source fluid beside/above the targeted block."));
|
||||
return Math.max(0, result);
|
||||
})))
|
||||
.then(Commands.literal("meteor").executes(context -> {
|
||||
var source = context.getSource(); requireAge(source);
|
||||
int result = PortMeteorProvider.pulseForTesting(source.getPlayerOrException());
|
||||
if (result > 0) source.sendSuccess(() -> Component.literal("Meteor descent scheduled; impact in 30 ticks. Saved score/plan unchanged."), false);
|
||||
else source.sendFailure(Component.literal("No meteor scheduled: check cooldown (200 ticks), active flight, safe loaded terrain and Age/config/provider policy."));
|
||||
return Math.max(0, result);
|
||||
}))
|
||||
.then(Commands.literal("hazard").then(Commands.argument("type", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (String type : java.util.List.of("lightning", "explosions"))
|
||||
if (type.startsWith(builder.getRemaining())) builder.suggest(type);
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> {
|
||||
var source = context.getSource(); var level = requireAge(source);
|
||||
String type = StringArgumentType.getString(context, "type");
|
||||
if (!type.equals("lightning") && !type.equals("explosions")) {
|
||||
source.sendFailure(Component.literal("Choose lightning or explosions")); return 0;
|
||||
}
|
||||
int result = PortHazardProviders.pulseForTesting(level, BlockPos.containing(source.getPosition()), type.equals("lightning"));
|
||||
if (result > 0) source.sendSuccess(() -> Component.literal("Triggered " + type
|
||||
+ " in a nearby loaded chunk; saved score/plan unchanged"), false);
|
||||
else source.sendFailure(Component.literal(result < 0 ? "Hazard disabled by config, Age or provider policy"
|
||||
: "No hazard triggered: cooldown, protected spawn, unloaded terrain or event cancellation"));
|
||||
return Math.max(0, result);
|
||||
})))
|
||||
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
|
||||
if (card.id().getPath().startsWith(builder.getRemaining())) builder.suggest(card.id().getPath());
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> {
|
||||
var source = context.getSource(); var level = requireAge(source);
|
||||
String name = StringArgumentType.getString(context, "provider");
|
||||
Identifier id = Identifier.tryParse(name.contains(":") ? name : "rivencraft:" + name);
|
||||
if (id == null || !InstabilityProviderCatalog.contains(id)) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Unknown instability provider")).create();
|
||||
var chunk = net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(source.getPosition()));
|
||||
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null)
|
||||
throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Current chunk must already be loaded")).create();
|
||||
var budget = new PortInstabilityRuntime.Budget(1);
|
||||
var effect = new PortInstabilityRuntime.Context(level, PortInstabilityData.get(level.getServer()).age(level),
|
||||
java.util.List.of(chunk), source.getServer().getTickCount(), budget);
|
||||
boolean attempted = PortInstabilityRuntime.invoke(effect, id, 1);
|
||||
boolean applied = attempted && budget.applied() > 0;
|
||||
if (applied) source.sendSuccess(() -> Component.literal("Applied " + id + " (" + budget.applied() + " successful action in this chunk)"), false);
|
||||
else source.sendFailure(Component.literal("No effect applied. Check eligible targets/terrain, sky exposure, existing effects and protection. Provider status: "
|
||||
+ PortInstabilityRuntime.providerStatus(level, id)));
|
||||
return applied ? 1 : 0;
|
||||
})))));
|
||||
}
|
||||
private static int decay(com.mojang.brigadier.context.CommandContext<CommandSourceStack> context, int attempts)
|
||||
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
|
||||
requireAge(context.getSource());
|
||||
var color = java.util.Arrays.stream(net.dnorberg.rivencraft.block.DecayBlock.Color.values())
|
||||
.filter(value -> value.getSerializedName().equalsIgnoreCase(StringArgumentType.getString(context, "color")))
|
||||
.findFirst().orElse(null);
|
||||
if (color == null) {
|
||||
context.getSource().sendFailure(Component.literal("Choose blue, purple, red or white")); return 0;
|
||||
}
|
||||
int changed = PortDecayProviders.pulseForTesting(context.getSource().getPlayerOrException(), color, attempts);
|
||||
if (changed < 0) {
|
||||
context.getSource().sendFailure(Component.literal("Decay is disabled by config, Age or provider policy")); return 0;
|
||||
}
|
||||
context.getSource().sendSuccess(() -> Component.literal("Decay " + color.getSerializedName() + ": " + changed
|
||||
+ " block changes from " + attempts + " bounded attempts; saved score/plan unchanged"), false);
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var nearby = PortDecaySavedData.get(player.level()).frontier(color).reversed().stream()
|
||||
.filter(pos -> net.minecraft.world.level.ChunkPos.containing(pos).equals(player.chunkPosition()))
|
||||
.limit(4).map(BlockPos::toShortString).toList();
|
||||
if (!nearby.isEmpty()) context.getSource().sendSuccess(() -> Component.literal("Nearby tracked decay: " + nearby), false);
|
||||
return changed;
|
||||
}
|
||||
private static net.minecraft.server.level.ServerLevel requireAge(CommandSourceStack source)
|
||||
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
|
||||
if (!PortInstabilityRuntime.isAge(source.getLevel())) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(
|
||||
Component.literal("Use this command inside a Rivencraft Age")).create();
|
||||
return source.getLevel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** The 1.21.1 scheduler and status-provider defaults, in the port's server-owned config. */
|
||||
public final class PortInstabilityConfig {
|
||||
private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay;
|
||||
private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
|
||||
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
|
||||
private static ModConfigSpec.BooleanValue hazards, hazardBlocks, hazardPlayers, hazardFire;
|
||||
private static ModConfigSpec.IntValue hazardInterval, hazardChunks, hazardMaxBlocks, hazardEntities, hazardDamage, hazardRadius;
|
||||
private static ModConfigSpec.BooleanValue meteor, meteorBlocks, meteorPlayers, meteorFire;
|
||||
private static ModConfigSpec.IntValue meteorMaxBlocks, meteorEntities, meteorDamage;
|
||||
private static ModConfigSpec.BooleanValue scorch, scorchPlayers, erosion;
|
||||
private static ModConfigSpec.IntValue scorchTargets;
|
||||
private static ModConfigSpec.IntValue difficulty;
|
||||
private static ModConfigSpec.ConfigValue<List<? extends String>> materialOverrides;
|
||||
private PortInstabilityConfig() {}
|
||||
public static void define(ModConfigSpec.Builder builder) {
|
||||
builder.push("instability");
|
||||
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
|
||||
.define("enabled", true);
|
||||
difficulty = builder.comment("Legacy instability difficulty: 0=quarter, 1=half, 2=normal, 3=1.75x; saved scores stay unchanged.")
|
||||
.defineInRange("difficulty", 2, 0, 3);
|
||||
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
|
||||
.define("world_mutation", true);
|
||||
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
|
||||
ages = builder.defineInRange("max_ages_per_tick", 4, 1, 16);
|
||||
providers = builder.defineInRange("max_providers_per_age", 2, 1, 16);
|
||||
actions = builder.defineInRange("max_actions_per_age", 1, 1, 16);
|
||||
level = builder.defineInRange("max_effect_level", 3, 1, 16);
|
||||
chunks = builder.defineInRange("max_loaded_chunks", 16, 1, 64);
|
||||
disabledProviders = builder.comment("Disable particular providers without changing saved selections. Accepts poison or rivencraft:poison.")
|
||||
.defineListAllowEmpty("disabled_providers", List.of(), () -> "poison", value -> value instanceof String text
|
||||
&& Identifier.tryParse(text) != null);
|
||||
builder.pop();
|
||||
builder.push("instability_status_effects");
|
||||
status = builder.define("enabled", true);
|
||||
players = builder.comment("Affect Survival/Adventure players; Creative and Spectator remain exempt.")
|
||||
.define("affect_players", true);
|
||||
nonplayers = builder.define("affect_nonplayers", true);
|
||||
hostiles = builder.define("buff_hostiles", true);
|
||||
active = builder.defineInRange("max_active_per_age", 32, 1, 128);
|
||||
candidates = builder.defineInRange("max_candidates_per_attempt", 8, 1, 16);
|
||||
duration = builder.defineInRange("max_duration_ticks", 200, 20, 200);
|
||||
builder.pop();
|
||||
builder.push("instability_decay");
|
||||
decay = builder.comment("Allow colored decay in loaded Ages. Also requires instability.world_mutation.")
|
||||
.define("enabled", true);
|
||||
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
|
||||
.defineInRange("max_attempts_per_call", 4, 1, 6);
|
||||
builder.pop();
|
||||
builder.push("instability_hazards");
|
||||
hazards = builder.comment("Run Legacy lightning and explosion cards in loaded Ages.").define("enabled", true);
|
||||
hazardBlocks = builder.comment("Allow block destruction; also requires world_mutation and mobGriefing.").define("block_damage", true);
|
||||
hazardPlayers = builder.comment("Allow damage to Survival/Adventure players.").define("player_damage", true);
|
||||
hazardFire = builder.comment("Allow ignition where the vanilla fire-spread rule permits it.").define("fire", true);
|
||||
hazardInterval = builder.defineInRange("minimum_gap_ticks", 20, 20, 12000);
|
||||
hazardChunks = builder.defineInRange("max_loaded_chunks", 81, 1, 169);
|
||||
hazardMaxBlocks = builder.defineInRange("max_explosion_blocks", 16, 0, 64);
|
||||
hazardEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
|
||||
hazardDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
|
||||
hazardRadius = builder.comment("Legacy strength 3, bounded to one already-loaded chunk.").defineInRange("explosion_radius", 3, 1, 3);
|
||||
builder.pop();
|
||||
builder.push("instability_meteor");
|
||||
meteor = builder.comment("Run the active Legacy meteor card in loaded Ages.").define("enabled", true);
|
||||
meteorBlocks = builder.comment("Allow impact block destruction; also requires world_mutation and mobGriefing.")
|
||||
.define("block_damage", true);
|
||||
meteorPlayers = builder.comment("Allow impacts to hurt Survival/Adventure players.").define("player_damage", true);
|
||||
meteorFire = builder.comment("Allow impact fire where the vanilla fire-spread rule permits it.").define("fire", true);
|
||||
meteorMaxBlocks = builder.defineInRange("max_impact_blocks", 24, 0, 64);
|
||||
meteorEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32);
|
||||
meteorDamage = builder.defineInRange("max_entity_damage", 12, 0, 40);
|
||||
builder.pop();
|
||||
builder.push("instability_scorching");
|
||||
scorch = builder.comment("Run Legacy's active burning card on sky-exposed entities.").define("enabled", true);
|
||||
scorchPlayers = builder.comment("Allow burning on Survival/Adventure players.").define("affect_players", true);
|
||||
scorchTargets = builder.defineInRange("max_targets_per_pass", 4, 0, 16);
|
||||
builder.pop();
|
||||
builder.push("instability_erosion");
|
||||
erosion = builder.comment("Enable Legacy's dormant erosion effect for testing; no saved card is added.",
|
||||
"Ages with a score of at least 10000 can spread adjacent source fluid into one eligible block per pass.")
|
||||
.define("enabled", true);
|
||||
builder.pop();
|
||||
builder.push("instability_modded_materials");
|
||||
materialOverrides = builder.comment("Recognized ores, mineral blocks and source fluids from installed mods are scored automatically.",
|
||||
"Optional exact modid:block_id=points overrides; zero excludes a block. No content mod is required.")
|
||||
.defineListAllowEmpty("block_weight_overrides", List.of(), () -> "examplemod:rare_ore=4000", PortInstabilityConfig::validMaterialOverride);
|
||||
builder.pop();
|
||||
}
|
||||
private static boolean validMaterialOverride(Object value) {
|
||||
if (!(value instanceof String entry)) return false;
|
||||
int separator = entry.lastIndexOf('=');
|
||||
if (separator < 1) return false;
|
||||
Identifier id = Identifier.tryParse(entry.substring(0, separator));
|
||||
if (id == null || !entry.substring(0, separator).contains(":")
|
||||
|| id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false;
|
||||
try { int weight = Integer.parseInt(entry.substring(separator + 1)); return weight >= 0 && weight <= 100000; }
|
||||
catch (NumberFormatException ignored) { return false; }
|
||||
}
|
||||
public static int instabilityDifficulty() { return difficulty.get(); }
|
||||
public static List<? extends String> moddedMaterialOverrides() { return materialOverrides.get(); }
|
||||
public static boolean scorchEnabled() { return scorch.get(); }
|
||||
public static boolean scorchPlayers() { return scorchPlayers.get(); }
|
||||
public static int scorchMaxTargets() { return scorchTargets.get(); }
|
||||
public static boolean erosionEnabled() { return erosion.get(); }
|
||||
public static boolean meteorEnabled() { return meteor.get(); }
|
||||
public static boolean meteorBlockDamage() { return meteorBlocks.get(); }
|
||||
public static boolean meteorPlayerDamage() { return meteorPlayers.get(); }
|
||||
public static boolean meteorFire() { return meteorFire.get(); }
|
||||
public static int meteorMaxBlocks() { return meteorMaxBlocks.get(); }
|
||||
public static int meteorMaxEntities() { return meteorEntities.get(); }
|
||||
public static int meteorMaxDamage() { return meteorDamage.get(); }
|
||||
public static boolean hazardsEnabled() { return hazards.get(); }
|
||||
public static boolean hazardsBlockDamage() { return hazardBlocks.get(); }
|
||||
public static boolean hazardsPlayerDamage() { return hazardPlayers.get(); }
|
||||
public static boolean hazardsFire() { return hazardFire.get(); }
|
||||
public static int hazardsMinInterval() { return hazardInterval.get(); }
|
||||
public static int hazardsMaxChunks() { return hazardChunks.get(); }
|
||||
public static int hazardsMaxBlocks() { return hazardMaxBlocks.get(); }
|
||||
public static int hazardsMaxEntities() { return hazardEntities.get(); }
|
||||
public static int hazardsMaxDamage() { return hazardDamage.get(); }
|
||||
public static int hazardsExplosionRadius() { return hazardRadius.get(); }
|
||||
public static boolean instabilityWorldMutation() { return worldMutation.get(); }
|
||||
public static boolean decayEnabled() { return decay.get(); }
|
||||
public static int decayMaxAttempts() { return decayAttempts.get(); }
|
||||
public static boolean instabilityEnabled() { return enabled.get(); }
|
||||
public static boolean instabilityProviderEnabled(Identifier id) {
|
||||
return disabledProviders.get().stream().noneMatch(value -> value.equals(id.toString())
|
||||
|| id.getNamespace().equals("rivencraft") && value.equals(id.getPath()));
|
||||
}
|
||||
public static int instabilityCooldown() { return cooldown.get(); }
|
||||
public static int instabilityMaxAges() { return ages.get(); }
|
||||
public static int instabilityMaxProviders() { return providers.get(); }
|
||||
public static int instabilityMaxActions() { return actions.get(); }
|
||||
public static int instabilityMaxLevel() { return level.get(); }
|
||||
public static int instabilityMaxChunks() { return chunks.get(); }
|
||||
public static boolean statusEffectsEnabled() { return status.get(); }
|
||||
public static boolean statusEffectsPlayers() { return players.get(); }
|
||||
public static boolean statusEffectsNonplayers() { return nonplayers.get(); }
|
||||
public static boolean statusEffectsHostiles() { return hostiles.get(); }
|
||||
public static int statusEffectsMaxActive() { return active.get(); }
|
||||
public static int statusEffectsMaxCandidates() { return candidates.get(); }
|
||||
public static int statusEffectsMaxDuration() { return duration.get(); }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Frozen creation-time contributions and Legacy deck selections. Never silently rescores older Ages. */
|
||||
public final class PortInstabilityData extends SavedData {
|
||||
public record Contribution(String reason, int page, int points) {
|
||||
public static final Codec<Contribution> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.STRING.fieldOf("reason").forGetter(Contribution::reason),
|
||||
Codec.INT.fieldOf("page").forGetter(Contribution::page),
|
||||
Codec.intRange(0, Integer.MAX_VALUE).fieldOf("points").forGetter(Contribution::points)
|
||||
).apply(instance, Contribution::new));
|
||||
}
|
||||
public record Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled, java.util.Optional<net.minecraft.core.BlockPos> spawn) {
|
||||
public static final Codec<Age> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.LONG.fieldOf("seed").forGetter(Age::seed),
|
||||
Contribution.CODEC.listOf().fieldOf("contributions").forGetter(Age::contributions),
|
||||
PortInstabilityPlan.CODEC.fieldOf("plan").forGetter(Age::plan),
|
||||
Codec.BOOL.optionalFieldOf("enabled", true).forGetter(Age::enabled),
|
||||
net.minecraft.core.BlockPos.CODEC.optionalFieldOf("spawn").forGetter(Age::spawn)
|
||||
).apply(instance, Age::new));
|
||||
public Age { contributions = List.copyOf(contributions); spawn = spawn.map(net.minecraft.core.BlockPos::immutable); }
|
||||
public Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled) {
|
||||
this(seed, contributions, plan, enabled, java.util.Optional.empty());
|
||||
}
|
||||
public int score() { return contributions.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); }
|
||||
}
|
||||
private static final SavedDataType<PortInstabilityData> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "instability"), () -> new PortInstabilityData(Map.of()),
|
||||
Codec.unboundedMap(Identifier.CODEC, Age.CODEC).fieldOf("ages").codec()
|
||||
.xmap(PortInstabilityData::new, data -> Map.copyOf(data.ages)));
|
||||
private final Map<Identifier, Age> ages = new HashMap<>();
|
||||
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
|
||||
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
public Age age(ServerLevel level) {
|
||||
Age age = ages.get(level.dimension().identifier());
|
||||
return age != null ? age : new Age(level.getSeed(),
|
||||
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
|
||||
}
|
||||
/** Older neutral Ages stay neutral even if an arrival or enabled flag was later saved. */
|
||||
public boolean supportsProfiling(ServerLevel level) {
|
||||
Age state = ages.get(level.dimension().identifier());
|
||||
return state != null && state.contributions().stream().noneMatch(cost -> cost.reason().equals("predates_instability"));
|
||||
}
|
||||
public void enabled(ServerLevel level, boolean enabled) {
|
||||
Age previous = age(level);
|
||||
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
|
||||
setDirty();
|
||||
}
|
||||
/** First resolved arrival remains protected without rescoring or rerolling the Age. */
|
||||
public void recordSpawn(ServerLevel level, net.minecraft.core.BlockPos spawn) {
|
||||
Age previous = age(level);
|
||||
if (previous.spawn().isPresent()) return;
|
||||
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(),
|
||||
previous.enabled(), java.util.Optional.of(spawn.immutable())));
|
||||
setDirty();
|
||||
}
|
||||
public void create(ServerLevel level, List<Identifier> pages, PortAgeTerrain terrain,
|
||||
PortLighting lighting, PortDayCycle cycle) {
|
||||
Identifier id = level.dimension().identifier();
|
||||
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
|
||||
List<Contribution> costs = new ArrayList<>();
|
||||
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
|
||||
for (var diagnostic : net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages).diagnostics())
|
||||
costs.add(new Contribution("writing_" + diagnostic.code(), diagnostic.index(), diagnostic.points()));
|
||||
boolean authoredBright = false;
|
||||
Map<Identifier, Integer> structures = new HashMap<>();
|
||||
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
|
||||
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).toList();
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
Identifier page = pages.get(i);
|
||||
if (!page.getNamespace().equals("rivencraft")) continue;
|
||||
String path = page.getPath();
|
||||
if (path.equals("lighting/bright")) { costs.add(new Contribution("bright_lighting", i, 500)); authoredBright = true; }
|
||||
if (page.equals(PortAcceleratedTicks.PAGE)) costs.add(new Contribution("accelerated", i, 1000));
|
||||
if (isCorrupted(page)) costs.add(new Contribution("corrupted_" + path.substring(10), i,
|
||||
path.endsWith("small") ? 2000 : path.endsWith("medium") ? 1000 : 0));
|
||||
// Legacy charges for sky/weather controllers that Cave terrain cannot apply.
|
||||
if (terrain == PortAgeTerrain.CAVE && (PortWeatherMode.forPage(page) != null
|
||||
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|
||||
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
|
||||
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
|
||||
if (PortStructurePages.supports(page)) {
|
||||
int count = structures.merge(page, 1, Integer::sum);
|
||||
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
|
||||
var allowed = PortStructurePages.compatibleBiomes(page, level.registryAccess());
|
||||
if (!allowed.isEmpty() && biomes.stream().anyMatch(biome -> !allowed.contains(biome)))
|
||||
costs.add(new Contribution("forced_biome_" + path, i, 100));
|
||||
}
|
||||
}
|
||||
if (lighting == PortLighting.BRIGHT && !authoredBright) costs.add(new Contribution("seeded_bright_lighting", -1, 500));
|
||||
int suns = 0;
|
||||
for (var body : cycle.bodies()) if (body.kind() == PortDayCycle.SUN && ++suns > 2)
|
||||
costs.add(new Contribution("extra_sun_" + suns, -1, 1000 * (suns - 2)));
|
||||
int score = costs.stream().mapToInt(Contribution::points).reduce(0, Math::addExact);
|
||||
ages.put(id, new Age(level.getSeed(), costs, PortInstabilityPlan.select(score, level.getSeed()), true));
|
||||
setDirty();
|
||||
}
|
||||
public static boolean isCorrupted(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "corrupted/large", "corrupted/medium", "corrupted/small" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
|
||||
public record PortInstabilityPlan(int version, List<ProviderLevel> providers) {
|
||||
public static final com.mojang.serialization.Codec<PortInstabilityPlan> CODEC =
|
||||
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
|
||||
com.mojang.serialization.Codec.INT.fieldOf("version").forGetter(PortInstabilityPlan::version),
|
||||
ProviderLevel.CODEC.listOf().fieldOf("providers").forGetter(PortInstabilityPlan::providers)
|
||||
).apply(instance, PortInstabilityPlan::new));
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
public PortInstabilityPlan {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
|
||||
providers = List.copyOf(providers);
|
||||
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
|
||||
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
|
||||
}
|
||||
}
|
||||
|
||||
public static PortInstabilityPlan empty() { return new PortInstabilityPlan(CURRENT_VERSION, List.of()); }
|
||||
|
||||
public static PortInstabilityPlan select(int score, long ageSeed) {
|
||||
int roundedScore = score / 500 * 500;
|
||||
Map<Identifier, Integer> levels = new LinkedHashMap<>();
|
||||
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
|
||||
int remaining = roundedScore - deck.entryCost();
|
||||
if (remaining <= 0) continue;
|
||||
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
|
||||
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
|
||||
for (InstabilityProviderCatalog.Card card : cards) {
|
||||
if (remaining < card.cost()) break;
|
||||
remaining -= card.cost();
|
||||
levels.merge(card.id(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return new PortInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
|
||||
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
|
||||
}
|
||||
|
||||
public record ProviderLevel(Identifier id, int level) {
|
||||
public static final com.mojang.serialization.Codec<ProviderLevel> CODEC =
|
||||
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.fieldOf("id").forGetter(ProviderLevel::id),
|
||||
com.mojang.serialization.Codec.intRange(1, 255).fieldOf("level").forGetter(ProviderLevel::level)
|
||||
).apply(instance, ProviderLevel::new));
|
||||
public ProviderLevel {
|
||||
Objects.requireNonNull(id, "id");
|
||||
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortInstabilityRuntime {
|
||||
private static final Map<ServerLevel, CachedPlan> PLANS = new WeakHashMap<>();
|
||||
private record CachedPlan(int score, long seed, PortInstabilityPlan plan) {}
|
||||
private static final Map<Identifier, Handler> HANDLERS = new HashMap<>();
|
||||
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
|
||||
private PortInstabilityRuntime() {}
|
||||
public static void register(Identifier id, ProviderEffect handler) {
|
||||
register(id, false, handler);
|
||||
}
|
||||
public static void register(Identifier id, boolean worldMutating, ProviderEffect handler) {
|
||||
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, handler)) != null)
|
||||
throw new IllegalArgumentException("Duplicate instability provider " + id);
|
||||
}
|
||||
public static boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; }
|
||||
public static PortInstabilityPlan plan(ServerLevel level) {
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
Integer test = testScore(level);
|
||||
int profile = test == null ? PortBlockInstability.score(level) : 0;
|
||||
int difficulty = PortInstabilityConfig.instabilityDifficulty();
|
||||
if (test == null && difficulty == 2 && profile == 0) return age.plan();
|
||||
int score = test != null ? test : PortBlockInstability.difficultyScore((long) age.score() + profile, difficulty);
|
||||
var cached = PLANS.get(level);
|
||||
if (cached == null || cached.score() != score || cached.seed() != age.seed()) {
|
||||
cached = new CachedPlan(score, age.seed(), PortInstabilityPlan.select(score, age.seed()));
|
||||
PLANS.put(level, cached);
|
||||
}
|
||||
return cached.plan();
|
||||
}
|
||||
public static Integer testScore(ServerLevel level) {
|
||||
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
|
||||
}
|
||||
public static int effectiveScore(ServerLevel level) {
|
||||
Integer test = testScore(level);
|
||||
if (test != null) return test;
|
||||
return PortBlockInstability.difficultyScore((long) PortInstabilityData.get(level.getServer()).age(level).score()
|
||||
+ PortBlockInstability.score(level), PortInstabilityConfig.instabilityDifficulty());
|
||||
}
|
||||
public static void testScore(ServerLevel level, Integer score) {
|
||||
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
|
||||
if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score);
|
||||
}
|
||||
@SubscribeEvent public static void tick(LevelTickEvent.Post event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level) || !isAge(level) || level.players().isEmpty()
|
||||
|| !PortInstabilityConfig.instabilityEnabled()) return;
|
||||
var server = level.getServer();
|
||||
long tick = server.getTickCount();
|
||||
int cooldown = PortInstabilityConfig.instabilityCooldown();
|
||||
if (tick % cooldown != 0) return;
|
||||
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
|
||||
if (state.tick != tick) {
|
||||
state.tick = tick;
|
||||
var eligible = new ArrayList<Identifier>();
|
||||
for (var loaded : server.getAllLevels()) if (isAge(loaded) && !loaded.players().isEmpty()
|
||||
&& PortInstabilityData.get(server).age(loaded).enabled() && !plan(loaded).providers().isEmpty())
|
||||
eligible.add(loaded.dimension().identifier());
|
||||
eligible.sort(Comparator.comparing(Identifier::toString));
|
||||
state.eligible = List.copyOf(eligible);
|
||||
}
|
||||
int index = state.eligible.indexOf(level.dimension().identifier());
|
||||
if (index < 0 || state.eligible.size() > PortInstabilityConfig.instabilityMaxAges()
|
||||
&& Math.floorMod(index - (int) Math.floorMod(tick / cooldown, state.eligible.size()), state.eligible.size())
|
||||
>= PortInstabilityConfig.instabilityMaxAges()) return;
|
||||
var age = PortInstabilityData.get(server).age(level);
|
||||
if (!age.enabled()) return;
|
||||
var providers = plan(level).providers();
|
||||
PortHazardProviders.tickPlanned(level, age, tick, providers);
|
||||
PortMeteorProvider.tickPlanned(level, age, tick, providers);
|
||||
PortEnvironmentalProviders.tickPlanned(level, age, tick, providers);
|
||||
var chunks = loadedChunks(level);
|
||||
if (providers.isEmpty() || chunks.isEmpty()) return;
|
||||
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
|
||||
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
|
||||
int ran = 0;
|
||||
for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders()
|
||||
&& context.budget().remaining() > 0; offset++) {
|
||||
var provider = providers.get((first + offset) % providers.size());
|
||||
if (!PortHazardProviders.isHazard(provider.id()) && !provider.id().equals(PortMeteorProvider.METEORS)
|
||||
&& !PortEnvironmentalProviders.isEnvironmental(provider.id()) && invoke(context, provider.id(), provider.level())) ran++;
|
||||
}
|
||||
}
|
||||
public static boolean invoke(Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.instabilityProviderEnabled(id)
|
||||
|| !PortInstabilityData.get(context.level().getServer()).age(context.level()).enabled()) return false;
|
||||
Handler handler = HANDLERS.get(id);
|
||||
if (handler == null || handler.worldMutating() && !PortInstabilityConfig.instabilityWorldMutation()) return false;
|
||||
var failures = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>());
|
||||
if (context.serverTick() < failures.getOrDefault(id, 0L)) return false;
|
||||
try {
|
||||
handler.effect().apply(context, id, Math.min(level, PortInstabilityConfig.instabilityMaxLevel()));
|
||||
} catch (RuntimeException exception) {
|
||||
failures.put(id, context.serverTick() + Math.max(200L, PortInstabilityConfig.instabilityCooldown() * 10L));
|
||||
Rivencraft.LOGGER.error("Instability provider {} failed in {}; temporarily quarantined", id,
|
||||
context.level().dimension().identifier(), exception);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static String providerStatus(ServerLevel level, Identifier id) {
|
||||
if (!HANDLERS.containsKey(id)) return "not ported yet";
|
||||
if (!PortInstabilityConfig.instabilityEnabled()) return "global config off";
|
||||
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
|
||||
if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off";
|
||||
if (PortHazardProviders.isHazard(id)) {
|
||||
if (!PortInstabilityConfig.hazardsEnabled()) return "hazards config off";
|
||||
} else if (id.equals(PortMeteorProvider.METEORS)) {
|
||||
if (!PortInstabilityConfig.meteorEnabled()) return "meteor config off";
|
||||
} else if (id.equals(PortEnvironmentalProviders.BURNING)) {
|
||||
if (!PortInstabilityConfig.scorchEnabled()) return "burning config off";
|
||||
if (PortInstabilityConfig.scorchMaxTargets() == 0) return "burning target budget zero";
|
||||
} else if (HANDLERS.get(id).worldMutating()) {
|
||||
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
|
||||
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
|
||||
if (id.equals(PortEnvironmentalProviders.EROSION)) {
|
||||
if (!PortInstabilityConfig.erosionEnabled()) return "erosion config off";
|
||||
if (!PortHazardProviders.canMutate(level)) return "mobGriefing off";
|
||||
if (effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()) return "erosion threshold not met";
|
||||
}
|
||||
} else {
|
||||
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
|
||||
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
|
||||
if (!id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !PortInstabilityConfig.statusEffectsNonplayers()) return "all general targets disabled";
|
||||
}
|
||||
if (level.getServer().getTickCount() < FAILURES.getOrDefault(level, Map.of()).getOrDefault(id, 0L)) return "quarantined";
|
||||
return level.players().isEmpty() ? "no players in Age" : "eligible; sky, targets and budgets still apply";
|
||||
}
|
||||
static List<ChunkPos> loadedChunks(ServerLevel level) {
|
||||
var chunks = new LinkedHashSet<ChunkPos>();
|
||||
for (var player : level.players()) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
|
||||
if (level.getChunkSource().getChunkNow(center.x() + x, center.z() + z) != null)
|
||||
chunks.add(new ChunkPos(center.x() + x, center.z() + z));
|
||||
if (chunks.size() >= PortInstabilityConfig.instabilityMaxChunks()) return List.copyOf(chunks);
|
||||
}
|
||||
}
|
||||
return List.copyOf(chunks);
|
||||
}
|
||||
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
|
||||
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); PLANS.clear();
|
||||
}
|
||||
public interface ProviderEffect { void apply(Context context, Identifier id, int level); }
|
||||
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
|
||||
long serverTick, Budget budget) { public Context { loadedChunks = List.copyOf(loadedChunks); } }
|
||||
public static final class Budget {
|
||||
private int remaining;
|
||||
private int applied;
|
||||
public Budget(int maximum) { remaining = maximum; }
|
||||
public boolean claim() { if (remaining <= 0) return false; remaining--; return true; }
|
||||
public int remaining() { return remaining; }
|
||||
public void recordApplied() { applied++; }
|
||||
public int applied() { return applied; }
|
||||
}
|
||||
private record Handler(boolean worldMutating, ProviderEffect effect) {}
|
||||
private static final class TickState { long tick = Long.MIN_VALUE; List<Identifier> eligible = List.of(); }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
|
||||
/** Explicit operator tools; normal generation does not search or force-load remote chunks. */
|
||||
@EventBusSubscriber(modid = "rivencraft")
|
||||
public final class PortLibraryCommands {
|
||||
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
|
||||
var root = Commands.literal("rivencraft_port").requires(Commands.hasPermission(Commands.LEVEL_ADMINS));
|
||||
root.then(Commands.literal("library")
|
||||
.then(Commands.literal("test_place").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var direction = context.getSource().getEntity() instanceof net.minecraft.world.entity.player.Player player
|
||||
? player.getDirection() : net.minecraft.core.Direction.NORTH;
|
||||
var result = PortLibraryFeature.testPlace(level, BlockPos.containing(context.getSource().getPosition()), direction,
|
||||
level.getSeed(), PortLibraryWriting.get(level.getServer()).symbols(level.dimension().identifier()));
|
||||
if (result.isEmpty()) { context.getSource().sendFailure(Component.literal("No clear placement nearby; use open ground with headroom.")); return 0; }
|
||||
context.getSource().sendSuccess(() -> Component.literal("Library placed. Chest: " + result.get().toShortString()), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("test_spacing").then(Commands.argument("chunks", IntegerArgumentType.integer(1, 32))
|
||||
.executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
if (!RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier()))
|
||||
throw new IllegalArgumentException("Use this test override inside a Rivencraft Age");
|
||||
int spacing = IntegerArgumentType.getInteger(context, "chunks");
|
||||
PortLibraryFeature.setTestSpacing(level.getSeed(), spacing);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Session-only library spacing: " + spacing
|
||||
+ " chunks. Applies to newly generated chunks; 32 restores normal spacing."), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("candidate").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var origin = ChunkPos.containing(BlockPos.containing(context.getSource().getPosition()));
|
||||
ChunkPos best = null;
|
||||
long distance = Long.MAX_VALUE;
|
||||
for (int spacing : new int[]{32, PortLibraryFeature.spacing(level.getSeed())}) {
|
||||
int rx = Math.floorDiv(origin.x(), spacing), rz = Math.floorDiv(origin.z(), spacing);
|
||||
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
|
||||
var candidate = PortLibraryFeature.candidateChunk(level.getSeed(), rx + dx, rz + dz, spacing);
|
||||
long x = candidate.x() - origin.x(), z = candidate.z() - origin.z();
|
||||
long d = x * x + z * z;
|
||||
if (d < distance) { best = candidate; distance = d; }
|
||||
}
|
||||
}
|
||||
var candidate = best;
|
||||
context.getSource().sendSuccess(() -> Component.literal("Nearest candidate chunk " + candidate
|
||||
+ ", center X=" + (candidate.getMinBlockX() + 8) + " Z=" + (candidate.getMinBlockZ() + 8)
|
||||
+ ". A candidate is not a guaranteed library: footing, slope and clear space must pass."), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("probe").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
|
||||
var chest = PortLibraryFeature.libraryChest(chunk);
|
||||
if (chest.isPresent()) context.getSource().sendSuccess(() -> Component.literal("Generated library chest: " + chest.get().toShortString()), false);
|
||||
else {
|
||||
var check = PortLibraryFeature.check(level, chunk.getPos(), level.getSeed());
|
||||
context.getSource().sendSuccess(() -> Component.literal(check.eligible() ? "Eligible library candidate, floor Y=" + check.floorY()
|
||||
: "No library in this chunk: " + check.reason()), false);
|
||||
}
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("inspect_loot").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
|
||||
var pos = PortLibraryFeature.libraryChest(chunk);
|
||||
if (pos.isEmpty()) { context.getSource().sendFailure(Component.literal("No marked library chest in this chunk.")); return 0; }
|
||||
var chest = (net.minecraft.world.level.block.entity.ChestBlockEntity) level.getBlockEntity(pos.get());
|
||||
var items = new java.util.ArrayList<String>();
|
||||
for (int i = 0; i < chest.getContainerSize(); i++) {
|
||||
var item = chest.getItem(i); // Materializes loot exactly as opening the chest does.
|
||||
if (!item.isEmpty()) items.add(item.getCount() + "x " + item.getHoverName().getString());
|
||||
}
|
||||
long stands = chunk.getBlockEntities().values().stream()
|
||||
.filter(be -> be instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity).count();
|
||||
context.getSource().sendSuccess(() -> Component.literal("Library at " + pos.get().toShortString()
|
||||
+ "; stands in this chunk=" + stands + "; chest=" + items), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("nearest_loaded").executes(context -> {
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var level = player.level();
|
||||
var origin = player.chunkPosition();
|
||||
BlockPos best = null; double distance = Double.MAX_VALUE;
|
||||
for (int x = origin.x() - 16; x <= origin.x() + 16; x++) for (int z = origin.z() - 16; z <= origin.z() + 16; z++) {
|
||||
var chunk = level.getChunkSource().getChunkNow(x, z);
|
||||
if (chunk == null) continue;
|
||||
var found = PortLibraryFeature.libraryChest(chunk);
|
||||
if (found.isPresent()) {
|
||||
double d = player.distanceToSqr(Vec3.atCenterOf(found.get()));
|
||||
if (d < distance) { best = found.get(); distance = d; }
|
||||
}
|
||||
}
|
||||
if (best == null) { context.getSource().sendFailure(Component.literal("No library in loaded chunks within 16 chunks. Use candidate or test_spacing to explore new terrain.")); return 0; }
|
||||
var facing = level.getBlockState(best).getValue(ChestBlock.FACING);
|
||||
var feet = best.relative(facing, 2).relative(facing.getCounterClockWise());
|
||||
var target = Vec3.atBottomCenterOf(feet);
|
||||
if (!level.getBlockState(feet.below()).isFaceSturdy(level, feet.below(), net.minecraft.core.Direction.UP)
|
||||
|| !level.getFluidState(feet).isEmpty() || !level.getFluidState(feet.above()).isEmpty()
|
||||
|| !level.noCollision(player, player.getBoundingBox().move(target.subtract(player.position())))) {
|
||||
context.getSource().sendFailure(Component.literal("Library found at " + best.toShortString() + " but its aisle is obstructed.")); return 0;
|
||||
}
|
||||
player.teleportTo(level, target.x, target.y, target.z, java.util.Set.of(), facing.getOpposite().toYRot(), 0, true);
|
||||
return 1;
|
||||
})));
|
||||
event.getDispatcher().register(root);
|
||||
}
|
||||
@SubscribeEvent public static void stopped(net.neoforged.neoforge.event.server.ServerStoppedEvent event) {
|
||||
PortLibraryFeature.clearTestSpacing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
|
||||
public final class PortLibraryFeature {
|
||||
public static final int REGION = 32;
|
||||
static final int FOOTPRINT = 11;
|
||||
private static final int OFFSET_BOUND = 24;
|
||||
private static final int MAX_SUPPORT_DEPTH = 24;
|
||||
private static final int SALT = 14357617;
|
||||
private static final int TEST_SALT = 0x4C494252;
|
||||
private static final String CHEST_MARKER = "RivencraftLibrary";
|
||||
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
|
||||
// x, y, z, clockwise turns relative to the left stand. The middle row
|
||||
// faces across the room; the far stand faces back toward the entrance.
|
||||
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
|
||||
{8, 1, 6, 1}, {6, 1, 8, 2}};
|
||||
private static final Identifier TEMPLATE = Identifier.fromNamespaceAndPath(
|
||||
"rivencraft", "mystcraft_library");
|
||||
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
|
||||
|
||||
private PortLibraryFeature() {}
|
||||
|
||||
record Site(int x, int z, int rotation) {}
|
||||
|
||||
public static int spacing(long seed) {
|
||||
return TEST_SPACING.getOrDefault(seed, REGION);
|
||||
}
|
||||
|
||||
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
|
||||
public static void setTestSpacing(long seed, int chunks) {
|
||||
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
|
||||
if (chunks == REGION) TEST_SPACING.remove(seed);
|
||||
else TEST_SPACING.put(seed, chunks);
|
||||
}
|
||||
|
||||
public static void clearTestSpacing() {
|
||||
TEST_SPACING.clear();
|
||||
}
|
||||
|
||||
/** The single deterministic candidate in one Legacy-sized region. */
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
|
||||
return candidateChunk(seed, regionX, regionZ, REGION);
|
||||
}
|
||||
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
|
||||
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
return new ChunkPos(regionX * spacing + random.nextInt(bound),
|
||||
regionZ * spacing + random.nextInt(bound));
|
||||
}
|
||||
|
||||
static Site site(long seed, ChunkPos chunk) {
|
||||
Site normal = site(seed, chunk, REGION);
|
||||
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
|
||||
}
|
||||
|
||||
private static Site site(long seed, ChunkPos chunk, int spacing) {
|
||||
int rx = Math.floorDiv(chunk.x(), spacing);
|
||||
int rz = Math.floorDiv(chunk.z(), spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, rx, rz,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
int candidateX = rx * spacing + random.nextInt(bound);
|
||||
int candidateZ = rz * spacing + random.nextInt(bound);
|
||||
if (chunk.x() != candidateX || chunk.z() != candidateZ) return null;
|
||||
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
|
||||
}
|
||||
|
||||
/** A saved marker distinguishes a generated library from player-built chests. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
|
||||
return chunk.getBlockEntities().entrySet().stream()
|
||||
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
|
||||
&& chest.getPersistentData().getBooleanOr(CHEST_MARKER, false))
|
||||
.map(java.util.Map.Entry::getKey).findFirst();
|
||||
}
|
||||
|
||||
/** Recognize libraries generated before the chest marker was introduced. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
|
||||
Optional<BlockPos> marked = libraryChest(chunk);
|
||||
if (marked.isPresent()) return marked;
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return Optional.empty();
|
||||
for (var entry : chunk.getBlockEntities().entrySet()) {
|
||||
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
|
||||
int y = entry.getKey().getY();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
|
||||
continue;
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
return Optional.of(entry.getKey());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Open interior aisle, directly above the cobblestone floor. */
|
||||
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
|
||||
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
|
||||
}
|
||||
|
||||
public record PlacementCheck(String reason, int floorY, int[][] heights) {
|
||||
public boolean eligible() { return reason == null; }
|
||||
}
|
||||
|
||||
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
|
||||
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) return rejected("not a candidate chunk");
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int low = Integer.MAX_VALUE;
|
||||
int high = Integer.MIN_VALUE;
|
||||
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
int wx = site.x() + x;
|
||||
int wz = site.z() + z;
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
|
||||
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
|
||||
while (y > level.getMinY() + 2) {
|
||||
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!below.isAir() && !isVegetation(below)) break;
|
||||
y--;
|
||||
}
|
||||
if (y < level.getMinY() + 2) return rejected("no surface footing");
|
||||
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
|
||||
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
|
||||
return rejected("ground is not sturdy terrain");
|
||||
heights[x][z] = y;
|
||||
low = Math.min(low, y);
|
||||
high = Math.max(high, y);
|
||||
}
|
||||
if (high + 10 >= level.getMaxY()) return rejected("insufficient build height");
|
||||
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
|
||||
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
|
||||
// Check the entire raised foundation and house. Foliage can be cleared, but not
|
||||
// structures, fluids, or block entities already occupying this volume.
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = heights[x][z]; y <= high + 10; y++) {
|
||||
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
|
||||
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
|
||||
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
|
||||
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
|
||||
return rejected("solid obstruction inside footprint: "
|
||||
+ state.getBlock().getDescriptionId());
|
||||
}
|
||||
return new PlacementCheck(null, high, heights);
|
||||
}
|
||||
|
||||
private static PlacementCheck rejected(String reason) {
|
||||
return new PlacementCheck(reason, 0, null);
|
||||
}
|
||||
|
||||
private static boolean isVegetation(BlockState state) {
|
||||
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|
||||
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
|
||||
List<Identifier> ageSymbols) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return false;
|
||||
PlacementCheck check = check(level, chunk.getPos(), seed);
|
||||
if (!check.eligible()) return false;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
|
||||
}
|
||||
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
|
||||
}
|
||||
|
||||
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
|
||||
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
|
||||
long seed, List<Identifier> ageSymbols) {
|
||||
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
|
||||
BlockPos center = playerPos.relative(horizontal, 13);
|
||||
int rotation = switch (horizontal) {
|
||||
case NORTH -> 2;
|
||||
case EAST -> 3;
|
||||
case WEST -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
|
||||
int floorY = playerPos.getY() + 2;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
|
||||
site.x() + x, site.z() + z) + 1);
|
||||
if (floorY + 10 >= level.getMaxY() || floorY - playerPos.getY() > 20)
|
||||
return Optional.empty();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = floorY; y <= floorY + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
|
||||
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
|
||||
return Optional.empty();
|
||||
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
|
||||
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
|
||||
}
|
||||
|
||||
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
|
||||
long seed, List<Identifier> ageSymbols) {
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
var template = level.getLevel().getStructureTemplateManager().get(TEMPLATE).orElse(null);
|
||||
if (template == null || template.getSize().getX() != FOOTPRINT
|
||||
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
|
||||
return false;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
|
||||
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
|
||||
}
|
||||
Rotation rotation = switch (site.rotation()) {
|
||||
case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180;
|
||||
case 3 -> Rotation.COUNTERCLOCKWISE_90;
|
||||
default -> Rotation.NONE;
|
||||
};
|
||||
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
|
||||
var settings = new StructurePlaceSettings().setRotation(rotation)
|
||||
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
|
||||
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).pack();
|
||||
if (!template.placeInWorld(level, origin, origin, settings,
|
||||
RandomSource.create(seed ^ positionSalt), 2)) return false;
|
||||
|
||||
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
|
||||
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
|
||||
Direction.from2DDataValue(site.rotation())), 2);
|
||||
PortConfig.LibraryLootMode mode = PortConfig.LIBRARY_LOOT_MODE.get();
|
||||
boolean ageOnly = mode == PortConfig.LibraryLootMode.AGE_ONLY;
|
||||
List<Identifier> symbols = symbolsForLoot(ageSymbols, mode);
|
||||
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
|
||||
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
if (ageOnly) {
|
||||
// Pages are frozen in the Age generator, then saved as real chest items.
|
||||
boolean[] occupied = new boolean[chest.getContainerSize()];
|
||||
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
|
||||
int slot;
|
||||
do slot = random.nextInt(occupied.length); while (occupied[slot]);
|
||||
occupied[slot] = true;
|
||||
chest.setItem(slot, page(symbols, random));
|
||||
}
|
||||
} else {
|
||||
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
|
||||
}
|
||||
}
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
|
||||
if (!level.getBlockState(pos).isAir()) continue;
|
||||
level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
|
||||
.setValue(BookStandBlock.HAS_BOOK, true), 2);
|
||||
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
|
||||
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Direction standFacing(Site site, int clockwiseTurns) {
|
||||
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
|
||||
}
|
||||
|
||||
/** Correct older generated stands in place, retaining their block entities and pages. */
|
||||
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
|
||||
int changed = 0;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (!state.is(Rivencraft.BOOK_STAND.get())) continue;
|
||||
Direction facing = standFacing(site, standCoords[3]);
|
||||
if (state.getValue(BookStandBlock.FACING) != facing) {
|
||||
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
|
||||
return mode == PortConfig.LibraryLootMode.AGE_ONLY
|
||||
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
|
||||
: PortSymbolPages.sealedCandidateIds();
|
||||
}
|
||||
|
||||
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
|
||||
return PageItem.createSymbolPage(symbols.get(random.nextInt(symbols.size())));
|
||||
}
|
||||
|
||||
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
|
||||
return switch (site.rotation()) {
|
||||
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
|
||||
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
|
||||
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
|
||||
default -> cursor.set(site.x() + x, y, site.z() + z);
|
||||
};
|
||||
}
|
||||
|
||||
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
|
||||
int x, int y, int z, BlockState state) {
|
||||
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Written pages for library Age-only loot, kept outside the legacy backend index codec. */
|
||||
final class PortLibraryWriting extends SavedData {
|
||||
static final Codec<PortLibraryWriting> CODEC = Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC.listOf())
|
||||
.fieldOf("ages").codec().xmap(PortLibraryWriting::new, data -> Map.copyOf(data.pages));
|
||||
static final SavedDataType<PortLibraryWriting> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "library_writing"), () -> new PortLibraryWriting(Map.of()), CODEC);
|
||||
private final Map<Identifier, List<Identifier>> pages = new HashMap<>();
|
||||
private PortLibraryWriting(Map<Identifier, List<Identifier>> pages) { pages.forEach((id, list) -> this.pages.put(id, List.copyOf(list))); }
|
||||
static PortLibraryWriting get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
List<Identifier> symbols(Identifier id) { return pages.getOrDefault(id, List.of()); }
|
||||
void put(Identifier id, List<Identifier> symbols) {
|
||||
if (symbols.isEmpty()) pages.remove(id); else pages.put(id, List.copyOf(symbols));
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.particles.ParticleTypes;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** The 1.21.1 meteor's short particle descent and bounded impact, without a new entity. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortMeteorProvider {
|
||||
public static final Identifier METEORS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "meteors");
|
||||
private static final int METEOR_FLIGHT_TICKS = 30;
|
||||
private static final int MAX_ACTIVE_AGES = 16;
|
||||
private static final Map<ServerLevel, Meteor> ACTIVE = new WeakHashMap<>();
|
||||
private PortMeteorProvider() {}
|
||||
public static void register() {
|
||||
PortInstabilityRuntime.register(METEORS, false, (context, id, level) -> {
|
||||
if (PortInstabilityRuntime.isAge(context.level())) scheduleMeteor(context, level, false);
|
||||
});
|
||||
}
|
||||
static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick,
|
||||
List<PortInstabilityPlan.ProviderLevel> providers) {
|
||||
var card = providers.stream().filter(provider -> provider.id().equals(METEORS)).findFirst();
|
||||
if (card.isEmpty() || !PortInstabilityConfig.meteorEnabled()) return;
|
||||
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks());
|
||||
if (!chunks.isEmpty()) PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks,
|
||||
tick, new PortInstabilityRuntime.Budget(1)), METEORS, card.get().level());
|
||||
}
|
||||
/** Skip the chance roll only; retain per-Age policy, loading, active-flight and cooldown checks. */
|
||||
public static int pulseForTesting(ServerPlayer player) {
|
||||
ServerLevel level = player.level();
|
||||
if (!PortInstabilityRuntime.isAge(level)) return -1;
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|
||||
|| !age.enabled() || !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) return 0;
|
||||
var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16);
|
||||
if (chunks.isEmpty()) return 0;
|
||||
return scheduleMeteor(new PortInstabilityRuntime.Context(level, age, chunks,
|
||||
level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)), 1, true) ? 1 : 0;
|
||||
}
|
||||
|
||||
private static boolean scheduleMeteor(PortInstabilityRuntime.Context context, int effectiveLevel, boolean force) {
|
||||
if (!PortInstabilityConfig.meteorEnabled() || ACTIVE.containsKey(context.level())
|
||||
|| ACTIVE.size() >= MAX_ACTIVE_AGES) return false;
|
||||
PortHazardSavedData data = PortHazardSavedData.get(context.level());
|
||||
long gameTime = context.level().getGameTime();
|
||||
if (!data.meteorReady(gameTime, 200)) return false;
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L
|
||||
^ METEORS.hashCode());
|
||||
if (!force && random.nextDouble() >= PortHazardProviders.legacyChance(context.loadedChunks().size(),
|
||||
PortInstabilityConfig.instabilityCooldown(), effectiveLevel, 50000)) return false;
|
||||
int start = random.nextInt(context.loadedChunks().size());
|
||||
for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) {
|
||||
ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size());
|
||||
int x = chunk.getMinBlockX() + 6 + random.nextInt(4);
|
||||
int z = chunk.getMinBlockZ() + 6 + random.nextInt(4);
|
||||
int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z);
|
||||
BlockPos impact = new BlockPos(x, surfaceY, z);
|
||||
if (surfaceY >= context.level().getMaxY() - 8
|
||||
|| !PortHazardProviders.safeTarget(context, impact, 8)) continue;
|
||||
if (!context.budget().claim()) return false;
|
||||
int startY = Math.min(context.level().getMaxY() - 2, surfaceY + 48);
|
||||
data.recordMeteor(gameTime);
|
||||
ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS));
|
||||
context.level().playSound(null, impact, Rivencraft.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F);
|
||||
context.budget().recordApplied();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)) return;
|
||||
Meteor meteor = ACTIVE.get(level);
|
||||
if (meteor == null) return;
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled()
|
||||
|| !PortInstabilityRuntime.isAge(level)
|
||||
|| level.players().isEmpty()
|
||||
|| level.getChunkSource().getChunkNow(meteor.chunk().x(), meteor.chunk().z()) == null) {
|
||||
ACTIVE.remove(level);
|
||||
return;
|
||||
}
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
if (!age.enabled()
|
||||
|| !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) {
|
||||
ACTIVE.remove(level);
|
||||
return;
|
||||
}
|
||||
int remaining = meteor.remaining() - 1;
|
||||
if (remaining <= 0) {
|
||||
ACTIVE.remove(level); // clear before impact so an exception cannot replay it
|
||||
PortHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(),
|
||||
PortInstabilityConfig.meteorBlockDamage(), PortInstabilityConfig.meteorPlayerDamage(),
|
||||
PortInstabilityConfig.meteorFire(), PortInstabilityConfig.meteorMaxBlocks(),
|
||||
PortInstabilityConfig.meteorMaxEntities(), PortInstabilityConfig.meteorMaxDamage(), 3);
|
||||
return;
|
||||
}
|
||||
double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS;
|
||||
double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress;
|
||||
double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0;
|
||||
double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0;
|
||||
level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01);
|
||||
level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02);
|
||||
ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining));
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onLevelUnload(LevelEvent.Unload event) {
|
||||
if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level);
|
||||
}
|
||||
|
||||
private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.neoforged.neoforge.common.util.FakePlayer;
|
||||
|
||||
/** FakePlayer normally discards all advancement progress; fixtures need vanilla tracking. */
|
||||
final class PortProgressionPlayer extends FakePlayer {
|
||||
private PlayerAdvancements progress;
|
||||
|
||||
PortProgressionPlayer(ServerLevel level, GameProfile profile) { super(level, profile); }
|
||||
|
||||
@Override public PlayerAdvancements getAdvancements() {
|
||||
if (progress == null) {
|
||||
var server = level().getServer();
|
||||
progress = new PlayerAdvancements(DataFixers.getDataFixer(), server.getPlayerList(),
|
||||
server.getAdvancements(), server.getWorldPath(LevelResource.PLAYER_ADVANCEMENTS_DIR)
|
||||
.resolve(getUUID() + ".json"), this);
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Disposable operator fixture for loaded advancement data and committed action awards. */
|
||||
final class PortProgressionProbe {
|
||||
/** Operator-fixture assertion; deliberately requires the real loaded advancement. */
|
||||
static boolean isComplete(net.minecraft.server.level.ServerPlayer player, String path) {
|
||||
var advancement = player.level().getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||
if (advancement == null) throw new IllegalStateException("Missing progression advancement: " + path);
|
||||
return player.getAdvancements().getOrStartProgress(advancement).isDone();
|
||||
}
|
||||
|
||||
|
||||
static void recipeDiscovery(ServerLevel level) {
|
||||
var materials = java.util.Map.of(
|
||||
"ink_vial", Items.DYE.black(),
|
||||
"ink_mixer", Items.GLASS_BOTTLE,
|
||||
"book_binder", Items.IRON_INGOT,
|
||||
"crystal_block", Rivencraft.CRYSTAL_SHARD.get(),
|
||||
"notebook", Items.LEATHER,
|
||||
"writing_desk", Items.FEATHER);
|
||||
for (var entry : materials.entrySet()) {
|
||||
var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", entry.getKey());
|
||||
var key = net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, id);
|
||||
var player = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
|
||||
java.util.UUID.nameUUIDFromBytes(id.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)),
|
||||
"RivenRecipe"));
|
||||
var advancement = level.getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "recipes/misc/" + entry.getKey()));
|
||||
if (advancement == null || level.getServer().getRecipeManager().byKey(key).isEmpty())
|
||||
throw new IllegalStateException("Missing recipe/discovery data: " + id);
|
||||
var progress = player.getAdvancements();
|
||||
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player,
|
||||
player.getInventory(), new ItemStack(Items.COBBLESTONE));
|
||||
if (player.getRecipeBook().contains(key))
|
||||
throw new IllegalStateException("Wrong material unlocked " + id);
|
||||
var material = new ItemStack(entry.getValue());
|
||||
player.getInventory().setItem(0, material);
|
||||
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), material);
|
||||
if (!player.getRecipeBook().contains(key) || !progress.getOrStartProgress(advancement).isDone())
|
||||
throw new IllegalStateException("Material did not unlock " + id);
|
||||
// The second criterion supports /recipe give without first obtaining its material.
|
||||
var other = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
|
||||
java.util.UUID.randomUUID(), "RivenRecipeGive"));
|
||||
if (other.getRecipeBook().contains(key)) throw new IllegalStateException("Recipe leaked between players");
|
||||
other.awardRecipes(java.util.List.of(level.getServer().getRecipeManager().byKey(key).orElseThrow()));
|
||||
if (!other.getAdvancements().getOrStartProgress(advancement).isDone())
|
||||
throw new IllegalStateException("Recipe unlock criterion did not complete " + id);
|
||||
}
|
||||
}
|
||||
|
||||
static void run(ServerLevel level) {
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("b428c3ef-57a6-43dc-95e8-441b6a171107"), "RivenProgression"));
|
||||
var pos = new BlockPos(4, 64, 0);
|
||||
player.setPos(4.5, 64, 2.5);
|
||||
level.setBlock(pos, Rivencraft.INK_MIXER.get().defaultBlockState(), 3);
|
||||
var mixer = (InkMixerBlockEntity) level.getBlockEntity(pos);
|
||||
try {
|
||||
// Every tree node must decode; portal advancement waits for its missing item/feature port.
|
||||
for (String path : new String[]{"root", "page", "linking_book", "link_panel", "copy_page", "bind_age", "enter_age"})
|
||||
if (isComplete(player, path))
|
||||
throw new IllegalStateException("Fixture requires fresh player advancement data");
|
||||
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
|
||||
if (isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Empty Mixer awarded progression");
|
||||
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
|
||||
menu.broadcastChanges();
|
||||
if (isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer preview awarded progression");
|
||||
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
|
||||
if (!isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer pickup did not award progression");
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
var advancement = level.getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "progression/link_panel"));
|
||||
player.getAdvancements().revoke(advancement, "completed");
|
||||
menu.quickMoveStack(player, InkMixerMenu.RESULT);
|
||||
if (!isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer shift pickup did not award progression");
|
||||
ProgressionAdvancements.awardAgeTravel(player);
|
||||
if (!isComplete(player, "enter_age"))
|
||||
throw new IllegalStateException("Registered Age did not award entry");
|
||||
} finally {
|
||||
player.getInventory().clearContent();
|
||||
level.removeBlock(pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ public final class PortReturnLinks {
|
||||
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
|
||||
if (!issue) return;
|
||||
ItemStack book = createBook(departure);
|
||||
var source = player.level().getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, departure.dimension()));
|
||||
if (source != null) book.set(Rivencraft.LINK_DESTINATION_NAME.get(),
|
||||
net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(source));
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
|
||||
// Persist the once-per-Age marker before the next arrival can issue another.
|
||||
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.effect.MobEffects;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.monster.Monster;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.entity.EntityTypeTest;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Legacy potion cards adapted to bounded, Age-local server effects. */
|
||||
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortStatusEffectProviders {
|
||||
private static final int MAX_TRACKED_AGES = 64;
|
||||
private static final Map<Identifier, Spec> SPECS = createSpecs();
|
||||
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
|
||||
|
||||
private PortStatusEffectProviders() {}
|
||||
|
||||
public static void register() {
|
||||
SPECS.keySet().forEach(id -> PortInstabilityRuntime.register(id, PortStatusEffectProviders::apply));
|
||||
}
|
||||
|
||||
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.statusEffectsEnabled()
|
||||
|| !PortInstabilityRuntime.isAge(context.level())) return;
|
||||
Spec spec = SPECS.get(id);
|
||||
if (spec == null || spec.target() == Target.HOSTILE && !PortInstabilityConfig.statusEffectsHostiles()
|
||||
|| spec.target() == Target.GENERAL && !PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !PortInstabilityConfig.statusEffectsNonplayers()) return;
|
||||
List<Lease> leases = LEASES.get(context.level());
|
||||
if (leases == null) {
|
||||
if (LEASES.size() >= MAX_TRACKED_AGES) return;
|
||||
leases = new ArrayList<>();
|
||||
LEASES.put(context.level(), leases);
|
||||
}
|
||||
prune(context.level(), leases, context.serverTick(), false);
|
||||
if (leases.size() >= PortInstabilityConfig.statusEffectsMaxActive()) return;
|
||||
|
||||
Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L)
|
||||
^ id.hashCode());
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinY(), chunk.getMinBlockZ(),
|
||||
chunk.getMaxBlockX() + 1, context.level().getMaxY(), chunk.getMaxBlockZ() + 1);
|
||||
int duration = Math.min(spec.legacyDuration(), PortInstabilityConfig.statusEffectsMaxDuration());
|
||||
int amplifier = Math.max(0, level - 1);
|
||||
MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier);
|
||||
List<LivingEntity> candidates = new ArrayList<>();
|
||||
context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
|
||||
entity -> eligible(context.level(), entity, spec, instance), candidates,
|
||||
PortInstabilityConfig.statusEffectsMaxCandidates());
|
||||
if (candidates.isEmpty()) return;
|
||||
candidates.sort(Comparator.comparing(Entity::getUUID));
|
||||
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
|
||||
if (!context.budget().claim() || !target.addEffect(instance)) return;
|
||||
context.budget().recordApplied();
|
||||
leases.add(new Lease(id, new WeakReference<>(target), instance,
|
||||
context.serverTick() + duration, spec.target()));
|
||||
}
|
||||
|
||||
private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec,
|
||||
MobEffectInstance instance) {
|
||||
if (!entity.isAlive() || entity.isRemoved() || entity.level() != level
|
||||
|| entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance)
|
||||
|| !spec.global() && !level.canSeeSky(entity.blockPosition())) return false;
|
||||
return allowedByPolicy(spec.target(), entity);
|
||||
}
|
||||
|
||||
private static boolean allowedByPolicy(Target target, LivingEntity entity) {
|
||||
if (entity instanceof Player player) {
|
||||
return target == Target.GENERAL && PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
}
|
||||
return target == Target.GENERAL ? PortInstabilityConfig.statusEffectsNonplayers()
|
||||
: entity instanceof Monster && PortInstabilityConfig.statusEffectsHostiles();
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
if (event.getServer().getTickCount() % 20 != 0) return;
|
||||
boolean disabled = !PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.statusEffectsEnabled();
|
||||
long tick = event.getServer().getTickCount();
|
||||
for (var entry : LEASES.entrySet()) {
|
||||
prune(entry.getKey(), entry.getValue(), tick, disabled
|
||||
|| !PortInstabilityData.get(entry.getKey().getServer()).age(entry.getKey()).enabled());
|
||||
}
|
||||
LEASES.values().removeIf(List::isEmpty);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
|
||||
Entity entity = event.getEntity();
|
||||
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
|
||||
clearEntity(living);
|
||||
}
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
long tick = event.getServer().getTickCount();
|
||||
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true);
|
||||
LEASES.clear();
|
||||
}
|
||||
|
||||
public static void clearAge(ServerLevel level) {
|
||||
var leases = LEASES.remove(level);
|
||||
if (leases != null) prune(level, leases, level.getServer().getTickCount(), true);
|
||||
}
|
||||
|
||||
private static void clearEntity(LivingEntity entity) {
|
||||
for (List<Lease> leases : LEASES.values()) {
|
||||
Iterator<Lease> iterator = leases.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Lease lease = iterator.next();
|
||||
if (lease.entity().get() != entity) continue;
|
||||
removeOwnedEffect(entity, lease);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void prune(ServerLevel level, List<Lease> leases, long tick, boolean removeAll) {
|
||||
Iterator<Lease> iterator = leases.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Lease lease = iterator.next();
|
||||
LivingEntity entity = lease.entity().get();
|
||||
if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
boolean revoke = removeAll || !PortInstabilityConfig.instabilityProviderEnabled(lease.provider())
|
||||
|| entity.level() != level
|
||||
|| !allowedByPolicy(lease.target(), entity);
|
||||
if (revoke || tick >= lease.expiresAt()) {
|
||||
removeOwnedEffect(entity, lease);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void removeOwnedEffect(LivingEntity entity, Lease lease) {
|
||||
// addEffect stores the supplied instance when no effect exists. Do not remove
|
||||
// a replacement from another mod (or a stronger/extended effect).
|
||||
if (entity.getEffect(lease.instance().getEffect()) == lease.instance()
|
||||
&& lease.instance().getAmplifier() == lease.amplifier()
|
||||
&& lease.instance().getDuration() <= lease.originalDuration()) {
|
||||
entity.removeEffect(lease.instance().getEffect());
|
||||
}
|
||||
}
|
||||
|
||||
static int legacyDuration(Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec == null ? -1 : spec.legacyDuration();
|
||||
}
|
||||
|
||||
static boolean localRequiresSky(Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec != null && !spec.global();
|
||||
}
|
||||
|
||||
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec != null && eligible(level, entity, spec,
|
||||
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
|
||||
}
|
||||
|
||||
private static Map<Identifier, Spec> createSpecs() {
|
||||
Map<Identifier, Spec> specs = new HashMap<>();
|
||||
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
|
||||
addPair(specs, "fatigue", MobEffects.MINING_FATIGUE, 80);
|
||||
addPair(specs, "hunger", MobEffects.HUNGER, 80);
|
||||
addPair(specs, "nausea", MobEffects.NAUSEA, 60);
|
||||
addPair(specs, "poison", MobEffects.POISON, 80);
|
||||
addPair(specs, "slow", MobEffects.SLOWNESS, 80);
|
||||
addPair(specs, "weakness", MobEffects.WEAKNESS, 80);
|
||||
addPair(specs, "wither", MobEffects.WITHER, 30);
|
||||
specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE));
|
||||
specs.put(id("enemyresist_global"), new Spec(MobEffects.RESISTANCE, 200, true, Target.HOSTILE));
|
||||
return Map.copyOf(specs);
|
||||
}
|
||||
|
||||
private static void addPair(Map<Identifier, Spec> specs, String name,
|
||||
Holder<MobEffect> effect, int duration) {
|
||||
specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL));
|
||||
specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL));
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private enum Target { GENERAL, HOSTILE }
|
||||
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
|
||||
private record Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target, int amplifier, int originalDuration) {
|
||||
private Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target) {
|
||||
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ final class PortWritingDeskProbe {
|
||||
|
||||
static void create(ServerLevel level) {
|
||||
PortAgeTravel.findLanding(level);
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
// New Minecraft drops Container block-entity contents during removal.
|
||||
@@ -69,6 +69,8 @@ final class PortWritingDeskProbe {
|
||||
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|
||||
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
|
||||
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
|
||||
if (!PortProgressionProbe.isComplete(player, "copy_page"))
|
||||
throw new IllegalStateException("Missing copy_page award");
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
|
||||
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
|
||||
|
||||
@@ -149,14 +149,26 @@ public final class RuntimeAgeBackend {
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, lighting, sky, colors, dayCycle, List.of());
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||
List<Identifier> librarySymbols) {
|
||||
checkServerThread(server);
|
||||
ageName(id);
|
||||
AgeLevelIndex index = index(server);
|
||||
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
|
||||
PortLibraryWriting.get(server).put(id, librarySymbols);
|
||||
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
((PortAgeLevel) level).configureWeather(weather);
|
||||
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle);
|
||||
// A newly bound book may be used immediately. Persist its Age identity
|
||||
// before publishing the catalog so a later crash cannot leave that
|
||||
// book pointing at a world omitted from the on-disk index.
|
||||
@@ -270,6 +282,18 @@ public final class RuntimeAgeBackend {
|
||||
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
||||
}
|
||||
|
||||
/** Display metadata never changes the dimension identity, seed or filesystem path. */
|
||||
public static java.util.Optional<String> savedAgeName(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
return java.util.Optional.ofNullable(index(server).displayName(id));
|
||||
}
|
||||
|
||||
public static void setAgeDisplayName(MinecraftServer server, Identifier id, String name) {
|
||||
checkServerThread(server);
|
||||
index(server).setDisplayName(id, name);
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
}
|
||||
|
||||
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
return index(server).entries();
|
||||
@@ -470,8 +494,8 @@ public final class RuntimeAgeBackend {
|
||||
};
|
||||
generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value()));
|
||||
}
|
||||
if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null)
|
||||
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed);
|
||||
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed,
|
||||
terrain != PortAgeTerrain.CAVE, PortLibraryWriting.get(server).symbols(id));
|
||||
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
|
||||
generator, OptionalLong.of(seed));
|
||||
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.PortalBookTravel;
|
||||
|
||||
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityTicker;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
|
||||
/** A book-holding crystal attachment, with server-authoritative frame activation. */
|
||||
public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.FACING;
|
||||
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
|
||||
|
||||
public BookReceptacleBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
|
||||
}
|
||||
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos,
|
||||
CollisionContext context) {
|
||||
return switch (state.getValue(FACING)) {
|
||||
case NORTH -> Block.box(0, 0, 10, 16, 16, 16);
|
||||
case SOUTH -> Block.box(0, 0, 0, 16, 16, 6);
|
||||
case EAST -> Block.box(0, 0, 0, 6, 16, 16);
|
||||
case WEST -> Block.box(10, 0, 0, 16, 16, 16);
|
||||
case UP -> Block.box(0, 0, 0, 16, 6, 16);
|
||||
case DOWN -> Block.box(0, 10, 0, 16, 16, 16);
|
||||
};
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING, HAS_BOOK);
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new BookReceptacleBlockEntity(pos, state);
|
||||
}
|
||||
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
|
||||
BlockEntityType<T> type) {
|
||||
return level instanceof ServerLevel
|
||||
? createTickerHelper(type, Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), (world, pos, current, holder) -> BookReceptacleBlockEntity.serverTick((ServerLevel) world, pos, holder))
|
||||
: null;
|
||||
}
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
Direction face = context.getClickedFace();
|
||||
if (!context.getLevel().getBlockState(
|
||||
context.getClickedPos().relative(face.getOpposite())).is(Rivencraft.CRYSTAL_BLOCK.get())) return null;
|
||||
return defaultBlockState().setValue(FACING, face);
|
||||
}
|
||||
|
||||
@Override protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
|
||||
Player player, InteractionHand hand, BlockHitResult hit) {
|
||||
if (!isBoundBook(stack)) return InteractionResult.TRY_WITH_EMPTY_HAND;
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (!(level instanceof ServerLevel server) ||
|
||||
!(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return InteractionResult.FAIL;
|
||||
if (!holder.getBook().isEmpty()) return InteractionResult.FAIL;
|
||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos);
|
||||
if (!plan.valid()) {
|
||||
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.portal.invalid_frame"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
if (!PortalBookTravel.available(server, stack)) {
|
||||
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
holder.setBook(stack.copyWithCount(1));
|
||||
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
|
||||
holder.takeBook();
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
if (!player.getAbilities().instabuild) stack.shrink(1);
|
||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder
|
||||
&& !holder.getBook().isEmpty()) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
ItemStack book = holder.takeBook();
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, net.minecraft.util.Prediction.SERVER_ONLY);
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
return InteractionResult.PASS;
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
|
||||
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
|
||||
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
|
||||
if (!level.isClientSide() && level.hasChunkAt(support) && !level.getBlockState(support)
|
||||
.is(Rivencraft.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
|
||||
}
|
||||
|
||||
private static boolean isBoundBook(ItemStack stack) {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) && stack.has(Rivencraft.LINK_BOOK_TARGET.get())
|
||||
|| stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && stack.has(Rivencraft.AGE_BOOK_ID.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
/** Vanilla lectern geometry with Rivencraft storage instead of a vanilla book menu. */
|
||||
public final class BookStandBlock extends LecternBlock {
|
||||
public BookStandBlock(Properties properties) { super(properties); }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new BookStandBlockEntity(pos, state); }
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
|
||||
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return InteractionResult.PASS;
|
||||
if (player.isShiftKeyDown() && !level.isClientSide()) {
|
||||
var book = stand.takeBook();
|
||||
level.setBlock(pos, Blocks.LECTERN.defaultBlockState().setValue(FACING, state.getValue(FACING)), 3);
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
@Override protected net.minecraft.world.MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { return null; }
|
||||
@Override protected int getAnalogOutputSignal(BlockState state, Level level, BlockPos pos, net.minecraft.core.Direction direction) { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.level.material.Fluid;
|
||||
|
||||
/** Non-solid linked surface; all travel is delegated to the held book's rules. */
|
||||
public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
// Retained so portal blocks already saved with this state still deserialize.
|
||||
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
|
||||
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
|
||||
|
||||
public CrystalPortalBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(LIVE_VIEW, AXIS);
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||
// Keep the animated fallback until the optional destination view has
|
||||
// actually been established, including after a world restart.
|
||||
return RenderShape.MODEL;
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new CrystalPortalBlockEntity(pos, state);
|
||||
}
|
||||
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
||||
|
||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, net.minecraft.world.entity.InsideBlockEffectApplier effects, boolean precise) {
|
||||
if (!(level instanceof ServerLevel server) || !entity.canUsePortal(false)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
if (PortalTravelCooldown.blocked(entity, now)) return;
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||
ItemStack book = holder.getBook();
|
||||
if (book.isEmpty()) return;
|
||||
PortalTravelCooldown.mark(entity, now);
|
||||
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
|
||||
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
|
||||
if (!(level instanceof ServerLevel server)
|
||||
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null) return;
|
||||
// Like a vanilla portal, validate when the frame changes, not on a
|
||||
// repeating timer that can race block entities loading after restart.
|
||||
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
|
||||
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
|
||||
}
|
||||
|
||||
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||
// Ignore validation ticks saved by older builds. Frame edits are
|
||||
// handled by neighborChanged, and the receptacle maintains the view.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.age.PortInstabilityRuntime;
|
||||
import net.dnorberg.rivencraft.age.PortInstabilityConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.StringRepresentable;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
|
||||
/** The four colored decay variants selected by the current instability deck. */
|
||||
public final class DecayBlock extends Block {
|
||||
public static final EnumProperty<Color> COLOR = EnumProperty.create("color", Color.class);
|
||||
|
||||
public DecayBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(COLOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) {
|
||||
if (level instanceof net.minecraft.server.level.ServerLevel server && state.getValue(COLOR) == Color.WHITE
|
||||
&& PortInstabilityConfig.decayEnabled() && PortInstabilityConfig.instabilityWorldMutation()
|
||||
&& PortInstabilityRuntime.isAge(server)) {
|
||||
entity.hurtServer(server, level.damageSources().magic(), 1.0F);
|
||||
}
|
||||
super.stepOn(level, pos, state, entity);
|
||||
}
|
||||
|
||||
public enum Color implements StringRepresentable {
|
||||
BLUE, PURPLE, RED, WHITE;
|
||||
|
||||
@Override
|
||||
public String getSerializedName() {
|
||||
return name().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One persisted book owns the whole crystal network. Portal cells retain only its position. */
|
||||
public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
private boolean restore = true;
|
||||
private int skyColor = 0xffffff;
|
||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
public ItemStack getBook() { return book; }
|
||||
public int skyColor() { return skyColor; }
|
||||
public void setBook(ItemStack value) {
|
||||
book = value.copyWithCount(1);
|
||||
skyColor = 0xffffff;
|
||||
if (level instanceof ServerLevel source) {
|
||||
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
|
||||
: destination == null ? null : destination.dimension();
|
||||
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, id));
|
||||
if (target != null) {
|
||||
var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target, !book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(),
|
||||
net.dnorberg.rivencraft.item.LinkProperties.NONE).has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM))
|
||||
: BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||
if (pos != null) {
|
||||
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
|
||||
String type = target.dimensionTypeRegistration().unwrapKey()
|
||||
.map(key -> key.identifier().getPath()).orElse("");
|
||||
skyColor = written >= 0 ? written
|
||||
: target.dimension() == net.minecraft.world.level.Level.NETHER || type.endsWith("_sky_nether")
|
||||
? 0x6b2725
|
||||
: target.dimension() == net.minecraft.world.level.Level.END || type.endsWith("_sky_end")
|
||||
? 0x211835
|
||||
: net.minecraft.util.ARGB.colorFromVector3f(target.environmentAttributes().getValue(
|
||||
net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos)) & 0xffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
sync();
|
||||
}
|
||||
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; }
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) {
|
||||
BlockState current = level.getBlockState(worldPosition);
|
||||
if (current.is(Rivencraft.BOOK_RECEPTACLE.get())) {
|
||||
level.setBlock(worldPosition, current.setValue(BookReceptacleBlock.HAS_BOOK, !book.isEmpty()), 2);
|
||||
level.sendBlockUpdated(worldPosition, current, level.getBlockState(worldPosition), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void serverTick(ServerLevel level, BlockPos pos, BookReceptacleBlockEntity holder) {
|
||||
if (holder.book.isEmpty()) { holder.restore = false; return; }
|
||||
int interval = holder.restore ? 20 : 200;
|
||||
if (level.getGameTime() % interval != Math.floorMod(pos.asLong(), interval)) return;
|
||||
var plan = CrystalPortalNetwork.plan(level, pos);
|
||||
if (plan.waitingForChunks()) return;
|
||||
boolean restoring = holder.restore;
|
||||
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
|
||||
plan = CrystalPortalNetwork.plan(level, pos);
|
||||
CrystalPortalNetwork.reconcile(level, pos, plan);
|
||||
holder.restore = false;
|
||||
if (restoring) holder.sync();
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
|
||||
skyColor = input.getIntOr("SkyColor", 0xffffff);
|
||||
restore = true;
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
|
||||
output.putInt("SkyColor", skyColor);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
|
||||
if (level instanceof ServerLevel server) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
ItemStack removed = book;
|
||||
book = ItemStack.EMPTY;
|
||||
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, removed);
|
||||
}
|
||||
super.preRemoveSideEffects(pos, oldState);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One stored book or page, synchronized independently of the player's inventory. */
|
||||
public final class BookStandBlockEntity extends BlockEntity {
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
public BookStandBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), pos, state); }
|
||||
public ItemStack getBook() { return book; }
|
||||
public void setBook(ItemStack stack) { book = stack; sync(); }
|
||||
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; sync(); return result; }
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
|
||||
if (level != null && !level.isClientSide())
|
||||
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5, takeBook());
|
||||
super.preRemoveSideEffects(pos, oldState);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One stored book or page, synchronized independently of the player's inventory. */
|
||||
public final class CrystalPortalBlockEntity extends BlockEntity {
|
||||
private BlockPos receptacle;
|
||||
public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.CRYSTAL_PORTAL_BLOCK_ENTITY.get(), pos, state); }
|
||||
public BlockPos receptacle() { return receptacle; }
|
||||
public void setReceptacle(BlockPos pos) {
|
||||
receptacle = pos.immutable();
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
receptacle = input.read("Receptacle", BlockPos.CODEC).orElse(null);
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (receptacle != null) output.store("Receptacle", BlockPos.CODEC, receptacle);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -116,13 +116,13 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
||||
public boolean canConsumeCopyMaterials() {
|
||||
ItemStack paper = items.get(PAPER);
|
||||
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
|
||||
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
|
||||
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
|
||||
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
|
||||
}
|
||||
|
||||
public boolean consumeCopyMaterials() {
|
||||
if (!canConsumeCopyMaterials()) return false;
|
||||
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
|
||||
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
|
||||
if (inkRemaining < cost) {
|
||||
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.model.object.book.BookModel;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
|
||||
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** The 1.21.1 receptacle book orientation in the 26.3 extracted renderer. */
|
||||
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity, BookReceptacleRenderer.State> {
|
||||
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
|
||||
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
}
|
||||
public static final class State extends BlockEntityRenderState {
|
||||
boolean present, descriptive;
|
||||
Direction facing = Direction.NORTH;
|
||||
}
|
||||
@Override public State createRenderState() { return new State(); }
|
||||
@Override public void extractRenderState(BookReceptacleBlockEntity holder, State state, float partialTick,
|
||||
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
|
||||
BlockEntityRenderer.super.extractRenderState(holder, state, partialTick, camera, overlay);
|
||||
state.present = !holder.getBook().isEmpty() && holder.getBlockState().getValue(BookReceptacleBlock.HAS_BOOK);
|
||||
state.descriptive = holder.getBook().is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
state.facing = holder.getBlockState().getValue(BookReceptacleBlock.FACING);
|
||||
}
|
||||
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
|
||||
if (!state.present) return;
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 0.5F, 0.5F);
|
||||
switch (state.facing) {
|
||||
case UP -> {
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
pose.rotateDegrees(Axis.XP, -90);
|
||||
pose.rotateDegrees(Axis.YP, 90);
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
}
|
||||
case DOWN -> {
|
||||
pose.rotateDegrees(Axis.XP, -90);
|
||||
pose.rotateDegrees(Axis.YP, 90);
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
}
|
||||
case NORTH -> pose.rotateDegrees(Axis.YP, -90);
|
||||
case SOUTH -> pose.rotateDegrees(Axis.YP, 90);
|
||||
case EAST -> pose.rotateDegrees(Axis.YP, 180);
|
||||
case WEST -> { }
|
||||
}
|
||||
pose.scale(0.8F, 0.8F, 0.8F);
|
||||
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.1F, 0.005F), pose,
|
||||
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
pose.popPose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.model.object.book.BookModel;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
|
||||
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||
import net.minecraft.client.renderer.item.ItemModelResolver;
|
||||
import net.minecraft.client.renderer.item.ItemStackRenderState;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemDisplayContext;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Extracted version of the 1.21.1 lectern display, retaining its final page orientation. */
|
||||
public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlockEntity, BookStandRenderer.State> {
|
||||
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
|
||||
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
private final ItemModelResolver resolver;
|
||||
private final Font font;
|
||||
public BookStandRenderer(BlockEntityRendererProvider.Context context) {
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
resolver = context.itemModelResolver();
|
||||
font = context.font();
|
||||
}
|
||||
public static final class State extends BlockEntityRenderState {
|
||||
final ItemStackRenderState page = new ItemStackRenderState();
|
||||
boolean present, isPage, descriptive;
|
||||
float rotation;
|
||||
Component name;
|
||||
}
|
||||
@Override public State createRenderState() { return new State(); }
|
||||
@Override public void extractRenderState(BookStandBlockEntity stand, State state, float partialTick,
|
||||
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
|
||||
BlockEntityRenderer.super.extractRenderState(stand, state, partialTick, camera, overlay);
|
||||
var book = stand.getBook();
|
||||
state.present = !book.isEmpty();
|
||||
state.isPage = book.is(Rivencraft.PAGE.get());
|
||||
state.descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
state.name = state.present && !state.isPage ? book.getHoverName().copy() : null;
|
||||
Direction facing = stand.getBlockState().getValue(BookStandBlock.FACING);
|
||||
state.rotation = state.isPage ? facing.toYRot() - 180 + (facing.getAxis() == Direction.Axis.X ? 180 : 0)
|
||||
: -facing.getClockWise().toYRot();
|
||||
resolver.updateForTopItem(state.page, state.isPage ? book : net.minecraft.world.item.ItemStack.EMPTY,
|
||||
ItemDisplayContext.FIXED, stand.getLevel(), null, 0);
|
||||
}
|
||||
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
|
||||
if (!state.present) return;
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 1.0625F, 0.5F);
|
||||
pose.rotateDegrees(Axis.YP, state.rotation);
|
||||
if (state.isPage) {
|
||||
pose.rotateDegrees(Axis.XP, 67.5F);
|
||||
pose.scale(0.7F, 0.7F, 0.7F);
|
||||
state.page.submit(pose, collector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
} else {
|
||||
pose.rotateDegrees(Axis.ZP, 67.5F);
|
||||
pose.translate(0, -0.125F, 0);
|
||||
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.9F, 1.2F), pose,
|
||||
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
}
|
||||
pose.popPose();
|
||||
if (state.name != null) {
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 1.6F, 0.5F);
|
||||
pose.rotate(camera.orientation);
|
||||
pose.scale(0.025F, -0.025F, 0.025F);
|
||||
collector.submitText(pose, -font.width(state.name) / 2.0F, 0, state.name.getVisualOrderText(),
|
||||
false, Font.DisplayMode.NORMAL, state.lightCoords, 0xFFFFFFFF, 0, 0);
|
||||
pose.popPose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Existing Legacy single-slot layout, using the 26.3 extraction/input APIs. */
|
||||
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
|
||||
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
|
||||
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
|
||||
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
|
||||
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
|
||||
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
|
||||
private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"};
|
||||
private EditBox titleInput;
|
||||
private ItemStack lastBook = ItemStack.EMPTY;
|
||||
private boolean refreshing;
|
||||
private final Button[] options = new Button[6];
|
||||
|
||||
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title, 176, 166);
|
||||
inventoryLabelY = 72;
|
||||
}
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
@Override protected void init() {
|
||||
super.init();
|
||||
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
|
||||
Component.translatable("screen.rivencraft.link_modifier.title"));
|
||||
titleInput.setMaxLength(32);
|
||||
titleInput.setResponder(value -> {
|
||||
if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value));
|
||||
});
|
||||
addRenderableWidget(titleInput);
|
||||
for (int index = 0; index < options.length; index++) {
|
||||
final int option = index;
|
||||
options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20,
|
||||
topPos + 10 + (index % 3) * 20, 18, 18, label(index), button -> {
|
||||
if (minecraft != null && minecraft.gameMode != null)
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, IDS[option]);
|
||||
}, java.util.function.Supplier::get) {
|
||||
@Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int x = getX(), y = getY();
|
||||
graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA);
|
||||
graphics.fill(x + 2, y + 2, x + 17, y + 17, !active ? 0xFF555555
|
||||
: selected(option, menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE))
|
||||
? 0xFFBBBBBB : 0xFF777777);
|
||||
}
|
||||
});
|
||||
options[index].setTooltip(Tooltip.create(label(index)));
|
||||
}
|
||||
refreshControls();
|
||||
}
|
||||
@Override protected void containerTick() { refreshControls(); }
|
||||
private void refreshControls() {
|
||||
ItemStack book = menu.book();
|
||||
// Changing book kinds refreshes immediately; otherwise avoid replacing
|
||||
// focused text with an earlier server echo while the player is typing.
|
||||
if (!ItemStack.isSameItem(book, lastBook) || !titleInput.isFocused()) {
|
||||
if (!ItemStack.isSameItemSameComponents(book, lastBook)) {
|
||||
Component name = book.get(DataComponents.CUSTOM_NAME);
|
||||
refreshing = true;
|
||||
titleInput.setValue(name == null ? "" : name.getString());
|
||||
refreshing = false;
|
||||
lastBook = book.copy();
|
||||
}
|
||||
}
|
||||
titleInput.setEditable(!book.isEmpty());
|
||||
for (int i = 0; i < options.length; i++) if (options[i] != null) {
|
||||
options[i].active = available(i);
|
||||
Component label = label(i);
|
||||
if (!options[i].getMessage().equals(label)) {
|
||||
options[i].setMessage(label);
|
||||
options[i].setTooltip(Tooltip.create(label));
|
||||
}
|
||||
}
|
||||
}
|
||||
private boolean available(int index) {
|
||||
return !menu.book().isEmpty() && ((index != 1 && index != 2) || menu.book().is(Rivencraft.LINKING_BOOK.get()));
|
||||
}
|
||||
private Component label(int index) {
|
||||
Component name = Component.translatable("item.rivencraft.link_property." + NAMES[index]);
|
||||
return name.copy().append(selected(index,
|
||||
menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off");
|
||||
}
|
||||
@Override public boolean keyPressed(KeyEvent event) {
|
||||
if (titleInput != null && titleInput.isFocused() && !event.isEscape()) {
|
||||
titleInput.keyPressed(event);
|
||||
return true; // Character input must never activate inventory/hotbar shortcuts.
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.extractBackground(graphics, mouseX, mouseY, partialTick);
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||
}
|
||||
|
||||
private boolean selected(int index, LinkProperties properties) {
|
||||
if (index != 5) return properties.has(FLAGS[index]);
|
||||
return menu.book().is(Rivencraft.LINKING_BOOK.get()) ? properties.has(LinkProperties.GENERATE_PLATFORM)
|
||||
: !properties.has(LinkProperties.DISABLE_PLATFORM);
|
||||
}
|
||||
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
|
||||
}
|
||||
}
|
||||
+18
@@ -20,12 +20,30 @@ public final class PortBookBinderClientEvents {
|
||||
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
|
||||
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), BookReceptacleRenderer::new);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void portalTint(net.neoforged.neoforge.client.event.RegisterColorHandlersEvent.BlockTintSources event) {
|
||||
event.register(java.util.List.of(new net.minecraft.client.color.block.BlockTintSource() {
|
||||
@Override public int color(net.minecraft.world.level.block.state.BlockState state) { return 0xffffffff; }
|
||||
@Override public int colorInWorld(net.minecraft.world.level.block.state.BlockState state,
|
||||
net.minecraft.client.renderer.block.BlockAndTintGetter level, net.minecraft.core.BlockPos pos) {
|
||||
if (level.getBlockEntity(pos) instanceof net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity portal
|
||||
&& portal.receptacle() != null
|
||||
&& level.getBlockEntity(portal.receptacle()) instanceof net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity holder)
|
||||
return 0xff000000 | holder.skyColor();
|
||||
return 0xffffffff;
|
||||
}
|
||||
}), Rivencraft.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerScreens(RegisterMenuScreensEvent event) {
|
||||
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
|
||||
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
|
||||
event.register(Rivencraft.LINK_MODIFIER_MENU.get(), LinkModifierScreen::new);
|
||||
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
|
||||
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
|
||||
}
|
||||
|
||||
+15
@@ -26,4 +26,19 @@ public final class PortBookInteractionHandler {
|
||||
? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
|
||||
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
|
||||
}
|
||||
@SubscribeEvent
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
if (!event.getLevel().isClientSide() || event.getEntity().isShiftKeyDown()
|
||||
|| !event.getItemStack().isEmpty()
|
||||
|| !(event.getLevel().getBlockEntity(event.getPos()) instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity stand)) return;
|
||||
var book = stand.getBook();
|
||||
if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
var source = new net.dnorberg.rivencraft.network.UseBookStandPayload(
|
||||
event.getLevel().dimension().identifier(), event.getPos(), book.copy());
|
||||
Minecraft.getInstance().setScreenAndShow(book.is(Rivencraft.LINKING_BOOK.get())
|
||||
? new PortLinkingBookScreen(source) : new PortDescriptiveBookScreen(source));
|
||||
}
|
||||
|
||||
}
|
||||
+88
-36
@@ -10,81 +10,111 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */
|
||||
/** Legacy book layout and authored glyph pages; destination rendering remains a port placeholder. */
|
||||
public final class PortDescriptiveBookScreen extends Screen {
|
||||
private static final int WIDTH = 327;
|
||||
private static final int HEIGHT = 199;
|
||||
private static final int TEXT_COLOR = 0xFF3B281B;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final boolean bound;
|
||||
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
|
||||
private final net.dnorberg.rivencraft.writing.DescriptiveBookPages pages;
|
||||
private int pageIndex;
|
||||
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
|
||||
private EditBox titleInput;
|
||||
|
||||
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
|
||||
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
|
||||
}
|
||||
|
||||
public PortDescriptiveBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
|
||||
this(InteractionHand.MAIN_HAND, source.book());
|
||||
standSource = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound) return;
|
||||
if (bound || standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
Component.literal("Age name"));
|
||||
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
|
||||
titleInput.setHint(Component.literal("Name this Age"));
|
||||
titleInput.setHint(Component.literal(standSource == null ? "Name this Age" : "Remove book to bind"));
|
||||
addRenderableWidget(titleInput);
|
||||
setInitialFocus(titleInput);
|
||||
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
|
||||
.bounds(left + 40, top + 100, 53, 20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
|
||||
.bounds(left + 97, top + 100, 53, 20).build());
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
|
||||
graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47,
|
||||
0xFFE9DFCB, false);
|
||||
graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65,
|
||||
0xFFE9DFCB, false);
|
||||
} else {
|
||||
graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39,
|
||||
TEXT_COLOR, false);
|
||||
PortLegacyBookArtwork.drawCover(graphics, left, top, true);
|
||||
if (pageIndex > 0) PortLegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||
if (pageIndex == 0) {
|
||||
if (bound) {
|
||||
graphics.nextStratum();
|
||||
linkPanel.render(graphics, left, top, partialTick);
|
||||
graphics.nextStratum();
|
||||
}
|
||||
PortLegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||
if (bound) {
|
||||
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||
int textY = PortLegacyBookArtwork.drawText(graphics, font, title, left + 40, top + 40, 110, TEXT_COLOR);
|
||||
Component author = pages.author().isBlank()
|
||||
? Component.translatable("screen.rivencraft.age.unknown_author") : Component.literal(pages.author());
|
||||
PortLegacyBookArtwork.drawText(graphics, font, author, left + 50, textY + 2, 100, TEXT_COLOR);
|
||||
graphics.disableScissor();
|
||||
} else {
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
graphics.text(font, Component.literal(standSource == null ? "Bind a prototype Age" : "Remove book to bind"),
|
||||
left + 183, top + 39, TEXT_COLOR, false);
|
||||
}
|
||||
} else if (pages.symbolAt(pageIndex).isPresent()) {
|
||||
PortLegacyBookArtwork.drawFullPage(graphics, left, top);
|
||||
graphics.nextStratum();
|
||||
var symbol = pages.symbolAt(pageIndex).orElseThrow();
|
||||
ItemStack glyph = new ItemStack(Rivencraft.PAGE.get());
|
||||
glyph.set(Rivencraft.SYMBOL_PAGE_ID.get(), symbol);
|
||||
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
|
||||
graphics.enableScissor(left + 174, top + 26, left + 306, top + 172);
|
||||
graphics.pose().pushMatrix();
|
||||
graphics.pose().translate(left + 177, top + 29);
|
||||
graphics.pose().scale(8.0F, 8.0F);
|
||||
graphics.fakeItem(glyph, 0, 0);
|
||||
graphics.pose().popMatrix();
|
||||
graphics.disableScissor();
|
||||
}
|
||||
graphics.nextStratum();
|
||||
PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, pages.lastPage());
|
||||
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
if (event.x() >= left + 173 && event.x() < left + 305
|
||||
&& event.y() >= top + 20 && event.y() < top + 103) {
|
||||
ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, pages.lastPage());
|
||||
if (direction != 0) { turnPage(direction); return true; }
|
||||
if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) {
|
||||
if (standSource != null) ClientPacketDistributor.sendToServer(standSource);
|
||||
else ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
@@ -92,6 +122,36 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
private void turnPage(int direction) {
|
||||
int next = pages.turn(pageIndex, direction);
|
||||
if (next == pageIndex) return;
|
||||
pageIndex = next;
|
||||
minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI(
|
||||
net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
private void updatePageControls() {
|
||||
for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control)
|
||||
control.visible = pageIndex == 0;
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
|
||||
if (event.isEscape()) return super.keyPressed(event);
|
||||
if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) {
|
||||
if (event.isLeft()) { turnPage(-1); return true; }
|
||||
if (event.isRight()) { turnPage(1); return true; }
|
||||
} else {
|
||||
// Keep inventory/hotbar actions out of a focused name field.
|
||||
return titleInput.keyPressed(event);
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override public void tick() {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
@@ -105,12 +165,4 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
onClose();
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0,
|
||||
* copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */
|
||||
final class PortLegacyBookArtwork {
|
||||
private static final int ATLAS_SIZE = 256;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
private static final Identifier RIGHT_FULL = texture("bookui_rpage_full.png");
|
||||
|
||||
private PortLegacyBookArtwork() {}
|
||||
|
||||
static void drawCover(GuiGraphicsExtractor graphics, int left, int top, boolean gilded) {
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
if (gilded) {
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 186, 0);
|
||||
blit(graphics, COVER, left + 293, top + 7, 34, 192, 186, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void drawLeftPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static void drawPanelPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static void drawFullPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static int drawText(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font,
|
||||
net.minecraft.network.chat.Component text, int x, int y, int width, int color) {
|
||||
for (var line : font.split(text, width)) {
|
||||
graphics.text(font, line, x, y, color, false);
|
||||
y += font.lineHeight;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
static void drawCounter(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font,
|
||||
int left, int top, int page, int last) {
|
||||
String counter = page + "/" + last;
|
||||
graphics.text(font, counter, left + 165 - font.width(counter) / 2, top + 185, 0xFF000000, false);
|
||||
}
|
||||
|
||||
static int turnDirection(double x, double y, int page, int last) {
|
||||
if (y < 164 || y >= 195) return 0;
|
||||
if (page > 0 && x >= 7 && x < 35) return -1;
|
||||
return page < last && x >= 293 && x < 320 ? 1 : 0;
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
|
||||
/**
|
||||
* Built-in linking-panel animation inspired by Mystcraft Legacy's portal bloom.
|
||||
*
|
||||
* <p>The original effect used a GLSL noise shader over an optional live world
|
||||
* view. This port retains the 1.21.1 fallback's two noisy spirals and default teal
|
||||
* tint, using the normal GUI API without a renderer mod.</p>
|
||||
*/
|
||||
final class PortLegacyLinkPanel {
|
||||
static final int X = 173, Y = 20, WIDTH = 132, HEIGHT = 83;
|
||||
private static final int CELL_SIZE = 3;
|
||||
private static final int BORDER_COLOR = 0xFF7B6AA8;
|
||||
private int ticks;
|
||||
private static final int LINK_COLOR = 0xFF006666;
|
||||
|
||||
static boolean contains(double x, double y) {
|
||||
return x >= X && x < X + WIDTH && y >= Y && y < Y + HEIGHT;
|
||||
}
|
||||
|
||||
void render(GuiGraphicsExtractor graphics, int bookLeft, int bookTop, float partialTick) {
|
||||
render(graphics, bookLeft + X, bookTop + Y, WIDTH, HEIGHT, partialTick);
|
||||
}
|
||||
|
||||
void tick() {
|
||||
ticks++;
|
||||
}
|
||||
|
||||
private void render(GuiGraphicsExtractor graphics, int left, int top, int width, int height, float partialTick) {
|
||||
graphics.fill(left, top, left + width, top + height, 0xFF000011);
|
||||
|
||||
float time = (ticks + partialTick) * 0.035F;
|
||||
float reveal = Math.min(1.0F, time / 1.8F);
|
||||
for (int y = 1; y < height - 1; y += CELL_SIZE) {
|
||||
for (int x = 1; x < width - 1; x += CELL_SIZE) {
|
||||
float normalizedX = (x - width * 0.5F) / height;
|
||||
float normalizedY = (y - height * 0.5F) / height;
|
||||
float first = spiral(normalizedX, normalizedY, time, 0.0F);
|
||||
float second = spiral(normalizedX, normalizedY, time, 2.35F);
|
||||
float intensity = Math.max(first, second) * reveal;
|
||||
if (intensity <= 0.035F) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int color = scaledColor(LINK_COLOR, Math.min(1.0F, 0.12F + intensity));
|
||||
graphics.fill(left + x, top + y,
|
||||
left + Math.min(width - 1, x + CELL_SIZE),
|
||||
top + Math.min(height - 1, y + CELL_SIZE), color);
|
||||
}
|
||||
}
|
||||
graphics.outline(left, top, width, height, BORDER_COLOR);
|
||||
}
|
||||
|
||||
private static float spiral(float x, float y, float time, float phase) {
|
||||
float distance = (float)Math.sqrt(x * x + y * y);
|
||||
float angle = (float)Math.atan2(y, x);
|
||||
float arm = 0.5F + 0.5F * (float)Math.cos(angle * 3.0F - distance * 25.0F + time * 4.0F + phase);
|
||||
float pulse = 0.72F + 0.28F * noise(x * 31.0F, y * 31.0F, time + phase);
|
||||
float core = Math.max(0.0F, 1.0F - distance * 2.15F);
|
||||
return core * (0.28F + arm * 0.72F) * pulse;
|
||||
}
|
||||
|
||||
private static float noise(float x, float y, float time) {
|
||||
double value = Math.sin(x * 12.9898D + y * 78.233D + time * 19.19D) * 43758.5453D;
|
||||
return (float)(value - Math.floor(value));
|
||||
}
|
||||
|
||||
private static int scaledColor(int color, float scale) {
|
||||
int red = Math.min(255, Math.round(((color >> 16) & 0xFF) * scale + 18.0F * scale));
|
||||
int green = Math.min(255, Math.round(((color >> 8) & 0xFF) * scale + 24.0F * scale));
|
||||
int blue = Math.min(255, Math.round((color & 0xFF) * scale + 42.0F * scale));
|
||||
return 0xFF000000 | red << 16 | green << 8 | blue;
|
||||
}
|
||||
}
|
||||
+82
-33
@@ -10,35 +10,48 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */
|
||||
/** Legacy Linking Book front and final spreads with the shared fallback panel. */
|
||||
public final class PortLinkingBookScreen extends Screen {
|
||||
private static final int WIDTH = 327;
|
||||
private static final int HEIGHT = 199;
|
||||
private static final int TEXT_COLOR = 0xFF3B281B;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final boolean bound;
|
||||
private final net.dnorberg.rivencraft.item.LinkBookTarget target;
|
||||
private final boolean returnLink;
|
||||
private final Component destination;
|
||||
private int pageIndex;
|
||||
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
|
||||
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
|
||||
private EditBox titleInput;
|
||||
|
||||
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
bound = book.has(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
bound = target != null;
|
||||
destination = bound ? net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(book, target) : Component.empty();
|
||||
Component customName = book.get(net.minecraft.core.component.DataComponents.CUSTOM_NAME);
|
||||
returnLink = customName != null
|
||||
&& customName.getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents translated
|
||||
&& translated.getKey().equals("item.rivencraft.linking_book.return");
|
||||
}
|
||||
|
||||
public PortLinkingBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
|
||||
this(InteractionHand.MAIN_HAND, source.book());
|
||||
standSource = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound) return;
|
||||
if (bound || standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
@@ -51,38 +64,55 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
.bounds(left + 40, top + 100, 53, 20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
|
||||
.bounds(left + 97, top + 100, 53, 20).build());
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
|
||||
graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false);
|
||||
graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false);
|
||||
PortLegacyBookArtwork.drawCover(graphics, left, top, false);
|
||||
if (pageIndex > 0) {
|
||||
PortLegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||
} else {
|
||||
graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39,
|
||||
TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.nextStratum();
|
||||
linkPanel.render(graphics, left, top, partialTick);
|
||||
graphics.nextStratum();
|
||||
}
|
||||
PortLegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||
if (bound) {
|
||||
int y = top + 40;
|
||||
if (!returnLink) y = PortLegacyBookArtwork.drawText(graphics, font, title,
|
||||
left + 40, y, 110, TEXT_COLOR) + 4;
|
||||
y = PortLegacyBookArtwork.drawText(graphics, font, destination, left + 50, y, 100, TEXT_COLOR) + 10;
|
||||
var pos = net.minecraft.core.BlockPos.containing(target.x(), target.y(), target.z());
|
||||
PortLegacyBookArtwork.drawText(graphics, font,
|
||||
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
|
||||
left + 40, y, 110, TEXT_COLOR);
|
||||
} else {
|
||||
PortLegacyBookArtwork.drawText(graphics, font, Component.literal(
|
||||
standSource == null ? "Name this Linking Book" : "Remove book to bind"),
|
||||
left + 40, top + 43, 110, TEXT_COLOR);
|
||||
}
|
||||
graphics.disableScissor();
|
||||
}
|
||||
graphics.nextStratum();
|
||||
PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, 1);
|
||||
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
if (event.x() >= left + 173 && event.x() < left + 305
|
||||
&& event.y() >= top + 20 && event.y() < top + 103) {
|
||||
ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, 1);
|
||||
if (direction != 0) { turnPage(direction); return true; }
|
||||
if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) {
|
||||
if (standSource != null) ClientPacketDistributor.sendToServer(standSource);
|
||||
else ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
@@ -90,6 +120,33 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
private void turnPage(int direction) {
|
||||
int next = Math.clamp(pageIndex + direction, 0, 1);
|
||||
if (next == pageIndex) return;
|
||||
pageIndex = next;
|
||||
minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI(
|
||||
net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
private void updatePageControls() {
|
||||
for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control)
|
||||
control.visible = pageIndex == 0;
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
|
||||
if (event.isEscape()) return super.keyPressed(event);
|
||||
if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) {
|
||||
if (event.isLeft()) { turnPage(-1); return true; }
|
||||
if (event.isRight()) { turnPage(1); return true; }
|
||||
} else return titleInput.keyPressed(event);
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override public void tick() {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
@@ -103,12 +160,4 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
onClose();
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,17 @@ public final class DroppedBookEntity extends ItemEntity {
|
||||
setPickUpDelay(20);
|
||||
}
|
||||
|
||||
/** Conversion preserves ordinary toss/drop state; the travel constructor has different placement rules. */
|
||||
public static DroppedBookEntity fromDrop(ItemEntity source) {
|
||||
var book = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), source.level());
|
||||
book.restoreFrom(source);
|
||||
// The original remains registered until replacement succeeds, so this needs a new identity.
|
||||
book.setUUID(java.util.UUID.randomUUID());
|
||||
book.setItem(source.getItem().copy());
|
||||
book.setUnlimitedLifetime();
|
||||
return book;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
|
||||
|
||||
public final class BookStandConversionHandler {
|
||||
private BookStandConversionHandler() {}
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
var book = event.getItemStack();
|
||||
if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get())) return;
|
||||
var state = event.getLevel().getBlockState(event.getPos());
|
||||
if (!state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK)) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
if (!event.getLevel().isClientSide()) convert(event.getEntity(), event.getHand(), event.getPos());
|
||||
}
|
||||
public static boolean convert(Player player, InteractionHand hand, BlockPos pos) {
|
||||
var level = player.level();
|
||||
var state = level.getBlockState(pos);
|
||||
var book = player.getItemInHand(hand);
|
||||
if (level.isClientSide() || !state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK)
|
||||
|| (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get()))) return false;
|
||||
if (!level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, state.getValue(LecternBlock.FACING)).setValue(BookStandBlock.HAS_BOOK, true), 3)) return false;
|
||||
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return false;
|
||||
stand.setBook(book.consumeAndReturn(1, player));
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PUT, SoundSource.BLOCKS, 1, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.minecraft.server.TickTask;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.bus.api.EventPriority;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent;
|
||||
|
||||
/** Converts ordinary book drops from every source, including entities restored from disk. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class DroppedBookConversionHandler {
|
||||
private DroppedBookConversionHandler() {}
|
||||
|
||||
@SubscribeEvent(priority = EventPriority.LOWEST)
|
||||
public static void onJoin(EntityJoinLevelEvent event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)
|
||||
|| !(event.getEntity() instanceof ItemEntity item) || item instanceof DroppedBookEntity
|
||||
|| !isBook(item.getItem())) return;
|
||||
// Join events can run before chunk promotion. Do not add another entity inside that callback.
|
||||
// Keep the original until the queued replacement has actually been accepted.
|
||||
level.getServer().schedule(new TickTask(0, () -> {
|
||||
if (level.getServer().getLevel(level.dimension()) != level || item.isRemoved()
|
||||
|| !item.isAddedToLevel() || item.level() != level || !isBook(item.getItem())) return;
|
||||
var replacement = DroppedBookEntity.fromDrop(item);
|
||||
if (level.addFreshEntity(replacement)) item.discard();
|
||||
}));
|
||||
}
|
||||
|
||||
private static boolean isBook(ItemStack stack) {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,6 @@ public final class DescriptiveBookItem extends Item {
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id));
|
||||
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook().appendTooltip(tooltip);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package net.dnorberg.rivencraft.item;
|
||||
import java.util.function.Consumer;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
@@ -17,8 +17,6 @@ import net.minecraft.world.level.Level;
|
||||
/** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */
|
||||
public final class InkVialItem extends Item {
|
||||
public static final int CAPACITY = 16;
|
||||
// Match the 1.21.1 default; port the configurable cost with the full configuration system.
|
||||
public static final int LINK_PANEL_COST = 2;
|
||||
|
||||
public InkVialItem(Properties properties) {
|
||||
super(properties);
|
||||
@@ -30,37 +28,16 @@ public final class InkVialItem extends Item {
|
||||
|
||||
@Override
|
||||
public InteractionResult use(Level level, Player player, InteractionHand hand) {
|
||||
InteractionHand paperHand = hand == InteractionHand.MAIN_HAND
|
||||
? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
ItemStack vial = player.getItemInHand(hand);
|
||||
ItemStack paper = player.getItemInHand(paperHand);
|
||||
int ink = remaining(vial);
|
||||
if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER)
|
||||
|| ink < LINK_PANEL_COST || ink > CAPACITY) {
|
||||
return InteractionResult.PASS;
|
||||
if (level.isClientSide()) {
|
||||
// LOCAL gameplay config is not synchronized. Predict only item eligibility;
|
||||
// the server decides affordability using its own configured cost.
|
||||
var paperHand = hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
int ink = remaining(player.getItemInHand(hand));
|
||||
return player.getItemInHand(hand).is(Rivencraft.INK_VIAL.get())
|
||||
&& player.getItemInHand(paperHand).is(Items.PAPER) && ink > 0 && ink <= CAPACITY
|
||||
? InteractionResult.SUCCESS : InteractionResult.PASS;
|
||||
}
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
int newInk = ink - LINK_PANEL_COST;
|
||||
ItemStack spentVial = newInk == 0
|
||||
? new ItemStack(Items.GLASS_BOTTLE)
|
||||
: new ItemStack(Rivencraft.INK_VIAL.get());
|
||||
if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
|
||||
|
||||
if (paper.getCount() == 1) {
|
||||
player.setItemInHand(paperHand, panel);
|
||||
} else {
|
||||
paper.shrink(1);
|
||||
giveOrDrop(player, panel);
|
||||
}
|
||||
if (vial.getCount() == 1) {
|
||||
player.setItemInHand(hand, spentVial);
|
||||
} else {
|
||||
vial.shrink(1);
|
||||
giveOrDrop(player, spentVial);
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
return WritingInkService.prepareLinkPanel(player, hand) ? InteractionResult.SUCCESS : InteractionResult.PASS;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -70,7 +47,4 @@ public final class InkVialItem extends Item {
|
||||
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage"));
|
||||
}
|
||||
|
||||
private static void giveOrDrop(Player player, ItemStack stack) {
|
||||
if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.animal.equine.AbstractHorse;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
|
||||
public final class LinkDisarmService {
|
||||
private LinkDisarmService() {}
|
||||
|
||||
public static boolean mayTravel(Entity entity) {
|
||||
// Legacy refuses loose items and dropped Link Books on Disarm links.
|
||||
return !(entity instanceof ItemEntity);
|
||||
}
|
||||
|
||||
public static void disarmAt(Entity entity, ServerLevel source, Vec3 departure) {
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
eject(player.getInventory(), source, departure);
|
||||
player.containerMenu.broadcastChanges();
|
||||
}
|
||||
if (entity instanceof Container container) eject(container, source, departure);
|
||||
if (entity instanceof AbstractHorse horse) eject(horse.getInventory(), source, departure);
|
||||
if (entity instanceof Mob mob) dropEquipment(mob, source, departure);
|
||||
}
|
||||
|
||||
private static void eject(Container inventory, ServerLevel source, Vec3 departure) {
|
||||
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
||||
ItemStack stack = inventory.getItem(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
inventory.setItem(slot, ItemStack.EMPTY);
|
||||
drop(stack, source, departure);
|
||||
}
|
||||
inventory.setChanged();
|
||||
}
|
||||
|
||||
private static void dropEquipment(Mob mob, ServerLevel source, Vec3 departure) {
|
||||
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||
ItemStack stack = mob.getItemBySlot(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
float chance = mob.getDropChances().byEquipment(slot);
|
||||
mob.setItemSlot(slot, ItemStack.EMPTY);
|
||||
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
|
||||
if (chance <= 1 && stack.isDamageableItem()) {
|
||||
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
|
||||
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
|
||||
1 + mob.getRandom().nextInt(minimum));
|
||||
stack.setDamageValue(Math.clamp(damage, 1, minimum));
|
||||
}
|
||||
drop(stack, source, departure);
|
||||
}
|
||||
}
|
||||
private static void drop(ItemStack stack, ServerLevel source, Vec3 position) {
|
||||
var dropped = new ItemEntity(source, position.x, position.y, position.z, stack);
|
||||
dropped.setDefaultPickUpDelay();
|
||||
source.addFreshEntity(dropped);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,17 @@ import io.netty.buffer.ByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
|
||||
/** The three Ink Mixer modifiers currently supported by the 26.3 port. */
|
||||
/** Link Panel flags shared by the mixer, books and Link Modifier station. */
|
||||
public record LinkProperties(int flags) {
|
||||
public static final int DISARM = 1;
|
||||
public static final int GENERATE_PLATFORM = 2;
|
||||
public static final int INTRA_LINKING_ONLY = 16;
|
||||
public static final int RELATIVE_LINK = 32;
|
||||
public static final int DISABLE_PLATFORM = 64;
|
||||
public static final int MAINTAIN_MOMENTUM = 4;
|
||||
public static final int INTRA_LINKING = 8;
|
||||
public static final int SUPPORTED = DISARM | MAINTAIN_MOMENTUM | INTRA_LINKING;
|
||||
public static final int SUPPORTED = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING
|
||||
| INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM;
|
||||
public static final LinkProperties NONE = new LinkProperties(0);
|
||||
public static final Codec<LinkProperties> CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags);
|
||||
public static final StreamCodec<ByteBuf, LinkProperties> STREAM_CODEC = StreamCodec.of(
|
||||
@@ -23,6 +28,18 @@ public record LinkProperties(int flags) {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean allowsTravel(boolean sameDimension) {
|
||||
return sameDimension ? has(INTRA_LINKING) || has(INTRA_LINKING_ONLY) : !has(INTRA_LINKING_ONLY);
|
||||
}
|
||||
public LinkProperties forDescriptiveBook() {
|
||||
return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY));
|
||||
}
|
||||
public void appendTooltip(java.util.function.Consumer<net.minecraft.network.chat.Component> tooltip) {
|
||||
int[] flags = {DISARM, MAINTAIN_MOMENTUM, INTRA_LINKING, INTRA_LINKING_ONLY, RELATIVE_LINK, GENERATE_PLATFORM, DISABLE_PLATFORM};
|
||||
String[] names = {"disarm", "maintain_momentum", "intra_linking", "intra_linking_only", "relative_link", "generate_platform", "disable_platform"};
|
||||
for (int i = 0; i < flags.length; i++) if (has(flags[i]))
|
||||
tooltip.accept(net.minecraft.network.chat.Component.translatable("item.rivencraft.link_property." + names[i]));
|
||||
}
|
||||
public boolean has(int flag) {
|
||||
return (flags & flag) != 0;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
|
||||
@@ -40,12 +39,16 @@ public final class LinkingBookItem extends Item {
|
||||
}
|
||||
|
||||
public static String travel(ServerPlayer player, ItemStack book) {
|
||||
return travel(player, book, false);
|
||||
}
|
||||
|
||||
public static String travel(ServerPlayer player, ItemStack book, boolean portal) {
|
||||
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
|
||||
if (!destination.valid()) return "Invalid Linking Book destination";
|
||||
if (player.level().dimension().identifier().equals(destination.dimension())
|
||||
&& !properties.has(LinkProperties.INTRA_LINKING)) {
|
||||
&& !properties.allowsTravel(true)) {
|
||||
return "Same-dimension linking requires an Intra-Linking page";
|
||||
}
|
||||
ServerLevel target = player.level().getServer().getLevel(
|
||||
@@ -54,11 +57,13 @@ public final class LinkingBookItem extends Item {
|
||||
if (!AgeCatalogSync.ready(player, destination.dimension())) {
|
||||
return "Destination catalog not acknowledged by client";
|
||||
}
|
||||
if (!properties.allowsTravel(target == player.level())) return "Intra-Linking Only cannot travel between dimensions";
|
||||
destination = PortLinkDestinations.resolve(player.level(), target, player.blockPosition(), destination, properties);
|
||||
if (!destination.valid()) return "Invalid relative destination";
|
||||
BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||
if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY()
|
||||
|| !target.getWorldBorder().isWithinBounds(feet)) {
|
||||
return "Destination is outside the world bounds";
|
||||
}
|
||||
|| !target.getWorldBorder().isWithinBounds(feet)) return "Destination is outside the world bounds";
|
||||
target.getChunkAt(feet);
|
||||
// Test the player's actual arrival volume: checking whole block shapes
|
||||
// would incorrectly reject links bound while standing on a slab.
|
||||
if (!target.noCollision(player, player.getBoundingBox().move(
|
||||
@@ -71,14 +76,23 @@ public final class LinkingBookItem extends Item {
|
||||
Vec3 incoming = player.getDeltaMovement();
|
||||
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
|
||||
Set.of(), destination.yaw(), destination.pitch(), true);
|
||||
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw());
|
||||
if (moved) {
|
||||
PortLinkDestinations.generatePlatform(target, player.position(), properties);
|
||||
applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
|
||||
}
|
||||
return moved ? null : "Teleportation failed";
|
||||
}
|
||||
|
||||
/** Link Panel effects are shared by Linking and Descriptive Books. */
|
||||
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
|
||||
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
|
||||
LinkVisualEffects.play(source, departure, player, properties, false);
|
||||
applySuccessfulLink(player, properties, source, departure, incoming, destinationYaw, false);
|
||||
}
|
||||
|
||||
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
|
||||
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw,
|
||||
boolean portal) {
|
||||
LinkVisualEffects.play(source, departure, player, properties, portal);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
|
||||
double speed = Math.hypot(incoming.x, incoming.z);
|
||||
double yaw = Math.toRadians(destinationYaw);
|
||||
@@ -88,33 +102,42 @@ public final class LinkingBookItem extends Item {
|
||||
player.setDeltaMovement(Vec3.ZERO);
|
||||
}
|
||||
if (properties.has(LinkProperties.DISARM)) {
|
||||
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) {
|
||||
ItemStack carried = player.getInventory().getItem(slot);
|
||||
if (carried.isEmpty()) continue;
|
||||
player.getInventory().setItem(slot, ItemStack.EMPTY);
|
||||
ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z,
|
||||
carried.copy());
|
||||
dropped.setDefaultPickUpDelay();
|
||||
source.addFreshEntity(dropped);
|
||||
}
|
||||
player.getInventory().setChanged();
|
||||
player.containerMenu.broadcastChanges();
|
||||
LinkDisarmService.disarmAt(player, source, departure);
|
||||
}
|
||||
}
|
||||
|
||||
public static String destinationName(ServerLevel level) {
|
||||
var id = level.dimension().identifier();
|
||||
return net.dnorberg.rivencraft.age.RuntimeAgeBackend.savedAgeName(level.getServer(), id)
|
||||
.orElseGet(() -> {
|
||||
if (id.equals(Level.OVERWORLD.identifier())) return "Overworld";
|
||||
if (id.equals(Level.NETHER.identifier())) return "The Nether";
|
||||
if (id.equals(Level.END.identifier())) return "The End";
|
||||
return id.toString();
|
||||
});
|
||||
}
|
||||
|
||||
public static Component destinationName(ItemStack book, LinkBookTarget target) {
|
||||
String name = book.get(Rivencraft.LINK_DESTINATION_NAME.get());
|
||||
if (name != null && !name.isBlank()) return Component.literal(name);
|
||||
var id = target.dimension();
|
||||
if (id.equals(Level.OVERWORLD.identifier())) return Component.translatable("dimension.minecraft.overworld");
|
||||
if (id.equals(Level.NETHER.identifier())) return Component.translatable("dimension.minecraft.the_nether");
|
||||
if (id.equals(Level.END.identifier())) return Component.translatable("dimension.minecraft.the_end");
|
||||
return Component.literal(id.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
|
||||
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
|
||||
+ (int) target.y() + ", " + (int) target.z() + ")"));
|
||||
if (target == null) tooltip.accept(Component.literal("Unbound (26.3 port preview)"));
|
||||
else {
|
||||
BlockPos pos = BlockPos.containing(target.x(), target.y(), target.z());
|
||||
tooltip.accept(Component.translatable("item.rivencraft.linking_book.destination",
|
||||
destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ()));
|
||||
}
|
||||
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
|
||||
if (properties.has(LinkProperties.INTRA_LINKING))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
|
||||
properties.appendTooltip(tooltip);
|
||||
}
|
||||
}
|
||||
@@ -96,15 +96,7 @@ public final class PageItem extends Item {
|
||||
return;
|
||||
}
|
||||
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum"));
|
||||
if (properties.has(LinkProperties.INTRA_LINKING))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking"));
|
||||
tooltip.accept(Component.translatable(isLinkPanel(stack)
|
||||
? "item.rivencraft.page.link_panel.port_status"
|
||||
: "item.rivencraft.page.blank.port_status"));
|
||||
properties.appendTooltip(tooltip);
|
||||
}
|
||||
|
||||
private static boolean isCorrupted(Identifier id) {
|
||||
|
||||
@@ -21,6 +21,7 @@ public final class PortHandheldTravel {
|
||||
try {
|
||||
String failure = travel.get();
|
||||
if (failure != null) return failure;
|
||||
net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
|
||||
dropped = source.addFreshEntity(new DroppedBookEntity(source, departure, book));
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.world.Container;
|
||||
@@ -110,6 +112,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
||||
return ItemStack.EMPTY;
|
||||
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
return output;
|
||||
}
|
||||
@@ -150,6 +154,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
}
|
||||
@Override public void onTake(Player player, ItemStack stack) {
|
||||
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
}
|
||||
super.onTake(player, stack);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** 1.21.1's persistent single-book editing station, adapted to 26.3. */
|
||||
public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3,
|
||||
TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7;
|
||||
private final LinkModifierBlockEntity modifier;
|
||||
private final Container books;
|
||||
private final Player owner;
|
||||
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
instanceof LinkModifierBlockEntity found ? found : null);
|
||||
}
|
||||
public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) {
|
||||
super(Rivencraft.LINK_MODIFIER_MENU.get(), id);
|
||||
this.modifier = modifier;
|
||||
books = modifier == null ? new SimpleContainer(1) : modifier;
|
||||
owner = inventory.player;
|
||||
addSlot(new Slot(books, 0, 80, 35) {
|
||||
@Override public boolean mayPlace(ItemStack stack) { return isBook(stack); }
|
||||
@Override public int getMaxStackSize() { return 1; }
|
||||
});
|
||||
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
|
||||
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18));
|
||||
for (int col = 0; col < 9; col++) addSlot(new Slot(inventory, col, 8 + col * 18, 142));
|
||||
}
|
||||
private static boolean isBook(ItemStack stack) {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
}
|
||||
public ItemStack book() { return books.getItem(0); }
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())
|
||||
|| title == null || title.length() > 32) return false;
|
||||
String clean = title.strip();
|
||||
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
|
||||
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
|
||||
books.setChanged();
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
@Override public boolean clickMenuButton(Player player, int id) {
|
||||
if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false;
|
||||
int flag = switch (id) {
|
||||
case TOGGLE_DISARM -> LinkProperties.DISARM;
|
||||
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
|
||||
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
|
||||
case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY;
|
||||
case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK;
|
||||
case TOGGLE_PLATFORM -> book().is(Rivencraft.LINKING_BOOK.get())
|
||||
? LinkProperties.GENERATE_PLATFORM : LinkProperties.DISABLE_PLATFORM;
|
||||
default -> 0;
|
||||
};
|
||||
// Descriptive Books travel to an Age; Intra Linking is a Linking Book property.
|
||||
if (flag == 0 || (flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY) && book().is(Rivencraft.DESCRIPTIVE_BOOK.get())) return false;
|
||||
int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
|
||||
if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0)
|
||||
flags |= LinkProperties.INTRA_LINKING;
|
||||
if (flag == LinkProperties.INTRA_LINKING && (flags & LinkProperties.INTRA_LINKING) != 0)
|
||||
flags &= ~LinkProperties.INTRA_LINKING_ONLY;
|
||||
book().set(Rivencraft.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag));
|
||||
books.setChanged();
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
@Override public boolean stillValid(Player player) {
|
||||
return player == owner && modifier != null && modifier.stillValid(player);
|
||||
}
|
||||
@Override public ItemStack quickMoveStack(Player player, int index) {
|
||||
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
|
||||
Slot slot = slots.get(index);
|
||||
if (!slot.hasItem()) return ItemStack.EMPTY;
|
||||
ItemStack source = slot.getItem(), copy = source.copy();
|
||||
if (index == 0) {
|
||||
if (!moveItemStackTo(source, 1, slots.size(), true)) return ItemStack.EMPTY;
|
||||
} else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY;
|
||||
if (source.isEmpty()) slot.set(ItemStack.EMPTY);
|
||||
else slot.setChanged();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
|
||||
var id = PortBookNetworking.createAge(player, title, blockEntity.symbolPages());
|
||||
if (id == null) return;
|
||||
book.set(Rivencraft.AGE_BOOK_ID.get(), id);
|
||||
book.set(Rivencraft.BOOK_AUTHOR.get(), player.getGameProfile().name());
|
||||
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
if (blockEntity.hasSymbolPages())
|
||||
book.set(Rivencraft.BOOK_SYMBOLS.get(), blockEntity.symbolPages());
|
||||
@@ -129,6 +130,8 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
|
||||
binder.setItem(PortBookBinderBlockEntity.PANEL, panel);
|
||||
blockEntity.clearSymbolPages();
|
||||
setCarried(book);
|
||||
net.dnorberg.rivencraft.advancement.ProgressionAdvancements.award(player,
|
||||
net.dnorberg.rivencraft.advancement.ProgressionAdvancements.BIND_AGE);
|
||||
broadcastChanges();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
@@ -321,6 +323,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY;
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
|
||||
refreshOutput();
|
||||
return copy;
|
||||
}
|
||||
@@ -439,6 +443,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|
||||
}
|
||||
@Override public void onTake(Player player, ItemStack stack) {
|
||||
if (!clientSide && blockEntity.consumeCopyMaterials()) {
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
|
||||
refreshOutput();
|
||||
}
|
||||
super.onTake(player, stack);
|
||||
|
||||
@@ -33,11 +33,21 @@ public final class PortBookNetworking {
|
||||
|
||||
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
||||
var registrar = event.registrar("1");
|
||||
registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player
|
||||
&& player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu
|
||||
&& menu.containerId == payload.containerId()) menu.setTitle(player, payload.title());
|
||||
});
|
||||
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC);
|
||||
registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player) bind(player, payload);
|
||||
});
|
||||
registrar.playToServer(UseBookStandPayload.TYPE, UseBookStandPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player) useStand(player, payload);
|
||||
});
|
||||
registrar.playToServer(UseLinkingBookPayload.TYPE, UseLinkingBookPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player) use(player, payload);
|
||||
@@ -73,6 +83,7 @@ public final class PortBookNetworking {
|
||||
String title = payload.title().strip();
|
||||
if (title.isEmpty() || title.length() > MAX_BOOK_TITLE_LENGTH) return;
|
||||
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), LinkBookTarget.at(player));
|
||||
stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), LinkingBookItem.destinationName(player.level()));
|
||||
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
|
||||
SoundSource.PLAYERS, 1.0F, 1.0F);
|
||||
@@ -96,6 +107,7 @@ public final class PortBookNetworking {
|
||||
Identifier id = createAge(player, title);
|
||||
if (id == null) return;
|
||||
stack.set(Rivencraft.AGE_BOOK_ID.get(), id);
|
||||
stack.set(Rivencraft.BOOK_AUTHOR.get(), player.getGameProfile().name());
|
||||
stack.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
player.level().playSound(null, player.blockPosition(), SoundEvents.BOOK_PAGE_TURN,
|
||||
SoundSource.PLAYERS, 1.0F, 1.0F);
|
||||
@@ -111,6 +123,8 @@ public final class PortBookNetworking {
|
||||
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
|
||||
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
|
||||
long seed = player.getRandom().nextLong();
|
||||
var recovery = net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages);
|
||||
List<Identifier> activePages = recovery.activePages();
|
||||
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
|
||||
PortAgeTerrain terrain;
|
||||
List<Identifier> generationPages;
|
||||
@@ -142,7 +156,7 @@ public final class PortBookNetworking {
|
||||
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
|
||||
}
|
||||
try {
|
||||
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain);
|
||||
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain);
|
||||
var randomFeatures = pages.isEmpty()
|
||||
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
|
||||
var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
|
||||
@@ -154,9 +168,10 @@ public final class PortBookNetworking {
|
||||
environment.weather(), environment.lighting(), environment.sky(),
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())));
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages);
|
||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level,
|
||||
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages));
|
||||
RuntimeAgeBackend.setAgeDisplayName(server, id, title);
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
|
||||
return null;
|
||||
@@ -167,22 +182,56 @@ public final class PortBookNetworking {
|
||||
private static void enterAge(ServerPlayer player, UseDescriptiveBookPayload payload) {
|
||||
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
|
||||
if (!stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
|
||||
travelDescriptive(player, stack, hand(payload.offhand()));
|
||||
}
|
||||
|
||||
/** Validation is shared with operator fixtures; no remote chunk is loaded to inspect a stand. */
|
||||
public static ItemStack validatedStandBook(ServerPlayer player, UseBookStandPayload payload) {
|
||||
var level = player.level();
|
||||
if (!level.dimension().identifier().equals(payload.dimension())
|
||||
|| player.distanceToSqr(Vec3.atCenterOf(payload.pos())) > 64
|
||||
|| !level.hasChunkAt(payload.pos())
|
||||
|| !level.getBlockState(payload.pos()).is(Rivencraft.BOOK_STAND.get())
|
||||
|| !(level.getBlockEntity(payload.pos()) instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity stand)
|
||||
|| !ItemStack.matches(stand.getBook(), payload.book())) return ItemStack.EMPTY;
|
||||
return stand.getBook();
|
||||
}
|
||||
|
||||
private static void useStand(ServerPlayer player, UseBookStandPayload payload) {
|
||||
var book = validatedStandBook(player, payload);
|
||||
if (book.is(Rivencraft.LINKING_BOOK.get())) {
|
||||
String failure = LinkingBookItem.travel(player, book);
|
||||
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
|
||||
else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
|
||||
} else if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) travelDescriptive(player, book, null);
|
||||
}
|
||||
|
||||
private static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand) {
|
||||
travelDescriptive(player, stack, heldHand, false);
|
||||
}
|
||||
|
||||
public static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand, boolean portal) {
|
||||
Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (id == null) return;
|
||||
ServerLevel source = player.level();
|
||||
LinkBookTarget returnTarget = LinkBookTarget.at(player);
|
||||
Vec3 departure = player.position();
|
||||
Vec3 incoming = player.getDeltaMovement();
|
||||
String failure = net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player,
|
||||
hand(payload.offhand()), stack, () -> {
|
||||
String result = PortAgeTravel.enter(player, id);
|
||||
if (result == null) LinkingBookItem.applySuccessfulLink(player,
|
||||
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
|
||||
source, departure, incoming, player.getYRot());
|
||||
return result;
|
||||
});
|
||||
java.util.function.Supplier<String> travel = () -> {
|
||||
String result = PortAgeTravel.enter(player, id,
|
||||
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook());
|
||||
if (result == null) LinkingBookItem.applySuccessfulLink(player,
|
||||
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
|
||||
source, departure, incoming, player.getYRot(), portal);
|
||||
return result;
|
||||
};
|
||||
String failure = heldHand == null ? travel.get()
|
||||
: net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player, heldHand, stack, travel);
|
||||
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
|
||||
else net.dnorberg.rivencraft.age.PortReturnLinks.afterSuccessfulTravel(player, id, returnTarget);
|
||||
else {
|
||||
if (heldHand == null) net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
|
||||
net.dnorberg.rivencraft.age.PortReturnLinks.afterSuccessfulTravel(player, id, returnTarget);
|
||||
}
|
||||
}
|
||||
|
||||
private static InteractionHand hand(boolean offhand) {
|
||||
|
||||
+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; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.dnorberg.rivencraft.network;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Carries the displayed source so a removed/replaced book cannot activate a stale screen. */
|
||||
public record UseBookStandPayload(Identifier dimension, BlockPos pos, ItemStack book) implements CustomPacketPayload {
|
||||
public static final Type<UseBookStandPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "use_book_stand"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, UseBookStandPayload> STREAM_CODEC = StreamCodec.composite(
|
||||
Identifier.STREAM_CODEC, UseBookStandPayload::dimension, BlockPos.STREAM_CODEC, UseBookStandPayload::pos,
|
||||
ItemStack.STREAM_CODEC, UseBookStandPayload::book, UseBookStandPayload::new);
|
||||
@Override public Type<UseBookStandPayload> type() { return TYPE; }
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.BlockSnapshot;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
|
||||
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
|
||||
public final class CrystalPortalNetwork {
|
||||
public static final int MAX_RADIUS = 16;
|
||||
private static final int CLEANUP_RADIUS = MAX_RADIUS + 1;
|
||||
private static final int MAX_CRYSTALS = 2048;
|
||||
private static final int MAX_PORTALS = 1024;
|
||||
private static final int[][] EXPANSION = {
|
||||
{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1},
|
||||
{1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0},
|
||||
{1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1},
|
||||
{0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1}
|
||||
};
|
||||
|
||||
private CrystalPortalNetwork() {}
|
||||
|
||||
public record Plan(List<BlockPos> portalCells, boolean waitingForChunks) {
|
||||
public Plan(List<BlockPos> portalCells) { this(portalCells, false); }
|
||||
public boolean valid() { return !portalCells.isEmpty(); }
|
||||
}
|
||||
|
||||
public enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS }
|
||||
|
||||
public static Plan plan(ServerLevel level, BlockPos receptacle) {
|
||||
return plan(level, receptacle, false);
|
||||
}
|
||||
|
||||
private static Plan plan(ServerLevel level, BlockPos receptacle, boolean allowUnownedCells) {
|
||||
BlockState holder = level.getBlockState(receptacle);
|
||||
if (!holder.is(Rivencraft.BOOK_RECEPTACLE.get())) return new Plan(List.of());
|
||||
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||
if (!level.hasChunkAt(base)) return new Plan(List.of(), true);
|
||||
if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return new Plan(List.of());
|
||||
|
||||
Set<BlockPos> crystals = new HashSet<>();
|
||||
ArrayDeque<BlockPos> pendingCrystals = new ArrayDeque<>();
|
||||
pendingCrystals.add(base);
|
||||
while (!pendingCrystals.isEmpty()) {
|
||||
BlockPos pos = pendingCrystals.removeFirst();
|
||||
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
|
||||
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
|
||||
if (!level.getBlockState(pos).is(Rivencraft.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
|
||||
if (atBoundary(base, pos)) return new Plan(List.of());
|
||||
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
|
||||
for (Direction direction : Direction.values()) pendingCrystals.add(pos.relative(direction));
|
||||
}
|
||||
|
||||
LinkedHashSet<BlockPos> portals = new LinkedHashSet<>();
|
||||
ArrayDeque<BlockPos> candidates = new ArrayDeque<>();
|
||||
Set<BlockPos> queued = new HashSet<>();
|
||||
for (BlockPos crystal : crystals) addCandidates(crystal, base, queued, candidates);
|
||||
while (!candidates.isEmpty()) {
|
||||
BlockPos pos = candidates.removeFirst();
|
||||
queued.remove(pos);
|
||||
if (!level.isInWorldBounds(pos)) return new Plan(List.of());
|
||||
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
|
||||
if (portals.contains(pos) || !mayFill(level, pos, receptacle, allowUnownedCells)) continue;
|
||||
if (adjacentCount(pos, crystals, portals) < 2) continue;
|
||||
portals.add(pos);
|
||||
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
|
||||
addCandidates(pos, base, queued, candidates);
|
||||
}
|
||||
|
||||
// Legacy's second pass deletes every cell without opposing neighbors on
|
||||
// at least two axes. Repeat because deleting one cell can unbrace another.
|
||||
boolean changed;
|
||||
do {
|
||||
changed = portals.removeIf(pos -> !hasTension(pos, crystals, portals));
|
||||
} while (changed);
|
||||
if (portals.isEmpty()) return new Plan(List.of());
|
||||
|
||||
// Every surviving cell must still be reachable from the receptacle's
|
||||
// crystal base through this one network, never through another portal.
|
||||
Set<BlockPos> reachable = new HashSet<>();
|
||||
ArrayDeque<BlockPos> frontier = new ArrayDeque<>();
|
||||
frontier.add(base);
|
||||
while (!frontier.isEmpty()) {
|
||||
BlockPos pos = frontier.removeFirst();
|
||||
if (!reachable.add(pos)) continue;
|
||||
for (Direction direction : Direction.values()) {
|
||||
BlockPos next = pos.relative(direction);
|
||||
if (!reachable.contains(next) && (crystals.contains(next) || portals.contains(next)))
|
||||
frontier.add(next);
|
||||
}
|
||||
}
|
||||
if (!reachable.containsAll(portals)) return new Plan(List.of());
|
||||
return new Plan(List.copyOf(portals));
|
||||
}
|
||||
|
||||
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
|
||||
return activate(level, receptacle, plan, null);
|
||||
}
|
||||
|
||||
/** Repairs only existing, framed cells whose owner pointer was lost by an older live-view update. */
|
||||
public static boolean recoverSavedOwners(ServerLevel level, BlockPos receptacle) {
|
||||
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
Plan plan = plan(level, receptacle, true);
|
||||
if (!plan.valid()) return false;
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() != null && !receptacle.equals(portal.receptacle())) return false;
|
||||
}
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
|
||||
if (portal.receptacle() == null) portal.setReceptacle(receptacle);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The player who inserted the book owns each newly placed portal cell for claim checks. */
|
||||
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan, Entity actor) {
|
||||
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
List<BlockPos> placed = new ArrayList<>();
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
if (!mayFill(level, pos, receptacle)) {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
if (level.getBlockState(pos).isAir()) {
|
||||
BlockSnapshot snapshot = actor == null ? null : BlockSnapshot.create(level.dimension(), level, pos);
|
||||
BlockState prior = level.getBlockState(pos);
|
||||
// Defer neighbor notifications until the entire opening exists.
|
||||
// Otherwise the first cells see an incomplete brace and the
|
||||
// portal's neighbor validator removes them during activation.
|
||||
if (!level.setBlock(pos, Rivencraft.CRYSTAL_PORTAL.get().defaultBlockState(), 2)) {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
if (snapshot != null) {
|
||||
var event = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(event);
|
||||
if (event.isCanceled()) {
|
||||
snapshot.restore();
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
placed.add(pos);
|
||||
}
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
|
||||
portal.setReceptacle(receptacle);
|
||||
} else {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
orientSurfaces(level, plan);
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| !receptacle.equals(portal.receptacle())) {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void shutdown(ServerLevel level, BlockPos receptacle) {
|
||||
// A broken support can disconnect the graph. Scan a bounded volume rather
|
||||
// than trusting a path through blocks that no longer exist.
|
||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||
if (!level.hasChunkAt(pos)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& receptacle.equals(portal.receptacle()))
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
|
||||
public static boolean reconcile(ServerLevel level, BlockPos receptacle) {
|
||||
return reconcile(level, receptacle, plan(level, receptacle));
|
||||
}
|
||||
|
||||
public static boolean reconcile(ServerLevel level, BlockPos receptacle, Plan plan) {
|
||||
if (!plan.valid()) {
|
||||
// Neighboring chunks can still be loading when the receptacle first
|
||||
// ticks after a restart. An incomplete view is not a broken frame.
|
||||
if (!plan.waitingForChunks()) shutdown(level, receptacle);
|
||||
return false;
|
||||
}
|
||||
Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
|
||||
// A background pass must not create blocks without the owner present:
|
||||
// that would bypass the placement/protection event used at activation.
|
||||
for (BlockPos pos : wanted) {
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| !receptacle.equals(portal.receptacle())) {
|
||||
shutdown(level, receptacle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||
if (!level.hasChunkAt(pos)) continue;
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
|
||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
orientSurfaces(level, plan);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void orientSurfaces(ServerLevel level, Plan plan) {
|
||||
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) {
|
||||
for (BlockPos pos : surface.cells()) {
|
||||
BlockState state = level.getBlockState(pos);
|
||||
Direction.Axis axis = surface.normal().getAxis();
|
||||
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
|
||||
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.AXIS, axis));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The vanilla-style surface remains visible until its optional live view is ready. */
|
||||
public static void setLiveView(ServerLevel level, Plan plan, boolean live) {
|
||||
for (BlockPos pos : plan.portalCells()) {
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (state.is(Rivencraft.CRYSTAL_PORTAL.get()) && state.getValue(CrystalPortalBlock.LIVE_VIEW) != live)
|
||||
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.LIVE_VIEW, live));
|
||||
}
|
||||
}
|
||||
|
||||
private static void updatePortalState(ServerLevel level, BlockPos pos, BlockState replacement) {
|
||||
BlockPos owner = level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
? portal.receptacle() : null;
|
||||
if (!level.setBlock(pos, replacement, 2) || owner == null) return;
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& !owner.equals(portal.receptacle())) portal.setReceptacle(owner);
|
||||
}
|
||||
|
||||
public static CellStatus cellStatus(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|
||||
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|
||||
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID;
|
||||
if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS;
|
||||
BlockState state = level.getBlockState(receptacle);
|
||||
if (!state.is(Rivencraft.BOOK_RECEPTACLE.get())) return CellStatus.INVALID;
|
||||
BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||
if (!level.hasChunkAt(base)) return CellStatus.WAITING_FOR_CHUNKS;
|
||||
if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return CellStatus.INVALID;
|
||||
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder))
|
||||
return CellStatus.WAITING_FOR_CHUNKS;
|
||||
if (holder.getBook().isEmpty()) return CellStatus.INVALID;
|
||||
// A neighboring chunk may hold one of the braces. Do not tear down a
|
||||
// persisted portal until all six neighbors are available to inspect.
|
||||
for (Direction direction : Direction.values())
|
||||
if (!level.hasChunkAt(pos.relative(direction))) return CellStatus.WAITING_FOR_CHUNKS;
|
||||
for (Direction.Axis axis : Direction.Axis.values()) {
|
||||
// Quick tension check before the bounded network/path revalidation.
|
||||
if (!axisBraced(level, pos, axis)) continue;
|
||||
int other = 0;
|
||||
for (Direction.Axis alternative : Direction.Axis.values())
|
||||
if (alternative != axis && axisBraced(level, pos, alternative)) other++;
|
||||
if (other > 0) return CellStatus.VALID;
|
||||
}
|
||||
return CellStatus.INVALID;
|
||||
}
|
||||
|
||||
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
|
||||
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
|
||||
Direction negative = positive.getOpposite();
|
||||
BlockPos a = pos.relative(positive);
|
||||
BlockPos b = pos.relative(negative);
|
||||
return level.hasChunkAt(a) && level.hasChunkAt(b)
|
||||
&& isFrameOrPortal(level.getBlockState(a)) && isFrameOrPortal(level.getBlockState(b));
|
||||
}
|
||||
|
||||
private static boolean isFrameOrPortal(BlockState state) {
|
||||
return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
|
||||
private static void rollback(ServerLevel level, List<BlockPos> placed) {
|
||||
for (BlockPos pos : placed) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||
}
|
||||
|
||||
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||
return mayFill(level, pos, receptacle, false);
|
||||
}
|
||||
|
||||
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle, boolean allowUnowned) {
|
||||
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
|
||||
BlockState state = level.getBlockState(pos);
|
||||
return state.isAir() || state.is(Rivencraft.CRYSTAL_PORTAL.get())
|
||||
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||
&& (receptacle.equals(portal.receptacle()) || allowUnowned && portal.receptacle() == null);
|
||||
}
|
||||
|
||||
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
int count = 0;
|
||||
for (Direction direction : Direction.values())
|
||||
if (crystals.contains(pos.relative(direction)) || portals.contains(pos.relative(direction))) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static boolean hasTension(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
int axes = 0;
|
||||
for (Direction.Axis axis : Direction.Axis.values()) {
|
||||
Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis);
|
||||
if (member(pos.relative(positive), crystals, portals)
|
||||
&& member(pos.relative(positive.getOpposite()), crystals, portals)) axes++;
|
||||
}
|
||||
return axes >= 2;
|
||||
}
|
||||
|
||||
private static boolean member(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||
return crystals.contains(pos) || portals.contains(pos);
|
||||
}
|
||||
|
||||
private static void addCandidates(BlockPos around, BlockPos base, Set<BlockPos> queued,
|
||||
ArrayDeque<BlockPos> pending) {
|
||||
for (int[] offset : EXPANSION) {
|
||||
BlockPos candidate = around.offset(offset[0], offset[1], offset[2]);
|
||||
if (within(base, candidate) && queued.add(candidate)) pending.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean within(BlockPos center, BlockPos pos) {
|
||||
return Math.abs(center.getX() - pos.getX()) <= MAX_RADIUS
|
||||
&& Math.abs(center.getY() - pos.getY()) <= MAX_RADIUS
|
||||
&& Math.abs(center.getZ() - pos.getZ()) <= MAX_RADIUS;
|
||||
}
|
||||
|
||||
private static boolean atBoundary(BlockPos center, BlockPos pos) {
|
||||
return Math.abs(center.getX() - pos.getX()) == MAX_RADIUS
|
||||
|| Math.abs(center.getY() - pos.getY()) == MAX_RADIUS
|
||||
|| Math.abs(center.getZ() - pos.getZ()) == MAX_RADIUS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
/** Groups portal cells into coherent, coplanar views while retaining irregular outlines. */
|
||||
public final class CrystalPortalSurfaces {
|
||||
private CrystalPortalSurfaces() {}
|
||||
|
||||
public record Surface(Direction normal, AABB bounds, List<BlockPos> cells) {}
|
||||
private record Plane(Direction normal, int coordinate) {}
|
||||
|
||||
public static List<Surface> group(ServerLevel level, CrystalPortalNetwork.Plan plan) {
|
||||
Map<Plane, List<BlockPos>> planes = new LinkedHashMap<>();
|
||||
for (BlockPos cell : plan.portalCells()) {
|
||||
Direction normal = planeNormal(level, cell);
|
||||
if (normal == null) continue;
|
||||
int coordinate = normal.getAxis().choose(cell.getX(), cell.getY(), cell.getZ());
|
||||
planes.computeIfAbsent(new Plane(normal, coordinate), ignored -> new ArrayList<>()).add(cell);
|
||||
}
|
||||
List<Surface> result = new ArrayList<>();
|
||||
for (Map.Entry<Plane, List<BlockPos>> entry : planes.entrySet()) {
|
||||
List<BlockPos> cells = entry.getValue();
|
||||
int minX = cells.stream().mapToInt(BlockPos::getX).min().orElseThrow();
|
||||
int minY = cells.stream().mapToInt(BlockPos::getY).min().orElseThrow();
|
||||
int minZ = cells.stream().mapToInt(BlockPos::getZ).min().orElseThrow();
|
||||
int maxX = cells.stream().mapToInt(BlockPos::getX).max().orElseThrow() + 1;
|
||||
int maxY = cells.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 1;
|
||||
int maxZ = cells.stream().mapToInt(BlockPos::getZ).max().orElseThrow() + 1;
|
||||
result.add(new Surface(entry.getKey().normal(), new AABB(minX, minY, minZ, maxX, maxY, maxZ),
|
||||
List.copyOf(cells)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Direction planeNormal(ServerLevel level, BlockPos cell) {
|
||||
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.EAST)) return Direction.NORTH;
|
||||
if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.SOUTH)) return Direction.EAST;
|
||||
if (braced(level, cell, Direction.EAST) && braced(level, cell, Direction.SOUTH)) return Direction.UP;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean braced(ServerLevel level, BlockPos cell, Direction direction) {
|
||||
return linked(level, cell.relative(direction)) && linked(level, cell.relative(direction.getOpposite()));
|
||||
}
|
||||
|
||||
private static boolean linked(ServerLevel level, BlockPos pos) {
|
||||
if (!level.hasChunkAt(pos)) return false;
|
||||
var state = level.getBlockState(pos);
|
||||
return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.PortAgeTravel;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.item.LinkDisarmService;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkVisualEffects;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.portal.TeleportTransition;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Portal transport uses the book's arrival rules; no independent portal exit offset. */
|
||||
public final class PortalBookTravel {
|
||||
private PortalBookTravel() {}
|
||||
public static boolean available(ServerLevel source, ItemStack book) {
|
||||
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
|
||||
: book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null;
|
||||
return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null;
|
||||
}
|
||||
public static void travel(Entity entity, ServerLevel source, ItemStack book) {
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
|
||||
net.dnorberg.rivencraft.network.PortBookNetworking.travelDescriptive(player, book, null, true);
|
||||
} else {
|
||||
String failure = LinkingBookItem.travel(player, book, true);
|
||||
if (failure != null) player.sendOverlayMessage(net.minecraft.network.chat.Component.literal(failure));
|
||||
else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
|
||||
}
|
||||
return;
|
||||
}
|
||||
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
|
||||
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
if (descriptive) {
|
||||
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (level == null) return;
|
||||
var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook());
|
||||
if (landing == null) return;
|
||||
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
entity.getYRot(), entity.getXRot());
|
||||
}
|
||||
if (destination == null || !destination.valid()) return;
|
||||
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
|
||||
if (target == null || !entity.canTeleport(source, target) || !descriptive && !properties.allowsTravel(target == source)) return;
|
||||
if (!descriptive) destination = net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(source, target,
|
||||
entity.blockPosition(), destination, properties);
|
||||
if (!destination.valid()) return;
|
||||
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|
||||
|| !target.getWorldBorder().isWithinBounds(feet)) return;
|
||||
Vec3 arrival = new Vec3(destination.x(), destination.y(), destination.z());
|
||||
target.getChunkAt(feet);
|
||||
if (!target.noCollision(entity, entity.getBoundingBox().move(arrival.subtract(entity.position())))) return;
|
||||
Vec3 incoming = entity.getDeltaMovement();
|
||||
Vec3 velocity = Vec3.ZERO;
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
|
||||
double speed = Math.hypot(incoming.x, incoming.z), yaw = Math.toRadians(destination.yaw());
|
||||
velocity = new Vec3(speed * Math.cos(yaw), incoming.y + 0.2, speed * Math.sin(yaw));
|
||||
}
|
||||
Vec3 departure = entity.position();
|
||||
// Disarm only after a successful transfer, leaving equipment in the source world.
|
||||
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
|
||||
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
|
||||
if (arrived == null) return;
|
||||
if (!descriptive) net.dnorberg.rivencraft.item.PortLinkDestinations.generatePlatform(target, arrival, properties);
|
||||
if (properties.has(LinkProperties.DISARM)) {
|
||||
LinkDisarmService.disarmAt(arrived, source, departure);
|
||||
}
|
||||
arrived.resetFallDistance();
|
||||
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
|
||||
var sound = LinkVisualEffects.sound(properties, true);
|
||||
source.playSound(null, departure.x, departure.y, departure.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
|
||||
target.playSound(null, arrival.x, arrival.y, arrival.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
|
||||
/** Session-only portal cooldowns: tick counters must never be saved with players. */
|
||||
public final class PortalTravelCooldown {
|
||||
public static final long TICKS = 40;
|
||||
private static final Map<Entity, Long> LAST_USE = new WeakHashMap<>();
|
||||
|
||||
private PortalTravelCooldown() {}
|
||||
|
||||
public static boolean blocked(long now, long previous) {
|
||||
return previous >= 0 && now >= previous && now - previous < TICKS;
|
||||
}
|
||||
|
||||
public static boolean blocked(Entity entity, long now) {
|
||||
return blocked(now, LAST_USE.getOrDefault(entity, -1L));
|
||||
}
|
||||
|
||||
public static void mark(Entity entity, long now) {
|
||||
LAST_USE.put(entity, now);
|
||||
}
|
||||
|
||||
public static void forget(Entity entity) {
|
||||
LAST_USE.remove(entity);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
LAST_USE.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
|
||||
|
||||
/** Collision callbacks enqueue once; transport occurs after the entering movement packet finishes. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortalTravelQueue {
|
||||
private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long tick) {}
|
||||
private static final Map<Entity, Pending> PENDING = new IdentityHashMap<>();
|
||||
private PortalTravelQueue() {}
|
||||
public static void enqueue(Entity entity, ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long now) {
|
||||
PENDING.putIfAbsent(entity, new Pending(source, portal.immutable(), receptacle.immutable(), book.copyWithCount(1), now));
|
||||
}
|
||||
@SubscribeEvent public static void tick(ServerTickEvent.Post event) {
|
||||
var server = event.getServer();
|
||||
var ready = new ArrayList<Map.Entry<Entity, Pending>>();
|
||||
var iterator = PENDING.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
var entry = iterator.next();
|
||||
if (server.getTickCount() <= entry.getValue().tick()) continue;
|
||||
ready.add(Map.entry(entry.getKey(), entry.getValue()));
|
||||
iterator.remove();
|
||||
}
|
||||
for (var entry : ready) {
|
||||
Entity entity = entry.getKey();
|
||||
Pending pending = entry.getValue();
|
||||
if (entity.isRemoved() || !entity.isAlive() || entity.level() != pending.source()
|
||||
|| entity instanceof ServerPlayer player && server.getPlayerList().getPlayer(player.getUUID()) != player
|
||||
|| !pending.source().hasChunkAt(pending.portal()) || !pending.source().hasChunkAt(pending.receptacle())
|
||||
|| !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal)
|
||||
|| !pending.receptacle().equals(portal.receptacle())
|
||||
|| !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder)
|
||||
|| !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())
|
||||
|| CrystalPortalNetwork.cellStatus(pending.source(), pending.portal(), pending.receptacle())
|
||||
!= CrystalPortalNetwork.CellStatus.VALID) continue;
|
||||
PortalBookTravel.travel(entity, pending.source(), pending.book());
|
||||
}
|
||||
}
|
||||
@SubscribeEvent public static void stopped(ServerStoppedEvent event) { PENDING.clear(); PortalTravelCooldown.clear(); }
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public final class ModCreativeTabs {
|
||||
output.accept(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
output.accept(Rivencraft.LINKING_BOOK.get());
|
||||
output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get());
|
||||
output.accept(Rivencraft.BOOK_RECEPTACLE_ITEM.get());
|
||||
output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get());
|
||||
output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get());
|
||||
output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get());
|
||||
@@ -32,6 +33,7 @@ public final class ModCreativeTabs {
|
||||
output.accept(Rivencraft.INK_VIAL.get());
|
||||
output.accept(Rivencraft.BOOK_BINDER_ITEM.get());
|
||||
output.accept(Rivencraft.INK_MIXER_ITEM.get());
|
||||
output.accept(Rivencraft.LINK_MODIFIER_ITEM.get());
|
||||
output.accept(Rivencraft.WRITING_DESK_ITEM.get());
|
||||
output.accept(Rivencraft.NOTEBOOK.get());
|
||||
output.accept(Rivencraft.COLLATION_FOLDER.get());
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Client-readable snapshot of authored pages; index zero is the front/link-panel spread. */
|
||||
public record DescriptiveBookPages(List<Identifier> symbols, String author) {
|
||||
public DescriptiveBookPages { symbols = List.copyOf(symbols); }
|
||||
public static DescriptiveBookPages from(ItemStack book) {
|
||||
return new DescriptiveBookPages(book.getOrDefault(Rivencraft.BOOK_SYMBOLS.get(), List.of()),
|
||||
book.getOrDefault(Rivencraft.BOOK_AUTHOR.get(), ""));
|
||||
}
|
||||
public int lastPage() { return Math.max(1, symbols.size()); }
|
||||
public int turn(int page, int direction) { return Math.clamp(page + direction, 0, lastPage()); }
|
||||
public Optional<Identifier> symbolAt(int page) {
|
||||
return page >= 1 && page <= symbols.size() ? Optional.of(symbols.get(page - 1)) : Optional.empty();
|
||||
}
|
||||
}
|
||||
+14
@@ -17,6 +17,20 @@ public final class PortAuthoredTerrainSelection {
|
||||
}
|
||||
|
||||
public static Selection select(MinecraftServer server, List<Identifier> pages, long seed) {
|
||||
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages))
|
||||
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||
for (var page : pages) {
|
||||
var entry = PortSymbolPages.find(page);
|
||||
if (entry != null && entry.role().equals("biome")) {
|
||||
var biome = PortRegistryPages.biomeId(page);
|
||||
if (biome == null || server.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME)
|
||||
.get(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.BIOME, biome)).isEmpty())
|
||||
throw new IllegalArgumentException("Unknown biome page: " + page);
|
||||
}
|
||||
}
|
||||
var recovered = PortWritingRecovery.recover(pages);
|
||||
if (!recovered.implemented()) throw new IllegalArgumentException("Unimplemented symbol page in this book");
|
||||
pages = recovered.activePages();
|
||||
var explicit = PortAuthoredWriting.explicitTerrain(pages);
|
||||
if (explicit != null) return compile(server, pages, seed, explicit);
|
||||
var preferred = PortRandomAgeSelection.terrain(seed);
|
||||
|
||||
+18
-11
@@ -57,7 +57,8 @@ public final class PortAuthoredWriting {
|
||||
if (entry == null) return false;
|
||||
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|
||||
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
|
||||
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) return true;
|
||||
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)
|
||||
|| net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) return true;
|
||||
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null
|
||||
|| PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true;
|
||||
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
|
||||
@@ -71,20 +72,25 @@ public final class PortAuthoredWriting {
|
||||
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
|
||||
}
|
||||
|
||||
/** A page never disappears into an Age unless this port can apply its effect. */
|
||||
/** Recover known category defects; unimplemented pages and unsupported runtime combinations still block binding. */
|
||||
public static boolean supports(List<Identifier> pages) {
|
||||
if (pages.stream().noneMatch(PortAuthoredWriting::isTerrain))
|
||||
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
|
||||
var recovered = PortWritingRecovery.recover(pages);
|
||||
if (!recovered.implemented()) return false;
|
||||
List<Identifier> active = recovered.activePages();
|
||||
if (active.stream().noneMatch(PortAuthoredWriting::isTerrain))
|
||||
return net.dnorberg.rivencraft.age.PortRandomAgeSelection.terrains().stream()
|
||||
.anyMatch(terrain -> supportsOnTerrain(pages, terrain, 0));
|
||||
try {
|
||||
return supportsExplicit(PortMaterialCompletion.complete(pages, 0));
|
||||
} catch (IllegalArgumentException exception) { return false; }
|
||||
.anyMatch(terrain -> supportsOnTerrain(active, terrain, 0));
|
||||
try { return supportsExplicit(PortMaterialCompletion.complete(active, 0)); }
|
||||
catch (IllegalArgumentException exception) { return false; }
|
||||
}
|
||||
|
||||
static boolean supportsOnTerrain(List<Identifier> pages, PortAgeTerrain terrain, long seed) {
|
||||
try {
|
||||
return supportsExplicit(PortMaterialCompletion.complete(withTerrain(pages, terrain), seed));
|
||||
} catch (IllegalArgumentException exception) { return false; }
|
||||
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
|
||||
var recovered = PortWritingRecovery.recover(pages);
|
||||
if (!recovered.implemented()) return false;
|
||||
try { return supportsExplicit(PortMaterialCompletion.complete(withTerrain(recovered.activePages(), terrain), seed)); }
|
||||
catch (IllegalArgumentException exception) { return false; }
|
||||
}
|
||||
|
||||
static List<Identifier> withTerrain(List<Identifier> pages, PortAgeTerrain terrain) {
|
||||
@@ -103,7 +109,6 @@ public final class PortAuthoredWriting {
|
||||
}
|
||||
|
||||
private static boolean supportsExplicit(List<Identifier> pages) {
|
||||
if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false;
|
||||
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
|
||||
boolean terrain = false;
|
||||
int biomes = 0;
|
||||
@@ -129,6 +134,8 @@ public final class PortAuthoredWriting {
|
||||
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|
||||
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) {
|
||||
// Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky.
|
||||
} else if (net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) {
|
||||
// Legacy corrupted pages contribute instability, not terrain features.
|
||||
} else if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) {
|
||||
// Every Accelerated page adds random-tick attempts; it is not a controller.
|
||||
} else if (isTerrain(page) && !terrain) {
|
||||
|
||||
@@ -101,7 +101,7 @@ public final class PortRegistryPages {
|
||||
|| path.equals("deep_dark") || path.endsWith("_caves");
|
||||
}
|
||||
|
||||
private static String materialCategory(Block block) {
|
||||
public static String materialCategory(Block block) {
|
||||
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|
||||
|| block == Rivencraft.CLOUD_PLATFORM.get()
|
||||
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
|
||||
|
||||
@@ -17,7 +17,13 @@ import net.dnorberg.rivencraft.item.PageItem;
|
||||
|
||||
/** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */
|
||||
public final class PortSymbolPages {
|
||||
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {}
|
||||
public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem, String role) {
|
||||
public Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {
|
||||
this(id, category, nameKey, descriptionKey, poem,
|
||||
category.equals("rivencraft:biome") ? "biome"
|
||||
: category.equals("rivencraft:block") || category.equals("rivencraft:fluid") ? "block" : "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<Identifier, Entry> ENTRIES = load();
|
||||
|
||||
@@ -100,7 +106,9 @@ public final class PortSymbolPages {
|
||||
category = "rivencraft:biome_generation";
|
||||
entries.put(id, new Entry(id, category, definition.get("name").getAsString(),
|
||||
definition.get("description").getAsString(),
|
||||
definition.has("poem") ? definition.get("poem").getAsString() : ""));
|
||||
definition.has("poem") ? definition.get("poem").getAsString() : "",
|
||||
definition.has("semantics") ? definition.getAsJsonObject("semantics").get("role").getAsString()
|
||||
: id.getPath().equals("lighting/ambient_color") ? "ambient_color" : "unknown"));
|
||||
});
|
||||
return Collections.unmodifiableMap(entries);
|
||||
} catch (java.io.IOException exception) {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** 1.21.1 grammar diagnostics and singular-controller recovery; preserve ordered modifier chains. */
|
||||
public final class PortWritingRecovery {
|
||||
private static final Set<String> SINGULAR = Set.of("terrain", "biome_controller", "weather", "season", "lighting", "sky");
|
||||
private static final Set<String> MODIFIERS = Set.of("period_modifier", "angle_modifier", "phase_modifier", "size_modifier",
|
||||
"celestial_color", "direction_modifier");
|
||||
public record Diagnostic(String code, int index, int points) {}
|
||||
public record Result(List<Identifier> activePages, List<Diagnostic> diagnostics, boolean implemented) {
|
||||
public Result { activePages = List.copyOf(activePages); diagnostics = List.copyOf(diagnostics); }
|
||||
}
|
||||
private PortWritingRecovery() {}
|
||||
public static Result recover(List<Identifier> pages) {
|
||||
Map<String, Integer> last = new HashMap<>();
|
||||
List<Diagnostic> diagnostics = new ArrayList<>();
|
||||
boolean implemented = pages.size() <= NotebookContents.MAX_PAGES;
|
||||
int previousStage = -1, biomes = 0;
|
||||
Map<String, Integer> pendingModifiers = new java.util.LinkedHashMap<>();
|
||||
Integer pendingNumber = null;
|
||||
for (int index = 0; index < pages.size(); index++) {
|
||||
Identifier page = pages.get(index);
|
||||
var entry = PortSymbolPages.find(page);
|
||||
if (entry == null || !PortAuthoredWriting.isPageImplemented(page)) { implemented = false; continue; }
|
||||
String role = entry.role();
|
||||
if (pendingNumber != null && !numberTarget(role)) {
|
||||
diagnostics.add(new Diagnostic("misplaced_modifier", pendingNumber, 100));
|
||||
pendingNumber = null;
|
||||
}
|
||||
if (role.equals("color_number")) pendingNumber = index;
|
||||
else if (numberTarget(role)) pendingNumber = null;
|
||||
if (MODIFIERS.contains(role)) pendingModifiers.put(role, index);
|
||||
else if (role.equals("celestial")) pendingModifiers.clear();
|
||||
if (SINGULAR.contains(role)) {
|
||||
Integer prior = last.put(role, index);
|
||||
if (prior != null) {
|
||||
diagnostics.add(new Diagnostic(pages.get(prior).equals(page) ? "duplicate" : "conflict", index, 500));
|
||||
// Match WritingGrammar: a repeated singular choice does not also earn an order penalty.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (role.equals("biome")) biomes++;
|
||||
int stage = stage(role);
|
||||
if (stage < previousStage && !MODIFIERS.contains(role) && !role.equals("color") && !role.equals("color_number"))
|
||||
diagnostics.add(new Diagnostic("out_of_order", index, 100));
|
||||
else if (!role.equals("color") && !role.equals("color_number")) previousStage = stage;
|
||||
}
|
||||
if (pendingNumber != null) diagnostics.add(new Diagnostic("dangling_modifier", pendingNumber, 100));
|
||||
for (int index : pendingModifiers.values())
|
||||
diagnostics.add(new Diagnostic("dangling_modifier", index, 100));
|
||||
Integer controller = last.get("biome_controller");
|
||||
boolean nativeBiomes = controller != null && pages.get(controller).getPath().equals("biome/native");
|
||||
boolean single = controller != null && pages.get(controller).getPath().equals("biome/single");
|
||||
if (nativeBiomes && biomes > 0 || single && biomes > 1)
|
||||
diagnostics.add(new Diagnostic("conflict", controller, 500));
|
||||
List<Identifier> active = new ArrayList<>();
|
||||
boolean retainedBiome = false;
|
||||
for (int index = 0; index < pages.size(); index++) {
|
||||
var page = pages.get(index);
|
||||
var entry = PortSymbolPages.find(page);
|
||||
if (entry != null) {
|
||||
String role = entry.role();
|
||||
if (SINGULAR.contains(role) && last.getOrDefault(role, -1) != index) continue;
|
||||
if (role.equals("biome")) {
|
||||
if (nativeBiomes || single && retainedBiome) continue;
|
||||
retainedBiome = true;
|
||||
}
|
||||
}
|
||||
active.add(page);
|
||||
}
|
||||
// Keep source order for material pairing and per-celestial modifier chains.
|
||||
// The port's category compilers select by role, so no physical resort is necessary.
|
||||
return new Result(active, diagnostics, implemented);
|
||||
}
|
||||
private static boolean numberTarget(String role) {
|
||||
return role.equals("color") || role.equals("period_modifier") || role.equals("angle_modifier")
|
||||
|| role.equals("phase_modifier") || role.equals("size_modifier");
|
||||
}
|
||||
private static int stage(String role) {
|
||||
return switch (role) {
|
||||
case "terrain" -> 0;
|
||||
case "biome" -> 1;
|
||||
case "biome_controller" -> 2;
|
||||
case "weather" -> 3;
|
||||
case "season" -> 4;
|
||||
case "lighting" -> 5;
|
||||
case "sky", "sky_color", "ambient_color", "color", "color_number" -> 6;
|
||||
case "period_modifier", "angle_modifier", "phase_modifier", "size_modifier", "celestial_color", "direction_modifier" -> 7;
|
||||
case "celestial" -> 8;
|
||||
default -> 9;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Server-owned costs and the baseline blank-page-to-link-panel transaction. */
|
||||
public final class WritingInkService {
|
||||
private WritingInkService() {
|
||||
}
|
||||
|
||||
public static int symbolCopyCost() {
|
||||
return PortConfig.SYMBOL_INK_COST.get();
|
||||
}
|
||||
|
||||
public static int linkPanelCost() {
|
||||
return PortConfig.LINK_PANEL_INK_COST.get();
|
||||
}
|
||||
|
||||
public static boolean canPrepareLinkPanel(Player player, InteractionHand vialHand) {
|
||||
ItemStack vial = player.getItemInHand(vialHand);
|
||||
ItemStack page = player.getItemInHand(other(vialHand));
|
||||
int remaining = InkVialItem.remaining(vial);
|
||||
return vial.is(Rivencraft.INK_VIAL.get()) && remaining >= linkPanelCost()
|
||||
&& remaining <= InkVialItem.CAPACITY
|
||||
&& page.is(Items.PAPER);
|
||||
}
|
||||
|
||||
public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) {
|
||||
if (player.level().isClientSide() || !canPrepareLinkPanel(player, vialHand)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
InteractionHand pageHand = other(vialHand);
|
||||
ItemStack page = player.getItemInHand(pageHand);
|
||||
ItemStack vial = player.getItemInHand(vialHand);
|
||||
int newInk = InkVialItem.remaining(vial) - linkPanelCost();
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
ItemStack spentVial = newInk == 0
|
||||
? new ItemStack(Items.GLASS_BOTTLE)
|
||||
: new ItemStack(Rivencraft.INK_VIAL.get());
|
||||
if (newInk > 0) {
|
||||
spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
|
||||
}
|
||||
|
||||
// All validation and output construction is complete before changing either input.
|
||||
if (page.getCount() == 1) {
|
||||
player.setItemInHand(pageHand, panel);
|
||||
} else {
|
||||
page.shrink(1);
|
||||
giveOrDrop(player, panel);
|
||||
}
|
||||
if (vial.getCount() == 1) {
|
||||
player.setItemInHand(vialHand, spentVial);
|
||||
} else {
|
||||
vial.shrink(1);
|
||||
giveOrDrop(player, spentVial);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static InteractionHand other(InteractionHand hand) {
|
||||
return hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
}
|
||||
|
||||
private static void giveOrDrop(Player player, ItemStack stack) {
|
||||
if (!player.getInventory().add(stack)) {
|
||||
player.drop(stack, false, Prediction.SERVER_ONLY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "rivencraft:item/book_receptacle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"model":{"type":"minecraft:model","model":"rivencraft:item/link_modifier"}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"display": {
|
||||
"icon": {
|
||||
"id": "rivencraft:descriptive_book"
|
||||
},
|
||||
"title": {
|
||||
"translate": "advancement.rivencraft.root.title"
|
||||
},
|
||||
"description": {
|
||||
"translate": "advancement.rivencraft.root.description"
|
||||
},
|
||||
"background": "rivencraft:block/crystal_block",
|
||||
"show_toast": false,
|
||||
"announce_to_chat": false
|
||||
},
|
||||
"criteria": {
|
||||
"completed": {
|
||||
"trigger": "minecraft:tick"
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_material": {
|
||||
"trigger": "minecraft:inventory_changed",
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "minecraft:iron_ingot"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"trigger": "minecraft:recipe_unlocked",
|
||||
"conditions": {
|
||||
"recipes": [
|
||||
"rivencraft:book_binder"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_material",
|
||||
"has_the_recipe"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"rivencraft:book_binder"
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_material": {
|
||||
"trigger": "minecraft:inventory_changed",
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "rivencraft:crystal_block"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"trigger": "minecraft:recipe_unlocked",
|
||||
"conditions": {
|
||||
"recipes": [
|
||||
"rivencraft:book_receptacle"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_material",
|
||||
"has_the_recipe"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"rivencraft:book_receptacle"
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_crystal_shard": {
|
||||
"trigger": "minecraft:inventory_changed",
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "rivencraft:crystal_shard"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"trigger": "minecraft:recipe_unlocked",
|
||||
"conditions": {
|
||||
"recipes": [
|
||||
"rivencraft:crystal_block"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_crystal_shard",
|
||||
"has_the_recipe"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"rivencraft:crystal_block"
|
||||
]
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"parent": "minecraft:recipes/root",
|
||||
"criteria": {
|
||||
"has_material": {
|
||||
"trigger": "minecraft:inventory_changed",
|
||||
"conditions": {
|
||||
"items": [
|
||||
{
|
||||
"items": "minecraft:glass_bottle"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"has_the_recipe": {
|
||||
"trigger": "minecraft:recipe_unlocked",
|
||||
"conditions": {
|
||||
"recipes": [
|
||||
"rivencraft:ink_mixer"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"requirements": [
|
||||
[
|
||||
"has_material",
|
||||
"has_the_recipe"
|
||||
]
|
||||
],
|
||||
"rewards": {
|
||||
"recipes": [
|
||||
"rivencraft:ink_mixer"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user