Compare commits

...
Author SHA1 Message Date
dnorberg d2904eaa8d Port libraries, crystal portals and instability decay to 26.3 beta
Build / build (push) Waiting to run
2026-11-10 04:14:59 -05:00
dnorberg 35980338e1 Port lectern stands, book GUIs and progression to 26.3 beta
Build / build (push) Waiting to run
2026-11-09 18:20:40 -05:00
dnorberg be8440ddb5 Port collections, dropped books, return links and Writing Desk to 26.3
Build / build (push) Canceled after 0s
2026-11-05 22:04:24 -05:00
dnorberg f13bedd26f Port seeded Age completion and Accelerated pages to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-05 15:19:11 -05:00
129 changed files with 10071 additions and 208 deletions

No files matched your search

+8 -2
View File
@@ -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.93-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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+514
View File
@@ -10,6 +10,77 @@ tracks issues #97–#103 under parent #96.
## Version boundary
Local slice `1.0.1.106-beta.1` ports the Writing Desk's two-part block,
88-tab inventory, authoritative catalog sync, copying transactions and 1.21.1
GUI/model assets. Vial exchange reuses the existing shared helper; symbol copy
cost defaults to one. Modern Container removal handles content drops while
the block cleans up its partner. The renderer uses extracted item states.
Current input helpers also correct Notebook right-click/scrollbar behavior.
Page content is a derived view of the existing port components; no saved Page
schema is replaced. Progression awards and optional JEI integration remain
pending. Client appearance/input/catalog acceptance remains required.
Local slice `1.0.1.105-beta.1` ports category Symbol Portfolios and Sealed
Notebooks, reusing item textures/models and collection menu/import behavior.
Portfolio volumes split at 252 pages. Sealed items use 1.21.1's 16-page draw
and candidate exclusions, with Survival consumption, Creative retention and
full-inventory drop fallback. No recipes are invented; discovery/library loot
and JEI integration remain later work. Unported effects are still marked red.
Local slice `1.0.1.104-beta.1` ports 1.21.1's all-or-nothing collection import
to the current Binder menu: shift-click appends and cursor strip-click inserts
before a page. Occupied page order is preserved, gaps are compacted and the
empty collection is returned. Capacity/panel conflicts are checked before any
mutation. This connects the newly ported collections to actual book assembly;
the dedicated stored-collection input/UI remains later parity work.
Local follow-up `1.0.1.103-beta.1` corrects old GLFW key literals retained in
the Notebook screen. Escape always reaches the normal container close path,
including focused search, and page-movement arrows use 26.3 key helpers.
The player reported the search-focused GUI could not be escaped in .102;
client keyboard acceptance must be repeated with .103.
Local slice `1.0.1.102-beta.1` ports Notebook/Collation Folder storage, menu,
Creative entries, Notebook crafting and the existing sparse page editor.
The 1.21.1 capacities (252/27), Link Panel insertion rejection, held-item
locking and search keyboard handling are preserved. The GUI uses 26.3 input
events/extraction and the existing glyph-only item pipeline, while retaining
the original page atlas proportions. Discovery, sealed notebooks, portfolios,
Writing Desk and bulk Binder collection use remain unfinished.
Local slice `1.0.1.101-beta.1` ports dropped-book entities, their open-book
renderer/textures, decay/weather damage and persistent components. Handheld
travel detaches only the active book and leaves it at departure after success;
failed travel or a cancelled entity spawn restores it. Shared handling covers
both book types and hands and keeps Return Link issuance after departure-book
handling. The newer render-state-based pickup path replaces the old copy()
workaround. Client rendering/pickup remain part of in-game validation.
Local slice `1.0.1.100-beta.1` ports automatic Return Link policy from 1.21.1
for handheld Descriptive Books, default DISABLED. ON_CREATION uses a persistent
world-wide per-Age marker; ALWAYS issues on each successful arrival. Exact
departure position/facing is captured before teleportation and issuance occurs
after Disarm, with inventory-full fallback to a destination item drop. Ordinary
Linking Books, failures and operator travel do not issue books. Stand/portal
integration remains pending with those systems.
Local slice `1.0.1.99-beta.1` ports the dedicated non-collectible arrival
platform block from 1.21.1, reusing its opaque crystal model and strength 0.3.
Fallback landings use it instead of collectible Crystal Blocks. No item,
loot, pick-block result, or material Page is available. Existing platforms
and the port's landing-search/footprint policy are unchanged.
Local slice `1.0.1.98-beta.1` ports the 1.21.1 Accelerated environment page:
stacking extra random block ticks in already-loaded player-nearby chunks,
eight-chunk/16,384-attempt default budgets, fair rotating section allocation,
and an Age-local saved page count. The 26.3 LOCAL config holds these limits.
Entities, machines and scheduled ticks are unaffected. Existing Ages remain
unaccelerated; random Age inclusion and instability scoring are not added.
The suspected host-seed biome zoom mismatch was checked against NeoForge's
patched ServerLevel constructor: seedOverride already replaces it correctly,
so the backend seed path was left unchanged. Native Cave policy issue #111
remains separate future work for both Minecraft tracks.
Minecraft 26.3/NeoForge uses Java 25, while Rivencraft 1.21.1 uses Java 21.
Build and publish separate artifacts; do not widen the Minecraft dependency
range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft.net/en-us/article/minecraft-java-edition-26-3),
@@ -1801,6 +1872,144 @@ fresh chunks after full restart again used Nylium/End Stone. No full feature
or entity chunk scans were run. The server was stopped and temporary world
deleted. Nothing was committed, pushed or published.
## Structure-aware biome completion
`1.0.1.94-beta.1` adapts root `AgeBiomeSelection.completeStructureBiomes` for
explicit non-Native biome controllers. It reads compatible biome IDs from
the active registered structure sets and shares this lookup with random
structure eligibility, avoiding two different compatibility definitions.
Repeated structure pages produce one requirement; Dungeons are excluded.
Single with no supplied biome chooses from the intersection of all structure
requirements and terrain-eligible biomes. No intersection leaves ordinary
seeded filling intact. An explicit Single biome is never rewritten.
Multi-biome completion adds one seeded compatible biome for each unmet
requirement where a terrain-eligible candidate exists, before ordinary minimum
filling. Root's salts, requirement order and shuffled candidate selection are
preserved. Existing biome order and duplicate weights remain, but a structure
can intentionally extend a pool beyond the usual minimum. Native and the
port's no-controller fallback remain unchanged. Impossible requirements do
not become new writing rejections: authored structure overrides still apply.
No saved format, network protocol or generated chunks are changed.
The completed-biome fixture accepts an optional structure page and checks
32 seeds for compatible Single/Cave Fortress choices, common Village/Stronghold
requirements, explicit End Single precedence, Native behavior, multi-biome
prefix preservation and avoiding unnecessary additions for already-compatible
or repeated structure pages. Existing biome plan codec checks remain in place.
The .94 build and disposable server checks passed. Village Single and Tiny
fixtures included compatible Plains; Cave Fortress Single selected Crimson
Forest. The 32-seed authoring assertions and the preexisting no-structure
completion checks passed. Saved generator pools survived unload/reload,
terrain reseed and full restart. The server was stopped and its temporary
world deleted. No structure-placement chunk scan was needed. Client book
authoring remains a manual check; no commit, push or publication occurred.
## Omitted biome-controller completion
`1.0.1.95-beta.1` ports the no-controller branch of root `AgeBiomeSelection`
and the first `AgeTraits.select` draw. The rank table and seed stream are
shared with `PortRandomAgeSelection` rather than duplicated. The production
seeded biome compiler uses that default when there is no explicit controller;
the registry-only diagnostic overload retains its exact older defaults.
Explicit controllers are unchanged. Native rolls with authored biomes, or
Single rolls with more than one biome page, fall back by authored count to
Single/Tiled/Medium. Other multi-biome rolls retain their distribution and
ordinary minimum-only filling, plus .94's structure requirements.
Void's omitted controller remains Single for empty pools and count-based
for supplied biome pages. Cave's omitted Native roll follows the port's
existing page-free seeded Single workaround; a native multi-cave source is
still not implemented. Explicit Native is not silently converted. No other
terrain, weather, lighting, color or body seed stream changes. Saved plans
remain frozen through restart and terrain reseed; old Ages are not rewritten.
The completed-biome diagnostic accepts `auto` and checks 128 seeds for all
nine distribution results, independence from unrelated weather pages,
authored prefix preservation, one/two-biome fallbacks, Void behavior and
structure-aware completion. Existing explicit-controller checks still run.
The .95 build and disposable-server checks passed. The expanded 128-seed
matrix covered all distributions, authored one/two-biome rules and Cave pools.
Created automatic Standard, Cave, Void and Cave Fortress fixtures, plus the
unchanged full-random command and explicit Single/Native Cave controls.
Generator pools survived unload/reload, reseed and full restart. A second seed
produced Small regions with three biomes, while explicit Native Cave retained
its Lush Caves source. No chunk-generation scan was needed. The server was
stopped and its temporary world deleted. Client authoring remains a manual
check; nothing was committed, pushed or published.
## Omitted terrain completion
`1.0.1.96-beta.1` adapts root `AgeRuntimeProfiles.selectForWriting`. A partly
authored book without terrain starts with the shared ranked terrain draw;
if unsupported, compatible profiles in stable ID order are indexed using
`floorMod(Long.hashCode(seed), count)`. Explicit terrain is never substituted.
Unlike root's feature-only candidate test, the port additionally preflights
registry-backed biome restrictions to avoid selecting an unloadable Cave
combination for supplied surface biomes. This is a deliberate compatibility
guard while the broader writing/instability recovery remains unported.
Binder availability checks now consider whether any supported terrain can
host an omitted-terrain book instead of assuming Standard. The server resolves
the actual terrain and biome plan once, then passes the resolved internal page
view to material/feature compilers. The inferred terrain is appended internally,
not inserted before original pages or written back to the book. Original
indices and ordered modifiers remain intact. Environment controllers use the
selected terrain, while sky colors and celestials still compile from original
pages. Page-free random books retain their original path and seed streams.
The chosen terrain and generator plans use existing saved fields, so no
network protocol change or retroactive terrain reroll is introduced.
The fixture checks 64 seeds for terrain coverage, deterministic selection,
original page prefixes, Cave-only Fortress support, implied-fluid constraints,
surface-biome compatibility, explicit terrain precedence and Binder
availability. Generator placement still uses the previously ported routines.
The .96 build and disposable server checks passed. The 64-seed assertions
covered all six terrains, deterministic fallback, page-prefix preservation,
explicit Flat, surface-biome protection and Cave-only Fortress availability.
Created Sky-only, Fortress-only, Water-only, Plains-only, explicit Flat and
full-random fixtures. A Water page retained Water as its implied lake fluid;
Fortress-only selected Cave with eligible biomes. Plans survived unload/reload,
reseed and full restart. The server was stopped and its temporary world
deleted. No chunk scan was needed; client Binder checks remain manual.
Nothing was committed, pushed or published.
## Native Cave regions
`1.0.1.97-beta.1` ports root `AgeBiomeSelection`'s full Native Cave pool and
`AgeDimensionService.createBiomeSource`'s Native-to-Tiny mapping. New Native
Cave plans retain distribution NATIVE with a saved recognized Cave/Nether
list, and the backend uses `PortScaledBiomeSource` at scale 0 (or Fixed for
a one-biome pool). Explicit Native biome pages remain prohibited. Both authored
and page-free/omitted-controller paths use the same eligibility query; the
temporary Native-to-Single Cave conversions are removed.
Native pool data is permitted only for Cave terrain at backend validation.
Other Native terrain sources are unchanged. Empty older Native Cave records
still load the old Lush source; old Single choices also remain frozen. New
Native pools are saved through the existing biome-plan codec without a new
network protocol, but earlier betas cannot decode populated Native pools, so
downgrading those saves is not supported. The current pool is passed to random
structure eligibility rather than incorrectly assuming Lush Caves.
The fixture verifies registry-derived pool ordering, old empty Native codec
defaults, the actual generator source, deterministic Tiny-region equivalence,
horizontal region variation and sampled pool membership without loading
chunks. The omitted-controller matrix now expects Native Cave to remain
Native with its full pool. This does not implement modded surface rules.
The .97 build and disposable-server checks passed: explicit and omitted
Native Cave, the full-random Native Cave path (seed 114), Native + Fortresses,
explicit Single and the legacy Lush-only control. The vanilla registry pool
contained nine biomes, including Sulfur Caves and all five Nether biomes;
the actual Native source passed Tiny-equivalence and sample-membership checks.
Pools survived unload/reload, reseed and full restart. The server was stopped
and its temporary world deleted. No chunk scan was needed. Client exploration
remains a manual check; nothing was committed, pushed or published.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
@@ -1812,3 +2021,308 @@ 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.
+834 -4
View File
@@ -2,14 +2,16 @@
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, and the Ink Mixer work, and the Binder can
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, discovery/stations, writing recovery and optional
integration parity remain unfinished. The latest published beta is
[1.0.1.93-beta.1](RELEASE_NOTES_1.0.1.93-beta.1.md).
Crystal portals and libraries are ported. Instability scoring/provider selection,
status effects and colored decay are partially ported; remaining hazards, live material profiling,
remaining discovery/stations, writing recovery and optional integrations remain unfinished. The latest published beta is
[1.0.1.120-beta.1](RELEASE_NOTES_1.0.1.120-beta.1.md).
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.
@@ -1291,6 +1293,388 @@ and `age material_probe <name> [chunk_x chunk_z]`. The latter examines one
terrain-stage chunk (default 0,0) without feature, lighting or entity generation. Nothing is committed
or published without explicit request.
The local `1.0.1.94-beta.1` slice ports **structure-aware completion of explicit
biome distributions** from 1.21.1. Single Biome without a supplied biome now
prefers a biome compatible with every authored structure page, when a common
candidate exists. Cave + Single + Nether Fortresses therefore selects an
eligible Cave/Nether biome rather than an unrelated cave biome. If there is
no common candidate, ordinary seeded filling and authored structure overrides
remain available, as in root.
For multi-biome controllers, each uncovered structure requirement can add one
compatible biome from the terrain's available pool. This is an intentional
exception to ordinary minimum-only filling: an already-full pool may gain
another biome for its structure pages. Supplied biomes, their order and
duplicate weights remain intact. A supplied Single Biome is never replaced,
Native remains unchanged, and Dungeons impose no biome requirement. The
no-controller fallback remains unchanged in this slice.
Test Single Biome + Villages without biome pages, Cave + Single Biome + Nether
Fortresses, and a multi-biome book with structures. Compare an explicit End
Single Biome + Villages to confirm that authored override still works.
`age create_completed_biomes <name> <seed> <terrain> <distribution> [structure-page-id]`
now accepts an optional structure fixture. Existing Ages are not rerolled;
restart/reseed retains completed pools. Nothing is committed or published
without explicit request.
The local `1.0.1.95-beta.1` slice seed-completes **omitted biome controllers**
in partly authored books, using 1.21.1's first AgeTraits distribution draw.
Native, Single and Medium are common; the other supported distributions are
rarer. Explicit controllers still win. Supplied biomes are never discarded:
a Native roll with authored biomes falls back by count, as does a Single roll
with multiple biome pages. Other seeded multi-biome controllers are retained,
so a lone supplied biome may receive a second biome to meet the minimum.
Void without biome pages still uses The Void; supplied Void biome pages keep
their existing count-based behavior. An omitted controller that rolls Native
on Cave terrain uses the same seeded Single fallback already used by random
Ages, since the port has no native multi-cave source. Explicit Native Cave
behavior remains unchanged. Structure-aware completion from .94 applies to
the resulting nonnative controller. Full Link-Panel-only random generation
retains its existing selector and seeds.
Test new terrain-only books without biome controllers, one/two supplied biome
pages without a controller, and Cave + Nether Fortresses without a biome
controller. Compare an explicit Single Biome book and restart/revisit.
`age create_completed_biomes <name> <seed> <terrain> auto [structure-page-id]`
provides an omitted-controller fixture. Existing Ages are not rerolled.
Nothing is committed or published without explicit request.
The local `1.0.1.96-beta.1` slice seed-completes **omitted terrain pages** in
partly authored books. It starts with the same ranked terrain draw as random
Ages, following 1.21.1's `selectForWriting` model. If that terrain cannot host
the supplied features/materials, a deterministic fallback chooses a compatible
terrain in stable profile order. The port also preflights its current biome
restrictions, avoiding an automatic Cave choice for supplied surface biomes.
Explicit terrain still wins and incompatible explicit combinations remain
rejected. Missing-terrain Fortress books can now bind using Cave terrain;
unpaired fluids use supported surface noise terrain. No extra random features
are added to partly authored books. The original book page list stays intact;
the chosen terrain is appended only to internal generation input so material
and color/controller indices do not shift. Sky/color/celestial compilation
still reads the original pages. Fully random Link-Panel-only Ages retain their
existing selector. Already-created Ages do not change terrain.
Test several new books with Sky or a feature but no terrain page, Nether
Fortresses alone, and an unpaired Water page. Compare an explicit Flat book
and a Plains biome page without terrain; the latter must not choose Cave.
`age create_auto_terrain <name> <seed> <page-id>` exercises the production
selector for one-page fixtures. Restart and revisit to confirm persistence.
Nothing is committed or published without explicit request.
The local `1.0.1.97-beta.1` slice ports **Native Cave biome distribution** from
1.21.1. A new Cave + Native book now freezes the full recognized Cave/Nether
biome pool and distributes it with the existing seeded Tiny-region sampler,
instead of forcing Lush Caves. An omitted controller or random Cave Age that
rolls Native now uses that same pool rather than converting to Single.
Random structures inspect the saved pool, so eligible Nether structures are
no longer hidden behind an assumed Lush-only source.
Existing Single Cave Ages remain Single. Old empty Native Cave records keep
their Lush fallback; they are not silently expanded on load. Other Native
terrains remain unchanged, and explicit Native still rejects biome pages.
Pools persist across restart/reseed; reseeding can still change future region
layout. Modded biome surface-rule integration remains separate pending work.
Back up before updating and do not downgrade worlds containing new Native
Cave pools to earlier beta builds.
Test a **new** Cave + Native Biomes book without individual biome pages,
explore multiple regions, then restart/revisit. Compare an explicit Single
Cave book and an existing old Cave Age. The existing
`age create_completed_biomes <name> <seed> cave native` fixture also checks
the native source against Tiny's sampler without generating chunks.
Nothing is committed or published without explicit request.
### Accelerated random block ticks
The local `1.0.1.98-beta.1` slice ports the **Accelerated** environment page
from 1.21.1. Repeated pages stack: each adds three extra random positions per
eligible block section per tick. Only already-loaded chunks within one chunk
of players are candidates; no extra chunks are loaded. Defaults match 1.21.1:
at most eight candidate chunks and 16,384 attempted positions per Age tick,
shared across sections with rotating selection. These limits live in
`config/rivencraft-local.toml` under `accelerated_age`; setting
`max_random_tick_attempts_per_tick` to zero disables the extra ticks.
This affects random-ticking blocks such as crops and saplings, not entities,
machines, scheduled block ticks, or the day/night clock. The authored page
count persists with the Age across unload/reload, reseeding and restart.
Existing Ages default to zero; pageless random Ages do not gain Accelerated
automatically. Instability costs remain part of the pending instability port.
Test two otherwise comparable new Ages, one with a single Accelerated page
and one with repeated pages. Plant crops/saplings with suitable light and
space, remain nearby, and compare growth. Revisit after restart. The operator
command `/rivencraft_port age acceleration <name>` reports the saved count
and current budgets. Disposable-server fixture
`age create_accelerated <name> <seed> <pages>` checks authoring and bounded
attempt arithmetic and creates a Flat Age; it does not prove live growth.
Build and disposable dedicated-server checks passed for counts 0, 1 and 32,
overflow/disabled budget arithmetic, unload/reload, reseed and full restart.
No player was connected for the server checks, so live growth remains an
in-game acceptance check. The test server was stopped and its world removed.
### Non-collectible arrival platforms
The local `1.0.1.99-beta.1` slice ports 1.21.1's dedicated `cloud_platform`
block for fallback Age landings. It reuses the existing opaque Crystal Block
texture, has glass-like breaking strength (0.3), and produces no loot or
pick-block item. It has no item, recipe, Creative entry or generated material
Page. Ordinary Crystal Blocks retain their normal behavior.
New fallback platforms use this block. Existing platforms made from ordinary
Crystal Blocks are not rewritten. This slice changes the fallback material;
the port's existing landing search and 3-by-3 fallback footprint remain.
Test a new Void Age: arrive on the platform, check its appearance and footing,
then break a block in Survival and confirm there is no drop. Creative
pick-block must not supply an item. Compare a normal Crystal Block, and revisit
the platform after restart. Operator fixtures
`/rivencraft_port age create_platform <name> <seed>` and
`/rivencraft_port age platform_probe <name>` validate a disposable Void
platform's properties and saved blocks.
The build and disposable dedicated-server checks passed for actual Void
arrival generation, collision/opacity, strength, loot, pick-block exclusion,
material-page exclusion, unload/reload and full restart. Shared model assets
were verified in the JAR. The test server was stopped and its world removed;
client appearance and Survival breaking remain the in-game check.
### Configurable automatic Return Links
The local `1.0.1.100-beta.1` slice ports the 1.21.1 automatic Return Link
policy for successful handheld Descriptive Book arrivals. In
`config/rivencraft-local.toml`, set `[descriptive_books] return_link_mode` to
`DISABLED` (the default), `ON_CREATION`, or `ALWAYS`. ON_CREATION issues one
Return Link when the Age is first reached with that mode enabled, shared
across players in the world. ALWAYS issues one on each successful arrival.
Disabled/Always arrivals do not consume the ON_CREATION marker, matching
1.21.1. The marker persists across reload, reseeding and restart.
Return Links retain the exact departure dimension, position, yaw and pitch.
They use ordinary Linking Book rules and have no automatic Link Panel
modifiers. They are issued after Disarm and dropped at the destination if
inventory space is unavailable. Failed travel, binding a book, ordinary
Linking Book travel and operator teleport probes do not issue Return Links.
Stands/portals remain pending and will need the same successful-travel hook.
For the in-game check, confirm the default gives no Return Link. Then enable
ON_CREATION and visit a fresh Age twice: only the first arrival should grant
one. Use that book to return and check position/facing; restart and revisit
to confirm no duplicate. In ALWAYS, each arrival should grant a book. Test
a Disarm Descriptive Book and a full inventory as well. Restore DISABLED
after testing if automatic Return Links are unwanted.
The build and disposable-server fixtures passed mode decisions, book target
components, visit-record codec round trips, world persistence through
unload/reload, reseeding and restart, and config loading with DISABLED and
ON_CREATION. No player joined those fixtures; actual travel, Disarm and
inventory-full delivery remain the in-game acceptance check. The server was
stopped and its test world removed.
### Handheld books left at departure
The local `1.0.1.101-beta.1` slice ports the 1.21.1 dropped-book entity and
handheld travel ownership. Successful handheld Linking and Descriptive Book
travel removes the active book from its hand and leaves it at departure as
an open-book entity. The existing Linking/Descriptive entity textures and
skyward-facing model orientation are reused with 26.3's render-state API.
The other hand and ordinary inventory remain unchanged unless Disarm applies.
Failed travel restores the book. A rejected dropped-entity spawn also restores
it instead of consuming it. Return Links are issued after the active book is
left behind. Dropped books have a one-second pickup delay, no ordinary five-
minute expiry, slow damage every 10,000 loaded ticks, and water/rain damage,
matching 1.21.1. Book components and the decay counter persist with the entity.
26.3 pickup particles use the extracted entity render state; 1.21.1's custom
`ItemEntity.copy()` workaround is not needed on this target.
Test travel from both hands with each book type, return to recover the open
book, verify pages face upward, and check pickup animation and restart.
Attempt a blocked/invalid link and confirm the book stays in hand. With
Disarm, the active book should use its model while other items drop normally.
With automatic Return Links enabled, the new Return Link should arrive with
the player. Test rain/water damage with disposable books only.
Build and disposable dedicated-server checks passed both hands, failed travel,
exception recovery, cancelled entity spawning, successful hand removal, saved
book components/lifetime, and a full restart with both book entities restored.
The fixture uses a fake player and does not prove client rendering or actual
cross-dimension travel. Entity loading is asynchronous: querying in the same
command batch as chunk loading can briefly see zero books. The server was
stopped and its world removed after validation. Operator fixtures are
`age create_dropped_fixture <name> <seed>` and `age dropped_probe <name>`.
### Notebook and Collation Folder
The local `1.0.1.102-beta.1` slice adapts the 1.21.1 Notebook and Collation
Folder, their server menu, persistent page component and existing page editor.
Notebook capacity is 252 positions; Folder capacity is 27. Sparse positions,
dark empty pages through the last occupied position, search, scrolling,
single-page movement, shift transfers and the held-item lock are retained.
Search consumes inventory keys while focused. Large pages keep the native
30-by-40 atlas proportions and use the existing glyph-only item renderer.
Unsupported symbol pages receive the port's red tint and retain their items.
Both collections appear in Creative. Notebook crafting reuses the 1.21.1
leather/string pattern. The Folder has no recipe in 1.21.1 and receives none
here; discovery/library loot is still pending. Link Panels cannot be inserted
through the collection menu. Unknown symbol pages remain stored and are
counted in the tooltip. Writing Desk integration, sealed notebooks, Symbol
Portfolios and bulk Binder interactions remain later work.
Test both collection types and hands. Insert and remove pages by normal and
shift clicks, place a page near the final position, move it with left/right
arrows after selection, scroll/search, and type E in the search field. Verify
Link Panels are rejected and the held collection cannot be moved/swapped.
Close/reopen, put the collection in a chest, restart and recover its contents.
Compare large glyphs and page outlines with the 1.21.1 editor.
Build and disposable-server checks passed both hands and capacities, sparse
positions, page movement, shift transfers through the actual menu input path,
Link Panel/non-Page rejection, held-item pickup locking, save/network codec
round trips, and all four collections stored in a chest across full restart.
The recipe and adapted recipe-unlock advancement loaded successfully. Shared
assets were checked in the JAR. No client joined these fixtures, so appearance,
mouse handling and search keys still need the in-game check. The test server
was stopped and every disposable test world removed.
The local `1.0.1.103-beta.1` follow-up fixes the Notebook/Folder keyboard port:
26.3 uses different key values from the old GLFW-based implementation. Escape
now delegates to normal container closing even with search focused, while
left/right page movement uses the current key helpers. Search still consumes
ordinary inventory/hotbar keys. Check Escape with search focused and while
holding a page on the cursor; closing should use normal carried-item recovery.
### Collection imports in the Book Binder
The local `1.0.1.104-beta.1` slice adapts 1.21.1's transactional collection
import. Shift-click a Notebook or Collation Folder in the Binder inventory
to append its pages. Carry one on the cursor and click the page strip to
insert its pages before that position; clicking the panel position inserts
at the beginning. Empty collection positions are skipped, preserving the
relative order of occupied pages. Later Binder pages shift to make room.
The import validates every page and total capacity before changing anything.
A full Binder, invalid insertion point, empty collection, or conflicting
Link Panel rejects the transaction without consuming pages. Successful
imports leave the empty collection on the cursor or in its inventory slot,
retaining its other components. Ordinary collection menus still reject Link
Panels; the Binder retains 1.21.1's handling for explicitly supplied collection
data containing a panel. Dedicated stored-collection input/UI remains unported.
Test shift-click append and cursor insertion for both collection types, using
distinct ordered pages and gaps. Check that the collection empties only after
success and that rejected imports leave both sides unchanged. Bind a book
with the imported pages, inspect its symbols, and restart with pages in the
Binder and collections in a chest to check persistence.
Build and disposable-server checks passed full-capacity and panel-conflict
rejection, invalid positions, sparse cursor insertion, shift append, empty-cover
retention, real Binder assembly and book page order. The empty collections and
finished book survived full restart in a chest. No client joined this fixture;
cursor/shift UI interaction remains the in-game check. The server was stopped
and its world removed. Include collection imports in the future JEI guide port.
### Symbol Portfolios and Sealed Notebooks
The local `1.0.1.105-beta.1` slice ports the remaining collection items from
1.21.1. The main Creative tab offers prefilled Symbol Portfolios for every
symbol-page category, including registered biome/material/fluid pages.
Categories exceeding 252 pages split into named volumes without truncation.
Portfolios use the Notebook editor and Binder collection import and can be
emptied/refilled like ordinary collections. No portfolio recipe is added.
A Sealed Notebook opens server-side into a Collation Folder with 16 random
pages. As in 1.21.1, the candidate pool excludes IDs under `block/` and
`biome/` and the corrupted category; repeated draws are allowed. This also
excludes biome-controller IDs under `biome/`, matching the existing code.
The pool is not restricted to already-ported effects, so unsupported pages
retain their red tint. Survival consumes one sealed item; Creative retains
it. The last item becomes the Folder in hand, otherwise the Folder goes into
inventory or drops if there is no space. Sealed Notebooks appear in Creative;
their discovery/library loot remains pending, and no crafting recipe is added.
Test portfolio names, volumes, contents, editor and Binder import. Open sealed
stacks from both hands in Survival, including with full inventory, and confirm
each Folder has 16 pages. Check Creative retains the sealed item. Put the
resulting items in a chest, restart and revisit.
Build and disposable-server checks passed the 252/253-page boundary, complete
base-registry category coverage/order, seeded draws and exclusions, both-hand
opening, Survival/Creative consumption, full-inventory delivery, and portfolio/
Folder persistence in a chest across restart. Shared models/textures were
verified in the JAR. The server was stopped and its world removed. No client
joined those fixtures; Creative presentation, editor and Binder interaction
remain the in-game check. Future JEI collection guides should cover these items.
### Writing Desk
The local `1.0.1.106-beta.1` slice adapts the 1.21.1 Writing Desk, its
two-block model, inventory, server catalog and existing GUI. It reuses the
Legacy desk/scrollbar/page atlases and current block textures. Selected pages
and white paper stacks render on the desk using the existing offsets.
There are 88 collection tabs, cycled four at a time. Each holds a loose Page
or a collection with its normal capacity. Search, AZ sorting, ALL view,
category filtering, page selection, movement and extraction retain the
1.21.1 menu rules. Link Panels cannot be stored or copied, and no Link Panel
creation recipe is added to the desk. Unknown pages remain recoverable but
cannot be copied while unresolved. Unsupported port effects remain red.
Supply paper and Ink Vials, select a known symbol, then take its virtual output
to copy it. Previewing consumes nothing; taking a copy consumes one paper and
one ink by default. `[writing] symbol_ink_cost` in `rivencraft-local.toml`
retains the 1.21.1 configurable cost. Vials fill a 16-unit meter and return
vanilla glass bottles. Refilling is event-driven and requires bottle space;
there is no refill server ticker. Originals remain in their collection.
The desk recipe and its recipe-unlock advancement are adapted to 26.3.
New Minecraft handles Container drops during block-entity removal, so the
old onRemove drop call is replaced by partner cleanup without duplicate
inventory drops. The GUI uses current input helpers, including Escape while
search is focused. The same mouse-button correction fixes Notebook right-click
and scrollbar dragging. Progression awards and JEI guides remain later work.
Test placement/orientation and opening from both halves, paper/selected-page
rendering and neighboring block visibility. Check the atlas layout and tab
numerals, cycling 0–87, searching/sorting/ALL, page dragging into inventory,
and copying with paper/ink. Confirm costs occur only on pickup, bottles block
refill when full, and removing bottle space allows refill. Restart with loaded
collections and a selected page. Break either half and check drops once.
Build and disposable-server checks passed copy preview/pickup costs,
inventory-full rejection, Link Panel rejection, sparse page movement/extraction,
88-tab reachability, all four facings and both halves' removal/drop counts.
Collections, active tab, ink, paper and display page survived full restart.
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
@@ -1312,3 +1696,449 @@ 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.
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.106-beta.1
Collects the player-validated port slices .99–.106 since 1.0.1.98-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Books and travel
- Fallback Age platforms now use the dedicated non-collectible platform block
from 1.21.1: opaque crystal texture, glass-like breaking strength, no drops
or pick-block item. Existing ordinary Crystal Block platforms are unchanged.
- Ported automatic Return Links for successful handheld Descriptive Book
travel. `descriptive_books.return_link_mode` in `config/rivencraft-local.toml`
defaults to **DISABLED**. `ON_CREATION` issues one on the first arrival per
Age/world with that mode enabled; `ALWAYS` issues one on each arrival.
- Return Links preserve departure dimension, position and facing. They are
issued after Disarm and drop at the destination if inventory is full.
First-arrival records persist across reload, reseeding and restart.
- Successful handheld Linking and Descriptive Book travel now leaves the
active book at departure as a recoverable open-book entity. Both hands are
supported. Failed travel or canceled entity spawning restores the book.
- Dropped books reuse the existing textures and skyward-facing model. They
have a one-second pickup delay, no ordinary five-minute expiry, slow decay
and water/rain damage, matching 1.21.1. Their components and decay counter
persist through restart.
## Page collections and binding
- Ported Notebooks and Collation Folders with their existing editor and assets,
capacities of 252 and 27 positions, sparse page storage, search, scrolling,
page movement and inventory transfers. Link Panels are rejected by the editor.
- Notebook crafting uses the existing leather/string pattern. The held
collection is locked against ordinary menu movement. Escape closes the menu
with search focused; search still consumes inventory/hotbar shortcuts.
- Corrected the port's keyboard and mouse-button mappings, including arrow
movement, right-click handling and scrollbar dragging.
- The Book Binder accepts collection imports: shift-click to append pages or
click a carried collection on the strip to insert them. Empty positions are
skipped and occupied page order is retained. Imports validate all inputs and
capacity before committing; successful imports return the empty collection.
- Added category Symbol Portfolios to Creative, including registry-derived
biome, material and fluid pages. Large categories split into volumes of up
to 252 pages. Portfolios use the editor and Binder import flow.
- Sealed Notebooks open into Folders containing 16 random pages using the
1.21.1 pool/exclusions. Survival consumes one; Creative retains the item.
Full inventories receive a dropped Folder. No new recipes are invented for
Folders, Portfolios or Sealed Notebooks; discovery/library loot is pending.
## Writing Desk
- Ported the two-block Writing Desk, its existing model/textures, selected-page
and white paper-stack display, and Legacy GUI atlases.
- Ported 88 tabs cycled four at a time, category/search/sort/ALL views, collection
and loose-page storage, page movement/extraction, and server-owned catalog
synchronization. Link Panels cannot be stored or copied in the desk.
- Copy previews are virtual. Taking a copy consumes one paper and one ink by
default while retaining the original page. `writing.symbol_ink_cost` is
configurable in `rivencraft-local.toml`.
- Vials fill a 16-unit meter and return vanilla glass bottles. Refills are
event-driven and require bottle space. Full inventory prevents shift-copy
consumption. Unknown pages are retained but cannot be copied unresolved.
- Ported crafting and adapted its recipe-unlock advancement to 26.3.
Removing either desk half removes its partner and drops contents once.
- Further corrections to the desk model, focused on page/paper placement,
remain tracked for both versions in issue #113.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. The 1.21.1 implementation is
unchanged. Use matching client/server builds; Age catalog protocol remains 10.
- Player in-game checks passed. Both build targets passed. Targeted disposable
server fixtures covered ownership recovery, collection/copy transactions,
save/network codecs, block removal, reload/reseed and restart persistence.
Servers were stopped and test worlds removed. This was not a full GameTest run.
- Full writing recovery, remaining discovery/stations, instability, crystal
portals, progression awards and optional integration parity remain unfinished.
JEI guides and discovery loot for the newly ported interactions are pending.
Unsupported effects remain marked red; Creative availability is not parity.
- Back up saves. New Native Cave profiles from .97 onward cannot be read by
earlier betas, and newly ported items/entities also require the newer build.
Keep 1.21.1 and 26.3 saves separate. Machine acceleration (#112) and Native
Cave default biome-pool policy (#111) remain separate planned work.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -0,0 +1,88 @@
# Rivencraft 26.3 beta 1.0.1.115-beta.1
Collects the player-validated port slices .107–.115 since 1.0.1.106-beta.1.
Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. This remains a beta with
incomplete Minecraft 1.21.1 gameplay and optional-integration parity.
## Book stands and dropped books
- Place a Linking Book, Descriptive Book or Page on an empty vanilla lectern
to convert it into a Rivencraft book stand, preserving facing. Occupied
vanilla lecterns are left alone.
- Books use the existing open-book model and textures and show their name
above the stand. Pages use their item artwork without a hover label,
retaining the 1.21.1 orientation corrections.
- Open a bound book with an empty hand, then click its destination panel to
travel while leaving the book on its stand. Existing link modifiers,
same-dimension rules, Return Link configuration and Age-entry awards apply.
- Shift-right-click with an empty hand retrieves the stored item and restores
a vanilla lectern. Breaking returns one lectern and its contents. Unwritten
books can be displayed; retrieve them to bind in hand in this beta.
- Ordinary dropped Linking/Descriptive Books now become recoverable open-book
entities, including unwritten books, inventory/player drops, container and
stand drops, death/Disarm drops and ordinary book entities loaded from disk.
- Conversion preserves the stack and components, position, motion, facing,
pickup delay and ownership/thrower metadata. It uses the existing open-book
lifetime, slow decay and water/rain behavior. Already-custom books and
non-book items are skipped.
- Conversion runs after registration without world scans or force-loading.
The original is removed only after replacement succeeds; a canceled
replacement leaves the ordinary item intact and eligible on a later reload.
- Stand travel validates source dimension, distance, loaded chunk and the exact
currently stored book, rejecting stale screens after removal/replacement.
## Book GUIs and names
- Descriptive Books reuse the Legacy gilded cover, name/author front spread,
and one authored symbol at a time on following right-hand pages. Saved page
order and repetitions are retained, with runtime glyphs and no extra item outline.
- New Descriptive Books record their author server-side. Older books without
that metadata use the unknown-author label.
- Linking Books use the plain Legacy cover, wrapped title, destination name
and block coordinates, plus the blank final spread. Return Links suppress
the redundant title. Bottom corners or arrow keys turn pages with sound.
- Both GUIs share the Legacy artwork/text/counter/corner helpers. Naming
controls hide on later pages and remain correct after resizing. Escape closes
even with naming focused; normal text input does not trigger inventory keys.
- Ported the existing 1.21.1 fallback's animated teal spiral panel to both GUIs,
including lecterns. Removed placeholder labels. The Legacy frame draws over
the animation; travel is active only on the front panel.
- The animated fallback is not a live destination view. Destination tint,
snapshot renderer integrations and random-Age glyph descriptions remain pending.
- New Ages save their entered display name independently of their ID, folder
and seed. Newly bound Linking Books and automatic Return Links capture their
destination/departure label for both GUI and tooltip. Older Ages/books retain
vanilla-name or dimension-ID fallbacks; existing labels are not rewritten.
- Corrected negative tooltip coordinates to floor consistently with the GUI.
## Progression and recipe discovery
- Ported the Rivencraft advancement tree: root, obtaining a Page/Linking Book,
taking a Link Panel from the Ink Mixer, copying a Page, binding an Age and
successfully travelling into an Age. Action awards occur after committed
server actions, including normal and shift pickup; previews earn nothing.
- The advancement background uses the existing blue Crystal Block texture,
with the corrected 26.3 resource-path format. The portal node waits for the
Receptacle/portal port.
- Added recipe-book discovery for Ink Vials (Black Dye), Ink Mixers (Glass
Bottle), Book Binders (Iron Ingot) and Crystal Blocks (Crystal Shard), alongside
the Notebook and Writing Desk unlocks. Three-by-three recipes appear in a
Crafting Table's recipe book, not the inventory's two-by-two book.
- Ingredients and costs are unchanged. Blank Pages remain uncraftable.
## Validation and remaining work
- Player in-game checks passed each slice and the final dropped-book changes.
- The 26.3 and independent 1.21.1 builds passed. Gradle test tasks have no
standalone test sources; targeted disposable-server fixtures covered real
station actions, advancements/recipe unlocks, stand ownership/drops and
persistence, book author/page/name persistence, and drop conversion/state.
- Temporary test servers were stopped and their worlds deleted. A no-player
entity fixture required one loaded fixture chunk for inspection; further
visual/restart validation was handed to the player.
- 1.21.1 gameplay code is unchanged. Shared issue #114 remains open for its
matching universal dropped-book fix; Writing Desk model placement stays in #113.
- Instability, full writing recovery, discovery/library loot, crystal portals,
remaining stations/progression and optional integrations still need port work.
Keep 26.3 and 1.21.1 instances/saves separate and back up beta worlds before updating.
@@ -0,0 +1,85 @@
# Rivencraft 26.3 beta 1.0.1.120-beta.1
Collects the player-validated .116–.120 port slices since 1.0.1.115-beta.1.
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent 1.21.1
release is unchanged; full gameplay and optional-integration parity remain incomplete.
## Libraries and writing
- Ported Legacy library generation, shared schematic, chest loot and five correctly
oriented book/page stands. Libraries generate after decoration in eligible new
non-Cave Age chunks, using the 1.21.1 spacing, terrain and clearance checks.
- Library loot defaults to `LEGACY`; optional `AGE_ONLY` limits new library Pages
to known symbols originally written into that Age. Original writing is saved
separately and restored on reload. Older Ages without it have an empty Age-only
pool. Ordinary Books replace stand Pages when that pool is empty.
- Added operator library placement, candidate/probe/loot diagnostics, session-only
test spacing and a locator limited to nearby already-loaded chunks.
- Hand-prepared Link Panel ink cost is server-configurable (default two). The
Ink Mixer still consumes one ink unit and one paper per panel. Writing Desk
ink handling now uses the shared service.
## Crystal portals
- Ported craftable Book Receptacles with all six mounting orientations and the
existing 1.21.1 model/book transforms. A bound Linking or Descriptive Book
activates a valid connected crystal frame, including supported irregular frames.
- Reused the bounded crystal flood/tension algorithm, placement protection events,
rollback, owner persistence, restart repair, frame cleanup and traversal cooldown.
- Empty-hand removal closes the portal and returns the book. Breaking the holder
or its crystal support closes it and drops a recoverable open-book entity.
- Player and non-player travel reuse book destinations, safe arrival, Intra-Linking,
Disarm and momentum rules. Descriptive Books share the same landing resolver
with portals; no separate portal exit offset is added. Player Return Link policy
and progression remain in force.
- The animated fallback surface renders its facing sides and uses destination sky
tint. The Open Portal advancement and receptacle recipe unlock are restored.
Live destination views are still pending optional integration work.
## Instability and colored decay
- New Ages save operator-only creation-time score contributions and deterministic
provider selections using all five Legacy decks. Existing Ages remain neutral;
reloads/config changes do not reroll stored plans.
- Initial scoring covers Bright Lighting, Accelerated, Corrupted Pages, ineffective
Cave sky/weather modifiers, excess and biome-forced structures, and extra suns.
Corrupted Pages now bind successfully and retain their animated redacted text.
- Ported 18 potion providers: local/global Blindness, Fatigue, Hunger, Nausea,
Poison, Slowness, Weakness and Wither, plus hostile Regeneration/Resistance.
General effects affect eligible Survival/Adventure players and living entities;
Creative/Spectator remain exempt. Local variants require sky exposure.
- Added a bounded occupied-Age scheduler, owned-effect cleanup, provider controls
and operator score/status, enable/disable, session test-score and pulse commands.
Effects supplied or extended by another source are preserved.
- Ported Blue, Purple, Red and White decay with the existing 16x16 textures,
hardness/air conversion odds, saved per-color frontiers and attempt sequence.
White retains stepping-contact magic damage. Decay has no item form or drops.
- Spread uses only already-loaded candidate chunks, bounded attempts and action
budgets. Fluids, block entities, critical/unbreakable blocks, infrastructure,
protected tags and claim events prevent conversion.
- The first resolved book/portal arrival is saved for spawn protection. Re-enter
an older Age once to record it. Global/Age/provider and world-mutation controls
gate spread; existing decay blocks remain when spreading is disabled.
- Test a color with `/rivencraft_port instability decay blue 32` (also `purple`,
`red`, `white`); output includes actual changes and nearby tracked coordinates.
Scores are not added to ordinary book or Binder displays.
## Validation and remaining work
Player in-game checks passed .116–.120. Both the 26.3 and 1.21.1 builds passed;
Gradle has no standalone test sources. Targeted disposable dedicated-server checks
covered library placement, portal entity travel and restart ownership/cleanup,
potion application/cleanup and persistence, all four decay colors, saved spread
continuation, and bedrock/fluid protection. Test servers were stopped and their
worlds removed.
Full writing recovery/penalties, live material-abundance profiling, other instability
hazards, remaining discovery/stations and optional integrations still need port work.
Unported hazard cards remain saved and are reported as unavailable, rather than
being replaced with different cards. Book previews remain the animated fallback.
Machine acceleration (#112), Writing Desk model placement (#113), and the matching
1.21.1 universal dropped-book fix (#114) remain tracked separately.
Use matching client/server beta builds (catalog protocol 10). Keep Minecraft tracks
and saves separate, back up beta worlds, and do not downgrade saves containing new
blocks or data to earlier betas.
@@ -0,0 +1,63 @@
# Rivencraft 26.3 beta 1.0.1.98-beta.1
Collects the player-validated port slices .94–.98 since 1.0.1.93-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Seeded terrain and biome completion
- Books without a Terrain page now choose terrain using the 1.21.1 weighted
selector. Incompatible choices are replaced with a seeded compatible terrain;
explicit Terrain pages retain their authored behavior.
- Omitted biome controllers now use the seeded weighted distribution selector.
Supplied biome pages are retained, with missing minimum slots completed.
Void without biome pages still uses The Void. Link-Panel-only random Ages
retain their existing selection path.
- Missing nonnative biome pools account for authored structures. Single chooses
a terrain-compatible biome satisfying all structure requirements where one
exists. Multiple-biome distributions add compatible biomes for unmet structure
requirements before completing their ordinary minimum. Explicit Single
choices are never replaced.
- New Native Cave Ages freeze the full recognized Cave/Nether biome pool and
distribute it using seeded Tiny regions, matching the 1.21.1 implementation.
This includes recognized modded cave biomes. Existing empty Native Cave
profiles retain their previous Lush Caves fallback; explicit Single is unchanged.
- Removing Nether-style biomes from the default Native Cave pool is separate
planned work for both versions in issue #111, not part of this release.
## Accelerated pages
- Ported the Accelerated environment page. Repeated pages stack, each adding
three extra random positions per eligible block section per tick near players.
- Defaults match 1.21.1: at most eight already-loaded candidate chunks and
16,384 extra random-tick attempts per Age tick, shared fairly across sections.
No extra chunks are force-loaded.
- Limits are configurable in `config/rivencraft-local.toml`, under
`accelerated_age`. A zero attempt budget disables the extra ticks.
- Page counts persist across unload/reload, reseeding and restart. Existing
Ages default to zero. Pageless random Ages do not gain Accelerated automatically.
- This currently affects random-ticking blocks such as crops and saplings.
Machine/block-entity acceleration is deferred for both versions under issue
#112; entities and scheduled block ticks are also unaffected. Instability
scoring remains part of the unfinished instability port.
- Operator command `/rivencraft_port age acceleration <name>` reports the
saved page count and current budgets.
## Compatibility and validation
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
- Catalog protocol remains **10**. Use matching client/server builds.
- Player in-game checks passed. Builds and targeted disposable-server checks
covered seeded completion, Native Cave sampling, tick-budget arithmetic,
unload/reload, reseeding and restart. Test servers were stopped and their
worlds removed. This was not a full automated GameTest run.
- Back up worlds before updating. New populated Native Cave profiles cannot
be read by earlier beta builds; do not downgrade those saves. Keep Minecraft
1.21.1 and 26.3 saves separate.
- Full writing recovery, discovery/stations, instability, crystal portals and
optional integration parity remain unfinished. Random-page eligibility config
and modded biome surface-rule integration also remain pending. Creative page
availability does not imply full support.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
+41
View File
@@ -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'
@@ -203,6 +207,25 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
include 'assets/rivencraft/models/item/page_link_panel.json'
include 'assets/rivencraft/textures/item/ink_vial.png'
include 'assets/rivencraft/textures/item/notebook.png'
include 'assets/rivencraft/textures/item/collation_folder.png'
include 'assets/rivencraft/models/item/notebook.json'
include 'assets/rivencraft/models/item/collation_folder.json'
include 'assets/rivencraft/textures/item/sealed_notebook.png'
include 'assets/rivencraft/textures/item/symbol_portfolio.png'
include 'assets/rivencraft/models/item/sealed_notebook.json'
include 'assets/rivencraft/models/item/symbol_portfolio.json'
include 'assets/rivencraft/textures/item/writing_desk.png'
include 'assets/rivencraft/textures/block/writing_desk.png'
include 'assets/rivencraft/textures/gui/writing_desk_legacy.png'
include 'assets/rivencraft/textures/gui/scrollbar.png'
include 'assets/rivencraft/models/item/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk.json'
include 'assets/rivencraft/models/block/writing_desk_extension.json'
include 'assets/rivencraft/blockstates/writing_desk.json'
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
include 'data/rivencraft/advancement/progression/*.json'
exclude 'data/rivencraft/advancement/progression/root.json'
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
@@ -214,7 +237,10 @@ 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'
@@ -230,9 +256,24 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/models/block/book_binder.json'
include 'assets/rivencraft/models/item/book_binder.json'
include 'assets/rivencraft/blockstates/book_binder.json'
include 'assets/rivencraft/blockstates/book_receptacle.json'
include 'assets/rivencraft/blockstates/crystal_portal.json'
include 'assets/rivencraft/models/block/book_receptacle.json'
include 'assets/rivencraft/models/block/crystal_portal.json'
include 'assets/rivencraft/models/item/book_receptacle.json'
include 'assets/rivencraft/textures/block/book_receptacle.png'
include 'assets/rivencraft/textures/block/crystal_portal.png'
include 'assets/rivencraft/textures/block/crystal_portal.png.mcmeta'
include 'data/rivencraft/loot_table/blocks/book_receptacle.json'
include 'assets/rivencraft/blockstates/book_stand.json'
include 'data/rivencraft/loot_table/blocks/book_stand.json'
include 'data/rivencraft/loot_table/chests/mystcraft_library.json'
include 'data/rivencraft/structure/mystcraft_library.nbt'
include 'data/rivencraft/loot_table/blocks/book_binder.json'
include 'assets/rivencraft/models/block/crystal_block.json'
include 'assets/rivencraft/blockstates/crystal_block.json'
include 'assets/rivencraft/models/block/cloud_platform.json'
include 'assets/rivencraft/blockstates/cloud_platform.json'
include 'assets/rivencraft/blockstates/budding_crystal.json'
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.93-beta.1
mod_version=1.0.1.120-beta.1
+2 -1
View File
@@ -85,10 +85,11 @@
"symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps",
"symbol.rivencraft.feature.abandoned_camps.description": "Allow abandoned camps on surface noise terrain, including outside their usual biomes.",
"symbol.rivencraft.block.generated.description": "Modify the next compatible feature. Without a following feature, choose a compatible feature from the Age seed.",
"symbol.rivencraft.biome.native.description": "Use the terrain's native biome source; do not include Biome pages.",
"symbol.rivencraft.biome.native.description": "Use the terrain's default biome source. Cave uses Tiny regions from recognized Cave/Nether biomes. Do not include Biome pages.",
"symbol.rivencraft.feature.dungeons.description": "Add monster-room dungeon attempts throughout surface noise or Cave terrain, including Nether cave biomes. Not Flat or Void.",
"symbol.rivencraft.biome.single.description": "Use one biome across the Age. If omitted, choose an eligible biome from the Age seed.",
"symbol.rivencraft.terrain.flat.description": "A level, biome-decorated landscape with its foundation at Y=0.",
"symbol.rivencraft.environment.accelerated.description": "Adds extra random block ticks near players in this Age. Repeated pages stack within server tick budgets; does not speed up entities or machines.",
"symbol.rivencraft.feature.floating_islands.description": "Add high floating islands alongside noise, Flat, or Void terrain; not Cave terrain.",
"symbol.rivencraft.feature.caves.description": "Add extra cave carving to noise terrain, including Cave; not Flat or Void.",
"symbol.rivencraft.feature.ravines.description": "Add canyon carving to noise terrain; not Flat or Void. Skips Nether biomes.",
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft;
import net.neoforged.neoforge.common.ModConfigSpec;
/** Server-owned gameplay policies adapted from 1.21.1. */
public final class PortConfig {
public enum LibraryLootMode { LEGACY, AGE_ONLY }
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec 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;
static {
var builder = new ModConfigSpec.Builder();
builder.push("descriptive_books");
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop();
builder.push("libraries");
LIBRARY_LOOT_MODE = builder.comment("LEGACY uses writing supplies/Sealed Notebooks and random lectern pages; AGE_ONLY uses only pages written into this Age.")
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
builder.pop();
builder.push("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 1, 16);
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline Link Panel by hand with paper.",
"The Ink Mixer uses one ink unit per panel independently of this setting.")
.defineInRange("link_panel_ink_cost", 2, 1, 16);
builder.pop();
builder.push("accelerated_age");
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
.defineInRange("max_loaded_chunks", 8, 1, 64);
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
builder.pop();
net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder);
SPEC = builder.build();
}
private PortConfig() {}
}
@@ -57,6 +57,15 @@ public final class Rivencraft {
public static final Logger LOGGER = LogUtils.getLogger();
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<net.minecraft.world.entity.EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID);
public static final Supplier<net.minecraft.world.entity.EntityType<net.dnorberg.rivencraft.entity.DroppedBookEntity>>
DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder
.<net.dnorberg.rivencraft.entity.DroppedBookEntity>of(
net.dnorberg.rivencraft.entity.DroppedBookEntity::new, net.minecraft.world.entity.MobCategory.MISC)
.sized(0.65F, 0.4F).clientTrackingRange(10).updateInterval(20)
.build(net.minecraft.resources.ResourceKey.create(Registries.ENTITY_TYPE,
Identifier.fromNamespaceAndPath(MOD_ID, "dropped_book"))));
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK = sound("linking.link");
@@ -82,6 +91,12 @@ public final class Rivencraft {
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
public static final Supplier<DataComponentType<net.dnorberg.rivencraft.writing.NotebookContents>> NOTEBOOK_CONTENTS =
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
public static final Supplier<DataComponentType<String>> LINK_DESTINATION_NAME = COMPONENTS.registerComponentType(
"link_destination_name", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
@@ -93,6 +108,8 @@ public final class Rivencraft {
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
.networkSynchronized(Identifier.STREAM_CODEC));
/** Ordered authored page IDs remain on the finished book for later port slices. */
public static final Supplier<DataComponentType<String>> BOOK_AUTHOR = COMPONENTS.registerComponentType(
"book_author", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
@@ -110,12 +127,39 @@ public final class Rivencraft {
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
public static final DeferredBlock<net.dnorberg.rivencraft.block.CloudPlatformBlock> CLOUD_PLATFORM =
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookReceptacleBlock> BOOK_RECEPTACLE =
BLOCKS.registerBlock("book_receptacle", net.dnorberg.rivencraft.block.BookReceptacleBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).noOcclusion().mapColor(MapColor.COLOR_BLUE));
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE_ITEM = ITEMS.registerSimpleBlockItem(BOOK_RECEPTACLE);
public static final DeferredBlock<net.dnorberg.rivencraft.block.CrystalPortalBlock> CRYSTAL_PORTAL =
BLOCKS.registerBlock("crystal_portal", net.dnorberg.rivencraft.block.CrystalPortalBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
.noCollision().noOcclusion().lightLevel(state -> 11).noLootTable());
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity>> BOOK_RECEPTACLE_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_receptacle", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity::new, BOOK_RECEPTACLE.get()));
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity>> CRYSTAL_PORTAL_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("crystal_portal", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity::new, CRYSTAL_PORTAL.get()));
public static final DeferredBlock<net.dnorberg.rivencraft.block.DecayBlock> DECAY =
BLOCKS.registerBlock("decay", net.dnorberg.rivencraft.block.DecayBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).strength(5.0F, 10.0F).noLootTable());
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
StarFissureBlockEntity::new, STAR_FISSURE.get()));
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookStandBlock> BOOK_STAND =
BLOCKS.registerBlock("book_stand", net.dnorberg.rivencraft.block.BookStandBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN));
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookStandBlockEntity>> BOOK_STAND_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("book_stand", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.BookStandBlockEntity::new, BOOK_STAND.get()));
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
"book_binder", PortBookBinderBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
@@ -134,6 +178,25 @@ public final class Rivencraft {
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredBlock<net.dnorberg.rivencraft.block.WritingDeskBlock> WRITING_DESK =
BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredItem<BlockItem> WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK);
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity>> WRITING_DESK_BLOCK_ENTITY =
BLOCK_ENTITY_TYPES.register("writing_desk", () -> new BlockEntityType<>(
net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity::new, WRITING_DESK.get()));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.WritingDeskMenu>> WRITING_DESK_MENU =
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new));
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.NotebookMenu>> NOTEBOOK_MENU =
MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new));
public static final DeferredItem<net.dnorberg.rivencraft.item.NotebookItem> NOTEBOOK =
ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.CollationFolderItem> COLLATION_FOLDER =
ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SymbolPortfolioItem> SYMBOL_PORTFOLIO =
ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1));
public static final DeferredItem<net.dnorberg.rivencraft.item.SealedNotebookItem> SEALED_NOTEBOOK =
ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16));
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
"budding_crystal", BuddingCrystalBlock::new,
@@ -166,10 +229,12 @@ public final class Rivencraft {
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
public Rivencraft(IEventBus modEventBus) {
public Rivencraft(IEventBus modEventBus, net.neoforged.fml.ModContainer container) {
container.registerConfig(net.neoforged.fml.config.ModConfig.Type.LOCAL, PortConfig.SPEC);
BLOCKS.register(modEventBus);
SOUNDS.register(modEventBus);
ITEMS.register(modEventBus);
ENTITIES.register(modEventBus);
BLOCK_ENTITY_TYPES.register(modEventBus);
MENUS.register(modEventBus);
COMPONENTS.register(modEventBus);
@@ -179,12 +244,16 @@ public final class Rivencraft {
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
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();
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
}
}
@@ -0,0 +1,28 @@
package net.dnorberg.rivencraft.advancement;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements {
public static final String OPEN_PORTAL = "open_portal";
public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age";
public static final String ENTER_AGE = "enter_age";
private ProgressionAdvancements() {}
public static void award(ServerPlayer player, String path) {
var advancement = player.level().getServer().getAdvancements().get(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement != null) player.getAdvancements().award(advancement, "completed");
}
public static void awardAgeTravel(ServerPlayer player) {
if (RuntimeAgeBackend.savedAges(player.level().getServer()).containsKey(player.level().dimension().identifier()))
award(player, ENTER_AGE);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,119 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import net.dnorberg.rivencraft.PortConfig;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
import net.minecraft.core.BlockPos;
import net.minecraft.core.SectionPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.util.RandomSource;
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
public final class PortAcceleratedTicks {
public static final Identifier PAGE = Identifier.fromNamespaceAndPath("rivencraft", "environment/accelerated");
private PortAcceleratedTicks() {}
public static int pageCount(List<Identifier> pages) {
return (int) pages.stream().filter(PAGE::equals).count();
}
public static void configure(ServerLevel level, int pages) {
if (!(level instanceof PortAgeLevel)) throw new IllegalArgumentException("Not a Rivencraft Age");
level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).setPages(pages);
level.getDataStorage().saveAndJoin();
}
public static int pages(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).pages();
}
public static void onLevelTickPost(LevelTickEvent.Post event) {
if (event.getLevel() instanceof PortAgeLevel level) tick(level);
}
static void tick(ServerLevel level) {
int maximumAttempts = PortConfig.ACCELERATED_MAX_ATTEMPTS.get();
if (maximumAttempts == 0) return;
int pages = pages(level);
if (pages == 0) return;
List<ChunkPos> chunks = loadedPlayerChunks(level);
if (chunks.isEmpty()) return;
int maximumChunks = Math.min(chunks.size(), PortConfig.ACCELERATED_MAX_CHUNKS.get());
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
List<TickSection> eligible = new ArrayList<>();
for (int offset = 0; offset < maximumChunks; offset++) {
ChunkPos pos = chunks.get((first + offset) % chunks.size());
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x(), pos.z());
if (chunk == null) continue;
LevelChunkSection[] sections = chunk.getSections();
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
LevelChunkSection section = sections[sectionIndex];
if (!section.isRandomlyTickingBlocks()) continue;
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
eligible.add(new TickSection(pos, baseY, section));
}
}
if (eligible.isEmpty()) return;
// Legacy attempts three positions per random-ticking section per page. Divide
// the bounded total between sections before ticking any one of them, so a
// lower section cannot exhaust the entire budget ahead of surface crops.
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
int perSection = total / eligible.size();
int remainder = total % eligible.size();
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
RandomSource random = level.getRandom();
for (int offset = 0; offset < eligible.size(); offset++) {
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
int samples = perSection + (offset < remainder ? 1 : 0);
for (int sample = 0; sample < samples; sample++) {
int x = random.nextInt(16);
int y = random.nextInt(16);
int z = random.nextInt(16);
BlockState state = candidate.section().getBlockState(x, y, z);
if (state.isRandomlyTicking()) {
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
}
}
}
}
static int attemptBudget(int pages, int sections, int maximumAttempts) {
if (pages < 0 || sections < 0 || maximumAttempts < 0)
throw new IllegalArgumentException("Negative accelerated tick budget input");
if (sections == 0) return 0;
long perSection = 3L * pages;
return perSection > maximumAttempts / (long) sections
? maximumAttempts : (int) (perSection * sections);
}
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
Set<ChunkPos> chunks = new LinkedHashSet<>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
int chunkX = center.x() + x;
int chunkZ = center.z() + z;
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
chunks.add(new ChunkPos(chunkX, chunkZ));
}
}
}
}
return new ArrayList<>(chunks);
}
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local authored page count, retained across unload, restart and reseeding. */
final class PortAccelerationSettings extends SavedData {
private static final Codec<PortAccelerationSettings> CODEC = Codec.intRange(0, Integer.MAX_VALUE)
.fieldOf("pages").codec().xmap(PortAccelerationSettings::new, settings -> settings.pages);
static final SavedDataType<PortAccelerationSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "acceleration"),
() -> new PortAccelerationSettings(0), CODEC);
private int pages;
private PortAccelerationSettings(int pages) { this.pages = pages; }
int pages() { return pages; }
void setPages(int pages) {
if (pages < 0) throw new IllegalArgumentException("Negative Accelerated page count");
this.pages = pages;
setDirty();
}
}
@@ -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,177 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("create_drop_conversion_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBookDropConversionProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Drop sources queued; run drop_conversion_probe after server tasks complete"), false);
return 1;
}))))
.then(Commands.literal("drop_conversion_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBookDropConversionProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Universal book drop conversion/ownership checks passed"), false);
return 1;
})))
.then(Commands.literal("create_book_stand_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBookStandProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Book Stand conversion/ownership/source checks passed"), false);
return 1;
}))))
.then(Commands.literal("book_stand_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBookStandProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Book Stand persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_writing_desk_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortWritingDeskProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk transaction/removal checks passed"), false);
return 1;
}))))
.then(Commands.literal("progression_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortProgressionProbe.run(level);
context.getSource().sendSuccess(() -> Component.literal("Progression data/action checks passed"), false);
return 1;
})))
.then(Commands.literal("writing_desk_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortWritingDeskProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Writing Desk persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_collection_extras_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortCollectionExtrasProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook checks passed"), false);
return 1;
}))))
.then(Commands.literal("collection_extras_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortCollectionExtrasProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Portfolio/Sealed Notebook persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_binder_collection_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortBinderCollectionProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection transaction/assembly checks passed"), false);
return 1;
}))))
.then(Commands.literal("binder_collection_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortBinderCollectionProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Binder collection persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_notebook_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortNotebookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook menu/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("notebook_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
PortNotebookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Notebook chest persistence passed"), false);
return 1;
})))
.then(Commands.literal("create_dropped_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
var level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
PortDroppedBookProbe.create(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book ownership/codec checks passed"), false);
return 1;
}))))
.then(Commands.literal("dropped_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(context -> {
var level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) throw new IllegalArgumentException("Age not loaded");
level.getChunk(0, 0);
PortDroppedBookProbe.verify(level);
context.getSource().sendSuccess(() -> Component.literal("Dropped book persistence checks passed"), false);
return 1;
})))
.then(Commands.literal("create_return_fixture")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createReturnFixture))))
.then(Commands.literal("return_link_status")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::returnLinkStatus)))
.then(Commands.literal("create_platform")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(PortAgeCommands::createPlatform))))
.then(Commands.literal("platform_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probePlatform)))
.then(Commands.literal("create_accelerated")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("pages", IntegerArgumentType.integer(0, 256))
.executes(PortAgeCommands::createAccelerated)))))
.then(Commands.literal("acceleration")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::acceleration)))
.then(Commands.literal("create_auto_terrain")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("page", IdentifierArgument.id())
.executes(PortAgeCommands::createAutomaticTerrain)))))
.then(Commands.literal("create_biome_surface")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -108,7 +285,10 @@ public final class PortAgeCommands {
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.then(Commands.argument("distribution", StringArgumentType.word())
.executes(PortAgeCommands::createCompletedBiomes))))))
.executes(context -> createCompletedBiomes(context, null))
.then(Commands.argument("structure", IdentifierArgument.id())
.executes(context -> createCompletedBiomes(context,
IdentifierArgument.getId(context, "structure")))))))))
.then(Commands.literal("biome_pool")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeBiomePool)))
@@ -692,6 +872,63 @@ public final class PortAgeCommands {
return 1;
}
private static int createAutomaticTerrain(CommandContext<CommandSourceStack> context) {
try {
var server = context.getSource().getServer();
verifyAutomaticTerrain(server);
long seed = LongArgumentType.getLong(context, "seed");
var source = java.util.List.of(IdentifierArgument.getId(context, "page"));
var selected = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, source, seed);
var pages = selected.generationPages();
int created = createAge(context, selected.terrain(), selected.biomes(),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(pages, seed),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.vegetationPlan(pages),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount(pages),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(pages, seed),
net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed));
if (created != 0) context.getSource().sendSuccess(() -> Component.literal("Automatic terrain: " + selected), false);
return created;
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static void verifyAutomaticTerrain(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var sky = java.util.List.of(symbol.apply("sky/overworld"));
var water = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(Identifier.withDefaultNamespace("water"));
var plains = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("plains"));
var seen = java.util.EnumSet.noneOf(PortAgeTerrain.class);
for (long seed = 0; seed < 64; seed++) {
var selected = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, sky, seed);
seen.add(selected.terrain());
if (selected.terrain() != PortRandomAgeSelection.terrain(seed)
|| !selected.generationPages().subList(0, sky.size()).equals(sky)
|| !selected.equals(net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, sky, seed)))
throw new IllegalStateException("Automatic terrain seed/determinism/page-prefix check failed");
var fortress = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server,
java.util.List.of(symbol.apply("feature/nether_fortresses")), seed);
if (fortress.terrain() != PortAgeTerrain.CAVE)
throw new IllegalStateException("Omitted terrain cannot host a Cave-only structure");
var wet = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, java.util.List.of(water), seed);
if (wet.terrain() == PortAgeTerrain.FLAT || wet.terrain() == PortAgeTerrain.VOID || wet.terrain() == PortAgeTerrain.CAVE)
throw new IllegalStateException("Automatic terrain cannot host its implied lake");
var biome = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server, java.util.List.of(plains), seed);
if (biome.terrain() == PortAgeTerrain.CAVE || !biome.biomes().biomes().contains(Identifier.withDefaultNamespace("plains")))
throw new IllegalStateException("Automatic terrain discarded a supplied surface biome");
var explicit = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(server,
java.util.List.of(symbol.apply("terrain/flat"), sky.getFirst()), seed);
if (explicit.terrain() != PortAgeTerrain.FLAT || explicit.generationPages().size() != 2)
throw new IllegalStateException("Explicit terrain lost precedence");
}
if (seen.size() != PortAgeTerrain.values().length
|| !net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(symbol.apply("feature/nether_fortresses")))
|| net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(
symbol.apply("terrain/flat"), symbol.apply("feature/dungeons"))))
throw new IllegalStateException("Automatic terrain availability/coverage failed");
}
private static int createBiomeSurface(CommandContext<CommandSourceStack> context) {
try {
var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
@@ -946,16 +1183,24 @@ public final class PortAgeCommands {
return 1;
}
private static int createCompletedBiomes(CommandContext<CommandSourceStack> context) {
private static int createCompletedBiomes(CommandContext<CommandSourceStack> context, Identifier structure) {
try {
var server = context.getSource().getServer();
long seed = LongArgumentType.getLong(context, "seed");
var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT));
var distribution = PortBiomePlan.Distribution.valueOf(
StringArgumentType.getString(context, "distribution").toUpperCase(Locale.ROOT));
String distributionName = StringArgumentType.getString(context, "distribution");
var distribution = distributionName.equalsIgnoreCase("auto") ? null
: PortBiomePlan.Distribution.valueOf(distributionName.toUpperCase(Locale.ROOT));
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var pages = java.util.List.of(symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)),
symbol.apply("biome/" + distribution.name().toLowerCase(Locale.ROOT)));
var pages = new java.util.ArrayList<Identifier>();
pages.add(symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)));
if (distribution != null) pages.add(symbol.apply("biome/" + distribution.name().toLowerCase(Locale.ROOT)));
else verifyAutomaticBiomes(server);
if (structure != null) {
if (!PortStructurePages.supports(structure)) throw new IllegalArgumentException("Unsupported structure page " + structure);
pages.add(structure);
verifyStructureBiomes(server);
}
var plan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server, seed);
// Controller-only and partial pools complete; already-sufficient pools never gain extras.
var plains = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("plains"));
@@ -980,7 +1225,10 @@ public final class PortAgeCommands {
var encoded = PortBiomePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, plan).getOrThrow();
if (!plan.equals(PortBiomePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow()))
throw new IllegalStateException("Completed biome plan codec mismatch");
int created = createAge(context, terrain, plan);
var features = net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed);
int created = createAge(context, terrain, plan, null, null, 0, null, features);
if (created != 0 && terrain == PortAgeTerrain.CAVE && plan.distribution() == PortBiomePlan.Distribution.NATIVE)
verifyNativeCave(server, ageId(context), seed, plan);
if (created != 0) context.getSource().sendSuccess(() -> Component.literal("Completed biome plan: " + plan), false);
return created == 0 ? 0 : probeBiomePool(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
@@ -989,6 +1237,121 @@ public final class PortAgeCommands {
}
}
private static void verifyAutomaticBiomes(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
var standard = symbol.apply("terrain/standard");
var end = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("the_end"));
var forest = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("forest"));
var seen = java.util.EnumSet.noneOf(PortBiomePlan.Distribution.class);
var caveIds = java.util.Set.copyOf(PortRandomAgeSelection.eligibleBiomes(server, PortAgeTerrain.CAVE));
for (long seed = 0; seed < 128; seed++) {
var expected = PortRandomAgeSelection.distribution(seed);
var selected = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard), server, seed);
seen.add(selected.distribution());
if (selected.distribution() != expected || !selected.equals(
net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(standard, symbol.apply("weather/off")), server, seed)))
throw new IllegalStateException("Missing biome controller is not seeded/independent");
var authored = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, end, forest), server, seed);
if (authored.biomes().size() != 2 || !authored.biomes().equals(java.util.List.of(
Identifier.withDefaultNamespace("the_end"), Identifier.withDefaultNamespace("forest")))
|| authored.distribution() != (expected == PortBiomePlan.Distribution.NATIVE
|| expected == PortBiomePlan.Distribution.SINGLE ? PortBiomePlan.Distribution.TILED : expected))
throw new IllegalStateException("Automatic controller discarded or extended authored biomes");
var one = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, end), server, seed);
if (!one.biomes().getFirst().equals(Identifier.withDefaultNamespace("the_end"))
|| one.distribution() != (expected == PortBiomePlan.Distribution.NATIVE ? PortBiomePlan.Distribution.SINGLE : expected)
|| one.biomes().size() != (one.distribution() == PortBiomePlan.Distribution.SINGLE ? 1 : 2))
throw new IllegalStateException("Automatic controller lost a supplied biome");
var cave = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/cave")), server, seed);
if (cave.distribution() != expected || !caveIds.containsAll(cave.biomes())
|| expected == PortBiomePlan.Distribution.NATIVE && !java.util.Set.copyOf(cave.biomes()).equals(caveIds))
throw new IllegalStateException("Automatic Cave controller/biome pool is invalid");
var emptyVoid = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/void")), server, seed);
var authoredVoid = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/void"), end), server, seed);
if (emptyVoid.distribution() != PortBiomePlan.Distribution.SINGLE
|| !emptyVoid.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_void")))
|| !authoredVoid.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_end"))))
throw new IllegalStateException("Automatic Void controller changed biome fallback");
var village = symbol.apply("feature/villages");
var withStructure = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(standard, village), server, seed);
if (withStructure.distribution() != PortBiomePlan.Distribution.NATIVE
&& withStructure.biomes().stream().noneMatch(PortStructurePages.compatibleBiomes(village, server.registryAccess())::contains))
throw new IllegalStateException("Automatic controller skipped structure-aware completion");
}
if (seen.size() != PortBiomePlan.Distribution.values().length)
throw new IllegalStateException("Automatic biome controller fixture lacks distribution coverage");
}
private static void verifyNativeCave(net.minecraft.server.MinecraftServer server, Identifier age, long seed, PortBiomePlan plan) {
var expected = PortRandomAgeSelection.caveBiomes(server.registryAccess());
if (!plan.biomes().equals(expected)) throw new IllegalStateException("Native Cave did not freeze the recognized biome pool");
var old = PortBiomePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE,
com.google.gson.JsonParser.parseString("{\"distribution\":\"NATIVE\",\"biomes\":[]}")).getOrThrow();
if (!old.biomes().isEmpty() || old.distribution() != PortBiomePlan.Distribution.NATIVE)
throw new IllegalStateException("Old Native Cave plan was rewritten");
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, age));
var source = level.getChunkSource().getGenerator().getBiomeSource();
if (expected.size() < 2) return;
if (!(source instanceof PortScaledBiomeSource scaled)) throw new IllegalStateException("Native Cave is not using region distribution");
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
var holders = plan.biomes().stream().map(id -> (net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome>)
registry.getOrThrow(ResourceKey.create(Registries.BIOME, id))).toList();
var tiny = new PortScaledBiomeSource(net.minecraft.core.HolderSet.direct(holders), seed, 0);
var sampled = new java.util.HashSet<Identifier>();
for (int x = -64; x <= 64; x += 4) for (int z = -64; z <= 64; z += 4) {
var biome = scaled.getNoiseBiome(x, 0, z);
if (!biome.equals(tiny.getNoiseBiome(x, 0, z)) || !biome.equals(scaled.getNoiseBiome(x, 16, z)))
throw new IllegalStateException("Native Cave does not match root's Tiny-region sampler");
sampled.add(biome.unwrapKey().orElseThrow().identifier());
}
if (!java.util.Set.copyOf(expected).containsAll(sampled) || sampled.size() < 2)
throw new IllegalStateException("Native Cave sampler did not distribute its recognized biome pool");
}
private static void verifyStructureBiomes(net.minecraft.server.MinecraftServer server) {
java.util.function.Function<String, Identifier> symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
java.util.function.Function<String, Identifier> biome = name ->
net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace(name));
var village = symbol.apply("feature/villages");
var fortress = symbol.apply("feature/nether_fortresses");
for (long seed = 0; seed < 32; seed++) {
var singlePages = java.util.List.of(symbol.apply("biome/single"), village);
var single = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(singlePages, server, seed);
var cave = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("terrain/cave"), symbol.apply("biome/single"), fortress), server, seed);
if (!PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(single.biomes().getFirst())
|| !single.equals(net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(singlePages, server, seed))
|| !PortStructurePages.compatibleBiomes(fortress, server.registryAccess()).contains(cave.biomes().getFirst()))
throw new IllegalStateException("Structure-aware Single completion failed");
var stronghold = symbol.apply("feature/strongholds");
var shared = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(
symbol.apply("biome/single"), village, stronghold), server, seed);
if (!PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(shared.biomes().getFirst())
|| !PortStructurePages.compatibleBiomes(stronghold, server.registryAccess()).contains(shared.biomes().getFirst()))
throw new IllegalStateException("Single completion did not satisfy common structure requirements");
var explicit = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("biome/single"), biome.apply("the_end"), village), server, seed);
var nativePlan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
java.util.List.of(symbol.apply("biome/native"), village), server, seed);
if (!explicit.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("the_end")))
|| nativePlan.distribution() != PortBiomePlan.Distribution.NATIVE || !nativePlan.biomes().isEmpty())
throw new IllegalStateException("Explicit Single/Native biome precedence changed");
var multiPages = java.util.List.of(symbol.apply("biome/tiny"), biome.apply("forest"), biome.apply("the_end"), village);
var multi = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(multiPages, server, seed);
if (multi.biomes().size() != 3 || !multi.biomes().subList(0, 2).equals(java.util.List.of(
Identifier.withDefaultNamespace("forest"), Identifier.withDefaultNamespace("the_end")))
|| !PortStructurePages.compatibleBiomes(village, server.registryAccess()).contains(multi.biomes().get(2)))
throw new IllegalStateException("Structure biome completion discarded authored choices");
var full = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(
symbol.apply("biome/tiny"), biome.apply("plains"), biome.apply("forest"), village, village), server, seed);
if (full.biomes().size() != 2) throw new IllegalStateException("Compatible/repeated structures added unnecessary biomes");
}
}
private static int probeBiomePool(CommandContext<CommandSourceStack> context) {
var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
@@ -1947,6 +2310,119 @@ public final class PortAgeCommands {
return 1;
}
private static int createReturnFixture(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var local = new PortAgeVisits(java.util.List.of());
var disabled = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.DISABLED;
var always = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS;
var once = net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ON_CREATION;
if (PortReturnLinks.shouldIssue(disabled, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(always, local, id)
|| !PortReturnLinks.shouldIssue(always, local, id) || local.hasVisited(id)
|| !PortReturnLinks.shouldIssue(once, local, id)
|| PortReturnLinks.shouldIssue(once, local, id))
throw new IllegalStateException("Return Link mode regression");
var encoded = PortAgeVisits.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, local).getOrThrow();
var decoded = PortAgeVisits.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow();
if (!decoded.hasVisited(id) || PortReturnLinks.shouldIssue(once, decoded, id))
throw new IllegalStateException("Return Link visit codec regression");
var target = new LinkBookTarget(Identifier.fromNamespaceAndPath("minecraft", "overworld"),
12.25, 80.5, -42.75, 135.0F, -20.0F);
var book = PortReturnLinks.createBook(target);
if (!book.is(Rivencraft.LINKING_BOOK.get())
|| !target.equals(book.get(Rivencraft.LINK_BOOK_TARGET.get()))
|| !book.getHoverName().getString().equals("Return Link")
|| book.has(Rivencraft.LINK_PROPERTIES.get()))
throw new IllegalStateException("Return Link book component regression");
var server = context.getSource().getServer();
RuntimeAgeBackend.create(server, id, LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var visits = server.overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
if (!PortReturnLinks.shouldIssue(once, visits, id) || PortReturnLinks.shouldIssue(once, visits, id))
throw new IllegalStateException("Return Link world marker regression");
server.overworld().getDataStorage().saveAndJoin();
return returnLinkStatus(context);
}
private static int returnLinkStatus(CommandContext<CommandSourceStack> context) {
var visits = context.getSource().getServer().overworld().getDataStorage().computeIfAbsent(PortAgeVisits.TYPE);
context.getSource().sendSuccess(() -> Component.literal(ageId(context) + " Return Link mode="
+ net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get()
+ "; ON_CREATION issued=" + visits.hasVisited(ageId(context))), false);
return 1;
}
private static int createPlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.VOID);
var landing = PortAgeTravel.findLanding(level);
if (!new net.minecraft.core.BlockPos(0, 64, 0).equals(landing))
throw new IllegalStateException("Unexpected Void landing: " + landing);
return probePlatform(context);
}
/** Inspect the disposable Void fixture without regenerating or repairing its platform. */
private static int probePlatform(CommandContext<CommandSourceStack> context) {
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + ageId(context)));
return 0;
}
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
var pos = new net.minecraft.core.BlockPos(x, 63, z);
var state = level.getBlockState(pos);
if (!state.is(Rivencraft.CLOUD_PLATFORM.get())
|| state.getDestroySpeed(level, pos) != 0.3F
|| !state.isSolidRender() || !state.isCollisionShapeFullBlock(level, pos)
|| !state.getCloneItemStack(level, pos, false).isEmpty()
|| !net.minecraft.world.level.block.Block.getDrops(state, level, pos, null).isEmpty())
throw new IllegalStateException("Platform properties mismatch at " + pos);
}
Identifier id = Identifier.fromNamespaceAndPath("rivencraft", "cloud_platform");
if (net.dnorberg.rivencraft.writing.PortRegistryPages.solidMaterialIds().contains(id)
|| net.dnorberg.rivencraft.writing.PortRegistryPages.find(
net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(id)) != null
|| !Rivencraft.CLOUD_PLATFORM.get().asItem().equals(net.minecraft.world.item.Items.AIR))
throw new IllegalStateException("Platform is obtainable as an item or material Page");
context.getSource().sendSuccess(() -> Component.literal(ageId(context)
+ " platform passed: solid/opaque, strength 0.3, no loot/pick item/material Page"), false);
return 1;
}
private static int createAccelerated(CommandContext<CommandSourceStack> context) {
int count = IntegerArgumentType.getInteger(context, "pages");
var pages = new java.util.ArrayList<Identifier>();
pages.add(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
for (int i = 0; i < count; i++) pages.add(PortAcceleratedTicks.PAGE);
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages)
|| !net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(PortAcceleratedTicks.PAGE)
|| PortAcceleratedTicks.pageCount(pages) != count
|| PortAcceleratedTicks.attemptBudget(1, 8, 16384) != 24
|| PortAcceleratedTicks.attemptBudget(32, 8, 16384) != 768
|| PortAcceleratedTicks.attemptBudget(Integer.MAX_VALUE, Integer.MAX_VALUE, 16384) != 16384
|| PortAcceleratedTicks.attemptBudget(32, 8, 0) != 0)
throw new IllegalStateException("Accelerated writing/budget regression");
ServerLevel level = RuntimeAgeBackend.create(context.getSource().getServer(), ageId(context),
LongArgumentType.getLong(context, "seed"), null, PortAgeTerrain.FLAT);
PortAcceleratedTicks.configure(level, count);
return acceleration(context);
}
private static int acceleration(CommandContext<CommandSourceStack> context) {
ServerLevel level = context.getSource().getServer().getLevel(
ResourceKey.create(Registries.DIMENSION, ageId(context)));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + ageId(context)));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(ageId(context) + " Accelerated pages="
+ PortAcceleratedTicks.pages(level) + "; extra attempts/eligible section="
+ 3L * PortAcceleratedTicks.pages(level)
+ "; max loaded chunks=" + net.dnorberg.rivencraft.PortConfig.ACCELERATED_MAX_CHUNKS.get()
+ "; max attempts/tick=" + net.dnorberg.rivencraft.PortConfig.ACCELERATED_MAX_ATTEMPTS.get()), false);
return 1;
}
private static int sampleBiome(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
@@ -23,6 +23,14 @@ public final class PortAgeTravel {
if (!AgeCatalogSync.ready(player, id)) {
return "Age catalog not acknowledged by client; remaining in source dimension";
}
BlockPos landing = resolveLanding(target);
if (landing == null) return "No safe landing available; remaining in source dimension";
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
}
public static BlockPos resolveLanding(ServerLevel target) {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
@@ -30,14 +38,13 @@ public final class PortAgeTravel {
? 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) return null;
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
Set.of(), player.getYRot(), player.getXRot(), true);
return moved ? null : "Teleportation failed; remaining in source dimension";
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
return landing;
}
private static BlockPos findLanding(ServerLevel target) {
static BlockPos findLanding(ServerLevel target) {
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
@@ -112,7 +119,7 @@ public final class PortAgeTravel {
}
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
target.setBlock(foot.offset(x, -1, z), Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
}
}
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** World-wide record of Ages for which an ON_CREATION Return Link was issued. */
final class PortAgeVisits extends SavedData {
static final Codec<PortAgeVisits> CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec()
.xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList());
static final SavedDataType<PortAgeVisits> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "age_visits"),
() -> new PortAgeVisits(List.of()), CODEC);
private final Set<Identifier> ages;
PortAgeVisits(List<Identifier> ages) { this.ages = new HashSet<>(ages); }
boolean hasVisited(Identifier age) { return ages.contains(age); }
boolean markFirstVisit(Identifier age) {
if (!ages.add(age)) return false;
setDirty();
return true;
}
}
@@ -0,0 +1,157 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
/** Disposable fixture for atomic collection imports and real Binder assembly. */
final class PortBinderCollectionProbe {
private static final BlockPos BINDER = new BlockPos(0, 64, 0);
private static final BlockPos CHEST = new BlockPos(1, 64, 0);
private static final List<Identifier> EXPECTED = List.of(id("weather/off"), id("lighting/bright"),
id("terrain/flat"), id("biome/single"));
private PortBinderCollectionProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static ItemStack page(String path) { return PageItem.createSymbolPage(id(path)); }
private static ItemStack collection(boolean folder, List<ItemStack> pages) {
ItemStack result = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
result.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return result;
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(BINDER, Rivencraft.BOOK_BINDER.get().defaultBlockState(), 3);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var binder = (PortBookBinderBlockEntity) level.getBlockEntity(BINDER);
var chest = (Container) level.getBlockEntity(CHEST);
ItemStack folder = collection(true, List.of(page("weather/off"), ItemStack.EMPTY, page("lighting/bright")));
ItemStack before = folder.copy();
for (int i = 0; i < PortBookBinderBlockEntity.PAGE_COUNT; i++)
binder.setItem(PortBookBinderBlockEntity.FIRST_PAGE + i, page("terrain/flat"));
if (!binder.importCollection(folder, 0).isEmpty() || binder.nextPageIndex() != 252
|| !ItemStack.isSameItemSameComponents(before, folder)
|| binder.symbolPages().stream().anyMatch(symbol -> !symbol.equals(id("terrain/flat"))))
throw new IllegalStateException("Full Binder import was not atomic");
binder.clearSymbolPages();
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
if (!binder.importCollection(collection(true, List.of(page("weather/off"), PageItem.createLinkPanelPage())), 0).isEmpty()
|| binder.hasSymbolPages() || binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Conflicting Link Panel import was not atomic");
binder.setItem(PortBookBinderBlockEntity.PANEL, ItemStack.EMPTY);
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
throw new IllegalStateException("Invalid collection insertion point accepted");
binder.insertPage(0, page("terrain/flat"));
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
player.setPos(0.5, 64, 2.5);
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
try {
menu.setCarried(folder);
if (!menu.clickMenuButton(player, PortBookBinderMenu.STRIP_CLICK_BASE + 1)
|| menu.getCarried().get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED.subList(0, 3)))
throw new IllegalStateException("Cursor collection order/empty-cover regression");
chest.setItem(0, menu.getCarried().copy());
menu.setCarried(ItemStack.EMPTY);
player.getInventory().setItem(9, collection(false, List.of(page("biome/single"))));
menu.quickMoveStack(player, PortBookBinderBlockEntity.SIZE);
ItemStack emptied = player.getInventory().getItem(9);
if (!emptied.is(Rivencraft.NOTEBOOK.get())
|| emptied.get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|| !binder.symbolPages().equals(EXPECTED))
throw new IllegalStateException("Shift collection import regression");
chest.setItem(1, emptied.copy());
binder.setItem(PortBookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
menu.assemble(player, "Collection Fixture104");
ItemStack book = menu.getCarried();
if (!book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !EXPECTED.equals(book.get(Rivencraft.BOOK_SYMBOLS.get()))
|| !book.has(Rivencraft.AGE_BOOK_ID.get()) || binder.hasSymbolPages()
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
throw new IllegalStateException("Collection-backed Binder assembly regression");
if (!PortProgressionProbe.isComplete(player, "bind_age"))
throw new IllegalStateException("Missing bind_age award");
if (!player.getGameProfile().name().equals(book.get(Rivencraft.BOOK_AUTHOR.get())))
throw new IllegalStateException("Binder author was not server assigned");
var pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
if (pages.lastPage() != EXPECTED.size() || pages.symbolAt(0).isPresent()
|| !pages.symbolAt(1).orElseThrow().equals(EXPECTED.getFirst())
|| !pages.symbolAt(EXPECTED.size()).orElseThrow().equals(EXPECTED.getLast())
|| pages.symbolAt(EXPECTED.size() + 1).isPresent()
|| pages.turn(0, -1) != 0 || pages.turn(pages.lastPage(), 1) != pages.lastPage())
throw new IllegalStateException("Authored book page indexing failed");
var old = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()));
if (old.lastPage() != 1 || !old.author().isEmpty() || old.symbolAt(1).isPresent())
throw new IllegalStateException("Older panel-only book fallback failed");
var repeated = new net.dnorberg.rivencraft.writing.DescriptiveBookPages(
List.of(EXPECTED.getFirst(), EXPECTED.getFirst()), "Test");
if (repeated.lastPage() != 2 || !repeated.symbolAt(1).equals(repeated.symbolAt(2)))
throw new IllegalStateException("Repeated symbol display was collapsed");
var ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
var server = level.getServer();
if (!RuntimeAgeBackend.savedAgeName(server, ageId).orElseThrow().equals("Collection Fixture104"))
throw new IllegalStateException("Created Age lost its display name");
var namedLevel = server.getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, ageId));
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel).equals("Collection Fixture104"))
throw new IllegalStateException("Link binding name lookup did not use Age title");
RuntimeAgeBackend.setAgeDisplayName(server, ageId, "Crystal Garden's Dawn");
var target = new net.dnorberg.rivencraft.item.LinkBookTarget(ageId, -0.5, 64, -1.25, 90, 0);
var link = new ItemStack(Rivencraft.LINKING_BOOK.get());
link.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
link.set(Rivencraft.LINK_DESTINATION_NAME.get(), net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel));
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(link, target).getString().equals("Crystal Garden's Dawn"))
throw new IllegalStateException("Link destination label ignored saved name");
var mode = net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get();
player.getInventory().clearContent();
try {
net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS);
PortReturnLinks.afterSuccessfulTravel(player, ageId, target);
var returned = java.util.stream.IntStream.range(0, player.getInventory().getContainerSize())
.mapToObj(i -> player.getInventory().getItem(i)).filter(i -> i.is(Rivencraft.LINKING_BOOK.get()))
.findFirst().orElseThrow();
if (!"Crystal Garden's Dawn".equals(returned.get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Automatic Return Link lost departure name");
chest.setItem(4, returned.copy());
} finally { net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(mode); }
chest.setItem(3, link);
chest.setItem(2, book.copy());
menu.setCarried(ItemStack.EMPTY);
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.COLLATION_FOLDER.get()) || !chest.getItem(1).is(Rivencraft.NOTEBOOK.get()))
throw new IllegalStateException("Empty collection covers were not preserved");
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
throw new IllegalStateException("Imported pages remained in a collection");
if (!"RivenBinderProbe".equals(chest.getItem(2).get(Rivencraft.BOOK_AUTHOR.get())))
throw new IllegalStateException("Authored book lost its author");
var ageId = chest.getItem(2).get(Rivencraft.AGE_BOOK_ID.get());
if (!RuntimeAgeBackend.savedAgeName(level.getServer(), ageId).orElseThrow().equals("Crystal Garden's Dawn")
|| !"Crystal Garden's Dawn".equals(chest.getItem(3).get(Rivencraft.LINK_DESTINATION_NAME.get()))
|| !"Crystal Garden's Dawn".equals(chest.getItem(4).get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Age/Linking/Return destination names did not survive persistence");
var old = new ItemStack(Rivencraft.LINKING_BOOK.get());
var overworld = new net.dnorberg.rivencraft.item.LinkBookTarget(net.minecraft.world.level.Level.OVERWORLD.identifier(), 0, 64, 0, 0, 0);
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(old, overworld).getString()
.equals(net.minecraft.network.chat.Component.translatable("dimension.minecraft.overworld").getString()))
throw new IllegalStateException("Older book vanilla name fallback changed");
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
throw new IllegalStateException("Authored book page order was not preserved");
}
}
@@ -5,7 +5,7 @@ import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import net.minecraft.resources.Identifier;
/** Frozen authored biome choice; repeated entries retain their selection weight. */
/** Frozen biome choice; Native may carry an explicit Cave pool. Repeats retain weight. */
public record PortBiomePlan(Distribution distribution, List<Identifier> biomes) {
public enum Distribution { NATIVE, SINGLE, TILED, GRID, TINY, SMALL, MEDIUM, LARGE, HUGE }
@@ -17,8 +17,7 @@ public record PortBiomePlan(Distribution distribution, List<Identifier> biomes)
public PortBiomePlan {
biomes = List.copyOf(biomes);
if (distribution == Distribution.NATIVE && !biomes.isEmpty()
|| distribution == Distribution.SINGLE && biomes.size() != 1
if (distribution == null || distribution == Distribution.SINGLE && biomes.size() != 1
|| distribution != Distribution.NATIVE && distribution != Distribution.SINGLE && biomes.size() < 2)
throw new IllegalArgumentException("Invalid authored biome distribution");
}
@@ -0,0 +1,110 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.TickTask;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
/** Join conversion runs on queued server tasks: create and inspect on separate commands. */
final class PortBookDropConversionProbe {
private static final UUID OWNER = UUID.fromString("cc5909e5-aafa-43d6-b841-a917ec9c0115");
private static final AABB AREA = new AABB(-16, -64, -16, 32, 320, 32);
private static ItemStack book(boolean descriptive, String label) {
var stack = new ItemStack(descriptive ? Rivencraft.DESCRIPTIVE_BOOK.get() : Rivencraft.LINKING_BOOK.get());
stack.set(DataComponents.CUSTOM_NAME, Component.literal(label));
return stack;
}
static void create(ServerLevel level) {
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) level.setBlock(new BlockPos(x, 63, z), Blocks.STONE.defaultBlockState(), 2);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(UUID.fromString("84f38604-48fe-48d3-a4fb-f858a7200115"), "RivenDropProbe"));
player.setPos(8.5, 64, 8.5);
// Test exact factory state before physics can advance position or pickup timers.
var sample = new ItemEntity(level, 8.5, 66, 8.5, book(false, "state_copy"));
sample.getItem().set(Rivencraft.LINK_BOOK_TARGET.get(), new net.dnorberg.rivencraft.item.LinkBookTarget(
level.dimension().identifier(), -0.5, 64, -1.25, 135, -20));
sample.getItem().set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
sample.getItem().set(Rivencraft.LINK_PROPERTIES.get(), new net.dnorberg.rivencraft.item.LinkProperties(net.dnorberg.rivencraft.item.LinkProperties.DISARM
| net.dnorberg.rivencraft.item.LinkProperties.MAINTAIN_MOMENTUM));
sample.setTarget(OWNER);
sample.setThrower(player);
sample.setPickUpDelay(80);
sample.setDeltaMovement(new Vec3(0.2, 0.1, -0.3));
sample.setYRot(37);
sample.setXRot(12);
sample.setNoGravity(true);
var copy = DroppedBookEntity.fromDrop(sample);
if (!copy.position().equals(sample.position()) || !copy.getDeltaMovement().equals(sample.getDeltaMovement())
|| copy.getYRot() != 37 || copy.getXRot() != 12 || !copy.isNoGravity()
|| !ItemStack.matches(copy.getItem(), sample.getItem()) || !OWNER.equals(copy.getTarget())
|| copy.getUUID().equals(sample.getUUID()) || copy.getAge() != -32768)
throw new IllegalStateException("Drop conversion lost placement, ownership or item components");
var originalTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
var copiedTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
sample.saveWithoutId(originalTag); copy.saveWithoutId(copiedTag);
for (String key : List.of("PickupDelay", "Owner", "Thrower", "Item"))
if (!java.util.Objects.equals(originalTag.buildResult().get(key), copiedTag.buildResult().get(key)))
throw new IllegalStateException("Drop conversion lost metadata: " + key);
// Four ordinary book variants must all convert through the actual event route.
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description")) {
var stack = book(name.endsWith("description"), name);
if (name.equals("manual_link")) {
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), sample.getItem().get(Rivencraft.LINK_BOOK_TARGET.get()));
stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
}
if (name.equals("manual_description")) stack.set(Rivencraft.AGE_BOOK_ID.get(), level.dimension().identifier());
var item = new ItemEntity(level, 8.5, 66, 8.5, stack);
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); item.setTarget(OWNER);
level.addFreshEntity(item);
}
player.drop(book(false, "player_toss"), false, net.minecraft.util.Prediction.SERVER_ONLY);
net.minecraft.world.Containers.dropItemStack(level, 8.5, 65, 8.5, book(true, "container"));
var standPos = new BlockPos(10, 64, 8);
level.setBlock(standPos, Rivencraft.BOOK_STAND.get().defaultBlockState(), 3);
((net.dnorberg.rivencraft.block.entity.BookStandBlockEntity) level.getBlockEntity(standPos)).setBook(book(false, "stand"));
level.destroyBlock(standPos, true);
var custom = new DroppedBookEntity(level, new Vec3(8.5, 66, 8.5), book(false, "already_custom"));
custom.setNoGravity(true);
level.addFreshEntity(custom);
level.addFreshEntity(new ItemEntity(level, 8.5, 66, 8.5, net.dnorberg.rivencraft.item.PageItem.createLinkPanelPage()));
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity dropped
&& dropped.getItem().getHoverName().getString().equals("cancel_preserve")) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
var item = new ItemEntity(level, 8.5, 66, 8.5, book(false, "cancel_preserve"));
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO);
level.addFreshEntity(item);
} finally {
// Queued after that source's conversion task; no fixture listener remains installed.
level.getServer().schedule(new TickTask(0, () -> net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel)));
}
}
static void verify(ServerLevel level) {
var drops = level.getEntitiesOfClass(ItemEntity.class, AREA);
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description",
"player_toss", "container", "stand", "already_custom", "cancel_preserve")) {
var found = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals(name)).toList();
if (found.size() != 1 || !name.equals("cancel_preserve") && !(found.getFirst() instanceof DroppedBookEntity))
throw new IllegalStateException("Expected one open book for " + name + ", found " + found.size());
}
var named = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals("manual_link")).findFirst().orElseThrow();
if (!OWNER.equals(named.getTarget()) || !"Named destination".equals(named.getItem().get(Rivencraft.LINK_DESTINATION_NAME.get())))
throw new IllegalStateException("Converted book lost owner or destination");
if (drops.stream().filter(e -> e.getItem().is(Rivencraft.PAGE.get())).anyMatch(e -> e instanceof DroppedBookEntity))
throw new IllegalStateException("Non-book item was converted");
}
}
@@ -0,0 +1,93 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.network.UseBookStandPayload;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LecternBlock;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.Vec3;
/** Isolated conversion, ownership, stale-screen and persistence checks. */
final class PortBookStandProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
static void create(ServerLevel level) {
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("a5c323a1-3b58-4dd6-927f-e08000800110"), "RivenStandProbe"));
player.setPos(0.5, 64, 2.5);
try {
for (Direction facing : Direction.Plane.HORIZONTAL) for (InteractionHand hand : InteractionHand.values())
for (var sample : java.util.List.of(new ItemStack(Rivencraft.LINKING_BOOK.get()),
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), PageItem.createLinkPanelPage().copyWithCount(3))) {
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.FACING, facing), 3);
player.setItemInHand(hand, sample.copy());
if (!BookStandConversionHandler.convert(player, hand, POS)) throw new IllegalStateException("Conversion failed");
var stand = (BookStandBlockEntity) level.getBlockEntity(POS);
var stored = sample.copyWithCount(1);
if (!ItemStack.matches(stand.getBook(), stored) || player.getItemInHand(hand).getCount() != sample.getCount() - 1
|| stand.getBlockState().getValue(BookStandBlock.FACING) != facing)
throw new IllegalStateException("Conversion lost ownership or facing");
var packet = new UseBookStandPayload(level.dimension().identifier(), POS, stored.copy());
if (PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Valid source rejected");
player.setPos(100, 64, 100);
if (!PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Distant source accepted");
player.setPos(0.5, 64, 2.5);
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(
net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "overworld"), POS, stored)).isEmpty())
throw new IllegalStateException("Wrong dimension accepted");
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(packet.dimension(), POS, new ItemStack(Items.DIRT))).isEmpty())
throw new IllegalStateException("Replaced book accepted");
var loaded = net.minecraft.world.level.block.entity.BlockEntity.loadStatic(POS, stand.getBlockState(),
stand.saveWithFullMetadata(level.registryAccess()), level.registryAccess());
if (!(loaded instanceof BookStandBlockEntity copy) || !ItemStack.matches(copy.getBook(), stored))
throw new IllegalStateException("Stand serialization lost content");
player.getInventory().clearContent();
player.setShiftKeyDown(true);
stand.getBlockState().useWithoutItem(level, player, new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false));
player.setShiftKeyDown(false);
if (!level.getBlockState(POS).is(Blocks.LECTERN)
|| level.getBlockState(POS).getValue(LecternBlock.FACING) != facing
|| player.getInventory().countItem(stored.getItem()) != 1
|| !PortBookNetworking.validatedStandBook(player, packet).isEmpty())
throw new IllegalStateException("Retrieval did not restore lectern or recover item exactly once");
player.getInventory().clearContent();
player.setItemInHand(hand, stored.copy());
BookStandConversionHandler.convert(player, hand, POS);
level.destroyBlock(POS, true);
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-2, 62, -2, 3, 68, 3));
int items = drops.stream().filter(e -> e.getItem().is(stored.getItem())).mapToInt(e -> e.getItem().getCount()).sum();
int lecterns = drops.stream().filter(e -> e.getItem().is(Items.LECTERN)).mapToInt(e -> e.getItem().getCount()).sum();
if (items != 1 || lecterns != 1) throw new IllegalStateException("Break drops duplicated/lost: " + items + "/" + lecterns);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.HAS_BOOK, true), 3);
player.setItemInHand(InteractionHand.MAIN_HAND, PageItem.createLinkPanelPage());
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Occupied lectern converted");
level.setBlock(POS, Blocks.LECTERN.defaultBlockState(), 3);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIRT));
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Unrelated item accepted");
var named = new ItemStack(Rivencraft.LINKING_BOOK.get());
named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Stand persistence"));
player.setItemInHand(InteractionHand.MAIN_HAND, named);
if (!BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Final stand missing");
} finally { player.setShiftKeyDown(false); player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof BookStandBlockEntity stand)
|| !stand.getBook().is(Rivencraft.LINKING_BOOK.get())
|| !stand.getBook().getHoverName().getString().equals("Stand persistence"))
throw new IllegalStateException("Stand restart persistence failed");
}
}
@@ -0,0 +1,109 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
/** Disposable-world boundary, booster-use and persistence checks. */
final class PortCollectionExtrasProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortCollectionExtrasProbe() {}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static NotebookContents contents(ItemStack stack) { return stack.get(Rivencraft.NOTEBOOK_CONTENTS.get()); }
private static List<Identifier> symbols(ItemStack collection) {
return contents(collection).pages().stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
}
private static void folder(ItemStack stack) {
if (!stack.is(Rivencraft.COLLATION_FOLDER.get()) || contents(stack).pageCount() != 16
|| symbols(stack).stream().anyMatch(id -> !PortSymbolPages.sealedCandidateIds().contains(id)))
throw new IllegalStateException("Sealed Notebook page pool/count regression");
}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
var chest = (Container) level.getBlockEntity(CHEST);
var pages = new java.util.ArrayList<ItemStack>();
for (int i = 0; i < 253; i++) pages.add(PageItem.createSymbolPage(id(i % 2 == 0 ? "terrain/flat" : "weather/off")));
var volumes = SymbolPortfolioItem.createVolumes(id("feature"), pages);
if (volumes.size() != 2 || contents(volumes.get(0)).pageCount() != 252
|| contents(volumes.get(1)).pageCount() != 1
|| !volumes.stream().flatMap(volume -> symbols(volume).stream()).toList()
.equals(pages.stream().map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList())
|| !SymbolPortfolioItem.createVolumes(id("feature"), List.of()).isEmpty())
throw new IllegalStateException("Portfolio volume boundary/order regression");
chest.setItem(0, volumes.get(0));
chest.setItem(1, volumes.get(1));
var actual = net.dnorberg.rivencraft.registry.ModCreativeTabs.creativeSymbolPortfolios(level.registryAccess());
var allPages = PortSymbolPages.categories().stream().flatMap(category -> PortSymbolPages.pages(category, level.registryAccess()).stream())
.map(page -> page.get(Rivencraft.SYMBOL_PAGE_ID.get())).toList();
if (!actual.stream().flatMap(volume -> symbols(volume).stream()).toList().equals(allPages))
throw new IllegalStateException("Creative portfolios omit/duplicate/reorder pages");
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("790b69dd-b62b-45be-9017-caf16515169a"), "RivenExtrasProbe"));
player.setPos(0.5, 64, 2.5);
try {
player.getAbilities().instabuild = false;
player.getRandom().setSeed(105);
var first = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
player.getRandom().setSeed(105);
var repeat = net.dnorberg.rivencraft.item.SealedNotebookItem.open(player);
folder(first);
if (!symbols(first).equals(symbols(repeat))) throw new IllegalStateException("Booster seeded selection changed");
chest.setItem(2, first);
for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(hand, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get()));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, hand);
folder(player.getItemInHand(hand));
}
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (player.getMainHandItem().getCount() != 1) throw new IllegalStateException("Booster stack consumption regression");
player.getAbilities().instabuild = true;
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
if (!player.getMainHandItem().is(Rivencraft.SEALED_NOTEBOOK.get()) || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Creative booster consumed its input");
player.getAbilities().instabuild = false;
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Rivencraft.SEALED_NOTEBOOK.get(), 2));
Rivencraft.SEALED_NOTEBOOK.get().use(level, player, InteractionHand.MAIN_HAND);
var dropped = level.getEntitiesOfClass(ItemEntity.class, new AABB(-3, 60, -3, 4, 70, 6),
entity -> entity.getItem().is(Rivencraft.COLLATION_FOLDER.get()));
if (dropped.size() != 1 || player.getMainHandItem().getCount() != 1)
throw new IllegalStateException("Full-inventory booster delivery regression");
folder(dropped.getFirst().getItem());
dropped.getFirst().discard();
} finally {
player.getAbilities().instabuild = false;
player.getInventory().clearContent();
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
}
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
if (!chest.getItem(0).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(0)).pageCount() != 252
|| !chest.getItem(1).is(Rivencraft.SYMBOL_PORTFOLIO.get()) || contents(chest.getItem(1)).pageCount() != 1)
throw new IllegalStateException("Portfolio persistence regression");
folder(chest.getItem(2));
}
}
@@ -0,0 +1,202 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
public final class PortDecayProviders {
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
private static final Map<Identifier, DecayBlock.Color> COLORS = Map.of(
id("decayblue"), DecayBlock.Color.BLUE,
id("decaypurple"), DecayBlock.Color.PURPLE,
id("decayred"), DecayBlock.Color.RED,
id("decaywhite"), DecayBlock.Color.WHITE);
private static final Direction[] DIRECTIONS = Direction.values();
private PortDecayProviders() {}
public static void register() {
COLORS.keySet().forEach(id -> PortInstabilityRuntime.register(id, true, PortDecayProviders::apply));
}
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
ServerLevel level = player.level();
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|| !PortInstabilityRuntime.isAge(level)
|| level.getChunkSource().getChunkNow(player.chunkPosition().x(), player.chunkPosition().z()) == null) return -1;
var age = PortInstabilityData.get(level.getServer()).age(level);
Identifier id = id("decay" + color.getSerializedName());
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return -1;
int changed = 0;
for (int i = 0; i < Math.min(count, 32); i++) {
PortInstabilityRuntime.Budget budget = new PortInstabilityRuntime.Budget(1);
apply(new PortInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
level.getServer().getTickCount(), budget), id, 1);
changed += budget.applied();
}
return changed;
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|| !PortInstabilityRuntime.isAge(context.level())
|| context.loadedChunks().isEmpty()) return;
DecayBlock.Color color = COLORS.get(id);
if (color == null) return;
PortDecaySavedData data = PortDecaySavedData.get(context.level());
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
List<BlockPos> frontier = data.frontier(color);
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
if (source == null) {
seed(context, color, data, random);
return;
}
BlockState sourceState = context.level().getBlockState(source);
if (!sourceState.is(Rivencraft.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
data.remove(color, source);
return;
}
for (int attempt = 0; attempt < PortInstabilityConfig.decayMaxAttempts(); attempt++) {
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|| isSpawnProtected(context.age(), target)) continue;
BlockState prior = context.level().getBlockState(target);
if (!canReplace(context.level(), target, prior, color)) continue;
int difficulty = conversionDifficulty(context.level(), target, prior, color);
if (random.nextInt(difficulty) != 0) continue;
if (replace(context, target, prior, color)) data.add(color, target);
return;
}
}
private static void seed(PortInstabilityRuntime.Context context, DecayBlock.Color color,
PortDecaySavedData data, Random random) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
BlockPos pos = new BlockPos(x,
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|| isSpawnProtected(context.age(), pos)) return;
BlockState prior = context.level().getBlockState(pos);
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
data.add(color, pos);
}
}
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
List<BlockPos> frontier, Random random) {
if (frontier.isEmpty()) return null;
int start = random.nextInt(frontier.size());
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
BlockPos pos = frontier.get((start + i) % frontier.size());
if (isLoadedTarget(level, chunks, pos)) return pos;
}
return null;
}
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
return level.isInWorldBounds(pos) && level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) != null
&& chunks.contains(ChunkPos.containing(pos));
}
static boolean isSpawnProtected(PortInstabilityData.Age age, BlockPos pos) {
BlockPos spawn = age.spawn().orElse(null);
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
}
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
if (!PortInstabilityRuntime.isAge(level)
|| !level.isInWorldBounds(pos) || level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) == null
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|| isCritical(prior)) return false;
return !prior.is(Rivencraft.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
}
private static boolean isCritical(BlockState prior) {
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|| prior.getBlock() != Rivencraft.DECAY.get()
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
.getNamespace().equals(Rivencraft.MOD_ID);
}
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
if (state.isAir()) return switch (color) {
case BLUE, RED -> 20;
case PURPLE -> 8;
case WHITE -> 50;
};
if (state.is(Rivencraft.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
return switch (color) {
case BLUE -> Math.max(1, (int) hardness * 2);
case RED -> Math.max(1, (int) hardness);
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
case WHITE -> 1;
};
}
private static boolean replace(PortInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
DecayBlock.Color color) {
ServerLevel level = context.level();
// A fake player gives claim/protection listeners the Age and exact block position.
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breakEvent = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breakEvent);
if (breakEvent.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
BlockState decay = Rivencraft.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
if (!level.setBlock(pos, decay, 3)) return false;
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placeEvent);
if (placeEvent.isCanceled()) {
snapshot.restore();
return false;
}
context.budget().recordApplied();
return true;
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Persisted per-Age frontier and attempt sequence, bounded to 1,024 positions per color. */
public final class PortDecaySavedData extends SavedData {
private static final int MAX_FRONTIER_PER_COLOR = 1024;
private static final Codec<PortDecaySavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
Codec.unboundedMap(StringRepresentable.fromEnum(DecayBlock.Color::values),
BlockPos.CODEC.listOf(0, MAX_FRONTIER_PER_COLOR)).fieldOf("frontiers").forGetter(data -> data.frontiers)
).apply(instance, PortDecaySavedData::new));
private static final SavedDataType<PortDecaySavedData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "decay"), () -> new PortDecaySavedData(0, Map.of()), CODEC);
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
private long attempts;
private PortDecaySavedData(long attempts, Map<DecayBlock.Color, List<BlockPos>> positions) {
this.attempts = attempts;
for (var color : DecayBlock.Color.values()) frontiers.put(color,
new ArrayList<>(positions.getOrDefault(color, List.of()).stream().distinct().map(BlockPos::immutable).toList()));
}
public static PortDecaySavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
public long nextAttempt() { setDirty(); return attempts++; }
public List<BlockPos> frontier(DecayBlock.Color color) { return frontiers.get(color); }
public void add(DecayBlock.Color color, BlockPos pos) {
var frontier = frontiers.get(color);
if (frontier.contains(pos)) return;
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.removeFirst();
frontier.add(pos.immutable()); setDirty();
}
public void remove(DecayBlock.Color color, BlockPos pos) { if (frontiers.get(color).remove(pos)) setDirty(); }
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.age;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.PortHandheldTravel;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.storage.TagValueInput;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
/** Disposable-world checks for book ownership and the new entity save format. */
final class PortDroppedBookProbe {
private PortDroppedBookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = FakePlayerFactory.get(level, new com.mojang.authlib.GameProfile(
UUID.fromString("4db5c544-0a0c-421d-a128-c520bda64642"), "RivenBookProbe"));
player.setPos(0.5, 64, 0.5);
try {
for (var hand : InteractionHand.values()) {
ItemStack book = new ItemStack(hand == InteractionHand.MAIN_HAND
? Rivencraft.LINKING_BOOK.get() : Rivencraft.DESCRIPTIVE_BOOK.get());
book.set(DataComponents.CUSTOM_NAME, Component.literal("Dropped fixture " + hand));
book.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(
Identifier.fromNamespaceAndPath("minecraft", "overworld"), 12.25, 80.5, -42.75, 135, -20));
player.setItemInHand(hand, book);
String failure = PortHandheldTravel.travel(player, hand, book, () -> "expected failure");
if (!"expected failure".equals(failure) || player.getItemInHand(hand) != book)
throw new IllegalStateException("Failed travel consumed the book");
try {
PortHandheldTravel.travel(player, hand, book, () -> { throw new IllegalStateException("fixture"); });
throw new AssertionError("Fixture exception was swallowed");
} catch (IllegalStateException expected) {
if (!"fixture".equals(expected.getMessage()) || player.getItemInHand(hand) != book) throw expected;
}
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity) event.setCanceled(true);
};
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
try {
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || player.getItemInHand(hand) != book)
throw new IllegalStateException("Rejected dropped entity consumed the book");
} finally { net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel); }
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
if (failure != null || !player.getItemInHand(hand).isEmpty())
throw new IllegalStateException("Successful travel retained the active book");
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
var books = level.getEntitiesOfClass(DroppedBookEntity.class, new AABB(-2, 60, -2, 3, 68, 3));
if (books.size() != 2) throw new IllegalStateException("Expected two dropped books, found " + books.size());
for (var entity : books) {
var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
entity.saveWithoutId(output);
var copy = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), level);
copy.load(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult()));
if (!ItemStack.isSameItemSameComponents(copy.getItem(), entity.getItem())
|| copy.getAge() != -32768 || copy.getItem().getCount() != 1)
throw new IllegalStateException("Dropped book persistence regression");
var copied = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
copy.saveWithoutId(copied);
if (copied.buildResult().getIntOr("BookDecayTicks", -1)
!= output.buildResult().getIntOr("BookDecayTicks", -2))
throw new IllegalStateException("Dropped book decay counter was not preserved");
}
}
}
@@ -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,121 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.BoolArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
/** Scores and test overrides are deliberately operator-only, never book tooltip data. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortInstabilityCommands {
private PortInstabilityCommands() {}
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft_port")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("instability")
.then(Commands.literal("status").executes(context -> {
var source = context.getSource(); var level = requireAge(source);
var age = PortInstabilityData.get(source.getServer()).age(level);
source.sendSuccess(() -> Component.literal("Age " + level.dimension().identifier()
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
for (var provider : PortInstabilityRuntime.plan(level).providers())
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
return 1;
}))
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
.executes(context -> {
var level = requireAge(context.getSource());
boolean enabled = BoolArgumentType.getBool(context, "enabled");
PortInstabilityData.get(level.getServer()).enabled(level, enabled);
if (!enabled) PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Age instability enabled=" + enabled
+ "; saved score and provider selection unchanged"), false);
return 1;
})))
.then(Commands.literal("test_score").then(Commands.argument("score", IntegerArgumentType.integer(0, 1_000_000))
.executes(context -> {
var level = requireAge(context.getSource());
PortInstabilityRuntime.testScore(level, IntegerArgumentType.getInteger(context, "score"));
PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Session-only score override set; frozen score unchanged. Restart or clear_test_score removes it."), false);
return 1;
})))
.then(Commands.literal("clear_test_score").executes(context -> {
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
PortStatusEffectProviders.clearAge(level);
context.getSource().sendSuccess(() -> Component.literal("Restored saved provider selection"), false); return 1;
}))
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
.suggests((context, builder) -> {
for (var color : net.dnorberg.rivencraft.block.DecayBlock.Color.values())
if (color.getSerializedName().startsWith(builder.getRemaining())) builder.suggest(color.getSerializedName());
return builder.buildFuture();
}).executes(context -> decay(context, 1))
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
.suggests((context, builder) -> {
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
if (card.id().getPath().startsWith(builder.getRemaining())) builder.suggest(card.id().getPath());
return builder.buildFuture();
}).executes(context -> {
var source = context.getSource(); var level = requireAge(source);
String name = StringArgumentType.getString(context, "provider");
Identifier id = Identifier.tryParse(name.contains(":") ? name : "rivencraft:" + name);
if (id == null || !InstabilityProviderCatalog.contains(id)) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Unknown instability provider")).create();
var chunk = net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(source.getPosition()));
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null)
throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Current chunk must already be loaded")).create();
var budget = new PortInstabilityRuntime.Budget(1);
var effect = new PortInstabilityRuntime.Context(level, PortInstabilityData.get(level.getServer()).age(level),
java.util.List.of(chunk), source.getServer().getTickCount(), budget);
boolean attempted = PortInstabilityRuntime.invoke(effect, id, 1);
boolean applied = attempted && budget.applied() > 0;
if (applied) source.sendSuccess(() -> Component.literal("Applied " + id + " (" + budget.applied() + " successful action in this chunk)"), false);
else source.sendFailure(Component.literal("No effect applied. Check eligible targets/terrain, sky exposure, existing effects and protection. Provider status: "
+ PortInstabilityRuntime.providerStatus(level, id)));
return applied ? 1 : 0;
})))));
}
private static int decay(com.mojang.brigadier.context.CommandContext<CommandSourceStack> context, int attempts)
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
requireAge(context.getSource());
var color = java.util.Arrays.stream(net.dnorberg.rivencraft.block.DecayBlock.Color.values())
.filter(value -> value.getSerializedName().equalsIgnoreCase(StringArgumentType.getString(context, "color")))
.findFirst().orElse(null);
if (color == null) {
context.getSource().sendFailure(Component.literal("Choose blue, purple, red or white")); return 0;
}
int changed = PortDecayProviders.pulseForTesting(context.getSource().getPlayerOrException(), color, attempts);
if (changed < 0) {
context.getSource().sendFailure(Component.literal("Decay is disabled by config, Age or provider policy")); return 0;
}
context.getSource().sendSuccess(() -> Component.literal("Decay " + color.getSerializedName() + ": " + changed
+ " block changes from " + attempts + " bounded attempts; saved score/plan unchanged"), false);
var player = context.getSource().getPlayerOrException();
var nearby = PortDecaySavedData.get(player.level()).frontier(color).reversed().stream()
.filter(pos -> net.minecraft.world.level.ChunkPos.containing(pos).equals(player.chunkPosition()))
.limit(4).map(BlockPos::toShortString).toList();
if (!nearby.isEmpty()) context.getSource().sendSuccess(() -> Component.literal("Nearby tracked decay: " + nearby), false);
return changed;
}
private static net.minecraft.server.level.ServerLevel requireAge(CommandSourceStack source)
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
if (!PortInstabilityRuntime.isAge(source.getLevel())) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(
Component.literal("Use this command inside a Rivencraft Age")).create();
return source.getLevel();
}
}
@@ -0,0 +1,67 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.common.ModConfigSpec;
/** The 1.21.1 scheduler and status-provider defaults, in the port's server-owned config. */
public final class PortInstabilityConfig {
private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay;
private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
private PortInstabilityConfig() {}
public static void define(ModConfigSpec.Builder builder) {
builder.push("instability");
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
.define("enabled", true);
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
.define("world_mutation", true);
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
ages = builder.defineInRange("max_ages_per_tick", 4, 1, 16);
providers = builder.defineInRange("max_providers_per_age", 2, 1, 16);
actions = builder.defineInRange("max_actions_per_age", 1, 1, 16);
level = builder.defineInRange("max_effect_level", 3, 1, 16);
chunks = builder.defineInRange("max_loaded_chunks", 16, 1, 64);
disabledProviders = builder.comment("Disable particular providers without changing saved selections. Accepts poison or rivencraft:poison.")
.defineListAllowEmpty("disabled_providers", List.of(), () -> "poison", value -> value instanceof String text
&& Identifier.tryParse(text) != null);
builder.pop();
builder.push("instability_status_effects");
status = builder.define("enabled", true);
players = builder.comment("Affect Survival/Adventure players; Creative and Spectator remain exempt.")
.define("affect_players", true);
nonplayers = builder.define("affect_nonplayers", true);
hostiles = builder.define("buff_hostiles", true);
active = builder.defineInRange("max_active_per_age", 32, 1, 128);
candidates = builder.defineInRange("max_candidates_per_attempt", 8, 1, 16);
duration = builder.defineInRange("max_duration_ticks", 200, 20, 200);
builder.pop();
builder.push("instability_decay");
decay = builder.comment("Allow colored decay in loaded Ages. Also requires instability.world_mutation.")
.define("enabled", true);
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
.defineInRange("max_attempts_per_call", 4, 1, 6);
builder.pop();
}
public static boolean instabilityWorldMutation() { return worldMutation.get(); }
public static boolean decayEnabled() { return decay.get(); }
public static int decayMaxAttempts() { return decayAttempts.get(); }
public static boolean instabilityEnabled() { return enabled.get(); }
public static boolean instabilityProviderEnabled(Identifier id) {
return disabledProviders.get().stream().noneMatch(value -> value.equals(id.toString())
|| id.getNamespace().equals("rivencraft") && value.equals(id.getPath()));
}
public static int instabilityCooldown() { return cooldown.get(); }
public static int instabilityMaxAges() { return ages.get(); }
public static int instabilityMaxProviders() { return providers.get(); }
public static int instabilityMaxActions() { return actions.get(); }
public static int instabilityMaxLevel() { return level.get(); }
public static int instabilityMaxChunks() { return chunks.get(); }
public static boolean statusEffectsEnabled() { return status.get(); }
public static boolean statusEffectsPlayers() { return players.get(); }
public static boolean statusEffectsNonplayers() { return nonplayers.get(); }
public static boolean statusEffectsHostiles() { return hostiles.get(); }
public static int statusEffectsMaxActive() { return active.get(); }
public static int statusEffectsMaxCandidates() { return candidates.get(); }
public static int statusEffectsMaxDuration() { return duration.get(); }
}
@@ -0,0 +1,108 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Frozen creation-time contributions and Legacy deck selections. Never silently rescores older Ages. */
public final class PortInstabilityData extends SavedData {
public record Contribution(String reason, int page, int points) {
public static final Codec<Contribution> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.STRING.fieldOf("reason").forGetter(Contribution::reason),
Codec.INT.fieldOf("page").forGetter(Contribution::page),
Codec.intRange(0, Integer.MAX_VALUE).fieldOf("points").forGetter(Contribution::points)
).apply(instance, Contribution::new));
}
public record Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled, java.util.Optional<net.minecraft.core.BlockPos> spawn) {
public static final Codec<Age> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.LONG.fieldOf("seed").forGetter(Age::seed),
Contribution.CODEC.listOf().fieldOf("contributions").forGetter(Age::contributions),
PortInstabilityPlan.CODEC.fieldOf("plan").forGetter(Age::plan),
Codec.BOOL.optionalFieldOf("enabled", true).forGetter(Age::enabled),
net.minecraft.core.BlockPos.CODEC.optionalFieldOf("spawn").forGetter(Age::spawn)
).apply(instance, Age::new));
public Age { contributions = List.copyOf(contributions); spawn = spawn.map(net.minecraft.core.BlockPos::immutable); }
public Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled) {
this(seed, contributions, plan, enabled, java.util.Optional.empty());
}
public int score() { return contributions.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); }
}
private static final SavedDataType<PortInstabilityData> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "instability"), () -> new PortInstabilityData(Map.of()),
Codec.unboundedMap(Identifier.CODEC, Age.CODEC).fieldOf("ages").codec()
.xmap(PortInstabilityData::new, data -> Map.copyOf(data.ages)));
private final Map<Identifier, Age> ages = new HashMap<>();
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
public Age age(ServerLevel level) {
Age age = ages.get(level.dimension().identifier());
return age != null ? age : new Age(level.getSeed(),
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
}
public void enabled(ServerLevel level, boolean enabled) {
Age previous = age(level);
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
setDirty();
}
/** First resolved arrival remains protected without rescoring or rerolling the Age. */
public void recordSpawn(ServerLevel level, net.minecraft.core.BlockPos spawn) {
Age previous = age(level);
if (previous.spawn().isPresent()) return;
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(),
previous.enabled(), java.util.Optional.of(spawn.immutable())));
setDirty();
}
public void create(ServerLevel level, List<Identifier> pages, PortAgeTerrain terrain,
PortLighting lighting, PortDayCycle cycle) {
Identifier id = level.dimension().identifier();
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
List<Contribution> costs = new ArrayList<>();
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
boolean authoredBright = false;
Map<Identifier, Integer> structures = new HashMap<>();
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).toList();
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
if (!page.getNamespace().equals("rivencraft")) continue;
String path = page.getPath();
if (path.equals("lighting/bright")) { costs.add(new Contribution("bright_lighting", i, 500)); authoredBright = true; }
if (page.equals(PortAcceleratedTicks.PAGE)) costs.add(new Contribution("accelerated", i, 1000));
if (isCorrupted(page)) costs.add(new Contribution("corrupted_" + path.substring(10), i,
path.endsWith("small") ? 2000 : path.endsWith("medium") ? 1000 : 0));
// Legacy charges for sky/weather controllers that Cave terrain cannot apply.
if (terrain == PortAgeTerrain.CAVE && (PortWeatherMode.forPage(page) != null
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
if (PortStructurePages.supports(page)) {
int count = structures.merge(page, 1, Integer::sum);
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
var allowed = PortStructurePages.compatibleBiomes(page, level.registryAccess());
if (!allowed.isEmpty() && biomes.stream().anyMatch(biome -> !allowed.contains(biome)))
costs.add(new Contribution("forced_biome_" + path, i, 100));
}
}
if (lighting == PortLighting.BRIGHT && !authoredBright) costs.add(new Contribution("seeded_bright_lighting", -1, 500));
int suns = 0;
for (var body : cycle.bodies()) if (body.kind() == PortDayCycle.SUN && ++suns > 2)
costs.add(new Contribution("extra_sun_" + suns, -1, 1000 * (suns - 2)));
int score = costs.stream().mapToInt(Contribution::points).reduce(0, Math::addExact);
ages.put(id, new Age(level.getSeed(), costs, PortInstabilityPlan.select(score, level.getSeed()), true));
setDirty();
}
public static boolean isCorrupted(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "corrupted/large", "corrupted/medium", "corrupted/small" -> true;
default -> false;
};
}
}
@@ -0,0 +1,60 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import net.minecraft.resources.Identifier;
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
public record PortInstabilityPlan(int version, List<ProviderLevel> providers) {
public static final com.mojang.serialization.Codec<PortInstabilityPlan> CODEC =
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
com.mojang.serialization.Codec.INT.fieldOf("version").forGetter(PortInstabilityPlan::version),
ProviderLevel.CODEC.listOf().fieldOf("providers").forGetter(PortInstabilityPlan::providers)
).apply(instance, PortInstabilityPlan::new));
public static final int CURRENT_VERSION = 1;
public PortInstabilityPlan {
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
providers = List.copyOf(providers);
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
}
}
public static PortInstabilityPlan empty() { return new PortInstabilityPlan(CURRENT_VERSION, List.of()); }
public static PortInstabilityPlan select(int score, long ageSeed) {
int roundedScore = score / 500 * 500;
Map<Identifier, Integer> levels = new LinkedHashMap<>();
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
int remaining = roundedScore - deck.entryCost();
if (remaining <= 0) continue;
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
for (InstabilityProviderCatalog.Card card : cards) {
if (remaining < card.cost()) break;
remaining -= card.cost();
levels.merge(card.id(), 1, Integer::sum);
}
}
return new PortInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
}
public record ProviderLevel(Identifier id, int level) {
public static final com.mojang.serialization.Codec<ProviderLevel> CODEC =
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("id").forGetter(ProviderLevel::id),
com.mojang.serialization.Codec.intRange(1, 255).fieldOf("level").forGetter(ProviderLevel::level)
).apply(instance, ProviderLevel::new));
public ProviderLevel {
Objects.requireNonNull(id, "id");
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
}
}
}
@@ -0,0 +1,145 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortInstabilityRuntime {
private static final Map<Identifier, Handler> HANDLERS = new HashMap<>();
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
private PortInstabilityRuntime() {}
public static void register(Identifier id, ProviderEffect handler) {
register(id, false, handler);
}
public static void register(Identifier id, boolean worldMutating, ProviderEffect handler) {
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, handler)) != null)
throw new IllegalArgumentException("Duplicate instability provider " + id);
}
public static boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; }
public static PortInstabilityPlan plan(ServerLevel level) {
var age = PortInstabilityData.get(level.getServer()).age(level);
Integer test = TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
return test == null ? age.plan() : PortInstabilityPlan.select(test, age.seed());
}
public static Integer testScore(ServerLevel level) {
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
}
public static void testScore(ServerLevel level, Integer score) {
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score);
}
@SubscribeEvent public static void tick(LevelTickEvent.Post event) {
if (!(event.getLevel() instanceof ServerLevel level) || !isAge(level) || level.players().isEmpty()
|| !PortInstabilityConfig.instabilityEnabled()) return;
var server = level.getServer();
long tick = server.getTickCount();
int cooldown = PortInstabilityConfig.instabilityCooldown();
if (tick % cooldown != 0) return;
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
if (state.tick != tick) {
state.tick = tick;
var eligible = new ArrayList<Identifier>();
for (var loaded : server.getAllLevels()) if (isAge(loaded) && !loaded.players().isEmpty()
&& PortInstabilityData.get(server).age(loaded).enabled() && !plan(loaded).providers().isEmpty())
eligible.add(loaded.dimension().identifier());
eligible.sort(Comparator.comparing(Identifier::toString));
state.eligible = List.copyOf(eligible);
}
int index = state.eligible.indexOf(level.dimension().identifier());
if (index < 0 || state.eligible.size() > PortInstabilityConfig.instabilityMaxAges()
&& Math.floorMod(index - (int) Math.floorMod(tick / cooldown, state.eligible.size()), state.eligible.size())
>= PortInstabilityConfig.instabilityMaxAges()) return;
var age = PortInstabilityData.get(server).age(level);
if (!age.enabled()) return;
var providers = plan(level).providers();
var chunks = loadedChunks(level);
if (providers.isEmpty() || chunks.isEmpty()) return;
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
int ran = 0;
for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders()
&& context.budget().remaining() > 0; offset++) {
var provider = providers.get((first + offset) % providers.size());
if (invoke(context, provider.id(), provider.level())) ran++;
}
}
public static boolean invoke(Context context, Identifier id, int level) {
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.instabilityProviderEnabled(id)
|| !PortInstabilityData.get(context.level().getServer()).age(context.level()).enabled()) return false;
Handler handler = HANDLERS.get(id);
if (handler == null || handler.worldMutating() && !PortInstabilityConfig.instabilityWorldMutation()) return false;
var failures = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>());
if (context.serverTick() < failures.getOrDefault(id, 0L)) return false;
try {
handler.effect().apply(context, id, Math.min(level, PortInstabilityConfig.instabilityMaxLevel()));
} catch (RuntimeException exception) {
failures.put(id, context.serverTick() + Math.max(200L, PortInstabilityConfig.instabilityCooldown() * 10L));
Rivencraft.LOGGER.error("Instability provider {} failed in {}; temporarily quarantined", id,
context.level().dimension().identifier(), exception);
}
return true;
}
public static String providerStatus(ServerLevel level, Identifier id) {
if (!HANDLERS.containsKey(id)) return "not ported yet";
if (!PortInstabilityConfig.instabilityEnabled()) return "global config off";
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off";
if (HANDLERS.get(id).worldMutating()) {
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
} else {
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
if (!id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsPlayers()
&& !PortInstabilityConfig.statusEffectsNonplayers()) return "all general targets disabled";
}
if (level.getServer().getTickCount() < FAILURES.getOrDefault(level, Map.of()).getOrDefault(id, 0L)) return "quarantined";
return level.players().isEmpty() ? "no players in Age" : "eligible; sky, targets and budgets still apply";
}
static List<ChunkPos> loadedChunks(ServerLevel level) {
var chunks = new LinkedHashSet<ChunkPos>();
for (var player : level.players()) {
ChunkPos center = player.chunkPosition();
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
if (level.getChunkSource().getChunkNow(center.x() + x, center.z() + z) != null)
chunks.add(new ChunkPos(center.x() + x, center.z() + z));
if (chunks.size() >= PortInstabilityConfig.instabilityMaxChunks()) return List.copyOf(chunks);
}
}
return List.copyOf(chunks);
}
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear();
}
public interface ProviderEffect { void apply(Context context, Identifier id, int level); }
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
long serverTick, Budget budget) { public Context { loadedChunks = List.copyOf(loadedChunks); } }
public static final class Budget {
private int remaining;
private int applied;
public Budget(int maximum) { remaining = maximum; }
public boolean claim() { if (remaining <= 0) return false; remaining--; return true; }
public int remaining() { return remaining; }
public void recordApplied() { applied++; }
public int applied() { return applied; }
}
private record Handler(boolean worldMutating, ProviderEffect effect) {}
private static final class TickState { long tick = Long.MIN_VALUE; List<Identifier> eligible = List.of(); }
}
@@ -0,0 +1,121 @@
package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import net.minecraft.commands.Commands;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
/** Explicit operator tools; normal generation does not search or force-load remote chunks. */
@EventBusSubscriber(modid = "rivencraft")
public final class PortLibraryCommands {
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
var root = Commands.literal("rivencraft_port").requires(Commands.hasPermission(Commands.LEVEL_ADMINS));
root.then(Commands.literal("library")
.then(Commands.literal("test_place").executes(context -> {
var level = context.getSource().getLevel();
var direction = context.getSource().getEntity() instanceof net.minecraft.world.entity.player.Player player
? player.getDirection() : net.minecraft.core.Direction.NORTH;
var result = PortLibraryFeature.testPlace(level, BlockPos.containing(context.getSource().getPosition()), direction,
level.getSeed(), PortLibraryWriting.get(level.getServer()).symbols(level.dimension().identifier()));
if (result.isEmpty()) { context.getSource().sendFailure(Component.literal("No clear placement nearby; use open ground with headroom.")); return 0; }
context.getSource().sendSuccess(() -> Component.literal("Library placed. Chest: " + result.get().toShortString()), false);
return 1;
}))
.then(Commands.literal("test_spacing").then(Commands.argument("chunks", IntegerArgumentType.integer(1, 32))
.executes(context -> {
var level = context.getSource().getLevel();
if (!RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier()))
throw new IllegalArgumentException("Use this test override inside a Rivencraft Age");
int spacing = IntegerArgumentType.getInteger(context, "chunks");
PortLibraryFeature.setTestSpacing(level.getSeed(), spacing);
context.getSource().sendSuccess(() -> Component.literal("Session-only library spacing: " + spacing
+ " chunks. Applies to newly generated chunks; 32 restores normal spacing."), false);
return 1;
})))
.then(Commands.literal("candidate").executes(context -> {
var level = context.getSource().getLevel();
var origin = ChunkPos.containing(BlockPos.containing(context.getSource().getPosition()));
ChunkPos best = null;
long distance = Long.MAX_VALUE;
for (int spacing : new int[]{32, PortLibraryFeature.spacing(level.getSeed())}) {
int rx = Math.floorDiv(origin.x(), spacing), rz = Math.floorDiv(origin.z(), spacing);
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
var candidate = PortLibraryFeature.candidateChunk(level.getSeed(), rx + dx, rz + dz, spacing);
long x = candidate.x() - origin.x(), z = candidate.z() - origin.z();
long d = x * x + z * z;
if (d < distance) { best = candidate; distance = d; }
}
}
var candidate = best;
context.getSource().sendSuccess(() -> Component.literal("Nearest candidate chunk " + candidate
+ ", center X=" + (candidate.getMinBlockX() + 8) + " Z=" + (candidate.getMinBlockZ() + 8)
+ ". A candidate is not a guaranteed library: footing, slope and clear space must pass."), false);
return 1;
}))
.then(Commands.literal("probe").executes(context -> {
var level = context.getSource().getLevel();
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
var chest = PortLibraryFeature.libraryChest(chunk);
if (chest.isPresent()) context.getSource().sendSuccess(() -> Component.literal("Generated library chest: " + chest.get().toShortString()), false);
else {
var check = PortLibraryFeature.check(level, chunk.getPos(), level.getSeed());
context.getSource().sendSuccess(() -> Component.literal(check.eligible() ? "Eligible library candidate, floor Y=" + check.floorY()
: "No library in this chunk: " + check.reason()), false);
}
return 1;
}))
.then(Commands.literal("inspect_loot").executes(context -> {
var level = context.getSource().getLevel();
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
var pos = PortLibraryFeature.libraryChest(chunk);
if (pos.isEmpty()) { context.getSource().sendFailure(Component.literal("No marked library chest in this chunk.")); return 0; }
var chest = (net.minecraft.world.level.block.entity.ChestBlockEntity) level.getBlockEntity(pos.get());
var items = new java.util.ArrayList<String>();
for (int i = 0; i < chest.getContainerSize(); i++) {
var item = chest.getItem(i); // Materializes loot exactly as opening the chest does.
if (!item.isEmpty()) items.add(item.getCount() + "x " + item.getHoverName().getString());
}
long stands = chunk.getBlockEntities().values().stream()
.filter(be -> be instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity).count();
context.getSource().sendSuccess(() -> Component.literal("Library at " + pos.get().toShortString()
+ "; stands in this chunk=" + stands + "; chest=" + items), false);
return 1;
}))
.then(Commands.literal("nearest_loaded").executes(context -> {
var player = context.getSource().getPlayerOrException();
var level = player.level();
var origin = player.chunkPosition();
BlockPos best = null; double distance = Double.MAX_VALUE;
for (int x = origin.x() - 16; x <= origin.x() + 16; x++) for (int z = origin.z() - 16; z <= origin.z() + 16; z++) {
var chunk = level.getChunkSource().getChunkNow(x, z);
if (chunk == null) continue;
var found = PortLibraryFeature.libraryChest(chunk);
if (found.isPresent()) {
double d = player.distanceToSqr(Vec3.atCenterOf(found.get()));
if (d < distance) { best = found.get(); distance = d; }
}
}
if (best == null) { context.getSource().sendFailure(Component.literal("No library in loaded chunks within 16 chunks. Use candidate or test_spacing to explore new terrain.")); return 0; }
var facing = level.getBlockState(best).getValue(ChestBlock.FACING);
var feet = best.relative(facing, 2).relative(facing.getCounterClockWise());
var target = Vec3.atBottomCenterOf(feet);
if (!level.getBlockState(feet.below()).isFaceSturdy(level, feet.below(), net.minecraft.core.Direction.UP)
|| !level.getFluidState(feet).isEmpty() || !level.getFluidState(feet.above()).isEmpty()
|| !level.noCollision(player, player.getBoundingBox().move(target.subtract(player.position())))) {
context.getSource().sendFailure(Component.literal("Library found at " + best.toShortString() + " but its aisle is obstructed.")); return 0;
}
player.teleportTo(level, target.x, target.y, target.z, java.util.Set.of(), facing.getOpposite().toYRot(), 0, true);
return 1;
})));
event.getDispatcher().register(root);
}
@SubscribeEvent public static void stopped(net.neoforged.neoforge.event.server.ServerStoppedEvent event) {
PortLibraryFeature.clearTestSpacing();
}
}
@@ -0,0 +1,358 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.Identifier;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.storage.loot.LootTable;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
public final class PortLibraryFeature {
public static final int REGION = 32;
static final int FOOTPRINT = 11;
private static final int OFFSET_BOUND = 24;
private static final int MAX_SUPPORT_DEPTH = 24;
private static final int SALT = 14357617;
private static final int TEST_SALT = 0x4C494252;
private static final String CHEST_MARKER = "RivencraftLibrary";
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
// x, y, z, clockwise turns relative to the left stand. The middle row
// faces across the room; the far stand faces back toward the entrance.
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
{8, 1, 6, 1}, {6, 1, 8, 2}};
private static final Identifier TEMPLATE = Identifier.fromNamespaceAndPath(
"rivencraft", "mystcraft_library");
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
Identifier.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
private PortLibraryFeature() {}
record Site(int x, int z, int rotation) {}
public static int spacing(long seed) {
return TEST_SPACING.getOrDefault(seed, REGION);
}
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
public static void setTestSpacing(long seed, int chunks) {
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
if (chunks == REGION) TEST_SPACING.remove(seed);
else TEST_SPACING.put(seed, chunks);
}
public static void clearTestSpacing() {
TEST_SPACING.clear();
}
/** The single deterministic candidate in one Legacy-sized region. */
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
return candidateChunk(seed, regionX, regionZ, REGION);
}
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
return new ChunkPos(regionX * spacing + random.nextInt(bound),
regionZ * spacing + random.nextInt(bound));
}
static Site site(long seed, ChunkPos chunk) {
Site normal = site(seed, chunk, REGION);
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
}
private static Site site(long seed, ChunkPos chunk, int spacing) {
int rx = Math.floorDiv(chunk.x(), spacing);
int rz = Math.floorDiv(chunk.z(), spacing);
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
random.setLargeFeatureWithSalt(seed, rx, rz,
spacing == REGION ? SALT : TEST_SALT ^ spacing);
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
int candidateX = rx * spacing + random.nextInt(bound);
int candidateZ = rz * spacing + random.nextInt(bound);
if (chunk.x() != candidateX || chunk.z() != candidateZ) return null;
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
}
/** A saved marker distinguishes a generated library from player-built chests. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
return chunk.getBlockEntities().entrySet().stream()
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
&& chest.getPersistentData().getBooleanOr(CHEST_MARKER, false))
.map(java.util.Map.Entry::getKey).findFirst();
}
/** Recognize libraries generated before the chest marker was introduced. */
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
Optional<BlockPos> marked = libraryChest(chunk);
if (marked.isPresent()) return marked;
Site site = site(seed, chunk.getPos());
if (site == null) return Optional.empty();
for (var entry : chunk.getBlockEntities().entrySet()) {
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
int y = entry.getKey().getY();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
continue;
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
return Optional.of(entry.getKey());
}
return Optional.empty();
}
/** Open interior aisle, directly above the cobblestone floor. */
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
Site site = site(seed, chunk);
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
}
public record PlacementCheck(String reason, int floorY, int[][] heights) {
public boolean eligible() { return reason == null; }
}
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
Site site = site(seed, chunk);
if (site == null) return rejected("not a candidate chunk");
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int low = Integer.MAX_VALUE;
int high = Integer.MIN_VALUE;
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
int wx = site.x() + x;
int wz = site.z() + z;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
while (y > level.getMinY() + 2) {
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!below.isAir() && !isVegetation(below)) break;
y--;
}
if (y < level.getMinY() + 2) return rejected("no surface footing");
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
return rejected("ground is not sturdy terrain");
heights[x][z] = y;
low = Math.min(low, y);
high = Math.max(high, y);
}
if (high + 10 >= level.getMaxY()) return rejected("insufficient build height");
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
// Check the entire raised foundation and house. Foliage can be cleared, but not
// structures, fluids, or block entities already occupying this volume.
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = heights[x][z]; y <= high + 10; y++) {
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
return rejected("solid obstruction inside footprint: "
+ state.getBlock().getDescriptionId());
}
return new PlacementCheck(null, high, heights);
}
private static PlacementCheck rejected(String reason) {
return new PlacementCheck(reason, 0, null);
}
private static boolean isVegetation(BlockState state) {
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
}
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
List<Identifier> ageSymbols) {
Site site = site(seed, chunk.getPos());
if (site == null) return false;
PlacementCheck check = check(level, chunk.getPos(), seed);
if (!check.eligible()) return false;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
}
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
}
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
long seed, List<Identifier> ageSymbols) {
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
BlockPos center = playerPos.relative(horizontal, 13);
int rotation = switch (horizontal) {
case NORTH -> 2;
case EAST -> 3;
case WEST -> 1;
default -> 0;
};
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
int floorY = playerPos.getY() + 2;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
site.x() + x, site.z() + z) + 1);
if (floorY + 10 >= level.getMaxY() || floorY - playerPos.getY() > 20)
return Optional.empty();
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
for (int y = floorY; y <= floorY + 10; y++) {
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
return Optional.empty();
}
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
return Optional.empty();
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
}
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
long seed, List<Identifier> ageSymbols) {
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
var template = level.getLevel().getStructureTemplateManager().get(TEMPLATE).orElse(null);
if (template == null || template.getSize().getX() != FOOTPRINT
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
return false;
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
}
Rotation rotation = switch (site.rotation()) {
case 1 -> Rotation.CLOCKWISE_90;
case 2 -> Rotation.CLOCKWISE_180;
case 3 -> Rotation.COUNTERCLOCKWISE_90;
default -> Rotation.NONE;
};
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
var settings = new StructurePlaceSettings().setRotation(rotation)
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).pack();
if (!template.placeInWorld(level, origin, origin, settings,
RandomSource.create(seed ^ positionSalt), 2)) return false;
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
Direction.from2DDataValue(site.rotation())), 2);
PortConfig.LibraryLootMode mode = PortConfig.LIBRARY_LOOT_MODE.get();
boolean ageOnly = mode == PortConfig.LibraryLootMode.AGE_ONLY;
List<Identifier> symbols = symbolsForLoot(ageSymbols, mode);
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
chest.setChanged();
if (ageOnly) {
// Pages are frozen in the Age generator, then saved as real chest items.
boolean[] occupied = new boolean[chest.getContainerSize()];
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
int slot;
do slot = random.nextInt(occupied.length); while (occupied[slot]);
occupied[slot] = true;
chest.setItem(slot, page(symbols, random));
}
} else {
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
}
}
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
if (!level.getBlockState(pos).isAir()) continue;
level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
.setValue(BookStandBlock.HAS_BOOK, true), 2);
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
}
}
return true;
}
static Direction standFacing(Site site, int clockwiseTurns) {
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
}
/** Correct older generated stands in place, retaining their block entities and pages. */
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
Site site = site(seed, chunk.getPos());
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
int changed = 0;
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
for (int[] standCoords : STANDS) {
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
BlockState state = level.getBlockState(pos);
if (!state.is(Rivencraft.BOOK_STAND.get())) continue;
Direction facing = standFacing(site, standCoords[3]);
if (state.getValue(BookStandBlock.FACING) != facing) {
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
changed++;
}
}
return changed;
}
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
return mode == PortConfig.LibraryLootMode.AGE_ONLY
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
: PortSymbolPages.sealedCandidateIds();
}
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
return PageItem.createSymbolPage(symbols.get(random.nextInt(symbols.size())));
}
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
return switch (site.rotation()) {
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
default -> cursor.set(site.x() + x, y, site.z() + z);
};
}
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
int x, int y, int z, BlockState state) {
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
}
}
@@ -0,0 +1,26 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Written pages for library Age-only loot, kept outside the legacy backend index codec. */
final class PortLibraryWriting extends SavedData {
static final Codec<PortLibraryWriting> CODEC = Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC.listOf())
.fieldOf("ages").codec().xmap(PortLibraryWriting::new, data -> Map.copyOf(data.pages));
static final SavedDataType<PortLibraryWriting> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "library_writing"), () -> new PortLibraryWriting(Map.of()), CODEC);
private final Map<Identifier, List<Identifier>> pages = new HashMap<>();
private PortLibraryWriting(Map<Identifier, List<Identifier>> pages) { pages.forEach((id, list) -> this.pages.put(id, List.copyOf(list))); }
static PortLibraryWriting get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
List<Identifier> symbols(Identifier id) { return pages.getOrDefault(id, List.of()); }
void put(Identifier id, List<Identifier> symbols) {
if (symbols.isEmpty()) pages.remove(id); else pages.put(id, List.copyOf(symbols));
setDirty();
}
}
@@ -0,0 +1,94 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.Blocks;
/** Disposable-world menu, ownership, codec and chest-persistence fixtures. */
final class PortNotebookProbe {
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
private PortNotebookProbe() {}
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
Container chest = (Container) level.getBlockEntity(CHEST);
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("1043b9f3-f348-4a73-b585-e0de13fd5c27"), "RivenNotebookProbe"));
int output = 0;
try {
for (boolean folder : List.of(false, true)) for (var hand : InteractionHand.values()) {
player.getInventory().clearContent();
player.getInventory().setSelectedSlot(0);
ItemStack collection = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
player.setItemInHand(hand, collection);
var menu = new NotebookMenu(1, player.getInventory(), hand);
int capacity = CollationFolderItem.capacity(collection);
if (!menu.stillValid(player) || menu.pageCapacity() != capacity
|| menu.getSlot(0).mayPlace(PageItem.createLinkPanelPage())
|| menu.getSlot(0).mayPlace(new ItemStack(net.minecraft.world.item.Items.DIAMOND)))
throw new IllegalStateException("Notebook capacity/page acceptance regression");
ItemStack page = PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
player.getInventory().setItem(9, page.copyWithCount(3));
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ContainerInput.QUICK_MOVE, player);
if (!player.getInventory().getItem(9).isEmpty() || contents(collection).pageCount() != 3)
throw new IllegalStateException("Notebook shift insertion regression");
for (int i = 0; i < 3; i++) menu.clicked(i, 0, ContainerInput.QUICK_MOVE, player);
if (contents(collection).pageCount() != 0) throw new IllegalStateException("Notebook shift extraction regression");
menu.getSlot(capacity - 1).set(page.copy());
if (contents(collection).pageCount() != 1 || !contents(collection).pages().get(0).isEmpty()
|| contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook sparse position regression");
if (!menu.clickMenuButton(player, NotebookMenu.MOVE_PREVIOUS_BASE + capacity - 1)
|| !contents(collection).pages().get(capacity - 1).isEmpty())
throw new IllegalStateException("Notebook page movement regression");
if (folder && menu.getSlot(capacity).mayPlace(page))
throw new IllegalStateException("Folder exceeded 27 positions");
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS + 27, 0, ContainerInput.PICKUP, player);
if (player.getItemInHand(hand) != collection || !menu.getCarried().isEmpty())
throw new IllegalStateException("Notebook active item was movable");
var ops = RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, level.registryAccess());
var encoded = NotebookContents.CODEC.encodeStart(ops, contents(collection)).getOrThrow();
var decoded = NotebookContents.CODEC.parse(ops, encoded).getOrThrow();
var buffer = new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), level.registryAccess());
try {
NotebookContents.STREAM_CODEC.encode(buffer, decoded);
decoded = NotebookContents.STREAM_CODEC.decode(buffer);
if (decoded.pageCount() != 1 || decoded.pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook save/network codec regression");
} finally { buffer.release(); }
chest.setItem(output++, collection.copy());
player.setItemInHand(hand, ItemStack.EMPTY);
}
} finally { player.getInventory().clearContent(); }
verify(level);
}
private static NotebookContents contents(ItemStack stack) {
return stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
for (int i = 0; i < 4; i++) {
var collection = chest.getItem(i);
int capacity = i < 2 ? 252 : 27;
if (CollationFolderItem.capacity(collection) != capacity || contents(collection).pageCount() != 1
|| contents(collection).pages().get(capacity - 2).isEmpty())
throw new IllegalStateException("Notebook chest persistence regression: " + i);
}
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.age;
import com.mojang.authlib.GameProfile;
import net.minecraft.server.PlayerAdvancements;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixers;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.util.FakePlayer;
/** FakePlayer normally discards all advancement progress; fixtures need vanilla tracking. */
final class PortProgressionPlayer extends FakePlayer {
private PlayerAdvancements progress;
PortProgressionPlayer(ServerLevel level, GameProfile profile) { super(level, profile); }
@Override public PlayerAdvancements getAdvancements() {
if (progress == null) {
var server = level().getServer();
progress = new PlayerAdvancements(DataFixers.getDataFixer(), server.getPlayerList(),
server.getAdvancements(), server.getWorldPath(LevelResource.PLAYER_ADVANCEMENTS_DIR)
.resolve(getUUID() + ".json"), this);
}
return progress;
}
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable operator fixture for loaded advancement data and committed action awards. */
final class PortProgressionProbe {
/** Operator-fixture assertion; deliberately requires the real loaded advancement. */
static boolean isComplete(net.minecraft.server.level.ServerPlayer player, String path) {
var advancement = player.level().getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement == null) throw new IllegalStateException("Missing progression advancement: " + path);
return player.getAdvancements().getOrStartProgress(advancement).isDone();
}
static void recipeDiscovery(ServerLevel level) {
var materials = java.util.Map.of(
"ink_vial", Items.DYE.black(),
"ink_mixer", Items.GLASS_BOTTLE,
"book_binder", Items.IRON_INGOT,
"crystal_block", Rivencraft.CRYSTAL_SHARD.get(),
"notebook", Items.LEATHER,
"writing_desk", Items.FEATHER);
for (var entry : materials.entrySet()) {
var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", entry.getKey());
var key = net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, id);
var player = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
java.util.UUID.nameUUIDFromBytes(id.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)),
"RivenRecipe"));
var advancement = level.getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "recipes/misc/" + entry.getKey()));
if (advancement == null || level.getServer().getRecipeManager().byKey(key).isEmpty())
throw new IllegalStateException("Missing recipe/discovery data: " + id);
var progress = player.getAdvancements();
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player,
player.getInventory(), new ItemStack(Items.COBBLESTONE));
if (player.getRecipeBook().contains(key))
throw new IllegalStateException("Wrong material unlocked " + id);
var material = new ItemStack(entry.getValue());
player.getInventory().setItem(0, material);
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), material);
if (!player.getRecipeBook().contains(key) || !progress.getOrStartProgress(advancement).isDone())
throw new IllegalStateException("Material did not unlock " + id);
// The second criterion supports /recipe give without first obtaining its material.
var other = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
java.util.UUID.randomUUID(), "RivenRecipeGive"));
if (other.getRecipeBook().contains(key)) throw new IllegalStateException("Recipe leaked between players");
other.awardRecipes(java.util.List.of(level.getServer().getRecipeManager().byKey(key).orElseThrow()));
if (!other.getAdvancements().getOrStartProgress(advancement).isDone())
throw new IllegalStateException("Recipe unlock criterion did not complete " + id);
}
}
static void run(ServerLevel level) {
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("b428c3ef-57a6-43dc-95e8-441b6a171107"), "RivenProgression"));
var pos = new BlockPos(4, 64, 0);
player.setPos(4.5, 64, 2.5);
level.setBlock(pos, Rivencraft.INK_MIXER.get().defaultBlockState(), 3);
var mixer = (InkMixerBlockEntity) level.getBlockEntity(pos);
try {
// Every tree node must decode; portal advancement waits for its missing item/feature port.
for (String path : new String[]{"root", "page", "linking_book", "link_panel", "copy_page", "bind_age", "enter_age"})
if (isComplete(player, path))
throw new IllegalStateException("Fixture requires fresh player advancement data");
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
if (isComplete(player, "link_panel"))
throw new IllegalStateException("Empty Mixer awarded progression");
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
menu.broadcastChanges();
if (isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer preview awarded progression");
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
if (!isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer pickup did not award progression");
menu.setCarried(ItemStack.EMPTY);
var advancement = level.getServer().getAdvancements().get(
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "progression/link_panel"));
player.getAdvancements().revoke(advancement, "completed");
menu.quickMoveStack(player, InkMixerMenu.RESULT);
if (!isComplete(player, "link_panel"))
throw new IllegalStateException("Mixer shift pickup did not award progression");
ProgressionAdvancements.awardAgeTravel(player);
if (!isComplete(player, "enter_age"))
throw new IllegalStateException("Registered Age did not award entry");
} finally {
player.getInventory().clearContent();
level.removeBlock(pos, false);
}
}
}
@@ -19,30 +19,18 @@ public final class PortRandomAgeSelection {
public static Selection select(MinecraftServer server, long seed) {
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
// the other terrain families are rarer. The sorted order is deliberate.
PortAgeTerrain terrain = weighted(new Random(seed ^ 0x5445525241494E4CL),
List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID),
new int[]{1, 1, 5, 1, 6, 1});
PortAgeTerrain terrain = terrain(seed);
if (terrain == PortAgeTerrain.VOID)
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
List.of(Identifier.withDefaultNamespace("the_void"))));
PortBiomePlan.Distribution distribution = weighted(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
PortBiomePlan.Distribution distribution = distribution(seed);
List<Identifier> eligible = eligibleBiomes(server, terrain);
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
// 26.3 has no native multi-cave biome source; choose a seeded Cave
// biome rather than silently using Lush Caves for every random Age.
if (terrain == PortAgeTerrain.CAVE && distribution == PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new Selection(terrain, new PortBiomePlan(distribution, List.of()));
return new Selection(terrain, new PortBiomePlan(distribution,
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
int count = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
if (count > 1) {
@@ -54,14 +42,30 @@ public final class PortRandomAgeSelection {
return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count)));
}
public static List<PortAgeTerrain> terrains() {
return List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID);
}
public static PortAgeTerrain terrain(long seed) {
return weighted(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{1, 1, 5, 1, 6, 1});
}
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
public static PortBiomePlan.Distribution distribution(long seed) {
return weighted(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
}
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
if (terrain == PortAgeTerrain.VOID) return List.of(Identifier.withDefaultNamespace("the_void"));
if (terrain == PortAgeTerrain.CAVE) {
List<Identifier> caves = server.registryAccess().lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome)
.map(holder -> holder.key().identifier())
.sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
return caveBiomes(server.registryAccess());
}
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
@@ -69,6 +73,12 @@ public final class PortRandomAgeSelection {
return surface.isEmpty() ? List.of(Identifier.withDefaultNamespace("plains")) : surface;
}
public static List<Identifier> caveBiomes(net.minecraft.core.HolderLookup.Provider registries) {
var caves = registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(PortRegistryPages::isCaveBiome).map(holder -> holder.key().identifier()).sorted().toList();
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
}
static <T> T weighted(Random random, List<T> choices, int[] weights) {
int total = 0;
for (int weight : weights) total += weight;
@@ -47,7 +47,7 @@ public final class PortRandomStructureSelection {
}
private static Set<Identifier> actualBiomes(MinecraftServer server, PortAgeTerrain terrain, PortBiomePlan plan) {
if (plan != null && plan.distribution() != PortBiomePlan.Distribution.NATIVE)
if (plan != null && !plan.biomes().isEmpty())
return Set.copyOf(plan.biomes());
if (terrain == PortAgeTerrain.FLAT) return Set.of(Identifier.withDefaultNamespace("plains"));
if (terrain == PortAgeTerrain.VOID) return Set.of(Identifier.withDefaultNamespace("the_void"));
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.age;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.item.ItemStack;
/** Called only after successful Descriptive Book travel and its Disarm effect. */
public final class PortReturnLinks {
private PortReturnLinks() {}
public static void afterSuccessfulTravel(ServerPlayer player, Identifier age, LinkBookTarget departure) {
var mode = PortConfig.RETURN_LINK_MODE.get();
if (mode == PortConfig.ReturnLinkMode.DISABLED) return;
var storage = player.level().getServer().overworld().getDataStorage();
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
if (!issue) return;
ItemStack book = createBook(departure);
var source = player.level().getServer().getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, departure.dimension()));
if (source != null) book.set(Rivencraft.LINK_DESTINATION_NAME.get(),
net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(source));
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
// Persist the once-per-Age marker before the next arrival can issue another.
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
storage.saveAndJoin();
}
static boolean shouldIssue(PortConfig.ReturnLinkMode mode, PortAgeVisits visits, Identifier age) {
return switch (mode) {
case DISABLED -> false;
case ALWAYS -> true;
case ON_CREATION -> visits.markFirstVisit(age);
};
}
static ItemStack createBook(LinkBookTarget departure) {
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
book.set(Rivencraft.LINK_BOOK_TARGET.get(), departure);
book.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
return book;
}
}
@@ -0,0 +1,233 @@
package net.dnorberg.rivencraft.age;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.WeakHashMap;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.Holder;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent;
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** Legacy potion cards adapted to bounded, Age-local server effects. */
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortStatusEffectProviders {
private static final int MAX_TRACKED_AGES = 64;
private static final Map<Identifier, Spec> SPECS = createSpecs();
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
private PortStatusEffectProviders() {}
public static void register() {
SPECS.keySet().forEach(id -> PortInstabilityRuntime.register(id, PortStatusEffectProviders::apply));
}
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
if (!PortInstabilityConfig.statusEffectsEnabled()
|| !PortInstabilityRuntime.isAge(context.level())) return;
Spec spec = SPECS.get(id);
if (spec == null || spec.target() == Target.HOSTILE && !PortInstabilityConfig.statusEffectsHostiles()
|| spec.target() == Target.GENERAL && !PortInstabilityConfig.statusEffectsPlayers()
&& !PortInstabilityConfig.statusEffectsNonplayers()) return;
List<Lease> leases = LEASES.get(context.level());
if (leases == null) {
if (LEASES.size() >= MAX_TRACKED_AGES) return;
leases = new ArrayList<>();
LEASES.put(context.level(), leases);
}
prune(context.level(), leases, context.serverTick(), false);
if (leases.size() >= PortInstabilityConfig.statusEffectsMaxActive()) return;
Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L)
^ id.hashCode());
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinY(), chunk.getMinBlockZ(),
chunk.getMaxBlockX() + 1, context.level().getMaxY(), chunk.getMaxBlockZ() + 1);
int duration = Math.min(spec.legacyDuration(), PortInstabilityConfig.statusEffectsMaxDuration());
int amplifier = Math.max(0, level - 1);
MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier);
List<LivingEntity> candidates = new ArrayList<>();
context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
entity -> eligible(context.level(), entity, spec, instance), candidates,
PortInstabilityConfig.statusEffectsMaxCandidates());
if (candidates.isEmpty()) return;
candidates.sort(Comparator.comparing(Entity::getUUID));
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
if (!context.budget().claim() || !target.addEffect(instance)) return;
context.budget().recordApplied();
leases.add(new Lease(id, new WeakReference<>(target), instance,
context.serverTick() + duration, spec.target()));
}
private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec,
MobEffectInstance instance) {
if (!entity.isAlive() || entity.isRemoved() || entity.level() != level
|| entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance)
|| !spec.global() && !level.canSeeSky(entity.blockPosition())) return false;
return allowedByPolicy(spec.target(), entity);
}
private static boolean allowedByPolicy(Target target, LivingEntity entity) {
if (entity instanceof Player player) {
return target == Target.GENERAL && PortInstabilityConfig.statusEffectsPlayers()
&& !player.isCreative() && !player.isSpectator();
}
return target == Target.GENERAL ? PortInstabilityConfig.statusEffectsNonplayers()
: entity instanceof Monster && PortInstabilityConfig.statusEffectsHostiles();
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerTickPost(ServerTickEvent.Post event) {
if (event.getServer().getTickCount() % 20 != 0) return;
boolean disabled = !PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.statusEffectsEnabled();
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) {
prune(entry.getKey(), entry.getValue(), tick, disabled
|| !PortInstabilityData.get(entry.getKey().getServer()).age(entry.getKey()).enabled());
}
LEASES.values().removeIf(List::isEmpty);
}
@net.neoforged.bus.api.SubscribeEvent
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
Entity entity = event.getEntity();
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
clearEntity(living);
}
}
@net.neoforged.bus.api.SubscribeEvent
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
@net.neoforged.bus.api.SubscribeEvent
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
}
@net.neoforged.bus.api.SubscribeEvent
public static void onServerStopping(ServerStoppingEvent event) {
long tick = event.getServer().getTickCount();
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true);
LEASES.clear();
}
public static void clearAge(ServerLevel level) {
var leases = LEASES.remove(level);
if (leases != null) prune(level, leases, level.getServer().getTickCount(), true);
}
private static void clearEntity(LivingEntity entity) {
for (List<Lease> leases : LEASES.values()) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
if (lease.entity().get() != entity) continue;
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void prune(ServerLevel level, List<Lease> leases, long tick, boolean removeAll) {
Iterator<Lease> iterator = leases.iterator();
while (iterator.hasNext()) {
Lease lease = iterator.next();
LivingEntity entity = lease.entity().get();
if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) {
iterator.remove();
continue;
}
boolean revoke = removeAll || !PortInstabilityConfig.instabilityProviderEnabled(lease.provider())
|| entity.level() != level
|| !allowedByPolicy(lease.target(), entity);
if (revoke || tick >= lease.expiresAt()) {
removeOwnedEffect(entity, lease);
iterator.remove();
}
}
}
private static void removeOwnedEffect(LivingEntity entity, Lease lease) {
// addEffect stores the supplied instance when no effect exists. Do not remove
// a replacement from another mod (or a stronger/extended effect).
if (entity.getEffect(lease.instance().getEffect()) == lease.instance()
&& lease.instance().getAmplifier() == lease.amplifier()
&& lease.instance().getDuration() <= lease.originalDuration()) {
entity.removeEffect(lease.instance().getEffect());
}
}
static int legacyDuration(Identifier id) {
Spec spec = SPECS.get(id);
return spec == null ? -1 : spec.legacyDuration();
}
static boolean localRequiresSky(Identifier id) {
Spec spec = SPECS.get(id);
return spec != null && !spec.global();
}
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, Identifier id) {
Spec spec = SPECS.get(id);
return spec != null && eligible(level, entity, spec,
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
}
private static Map<Identifier, Spec> createSpecs() {
Map<Identifier, Spec> specs = new HashMap<>();
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
addPair(specs, "fatigue", MobEffects.MINING_FATIGUE, 80);
addPair(specs, "hunger", MobEffects.HUNGER, 80);
addPair(specs, "nausea", MobEffects.NAUSEA, 60);
addPair(specs, "poison", MobEffects.POISON, 80);
addPair(specs, "slow", MobEffects.SLOWNESS, 80);
addPair(specs, "weakness", MobEffects.WEAKNESS, 80);
addPair(specs, "wither", MobEffects.WITHER, 30);
specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE));
specs.put(id("enemyresist_global"), new Spec(MobEffects.RESISTANCE, 200, true, Target.HOSTILE));
return Map.copyOf(specs);
}
private static void addPair(Map<Identifier, Spec> specs, String name,
Holder<MobEffect> effect, int duration) {
specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL));
specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL));
}
private static Identifier id(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
private enum Target { GENERAL, HOSTILE }
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
private record Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target, int amplifier, int originalDuration) {
private Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
Target target) {
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
}
}
}
@@ -95,11 +95,22 @@ public final class PortStructurePages {
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)));
if (set.isEmpty()) return false;
var compatible = set.get().value().structures().stream()
var compatible = compatibleBiomes(set.get().value());
return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains);
}
public static Set<Identifier> compatibleBiomes(Identifier page, HolderLookup.Provider registries) {
if (!supports(page)) return Set.of();
return registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.map(holder -> compatibleBiomes(holder.value())).orElse(Set.of());
}
private static Set<Identifier> compatibleBiomes(StructureSet set) {
return set.structures().stream()
.flatMap(entry -> entry.structure().value().biomes().stream())
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier())
.collect(java.util.stream.Collectors.toSet());
return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains);
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
@@ -0,0 +1,100 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Disposable server fixture for copying transactions, page moves and two-part removal. */
final class PortWritingDeskProbe {
private static final BlockPos POS = new BlockPos(0, 64, 0);
private PortWritingDeskProbe() {}
private static ItemStack page(String path) { return PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", path)); }
static void create(ServerLevel level) {
PortAgeTravel.findLanding(level);
var player = new PortProgressionPlayer(level,
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
player.setPos(0.5, 64, 2.5);
// New Minecraft drops Container block-entity contents during removal.
// Removing either half must remove its partner and drop contents once.
for (Direction facing : Direction.Plane.HORIZONTAL) for (boolean extension : List.of(false, true)) {
var state = Rivencraft.WRITING_DESK.get().defaultBlockState().setValue(WritingDeskBlock.FACING, facing);
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
BlockPos other = POS.relative(facing.getClockWise());
if (!level.getBlockState(other).getValue(WritingDeskBlock.EXTENSION))
throw new IllegalStateException("Desk extension not placed");
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 3));
level.destroyBlock(extension ? other : POS, true);
if (level.getBlockState(POS).is(Rivencraft.WRITING_DESK.get()) || level.getBlockState(other).is(Rivencraft.WRITING_DESK.get()))
throw new IllegalStateException("Desk partner survived removal");
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
new net.minecraft.world.phys.AABB(-3, 60, -3, 4, 69, 4));
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
int desks = drops.stream().filter(e -> e.getItem().is(Rivencraft.WRITING_DESK_ITEM.get())).mapToInt(e -> e.getItem().getCount()).sum();
if (paper != 3 || desks != 1) throw new IllegalStateException("Desk removal duplicated/lost drops: " + paper + "/" + desks);
drops.forEach(net.minecraft.world.entity.Entity::discard);
}
var state = Rivencraft.WRITING_DESK.get().defaultBlockState();
level.setBlock(POS, state, 3);
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
try {
if (desk.canPlaceItem(0, PageItem.createLinkPanelPage())) throw new IllegalStateException("Desk accepted Link Panel");
desk.setItem(0, page("terrain/flat"));
var folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page("weather/off"))));
desk.setItem(1, folder);
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
if (desk.inkRemaining() != 16 || !desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE))
throw new IllegalStateException("Desk ink exchange regression");
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
menu.broadcastChanges();
if (menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty() || desk.inkRemaining() != 16)
throw new IllegalStateException("Desk virtual result consumed inputs");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ContainerInput.PICKUP, player);
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
if (!PortProgressionProbe.isComplete(player, "copy_page"))
throw new IllegalStateException("Missing copy_page award");
menu.setCarried(ItemStack.EMPTY);
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
if (!menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty() || desk.inkRemaining() != 15)
throw new IllegalStateException("Full inventory consumed copy inputs");
player.getInventory().clearContent();
if (!menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 0, 1, 26)))
throw new IllegalStateException("Desk sparse page movement failed");
if (!menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 26, -1)) || menu.getCarried().isEmpty())
throw new IllegalStateException("Desk page extraction failed");
menu.setCarried(ItemStack.EMPTY);
for (int i = 0; i < 21; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
if (menu.firstVisibleTab() != 84 || !menu.clickMenuButton(player, 87)) throw new IllegalStateException("88 desk tabs not reachable");
desk.setItem(87, page("lighting/bright"));
menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE);
menu.broadcastChanges();
} finally { player.getInventory().clearContent(); }
verify(level);
}
static void verify(ServerLevel level) {
if (!(level.getBlockEntity(POS) instanceof WritingDeskBlockEntity desk)
|| desk.activeCollection() != 87 || desk.inkRemaining() != 15
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(87).isEmpty()
|| desk.displayPage().isEmpty()) throw new IllegalStateException("Desk persistence regression");
}
}
@@ -149,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();
@@ -366,15 +390,14 @@ public final class RuntimeAgeBackend {
}
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
if (plan.distribution() == PortBiomePlan.Distribution.NATIVE)
throw new IllegalArgumentException("Native biome distribution has no authored source");
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = plan.biomes().stream().map(id ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, id))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + id)))
.toList();
return switch (plan.distribution()) {
case NATIVE -> throw new IllegalStateException("Native is not an override");
case NATIVE -> biomes.size() == 1 ? new FixedBiomeSource(biomes.getFirst())
: new PortScaledBiomeSource(HolderSet.direct(biomes), seed, 0);
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
plan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
@@ -399,9 +422,12 @@ public final class RuntimeAgeBackend {
PortAgeColors colors, PortDayCycle dayCycle) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
&& !biomePlan.biomes().isEmpty() && terrain != PortAgeTerrain.CAVE)
throw new IllegalArgumentException("Only Native Cave terrain may carry an explicit native biome pool");
// Noise terrains reuse the Overworld biome source unless a
// non-Native authored distribution replaces it.
// non-Native authored distribution or a frozen Native Cave pool replaces it.
// LevelStem.seedOverride controls chunk random state and getSeed().
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
.getValue(LevelStem.OVERWORLD);
@@ -453,7 +479,8 @@ public final class RuntimeAgeBackend {
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
}
}
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
if (biomePlan != null && (biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE
|| terrain == PortAgeTerrain.CAVE && !biomePlan.biomes().isEmpty())) {
source = authoredBiomeSource(server, biomePlan, seed);
}
var settings = switch (terrain) {
@@ -467,8 +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,17 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid crystal-textured arrival footing that cannot be collected. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) { super(properties); }
@Override
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
return ItemStack.EMPTY;
}
}
@@ -0,0 +1,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,83 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.EnumProperty;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.entity.LivingEntity;
import org.jetbrains.annotations.Nullable;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.BlockHitResult;
public final class WritingDeskBlock extends BaseEntityBlock {
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
public WritingDeskBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
}
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
builder.add(FACING, EXTENSION);
}
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
return context.getLevel().getBlockState(extension).canBeReplaced(context)
? defaultBlockState().setValue(FACING, facing) : null;
}
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
@Nullable LivingEntity placer, ItemStack stack) {
super.setPlacedBy(level, pos, state, placer, stack);
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
state.setValue(EXTENSION, true), 3);
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
BlockPos controller = state.getValue(EXTENSION)
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
Component.translatable("container.rivencraft.writing_desk")),
buffer -> {
buffer.writeBlockPos(controller);
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
});
}
return InteractionResult.SUCCESS;
}
@Override protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel level,
BlockPos pos, boolean movedByPiston) {
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(), state.getValue(EXTENSION) ? -1 : 1);
BlockState otherState = level.getBlockState(other);
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)
&& otherState.getValue(FACING) == state.getValue(FACING))
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
Containers.updateNeighboursAfterDestroy(state, level, pos);
}
}
@@ -0,0 +1,101 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One persisted book owns the whole crystal network. Portal cells retain only its position. */
public final class BookReceptacleBlockEntity extends BlockEntity {
private ItemStack book = ItemStack.EMPTY;
private boolean restore = true;
private int skyColor = 0xffffff;
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), pos, state);
}
public ItemStack getBook() { return book; }
public int skyColor() { return skyColor; }
public void setBook(ItemStack value) {
book = value.copyWithCount(1);
skyColor = 0xffffff;
if (level instanceof ServerLevel source) {
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
: destination == null ? null : destination.dimension();
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
net.minecraft.core.registries.Registries.DIMENSION, id));
if (target != null) {
var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target)
: BlockPos.containing(destination.x(), destination.y(), destination.z());
if (pos != null) {
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
String type = target.dimensionTypeRegistration().unwrapKey()
.map(key -> key.identifier().getPath()).orElse("");
skyColor = written >= 0 ? written
: target.dimension() == net.minecraft.world.level.Level.NETHER || type.endsWith("_sky_nether")
? 0x6b2725
: target.dimension() == net.minecraft.world.level.Level.END || type.endsWith("_sky_end")
? 0x211835
: net.minecraft.util.ARGB.colorFromVector3f(target.environmentAttributes().getValue(
net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos)) & 0xffffff;
}
}
}
sync();
}
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; }
private void sync() {
setChanged();
if (level != null && !level.isClientSide()) {
BlockState current = level.getBlockState(worldPosition);
if (current.is(Rivencraft.BOOK_RECEPTACLE.get())) {
level.setBlock(worldPosition, current.setValue(BookReceptacleBlock.HAS_BOOK, !book.isEmpty()), 2);
level.sendBlockUpdated(worldPosition, current, level.getBlockState(worldPosition), 2);
}
}
}
public static void serverTick(ServerLevel level, BlockPos pos, BookReceptacleBlockEntity holder) {
if (holder.book.isEmpty()) { holder.restore = false; return; }
int interval = holder.restore ? 20 : 200;
if (level.getGameTime() % interval != Math.floorMod(pos.asLong(), interval)) return;
var plan = CrystalPortalNetwork.plan(level, pos);
if (plan.waitingForChunks()) return;
boolean restoring = holder.restore;
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
plan = CrystalPortalNetwork.plan(level, pos);
CrystalPortalNetwork.reconcile(level, pos, plan);
holder.restore = false;
if (restoring) holder.sync();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
skyColor = input.getIntOr("SkyColor", 0xffffff);
restore = true;
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
output.putInt("SkyColor", skyColor);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level instanceof ServerLevel server) {
CrystalPortalNetwork.shutdown(server, pos);
ItemStack removed = book;
book = ItemStack.EMPTY;
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, removed);
}
super.preRemoveSideEffects(pos, oldState);
}
}
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One stored book or page, synchronized independently of the player's inventory. */
public final class BookStandBlockEntity extends BlockEntity {
private ItemStack book = ItemStack.EMPTY;
public BookStandBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), pos, state); }
public ItemStack getBook() { return book; }
public void setBook(ItemStack stack) { book = stack; sync(); }
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; sync(); return result; }
private void sync() {
setChanged();
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
if (level != null && !level.isClientSide())
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5, takeBook());
super.preRemoveSideEffects(pos, oldState);
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/** One stored book or page, synchronized independently of the player's inventory. */
public final class CrystalPortalBlockEntity extends BlockEntity {
private BlockPos receptacle;
public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.CRYSTAL_PORTAL_BLOCK_ENTITY.get(), pos, state); }
public BlockPos receptacle() { return receptacle; }
public void setReceptacle(BlockPos pos) {
receptacle = pos.immutable();
setChanged();
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
receptacle = input.read("Receptacle", BlockPos.CODEC).orElse(null);
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
if (receptacle != null) output.store("Receptacle", BlockPos.CODEC, receptacle);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
}
@@ -112,6 +112,36 @@ public final class PortBookBinderBlockEntity extends BlockEntity implements Cont
return true;
}
/** Validate the complete collection before moving any page, as in 1.21.1. */
public ItemStack importCollection(ItemStack collection, int index) {
if (!net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(collection)
|| collection.getCount() != 1) return ItemStack.EMPTY;
var contents = collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(),
net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
int end = nextPageIndex();
if (index < 0 || index > end) return ItemStack.EMPTY;
ItemStack panel = ItemStack.EMPTY;
List<ItemStack> writing = new ArrayList<>();
for (ItemStack page : contents.pages()) {
if (page.isEmpty()) continue;
if (!page.is(Rivencraft.PAGE.get()) || page.getCount() != 1) return ItemStack.EMPTY;
if (PageItem.isLinkPanel(page)) {
if (!panel.isEmpty() || !items.get(PANEL).isEmpty()) return ItemStack.EMPTY;
panel = page;
} else if (validSymbolPage(page)) writing.add(page);
else return ItemStack.EMPTY;
}
if (end + writing.size() > PAGE_COUNT || writing.isEmpty() && panel.isEmpty()) return ItemStack.EMPTY;
if (!panel.isEmpty()) items.set(PANEL, panel.copy());
for (int current = end - 1; current >= index; current--)
items.set(FIRST_PAGE + current + writing.size(), items.get(FIRST_PAGE + current));
for (ItemStack page : writing) items.set(FIRST_PAGE + index++, page.copy());
ItemStack emptied = collection.copy();
emptied.set(Rivencraft.NOTEBOOK_CONTENTS.get(), net.dnorberg.rivencraft.writing.NotebookContents.EMPTY);
setChanged();
return emptied;
}
public ItemStack takePage(int index) {
int end = nextPageIndex();
if (index < 0 || index >= end) return ItemStack.EMPTY;
@@ -0,0 +1,173 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
public static final int COLLECTIONS = 88;
public static final int INK = COLLECTIONS;
public static final int RETURN = INK + 1;
public static final int PAPER = INK + 2;
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
public static final int LEGACY_OUTPUT = INK + 3;
public static final int SIZE = INK + 4;
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
private int activeCollection;
private int inkRemaining;
private ItemStack displayPage = ItemStack.EMPTY;
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
super(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), pos, state);
}
public int activeCollection() { return activeCollection; }
public int inkRemaining() { return inkRemaining; }
public ItemStack displayPage() { return displayPage; }
public void setDisplayPage(ItemStack page) {
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
if (!ItemStack.matches(displayPage, next)) {
displayPage = next;
setChanged();
}
}
public void setActiveCollection(int index) {
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
activeCollection = index;
setChanged();
}
}
@Override public int getContainerSize() { return SIZE; }
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) {
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == RETURN) refillEmptyInkBar();
}
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack);
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
setChanged();
if (slot == INK || slot == RETURN) refillEmptyInkBar();
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition)
&& level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
return switch (slot) {
case INK -> stack.is(Rivencraft.INK_VIAL.get());
case RETURN -> false;
case PAPER -> stack.is(Items.PAPER);
case LEGACY_OUTPUT -> false;
default -> false;
};
}
/** Consume one vial only when the empty meter can return its glass bottle. */
private void refillEmptyInkBar() {
if (level != null && !level.isClientSide() && inkRemaining == 0) {
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
if (addedInk > 0) {
inkRemaining = addedInk;
setChanged();
}
}
}
/** The output is virtual; inputs are committed only after a result can be given to a player. */
public boolean canConsumeCopyMaterials() {
ItemStack paper = items.get(PAPER);
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
if (inkRemaining < cost) {
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
}
inkRemaining -= cost;
removeItem(PAPER, 1);
setChanged();
if (inkRemaining == 0) refillEmptyInkBar();
return true;
}
@Override public void setLevel(Level level) {
super.setLevel(level);
refillEmptyInkBar();
}
@Override public void clearContent() {
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
inkRemaining = 0;
displayPage = ItemStack.EMPTY;
setChanged();
}
@Override protected void loadAdditional(ValueInput input) {
super.loadAdditional(input);
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
ContainerHelper.loadAllItems(input, items);
activeCollection = Math.clamp(input.getIntOr("ActiveCollection", 0), 0, COLLECTIONS - 1);
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
displayPage = input.read("DisplayPage", ItemStack.CODEC).orElse(ItemStack.EMPTY);
refillEmptyInkBar();
}
@Override protected void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
ContainerHelper.saveAllItems(output, items);
output.putInt("ActiveCollection", activeCollection);
output.putInt("InkRemaining", inkRemaining);
if (!displayPage.isEmpty()) output.store("DisplayPage", ItemStack.CODEC, displayPage);
}
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return saveWithoutMetadata(registries);
}
@Override public void setChanged() {
super.setChanged();
if (level != null && !level.isClientSide()) {
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
}
}
}
@@ -26,6 +26,7 @@ public final class AgeCatalogClient {
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
@@ -0,0 +1,67 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.phys.Vec3;
/** The 1.21.1 receptacle book orientation in the 26.3 extracted renderer. */
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity, BookReceptacleRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
}
public static final class State extends BlockEntityRenderState {
boolean present, descriptive;
Direction facing = Direction.NORTH;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(BookReceptacleBlockEntity holder, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(holder, state, partialTick, camera, overlay);
state.present = !holder.getBook().isEmpty() && holder.getBlockState().getValue(BookReceptacleBlock.HAS_BOOK);
state.descriptive = holder.getBook().is(Rivencraft.DESCRIPTIVE_BOOK.get());
state.facing = holder.getBlockState().getValue(BookReceptacleBlock.FACING);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
if (!state.present) return;
pose.pushPose();
pose.translate(0.5F, 0.5F, 0.5F);
switch (state.facing) {
case UP -> {
pose.rotateDegrees(Axis.ZP, 180);
pose.rotateDegrees(Axis.XP, -90);
pose.rotateDegrees(Axis.YP, 90);
pose.rotateDegrees(Axis.ZP, 180);
}
case DOWN -> {
pose.rotateDegrees(Axis.XP, -90);
pose.rotateDegrees(Axis.YP, 90);
pose.rotateDegrees(Axis.ZP, 180);
}
case NORTH -> pose.rotateDegrees(Axis.YP, -90);
case SOUTH -> pose.rotateDegrees(Axis.YP, 90);
case EAST -> pose.rotateDegrees(Axis.YP, 180);
case WEST -> { }
}
pose.scale(0.8F, 0.8F, 0.8F);
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.1F, 0.005F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
pose.popPose();
}
}
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.minecraft.client.gui.Font;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.item.ItemModelResolver;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemDisplayContext;
import net.minecraft.world.phys.Vec3;
/** Extracted version of the 1.21.1 lectern display, retaining its final page orientation. */
public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlockEntity, BookStandRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
private final ItemModelResolver resolver;
private final Font font;
public BookStandRenderer(BlockEntityRendererProvider.Context context) {
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
resolver = context.itemModelResolver();
font = context.font();
}
public static final class State extends BlockEntityRenderState {
final ItemStackRenderState page = new ItemStackRenderState();
boolean present, isPage, descriptive;
float rotation;
Component name;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(BookStandBlockEntity stand, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(stand, state, partialTick, camera, overlay);
var book = stand.getBook();
state.present = !book.isEmpty();
state.isPage = book.is(Rivencraft.PAGE.get());
state.descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
state.name = state.present && !state.isPage ? book.getHoverName().copy() : null;
Direction facing = stand.getBlockState().getValue(BookStandBlock.FACING);
state.rotation = state.isPage ? facing.toYRot() - 180 + (facing.getAxis() == Direction.Axis.X ? 180 : 0)
: -facing.getClockWise().toYRot();
resolver.updateForTopItem(state.page, state.isPage ? book : net.minecraft.world.item.ItemStack.EMPTY,
ItemDisplayContext.FIXED, stand.getLevel(), null, 0);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
if (!state.present) return;
pose.pushPose();
pose.translate(0.5F, 1.0625F, 0.5F);
pose.rotateDegrees(Axis.YP, state.rotation);
if (state.isPage) {
pose.rotateDegrees(Axis.XP, 67.5F);
pose.scale(0.7F, 0.7F, 0.7F);
state.page.submit(pose, collector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
} else {
pose.rotateDegrees(Axis.ZP, 67.5F);
pose.translate(0, -0.125F, 0);
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.9F, 1.2F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
}
pose.popPose();
if (state.name != null) {
pose.pushPose();
pose.translate(0.5F, 1.6F, 0.5F);
pose.rotate(camera.orientation);
pose.scale(0.025F, -0.025F, 0.025F);
collector.submitText(pose, -font.width(state.name) / 2.0F, 0, state.name.getVisualOrderText(),
false, Font.DisplayMode.NORMAL, state.lightCoords, 0xFFFFFFFF, 0, 0);
pose.popPose();
}
}
}
@@ -0,0 +1,20 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.client.Minecraft;
import net.neoforged.neoforge.network.handling.IPayloadContext;
public final class ClientWritingDeskCatalog {
private ClientWritingDeskCatalog() {}
public static void handle(WritingDeskCatalogPayload payload, IPayloadContext context) {
context.enqueueWork(() -> {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof WritingDeskMenu menu
&& menu.containerId == payload.containerId()) {
menu.setCatalog(payload.symbols());
}
});
}
}
@@ -0,0 +1,57 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.model.object.book.BookModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.Identifier;
/** Uses the same skyward-facing open book and textures as the 1.21.1 renderer. */
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity, DroppedBookRenderer.State> {
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public DroppedBookRenderer(EntityRendererProvider.Context context) {
super(context);
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
shadowRadius = 0.25F;
}
public static final class State extends EntityRenderState {
float yaw;
boolean descriptive;
}
@Override
public State createRenderState() { return new State(); }
@Override
public void extractRenderState(DroppedBookEntity entity, State state, float partialTick) {
super.extractRenderState(entity, state, partialTick);
state.yaw = entity.getYRot();
state.descriptive = entity.getItem().is(Rivencraft.DESCRIPTIVE_BOOK.get());
}
@Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
pose.pushPose();
pose.translate(0.0F, 0.0625F, 0.0F);
pose.rotateDegrees(Axis.YP, state.yaw + 90.0F);
pose.rotateDegrees(Axis.ZP, 90.0F);
pose.scale(0.8F, 0.8F, 0.8F);
collector.submitModel(model, BookModel.State.forAnimation(0, 0, 0, 1.2F), pose,
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY,
state.outlineColor);
pose.popPose();
super.submit(state, pose, collector, camera);
}
}
@@ -0,0 +1,317 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.Locale;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.ItemStack;
/** A page surface modelled on Legacy's folder GUI, backed by the Notebook menu. */
public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu> {
private static final int SURFACE_TOP = 20;
private static final int SURFACE_BOTTOM = 132;
private static final int SURFACE_RIGHT = 156;
private static final int PAGE_WIDTH = 30;
private static final int PAGE_HEIGHT = 40;
private static final int PAGE_STEP_X = 31;
private static final int PAGE_STEP_Y = 41;
private static final int PAGE_COLUMNS = 5;
private static final Identifier PAGE_ATLAS = texture("gui/notebook_pages");
private EditBox textInput;
private boolean draggingScrollbar;
private boolean surfaceClickPendingRelease;
private int selectedSlot = -1;
private int scroll;
public NotebookScreen(NotebookMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 213);
inventoryLabelY = 123;
}
@Override public boolean isPauseScreen() { return false; }
@Override
protected void init() {
super.init();
Button sortButton = Button.builder(Component.literal("AZ"), button -> {})
.bounds(leftPos, topPos, 18, 18).build();
Button allButton = Button.builder(Component.literal("ALL"), button -> {})
.bounds(leftPos + 20, topPos, 26, 18).build();
sortButton.active = false;
allButton.active = false;
addRenderableWidget(sortButton);
addRenderableWidget(allButton);
textInput = new EditBox(font, leftPos + 48, topPos, 128, 18,
Component.translatable("screen.rivencraft.notebook.search"));
textInput.setMaxLength(32);
textInput.setHint(Component.translatable("screen.rivencraft.notebook.search"));
textInput.setResponder(value -> scroll = 0);
addRenderableWidget(textInput);
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
graphics.fill(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT,
topPos + SURFACE_BOTTOM, 0xB0000000);
drawBeveledPanel(graphics, leftPos, topPos + 132, imageWidth, 81);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 134 + row * 18);
}
for (int column = 0; column < 9; column++) drawSlot(graphics, 7 + column * 18, 192);
}
@Override
public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractContents(graphics, mouseX, mouseY, partialTick);
int pageSlots = displayedPageSlots();
scroll = Math.min(scroll, maxScroll(pageSlots));
graphics.nextStratum();
graphics.enableScissor(leftPos, topPos + SURFACE_TOP, leftPos + SURFACE_RIGHT, topPos + SURFACE_BOTTOM);
for (int index = 0; index < pageSlots; index++) {
int x = leftPos + (index % PAGE_COLUMNS) * PAGE_STEP_X;
int y = topPos + SURFACE_TOP + (index / PAGE_COLUMNS) * PAGE_STEP_Y - scroll;
if (y + PAGE_HEIGHT > topPos + SURFACE_TOP && y < topPos + SURFACE_BOTTOM) drawPage(graphics, index, x, y);
}
graphics.disableScissor();
drawScrollbar(graphics, pageSlots);
}
@Override
protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
int hovered = pageAt(mouseX, mouseY);
if (hovered >= 0 && menu.getCarried().isEmpty() && !menu.getSlot(hovered).getItem().isEmpty()) {
ItemStack page = menu.getSlot(hovered).getItem();
if (matchesSearch(page)) graphics.setTooltipForNextFrame(font, page, mouseX, mouseY);
} else super.extractTooltip(graphics, mouseX, mouseY);
}
@Override
protected void extractSlots(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
// Collection slots are drawn above as large pages. Do not submit up to
// 252 additional item models at their off-screen server-slot positions.
for (int index = NotebookMenu.NOTEBOOK_SLOTS; index < menu.slots.size(); index++) {
var slot = menu.getSlot(index);
if (slot.isActive()) extractSlot(graphics, slot, mouseX, mouseY);
}
}
@Override
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (inScrollbar(mouseX, mouseY) && button == InputConstants.MOUSE_BUTTON_LEFT) {
draggingScrollbar = true;
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
int index = pageAt(mouseX, mouseY);
if (index >= 0 && (button == InputConstants.MOUSE_BUTTON_LEFT || button == InputConstants.MOUSE_BUTTON_RIGHT)) {
ItemStack page = menu.getSlot(index).getItem();
if ((!page.isEmpty() && !matchesSearch(page))
|| (page.isEmpty() && (!menu.getCarried().is(Rivencraft.PAGE.get())
|| PageItem.isLinkPanel(menu.getCarried())))) {
return true;
}
setFocused(null);
textInput.setFocused(false);
selectedSlot = index;
slotClicked(menu.getSlot(index), index, button == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0, ContainerInput.PICKUP);
surfaceClickPendingRelease = true;
return true;
}
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
double mouseY = event.y();
int button = event.button();
if (draggingScrollbar && button == InputConstants.MOUSE_BUTTON_LEFT) {
setScrollFromMouse(mouseY, displayedPageSlots());
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override
public boolean mouseReleased(MouseButtonEvent event) {
if (draggingScrollbar || surfaceClickPendingRelease) {
draggingScrollbar = false;
surfaceClickPendingRelease = false;
return true;
}
draggingScrollbar = false;
return super.mouseReleased(event);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (inSurface(mouseX, mouseY) || inScrollbar(mouseX, mouseY)) {
scroll = Math.clamp(scroll - (int)Math.signum(scrollY) * 20,
0, maxScroll(displayedPageSlots()));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
@Override
public boolean keyPressed(KeyEvent event) {
// Let the container close normally, including returning its carried
// stack, even while the search field owns keyboard focus.
if (event.isEscape()) return super.keyPressed(event);
if (textInput != null && textInput.isFocused()) {
textInput.keyPressed(event);
// EditBox does not consume ordinary letter key presses; the matching charTyped
// event handles text input. Do not pass them to the container's inventory keys.
return true;
}
if (selectedSlot >= 0 && (event.isLeft() || event.isRight())) {
moveSelected(event.isRight());
return true;
}
return super.keyPressed(event);
}
private int displayedPageSlots() {
if (menu.getCarried().is(Rivencraft.PAGE.get())) {
return menu.pageCapacity();
}
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
}
return 0;
}
private boolean matchesSearch(ItemStack page) {
return textInput.getValue().isBlank()
|| page.getHoverName().getString().toLowerCase(Locale.ROOT)
.contains(textInput.getValue().toLowerCase(Locale.ROOT));
}
private int pageAt(double mouseX, double mouseY) {
if (!inSurface(mouseX, mouseY)) return -1;
int localX = (int)mouseX - leftPos;
int localY = (int)mouseY - topPos - SURFACE_TOP + scroll;
int column = localX / PAGE_STEP_X;
int index = (localY / PAGE_STEP_Y) * PAGE_COLUMNS + column;
return column < PAGE_COLUMNS && localX % PAGE_STEP_X < PAGE_WIDTH
&& localY % PAGE_STEP_Y < PAGE_HEIGHT && index < displayedPageSlots()
? index : -1;
}
private boolean inSurface(double mouseX, double mouseY) {
return mouseX >= leftPos && mouseX < leftPos + SURFACE_RIGHT
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private boolean inScrollbar(double mouseX, double mouseY) {
return mouseX >= leftPos + SURFACE_RIGHT && mouseX < leftPos + imageWidth
&& mouseY >= topPos + SURFACE_TOP && mouseY < topPos + SURFACE_BOTTOM;
}
private int maxScroll(int pageCount) {
int rows = (pageCount + PAGE_COLUMNS - 1) / PAGE_COLUMNS;
return Math.max(0, rows * PAGE_STEP_Y - (SURFACE_BOTTOM - SURFACE_TOP));
}
private void setScrollFromMouse(double mouseY, int pageCount) {
int maximum = maxScroll(pageCount);
double fraction = (mouseY - topPos - SURFACE_TOP - 10.0)
/ (SURFACE_BOTTOM - SURFACE_TOP - 20.0);
scroll = Math.clamp((int)(Math.clamp(fraction, 0.0, 1.0) * maximum), 0, maximum);
}
private void drawScrollbar(GuiGraphicsExtractor graphics, int pageCount) {
int x = leftPos + SURFACE_RIGHT;
int y = topPos + SURFACE_TOP;
int height = SURFACE_BOTTOM - SURFACE_TOP;
graphics.fill(x, y, x + 20, y + height, 0xFF858585);
graphics.outline(x, y, 20, height, 0xFF262626);
for (int line = y + 5; line < y + height - 4; line += 5) {
graphics.fill(x + 4, line, x + 16, line + 1, 0xFFB7B7B7);
}
int maximum = maxScroll(pageCount);
int thumbY = y + 4 + (maximum == 0 ? 0 : scroll * (height - 23) / maximum);
graphics.fill(x + 4, thumbY, x + 16, thumbY + 15, 0xFFC9C9C9);
graphics.outline(x + 4, thumbY, 12, 15, 0xFF414141);
}
private void drawPage(GuiGraphicsExtractor graphics, int index, int x, int y) {
ItemStack page = menu.getSlot(index).getItem();
boolean dimmed = page.isEmpty() || !matchesSearch(page);
// Legacy's Notebook draws the atlas' native 30x40 page, not a stretched item icon.
graphics.nextStratum();
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS, x, y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
if (!page.isEmpty() && page.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
graphics.nextStratum();
ItemStack glyph = page.copy();
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 6);
graphics.pose().scale(1.75F, 1.75F);
graphics.fakeItem(glyph, 0, 0);
graphics.pose().popMatrix();
graphics.nextStratum();
}
if (dimmed) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xB8000000);
} else if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(
page.get(Rivencraft.SYMBOL_PAGE_ID.get()))) {
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF3333);
}
if (index == selectedSlot && !page.isEmpty()) {
graphics.outline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFFD061);
}
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
private void moveSelected(boolean next) {
int target = selectedSlot + (next ? 1 : -1);
if (selectedSlot < 0 || target < 0 || target >= NotebookMenu.NOTEBOOK_SLOTS
|| menu.getSlot(selectedSlot).getItem().isEmpty()) return;
int buttonId = (next ? NotebookMenu.MOVE_NEXT_BASE : NotebookMenu.MOVE_PREVIOUS_BASE) + selectedSlot;
if (menu.clickMenuButton(minecraft.player, buttonId)) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId);
selectedSlot = target;
}
}
private static void drawBeveledPanel(GuiGraphicsExtractor graphics, int x, int y, int width, int height) {
graphics.fill(x, y, x + width, y + height, 0xFFC6C6C6);
graphics.fill(x, y, x + width, y + 2, 0xFFFFFFFF);
graphics.fill(x, y, x + 2, y + height, 0xFFFFFFFF);
graphics.fill(x, y + height - 2, x + width, y + height, 0xFF555555);
graphics.fill(x + width - 2, y, x + width, y + height, 0xFF555555);
}
private void drawSlot(GuiGraphicsExtractor graphics, int x, int y) {
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFF8B8B8B);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 18, topPos + y + 1, 0xFF373737);
graphics.fill(leftPos + x, topPos + y, leftPos + x + 1, topPos + y + 18, 0xFF373737);
graphics.fill(leftPos + x + 17, topPos + y, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
graphics.fill(leftPos + x, topPos + y + 17, leftPos + x + 18, topPos + y + 18, 0xFFFFFFFF);
}
}
@@ -18,12 +18,33 @@ public final class PortBookBinderClientEvents {
public static void registerBlockEntityRenderers(
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), BookReceptacleRenderer::new);
}
@SubscribeEvent
public static void portalTint(net.neoforged.neoforge.client.event.RegisterColorHandlersEvent.BlockTintSources event) {
event.register(java.util.List.of(new net.minecraft.client.color.block.BlockTintSource() {
@Override public int color(net.minecraft.world.level.block.state.BlockState state) { return 0xffffffff; }
@Override public int colorInWorld(net.minecraft.world.level.block.state.BlockState state,
net.minecraft.client.renderer.block.BlockAndTintGetter level, net.minecraft.core.BlockPos pos) {
if (level.getBlockEntity(pos) instanceof net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity portal
&& portal.receptacle() != null
&& level.getBlockEntity(portal.receptacle()) instanceof net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity holder)
return 0xff000000 | holder.skyColor();
return 0xffffffff;
}
}), Rivencraft.CRYSTAL_PORTAL.get());
}
@SubscribeEvent
public static void registerScreens(RegisterMenuScreensEvent event) {
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
}
@SubscribeEvent
@@ -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));
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -0,0 +1,60 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
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.world.item.ItemDisplayContext;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.phys.Vec3;
/** The existing selected-page and white-paper display, extracted for 26.3. */
public final class WritingDeskRenderer implements BlockEntityRenderer<WritingDeskBlockEntity, WritingDeskRenderer.State> {
private final ItemModelResolver resolver;
public WritingDeskRenderer(BlockEntityRendererProvider.Context context) { resolver = context.itemModelResolver(); }
public static final class State extends BlockEntityRenderState {
final ItemStackRenderState paper = new ItemStackRenderState();
final ItemStackRenderState page = new ItemStackRenderState();
int layers;
float rotation;
}
@Override public State createRenderState() { return new State(); }
@Override public void extractRenderState(WritingDeskBlockEntity desk, State state, float partialTick,
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
BlockEntityRenderer.super.extractRenderState(desk, state, partialTick, camera, overlay);
int count = desk.getItem(WritingDeskBlockEntity.PAPER).getCount();
state.layers = count == 0 ? 0 : Math.min(4, 1 + count / 16);
state.rotation = 180 - desk.getBlockState().getValue(WritingDeskBlock.FACING).toYRot();
resolver.updateForTopItem(state.paper, new ItemStack(Items.PAPER), ItemDisplayContext.FIXED, desk.getLevel(), null, 0);
resolver.updateForTopItem(state.page, desk.displayPage(), ItemDisplayContext.FIXED, desk.getLevel(), null, 0);
}
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
pose.pushPose();
pose.translate(0.5, 0, 0.5);
pose.rotateDegrees(Axis.YP, state.rotation);
for (int i = 0; i < state.layers; i++) draw(state.paper, pose, collector, state.lightCoords,
1.22, 0.27 + i * 0.008, -0.14, 0.55F);
if (!state.page.isEmpty()) draw(state.page, pose, collector, state.lightCoords, 1.0, 0.572, 0, 0.8F);
pose.popPose();
}
private static void draw(ItemStackRenderState item, PoseStack pose, SubmitNodeCollector collector,
int light, double x, double y, double z, float scale) {
pose.pushPose();
pose.translate(x, y, z);
pose.rotateDegrees(Axis.XP, 90);
pose.scale(scale, scale, scale);
item.submit(pose, collector, light, OverlayTexture.NO_OVERLAY, 0);
pose.popPose();
}
@Override public boolean shouldRenderOffScreen() { return true; }
}
@@ -0,0 +1,510 @@
package net.dnorberg.rivencraft.client;
import java.util.List;
import java.util.Locale;
import java.util.ArrayList;
import java.util.Comparator;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.KeyEvent;
import com.mojang.blaze3d.platform.InputConstants;
import net.minecraft.client.renderer.RenderPipelines;
/** Legacy desk atlas layout, with four visible tabs out of the desk's 88 collections. */
public final class WritingDeskScreen extends AbstractContainerScreen<WritingDeskMenu> {
private static final Identifier PAGE_ATLAS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/notebook_pages.png");
private static final Identifier LEGACY_DESK = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/writing_desk_legacy.png");
private static final Identifier SCROLLBAR = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "textures/gui/scrollbar.png");
private int lastCollection = -1;
private int selectedPage;
private int scrollRows;
private boolean allMode;
private boolean sortAlphabetically;
private int selectedCollection = -1;
private Identifier selectedCategory;
private EditBox search;
private Button categoryButton;
private Button allButton;
private Button sortButton;
private boolean draggingScrollbar;
private record DisplayPage(int collection, int index, ItemStack stack) {}
private DisplayPage pageDragSource;
private double pageDragStartX;
private double pageDragStartY;
private boolean draggingPage;
private boolean isPhysicalPageView() {
return !allMode && !sortAlphabetically && selectedCategory == null
&& (search == null || search.getValue().isBlank());
}
public WritingDeskScreen(WritingDeskMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 409, 186);
titleLabelX = 222;
}
@Override public boolean isPauseScreen() { return false; }
@Override protected void init() {
super.init();
sortButton = addRenderableWidget(Button.builder(Component.literal("AZ"), button -> {
sortAlphabetically = !sortAlphabetically;
scrollRows = 0;
}).bounds(leftPos + 58, topPos, 20, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.sort"))).build());
allButton = addRenderableWidget(Button.builder(Component.literal("ALL"), button -> {
allMode = !allMode;
scrollRows = 0;
if (!allMode) {
selectedCollection = -1;
selectedPage = 0;
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PAGE_BUTTON_BASE);
}
}).bounds(leftPos + 80, topPos, 28, 18)
.tooltip(Tooltip.create(Component.translatable("screen.rivencraft.writing_desk.all"))).build());
search = new EditBox(font, leftPos + 110, topPos, 101, 18,
Component.translatable("screen.rivencraft.writing_desk.search"));
search.setMaxLength(64);
search.setHint(Component.translatable("screen.rivencraft.writing_desk.search"));
search.setResponder(value -> scrollRows = 0);
addRenderableWidget(search);
categoryButton = addRenderableWidget(Button.builder(Component.literal("C"), button -> {
List<Identifier> categories = menu.catalog().stream()
.map(SymbolDisplayMetadata::category).distinct().sorted().toList();
int next = selectedCategory == null ? 0 : categories.indexOf(selectedCategory) + 1;
selectedCategory = next >= categories.size() ? null : categories.get(next);
categoryButton.setMessage(Component.literal(selectedCategory == null ? "C" : "C*"));
scrollRows = 0;
}).bounds(leftPos + 213, topPos, 18, 18).build());
}
private List<DisplayPage> filteredPages() {
String query = search == null ? "" : search.getValue().trim().toLowerCase(Locale.ROOT);
List<DisplayPage> entries = new ArrayList<>();
int from = allMode ? 0 : menu.activeCollection();
int until = allMode ? WritingDeskBlockEntity.COLLECTIONS : from + 1;
for (int collection = from; collection < until; collection++) {
List<ItemStack> pages = pages(collection);
int last = -1;
for (int i = 0; i < pages.size(); i++) if (!pages.get(i).isEmpty()) last = i;
// A notebook has exactly its normal GUI capacity; a loose page has one position.
if (isPhysicalPageView()) last = menu.pageCapacity(collection) - 1;
for (int i = 0; i <= last; i++) {
ItemStack page = i < pages.size() ? pages.get(i) : ItemStack.EMPTY;
if (page.isEmpty() && (allMode || sortAlphabetically || !query.isEmpty() || selectedCategory != null)) continue;
if (!page.isEmpty() && !query.isEmpty()
&& !page.getHoverName().getString().toLowerCase(Locale.ROOT).contains(query)
&& !PageItem.content(page).symbol().map(symbol -> symbol.id().toString()
.toLowerCase(Locale.ROOT).contains(query)).orElse(false)) continue;
if (!page.isEmpty() && selectedCategory != null) {
Identifier id = PageItem.content(page).symbol().map(symbol -> symbol.id()).orElse(null);
if (menu.catalog().stream().noneMatch(metadata -> metadata.id().equals(id)
&& metadata.category().equals(selectedCategory))) continue;
}
entries.add(new DisplayPage(collection, i, page));
}
}
if (sortAlphabetically) entries.sort(Comparator
.comparing((DisplayPage entry) -> entry.stack().getHoverName().getString(), String.CASE_INSENSITIVE_ORDER)
.thenComparingInt(DisplayPage::collection).thenComparingInt(DisplayPage::index));
return entries;
}
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
int x = leftPos;
int y = topPos;
// Legacy GuiWritingDesk draws the entire 176x166 panel, not a stretched swatch.
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, x + 233, y + 20, 0, 0, 176, 166, 256, 256);
drawTabControl(graphics, 20, 203, menu.firstVisibleTab() > 0);
for (int i = 0; i < 4; i++) {
int tab = menu.firstVisibleTab() + i;
int tabY = y + 29 + i * 37;
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, x, tabY, 0, 166, 58, 37, 256, 256,
tab == menu.activeCollection() && !allMode ? 0xFF8080FF : -1);
// Legacy numbers its Writing Desk collections from zero.
drawNumeral(graphics, tab, x + (tab < 25 ? 8 : 4), tabY + 3, 19);
ItemStack collection = menu.getSlot(tab).getItem();
if (!collection.isEmpty()) {
String name = collection.getHoverName().getString();
float scale = Math.min(1F, 50F / Math.max(1, font.width(name)));
graphics.pose().pushMatrix();
graphics.pose().translate(x + 4, tabY + 25);
graphics.pose().scale(scale, scale);
graphics.text(font, name, 0, 0, 0xFF404040, false);
graphics.pose().popMatrix();
}
}
drawTabControl(graphics, 177, 212, menu.firstVisibleTab() < WritingDeskBlockEntity.COLLECTIONS - 4);
// GuiElementVSlider: 4-pixel caps, repeated 22-pixel track, 12x15 thumb.
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 20, 0, 0, 20, 4, 32, 32);
for (int offset = 4; offset < 162; offset += 22)
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 20 + offset, 0, 4, 20, Math.min(22, 162 - offset), 32, 32);
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 213, y + 182, 0, 26, 20, 4, 32, 32);
int totalRows = (filteredPages().size() + 4) / 5;
int maxScroll = Math.max(0, totalRows - 4);
scrollRows = Math.clamp(scrollRows, 0, maxScroll);
int thumbY = y + 24 + (maxScroll == 0 ? 0 : scrollRows * 143 / maxScroll);
graphics.blit(RenderPipelines.GUI_TEXTURED, SCROLLBAR, x + 217, thumbY, 20, draggingScrollbar ? 15 : 0, 12, 15, 32, 32);
graphics.fill(x + 365, y + 27, x + 381, y + 97, 0xFF303035);
graphics.fill(x + 366, y + 28, x + 380, y + 96, 0xFF6666AA);
int filled = 68 * menu.inkRemaining() / 16;
if (filled > 0) graphics.fill(x + 366, y + 96 - filled, x + 380, y + 96, 0xFF15151B);
}
private void drawTabControl(GuiGraphicsExtractor graphics, int y, int textureY, boolean enabled) {
graphics.blit(RenderPipelines.GUI_TEXTURED, LEGACY_DESK, leftPos, topPos + y, 0, textureY, 58, 9, 256, 256,
enabled ? -1 : 0xFF666666);
}
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
// Legacy leaves the inventory grid unlabeled.
}
@Override public void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractContents(graphics, mouseX, mouseY, partialTick);
if (sortAlphabetically) graphics.outline(leftPos + 58, topPos, 20, 18, 0xFFFFD061);
if (allMode) graphics.outline(leftPos + 80, topPos, 28, 18, 0xFFFFD061);
renderCollection(graphics);
if (draggingPage && pageDragSource != null) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)) {
int x = leftPos + 58 + ((int) mouseX - leftPos - 58) / 31 * 31;
int y = topPos + 20 + ((int) mouseY - topPos - 20) / 41 * 41;
graphics.outline(x, y, 30, 40, 0xFF57D478);
}
graphics.fakeItem(pageDragSource.stack(), mouseX - 8, mouseY - 8);
}
}
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
super.extractTooltip(graphics, mouseX, mouseY);
if (mouseX >= leftPos + 365 && mouseX < leftPos + 381
&& mouseY >= topPos + 27 && mouseY < topPos + 97) {
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.ink",
menu.inkRemaining(), 16), mouseX, mouseY);
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 231
&& mouseY >= topPos && mouseY < topPos + 18) {
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.category",
selectedCategory == null
? Component.translatable("screen.rivencraft.writing_desk.all_categories")
: Component.literal(selectedCategory.toString())), mouseX, mouseY);
}
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int localY = mouseY - topPos;
if (localY >= 20 && localY < 29)
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.previous_tabs"), mouseX, mouseY);
else if (localY >= 177 && localY < 186)
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.next_tabs"), mouseX, mouseY);
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
graphics.setTooltipForNextFrame(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
tab), mouseX, mouseY);
}
}
}
private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(Rivencraft.PAGE.get())) return List.of(collection);
if (CollationFolderItem.isCollection(collection)) {
return collection.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
}
private void renderCollection(GuiGraphicsExtractor graphics) {
if (lastCollection != menu.activeCollection()) {
lastCollection = menu.activeCollection();
scrollRows = 0;
selectedPage = 0;
selectedCollection = -1;
}
List<DisplayPage> entries = filteredPages();
for (int i = scrollRows * 5; i < entries.size() && i < scrollRows * 5 + 20; i++) {
int column = i % 5;
int row = i / 5 - scrollRows;
DisplayPage entry = entries.get(i);
ItemStack page = entry.stack();
int x = leftPos + 58 + column * 31;
int y = topPos + 20 + row * 41;
drawPage(graphics, page, x, y);
if (page.isEmpty()) graphics.fill(x, y, x + 30, y + 40, 0xB8000000);
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
if (entry.collection() == active && entry.index() == selectedPage && !page.isEmpty())
graphics.outline(x, y, 30, 40, 0xFFFFD061);
}
int active = selectedCollection >= 0 ? selectedCollection : menu.activeCollection();
List<ItemStack> source = pages(active);
if (selectedPage >= source.size() || source.get(selectedPage).isEmpty()) return;
ItemStack selected = source.get(selectedPage);
drawPage(graphics, selected, leftPos + 265, topPos + 26);
graphics.fill(leftPos + 261, topPos + 81, leftPos + 360, topPos + 95, 0xFF202020);
var symbol = PageItem.content(selected).symbol();
if (symbol.isPresent() && PageItem.isCorrupted(symbol.orElseThrow())) {
graphics.enableScissor(leftPos + 263, topPos + 82, leftPos + 358, topPos + 94);
graphics.text(font, selected.getHoverName(), leftPos + 263, topPos + 84,
0xFFE0E0E0, false);
graphics.disableScissor();
} else {
String title = font.plainSubstrByWidth(selected.getHoverName().getString(), 95);
graphics.text(font, title, leftPos + 263, topPos + 84, 0xFFE0E0E0, false);
}
}
private void drawPage(GuiGraphicsExtractor graphics, ItemStack page, int x, int y) {
graphics.nextStratum();
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS, x, y, 156, 0, 30, 40, 256, 256);
if (page.isEmpty()) return;
if (page.has(Rivencraft.SYMBOL_PAGE_ID.get())) {
graphics.nextStratum();
ItemStack glyph = page.copy();
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
graphics.pose().pushMatrix();
graphics.pose().translate(x + 1, y + 6);
graphics.pose().scale(1.75F, 1.75F);
graphics.fakeItem(glyph, 0, 0);
graphics.pose().popMatrix();
graphics.nextStratum();
if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.isPageImplemented(page.get(Rivencraft.SYMBOL_PAGE_ID.get())))
graphics.fill(x, y, x + 30, y + 40, 0x55FF3333);
}
}
private static void drawNumeral(GuiGraphicsExtractor graphics, int number, int x, int y, int size) {
int[] digits = net.dnorberg.rivencraft.writing.PortSymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
Identifier components = Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/symbolcomponents.png");
for (int digit : digits) {
for (int component : net.dnorberg.rivencraft.writing.PortSymbolNumerals.componentsForDigit(digit))
graphics.blit(RenderPipelines.GUI_TEXTURED, components, x, y + (size - digitSize) / 2,
(component % 8) * 64, (component / 8) * 64, digitSize, digitSize, 64, 64, 512, 512, 0xFF211A14);
x += digitSize + 1;
}
}
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x < 0 || x >= 155 || y < 0 || y >= 164 || x % 31 >= 30 || y % 41 >= 40) return null;
int index = (y / 41 + scrollRows) * 5 + x / 31;
List<DisplayPage> entries = filteredPages();
return index < entries.size() ? entries.get(index) : null;
}
private boolean canMovePage(DisplayPage source, DisplayPage target) {
return !source.stack().isEmpty()
&& (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|| menu.getSlot(source.collection()).getItem().is(Rivencraft.PAGE.get()))
&& (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|| menu.getSlot(target.collection()).getItem().is(Rivencraft.PAGE.get()))
&& (source.collection() != target.collection() || source.index() != target.index());
}
private int extractTargetSlot(double mouseX, double mouseY) {
for (Slot slot : menu.slots) {
int index = slot.index;
if (index >= WritingDeskBlockEntity.COLLECTIONS && index <= WritingDeskMenu.COPY_RESULT) continue;
if (!slot.isActive() || !isHovering(slot.x, slot.y, 16, 16, mouseX, mouseY)) continue;
ItemStack held = pageDragSource.stack();
if (!slot.mayPlace(held)) return -1;
ItemStack current = slot.getItem();
if (current.isEmpty()) return index;
if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
List<ItemStack> pages = current.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
return index;
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
&& current.getCount() < slot.getMaxStackSize(held)) return index;
return -1;
}
return -1;
}
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT) {
pageDragSource = null;
draggingPage = false;
}
int surfaceX = (int) Math.floor(mouseX - leftPos - 58);
int surfaceY = (int) Math.floor(mouseY - topPos - 20);
if ((button == InputConstants.MOUSE_BUTTON_LEFT || button == InputConstants.MOUSE_BUTTON_RIGHT) && !menu.getCarried().isEmpty()
&& surfaceX >= 0 && surfaceX < 155 && surfaceY >= 0 && surfaceY < 164) {
if (menu.getCarried().is(Rivencraft.PAGE.get())
&& PageItem.content(menu.getCarried()).kind() != PageContent.Kind.LINK_PANEL
&& surfaceX % 31 < 30 && surfaceY % 41 < 40) {
int index = (surfaceY / 41 + scrollRows) * 5 + surfaceX / 31;
List<DisplayPage> entries = filteredPages();
// Only displayed positions are addressable. Filtering never turns
// empty screen space into additional storage for a loose-page tab.
int collection = index < entries.size() ? entries.get(index).collection() : menu.activeCollection();
int page = index < entries.size() ? entries.get(index).index() : index;
if (index < entries.size() && page < menu.pageCapacity(collection)
&& minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.PLACE_PAGE_BUTTON_BASE + collection * NotebookContents.MAX_PAGES + page);
}
}
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT) {
if (mouseX >= leftPos && mouseX < leftPos + 58) {
int y = (int) mouseY - topPos;
if (y >= 20 && y < 29 || y >= 177 && y < 186) {
if (minecraft != null && minecraft.gameMode != null)
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
y < 29 ? WritingDeskMenu.PREVIOUS_TABS : WritingDeskMenu.NEXT_TABS);
return true;
}
}
if (mouseX >= leftPos + 213 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
draggingScrollbar = true;
setScrollFromMouse(mouseY);
return true;
}
int x = (int) Math.floor(mouseX - leftPos - 58);
int y = (int) Math.floor(mouseY - topPos - 20);
if (x >= 0 && x < 155 && y >= 0 && y < 164) {
DisplayPage entry = pageAtMouse(mouseX, mouseY);
if (entry != null && !entry.stack().isEmpty()) {
selectedCollection = entry.collection();
selectedPage = entry.index();
if (menu.getCarried().isEmpty()) {
pageDragSource = entry;
pageDragStartX = mouseX;
pageDragStartY = mouseY;
}
if (minecraft != null && minecraft.gameMode != null) {
int id = WritingDeskMenu.ALL_PAGE_BUTTON_BASE
+ entry.collection() * NotebookContents.MAX_PAGES + entry.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
}
return true;
}
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && mouseX >= leftPos && mouseX < leftPos + 58) {
for (int i = 0; i < 4; i++) {
int top = topPos + 29 + i * 37;
boolean overSlot = mouseX >= leftPos + 36 && mouseX < leftPos + 54
&& mouseY >= top + 4 && mouseY < top + 22;
if (mouseY >= top && mouseY < top + 37 && !overSlot) {
allMode = false;
selectedCollection = -1;
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, menu.firstVisibleTab() + i);
}
return true;
}
}
}
return super.mouseClicked(event, doubleClick);
}
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
if (mouseX >= leftPos + 58 && mouseX < leftPos + 233
&& mouseY >= topPos + 20 && mouseY < topPos + 186) {
int rows = (filteredPages().size() + 4) / 5;
scrollRows = Math.clamp(scrollRows - (int) Math.signum(scrollY), 0, Math.max(0, rows - 4));
return true;
}
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
}
private void setScrollFromMouse(double mouseY) {
int rows = (filteredPages().size() + 4) / 5;
int max = Math.max(0, rows - 4);
scrollRows = Math.clamp((int) Math.round((mouseY - topPos - 31.5) * max / 143.0), 0, max);
}
@Override public boolean mouseDragged(MouseButtonEvent event, double dragX, double dragY) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT && pageDragSource != null) {
if (Math.abs(mouseX - pageDragStartX) >= 4 || Math.abs(mouseY - pageDragStartY) >= 4)
draggingPage = true;
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && draggingScrollbar) {
setScrollFromMouse(mouseY);
return true;
}
return super.mouseDragged(event, dragX, dragY);
}
@Override public boolean mouseReleased(MouseButtonEvent event) {
double mouseX = event.x(), mouseY = event.y();
int button = event.button();
if (button == InputConstants.MOUSE_BUTTON_LEFT && pageDragSource != null) {
if (draggingPage) {
DisplayPage target = pageAtMouse(mouseX, mouseY);
if (target != null && canMovePage(pageDragSource, target)
&& minecraft != null && minecraft.gameMode != null) {
selectedCollection = target.collection();
selectedPage = target.index();
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.movePageButtonId(pageDragSource.collection(), pageDragSource.index(),
target.collection(), target.index()));
} else if (target == null && minecraft != null && minecraft.gameMode != null) {
int targetSlot = extractTargetSlot(mouseX, mouseY);
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
WritingDeskMenu.extractPageButtonId(pageDragSource.collection(), pageDragSource.index(),
targetSlot));
}
}
pageDragSource = null;
draggingPage = false;
return true;
}
if (button == InputConstants.MOUSE_BUTTON_LEFT && draggingScrollbar) {
draggingScrollbar = false;
return true;
}
return super.mouseReleased(event);
}
@Override public boolean keyPressed(KeyEvent event) {
if (event.isEscape()) return super.keyPressed(event);
if (search != null && search.isFocused()) {
search.keyPressed(event);
return true;
}
if (minecraft != null && minecraft.gameMode != null) {
if (event.key() == InputConstants.KEY_PAGEUP) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.PREVIOUS_TABS);
return true;
}
if (event.key() == InputConstants.KEY_PAGEDOWN) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, WritingDeskMenu.NEXT_TABS);
return true;
}
}
return super.keyPressed(event);
}
}
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.entity;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.Vec3;
/** Recoverable open-book entity, with the 1.21.1 decay and weather behavior. */
public final class DroppedBookEntity extends ItemEntity {
private static final int SLOW_DECAY_INTERVAL = 10_000;
private int decayTicks;
public DroppedBookEntity(EntityType<? extends DroppedBookEntity> type, Level level) { super(type, level); }
public DroppedBookEntity(Level level, Vec3 position, ItemStack book) {
this(Rivencraft.DROPPED_BOOK.get(), level);
setPos(position.x, position.y + 0.25, position.z);
setDeltaMovement(Vec3.ZERO);
setItem(book);
setUnlimitedLifetime();
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();
if (!(level() instanceof ServerLevel serverLevel) || isRemoved()) return;
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
decayTicks = 0;
hurtServer(serverLevel, damageSources().starve(), 1.0F);
}
if (!isRemoved() && isInWaterOrRain()) hurtServer(serverLevel, damageSources().drown(), 1.0F);
}
@Override
protected void addAdditionalSaveData(ValueOutput output) {
super.addAdditionalSaveData(output);
output.putInt("BookDecayTicks", decayTicks);
}
@Override
protected void readAdditionalSaveData(ValueInput input) {
super.readAdditionalSaveData(input);
decayTicks = Math.clamp(input.getIntOr("BookDecayTicks", 0), 0, SLOW_DECAY_INTERVAL - 1);
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.world.item.ItemStack;
/** Legacy's compact page collection, using the existing Notebook page editor. */
public final class CollationFolderItem extends NotebookItem {
public static final int MAX_PAGES = 27;
public CollationFolderItem(Properties properties) {
super(properties);
}
public static boolean isCollection(ItemStack stack) {
return stack.is(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.COLLATION_FOLDER.get())
|| stack.is(Rivencraft.SYMBOL_PORTFOLIO.get());
}
public static int capacity(ItemStack stack) {
return stack.is(Rivencraft.COLLATION_FOLDER.get()) ? MAX_PAGES
: stack.is(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.SYMBOL_PORTFOLIO.get())
? NotebookContents.MAX_PAGES : 0;
}
}
@@ -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);
}
}
@@ -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,6 +39,10 @@ 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)";
@@ -71,14 +74,20 @@ 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) 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,27 +97,41 @@ 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"));
@@ -0,0 +1,44 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
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;
/** The 1.21.1 collection editor opened through a server-owned menu. */
public class NotebookItem extends Item {
public NotebookItem(Properties properties) { super(properties); }
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
ItemStack notebook = player.getItemInHand(hand);
if (player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new NotebookMenu(id, inventory, hand), notebook.getHoverName()),
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
}
return InteractionResult.SUCCESS;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
var contents = stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.accept(Component.translatable("item.rivencraft.notebook.pages", contents.pageCount(),
CollationFolderItem.capacity(stack)).withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) tooltip.accept(Component.translatable(
"item.rivencraft.notebook.unresolved", contents.unresolvedCount()).withStyle(ChatFormatting.RED));
}
}
@@ -42,6 +42,22 @@ public final class PageItem extends Item {
return stack;
}
public static ItemStack createSymbolPage(net.dnorberg.rivencraft.writing.SymbolReference symbol) {
return createSymbolPage(symbol.id());
}
/** Resolved view of the port's existing components; no second saved schema. */
public static net.dnorberg.rivencraft.writing.PageContent content(ItemStack stack) {
var id = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
if (isLinkPanel(stack)) return net.dnorberg.rivencraft.writing.PageContent.LINK_PANEL;
return id == null ? net.dnorberg.rivencraft.writing.PageContent.BLANK
: net.dnorberg.rivencraft.writing.PageContent.symbol(
new net.dnorberg.rivencraft.writing.SymbolReference(id)).resolve();
}
public static boolean isUsableWriting(ItemStack stack) { return content(stack).isResolvedSymbol(); }
public static boolean isCorrupted(net.dnorberg.rivencraft.writing.SymbolReference symbol) { return isCorrupted(symbol.id()); }
@Override
public Component getName(ItemStack stack) {
Component customName = stack.get(DataComponents.CUSTOM_NAME);
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.item;
import java.util.function.Supplier;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.Prediction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
/** Leaves only the active book behind after successful handheld travel. */
public final class PortHandheldTravel {
private PortHandheldTravel() {}
public static String travel(ServerPlayer player, InteractionHand hand, ItemStack book,
Supplier<String> travel) {
if (book.isEmpty() || player.getItemInHand(hand) != book) return "Book is no longer in the active hand";
var source = player.level();
var departure = player.position();
player.setItemInHand(hand, ItemStack.EMPTY);
boolean dropped = false;
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 {
// Rejected travel or a cancelled entity spawn must not consume the book.
if (!dropped) {
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, book);
else if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
}
player.containerMenu.broadcastChanges();
}
}
}
@@ -0,0 +1,43 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.minecraft.server.level.ServerPlayer;
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.level.Level;
/** The 1.21.1 booster item, opened into a 16-page Collation Folder. */
public final class SealedNotebookItem extends Item {
public SealedNotebookItem(Properties properties) { super(properties); }
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS;
ItemStack sealed = player.getItemInHand(hand);
ItemStack folder = open(serverPlayer);
if (folder.isEmpty()) return InteractionResult.FAIL;
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
if (sealed.isEmpty()) player.setItemInHand(hand, folder);
else if (!player.getInventory().add(folder)) player.drop(folder, false, Prediction.SERVER_ONLY);
return InteractionResult.SUCCESS;
}
public static ItemStack open(ServerPlayer player) {
var candidates = PortSymbolPages.sealedCandidateIds();
if (candidates.isEmpty()) return ItemStack.EMPTY;
var pages = new ArrayList<ItemStack>(16);
for (int i = 0; i < 16; i++) pages.add(PageItem.createSymbolPage(
candidates.get(player.getRandom().nextInt(candidates.size()))));
ItemStack folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return folder;
}
}
@@ -0,0 +1,39 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
public final class SymbolPortfolioItem extends NotebookItem {
public SymbolPortfolioItem(Properties properties) {
super(properties);
}
public static List<ItemStack> createVolumes(Identifier category, List<ItemStack> pages) {
if (pages.isEmpty()) return List.of();
int capacity = NotebookContents.MAX_PAGES;
int volumeCount = (pages.size() + capacity - 1) / capacity;
List<ItemStack> volumes = new ArrayList<>(volumeCount);
for (int volume = 0; volume < volumeCount; volume++) {
ItemStack portfolio = new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get());
int first = volume * capacity;
int last = Math.min(first + capacity, pages.size());
portfolio.set(Rivencraft.NOTEBOOK_CONTENTS.get(),
new NotebookContents(pages.subList(first, last)));
Component categoryName = Component.translatableWithFallback(
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
categoryName, volume + 1, volumeCount));
volumes.add(portfolio);
}
return List.copyOf(volumes);
}
}
@@ -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,205 @@
package net.dnorberg.rivencraft.menu;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
import net.minecraft.world.InteractionHand;
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.ContainerInput;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
public final class NotebookMenu extends AbstractContainerMenu {
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
public static final int MOVE_PREVIOUS_BASE = 0;
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
private final Inventory playerInventory;
private final InteractionHand hand;
private final int openedHotbarSlot;
private final NotebookContainer contents;
private final int pageCapacity;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
}
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
super(Rivencraft.NOTEBOOK_MENU.get(), containerId);
this.playerInventory = inventory;
this.hand = hand;
this.openedHotbarSlot = inventory.getSelectedSlot();
this.pageCapacity = CollationFolderItem.capacity(inventory.player.getItemInHand(hand));
this.contents = new NotebookContainer(inventory, hand);
for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, page, -1000, -1000, page < pageCapacity));
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
int inventoryIndex = column + row * 9 + 9;
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(playerSlot(column, 8 + column * 18, 193));
}
}
private Slot playerSlot(int inventoryIndex, int x, int y) {
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
: new Slot(playerInventory, inventoryIndex, x, y);
}
public int pageCapacity() { return pageCapacity; }
@Override
public boolean stillValid(Player player) {
return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().getSelectedSlot() == openedHotbarSlot)
&& CollationFolderItem.isCollection(player.getItemInHand(hand));
}
@Override
public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) {
return ItemStack.EMPTY;
}
ItemStack source = slot.getItem();
ItemStack original = source.copy();
if (index < PLAYER_INVENTORY_START) {
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
return ItemStack.EMPTY;
}
} else {
if (!source.is(Rivencraft.PAGE.get())
|| PageItem.isLinkPanel(source)
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
return ItemStack.EMPTY;
}
}
if (source.isEmpty()) {
slot.setByPlayer(ItemStack.EMPTY);
} else {
slot.setChanged();
}
return original;
}
@Override
public boolean clickMenuButton(Player player, int id) {
int source;
int target;
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
source = id;
target = source - 1;
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
source = id - MOVE_NEXT_BASE;
target = source + 1;
} else {
return false;
}
if (source < 0 || source >= pageCapacity || target < 0 || target >= pageCapacity
|| contents.getItem(source).isEmpty()) {
return false;
}
contents.swap(source, target);
broadcastChanges();
return true;
}
@Override
public void clicked(int slotId, int button, ContainerInput clickType, Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
if (clickType == ContainerInput.SWAP
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
return;
}
super.clicked(slotId, button, clickType, player);
}
private static final class PageSlot extends Slot {
private final boolean enabled;
private PageSlot(Container container, int slot, int x, int y, boolean enabled) {
super(container, slot, x, y);
this.enabled = enabled;
}
@Override public boolean mayPlace(ItemStack stack) {
return enabled && stack.is(Rivencraft.PAGE.get())
&& !PageItem.isLinkPanel(stack);
}
@Override public boolean mayPickup(Player player) { return enabled; }
@Override public int getMaxStackSize() { return 1; }
}
private static final class LockedSlot extends Slot {
private LockedSlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
@Override public boolean mayPickup(Player player) { return false; }
@Override public boolean mayPlace(ItemStack stack) { return false; }
}
private static final class NotebookContainer extends SimpleContainer {
private final Inventory inventory;
private final InteractionHand hand;
private boolean loading;
private NotebookContainer(Inventory inventory, InteractionHand hand) {
super(NOTEBOOK_SLOTS);
this.inventory = inventory;
this.hand = hand;
ItemStack notebook = inventory.player.getItemInHand(hand);
loading = true;
List<ItemStack> saved = notebook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
for (int index = 0; index < saved.size(); index++) {
super.setItem(index, saved.get(index).copy());
}
loading = false;
}
private void swap(int first, int second) {
loading = true;
ItemStack held = getItem(first);
setItem(first, getItem(second));
setItem(second, held);
loading = false;
setChanged();
}
@Override
public void setChanged() {
super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !CollationFolderItem.isCollection(notebook)) {
return;
}
int capacity = CollationFolderItem.capacity(notebook);
NonNullList<ItemStack> saved = NonNullList.withSize(capacity, ItemStack.EMPTY);
for (int index = 0; index < capacity; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
notebook.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
}
}
}
@@ -72,6 +72,14 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
if (player.level().isClientSide()) return true;
int index = id - STRIP_CLICK_BASE;
ItemStack carried = getCarried();
if (net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(carried)) {
ItemStack emptied = blockEntity.importCollection(carried,
Math.min(index == 0 ? 0 : index - 1, blockEntity.nextPageIndex()));
if (emptied.isEmpty()) return false;
setCarried(emptied);
broadcastChanges();
return true;
}
if (!carried.isEmpty()) {
if (index == 0) {
if (!PageItem.isLinkPanel(carried)
@@ -110,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());
@@ -121,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();
}
@@ -145,6 +156,12 @@ public final class PortBookBinderMenu extends AbstractContainerMenu {
} else if (stack.is(Items.LEATHER)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.COVER,
PortBookBinderBlockEntity.COVER + 1, false);
} else if (net.dnorberg.rivencraft.item.CollationFolderItem.isCollection(stack)) {
ItemStack emptied = blockEntity.importCollection(stack, blockEntity.nextPageIndex());
if (emptied.isEmpty()) return ItemStack.EMPTY;
slot.setByPlayer(emptied);
broadcastChanges();
return original;
} else if (PageItem.isLinkPanel(stack)) {
moved = moveItemStackTo(stack, PortBookBinderBlockEntity.PANEL,
PortBookBinderBlockEntity.PANEL + 1, false);
@@ -0,0 +1,469 @@
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;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.dnorberg.rivencraft.network.WritingDeskCatalogPayload;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.PacketDistributor;
import java.util.List;
import net.minecraft.network.RegistryFriendlyByteBuf;
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.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
public final class WritingDeskMenu extends AbstractContainerMenu {
public static final int COPY_RESULT = WritingDeskBlockEntity.SIZE;
public static final int VISIBLE_TABS = 4;
public static final int PREVIOUS_TABS = 90;
public static final int NEXT_TABS = 91;
public static final int PAGE_BUTTON_BASE = 128;
public static final int CATALOG_BUTTON_BASE = 512;
public static final int ALL_PAGE_BUTTON_BASE = 32768;
public static final int PLACE_PAGE_BUTTON_BASE = 65536;
private static final int PAGE_ADDRESS_COUNT = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
public static final int MOVE_PAGE_BUTTON_BASE = PLACE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT;
private static final int PLAYER_START = COPY_RESULT + 1;
private static final int EXTRACT_TARGET_COUNT = PLAYER_START + 36 + 1;
public static final int EXTRACT_PAGE_BUTTON_BASE = MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT;
private final Container desk;
private final WritingDeskBlockEntity blockEntity;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private int syncedActiveCollection;
private int syncedInkRemaining;
private int firstVisibleTab;
private int selectedPage;
private int selectedCollection = -1;
private Identifier selectedSymbol;
private List<SymbolDisplayMetadata> catalog = List.of();
private final ServerPlayer serverViewer;
public WritingDeskMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof WritingDeskBlockEntity found ? found : null);
catalog = WritingDeskCatalog.read(data);
}
public WritingDeskMenu(int id, Inventory inventory, WritingDeskBlockEntity blockEntity) {
super(Rivencraft.WRITING_DESK_MENU.get(), id);
this.blockEntity = blockEntity;
this.desk = blockEntity == null ? new SimpleContainer(WritingDeskBlockEntity.SIZE) : blockEntity;
this.clientSide = inventory.player.level().isClientSide();
this.serverViewer = inventory.player instanceof ServerPlayer serverPlayer ? serverPlayer : null;
if (blockEntity != null && !clientSide) catalog = WritingDeskCatalog.available(blockEntity);
firstVisibleTab = blockEntity == null ? 0 : blockEntity.activeCollection() / VISIBLE_TABS * VISIBLE_TABS;
addDataSlot(new DataSlot() {
@Override public int get() { return firstVisibleTab; }
@Override public void set(int value) { firstVisibleTab = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedActiveCollection : WritingDeskMenu.this.blockEntity.activeCollection(); }
@Override public void set(int value) { syncedActiveCollection = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return WritingDeskMenu.this.blockEntity == null
? syncedInkRemaining : WritingDeskMenu.this.blockEntity.inkRemaining(); }
@Override public void set(int value) { syncedInkRemaining = value; }
});
for (int i = 0; i < WritingDeskBlockEntity.COLLECTIONS; i++) {
addSlot(new DeskSlot(desk, i, 37, 34 + (i % VISIBLE_TABS) * 37, 1));
}
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.INK, 385, 28, 16));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.RETURN, 385, 80, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.PAPER, 241, 28, 64));
addSlot(new DeskSlot(desk, WritingDeskBlockEntity.LEGACY_OUTPUT, -1000, -1000, 64));
addSlot(new CopySlot(result, 0, 241, 80));
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column + row * 9 + 9, 241 + column * 18, 104 + row * 18));
}
}
for (int column = 0; column < 9; column++) {
addSlot(new Slot(inventory, column, 241 + column * 18, 162));
}
}
public int activeCollection() { return syncedActiveCollection; }
public int firstVisibleTab() { return firstVisibleTab; }
public int inkRemaining() { return syncedInkRemaining; }
public int pageCapacity(int collectionIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
ItemStack collection = desk.getItem(collectionIndex);
return CollationFolderItem.isCollection(collection) ? CollationFolderItem.capacity(collection)
: collection.is(Rivencraft.PAGE.get()) ? 1 : 0;
}
public List<SymbolDisplayMetadata> catalog() { return catalog; }
public void setCatalog(List<SymbolDisplayMetadata> symbols) { catalog = List.copyOf(symbols); }
public static int movePageButtonId(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
int target = targetCollection * NotebookContents.MAX_PAGES + targetPage;
return MOVE_PAGE_BUTTON_BASE + source * PAGE_ADDRESS_COUNT + target;
}
/** targetSlot is -1 for the cursor, otherwise a visible player or desk-tab slot. */
public static int extractPageButtonId(int sourceCollection, int sourcePage, int targetSlot) {
int source = sourceCollection * NotebookContents.MAX_PAGES + sourcePage;
return EXTRACT_PAGE_BUTTON_BASE + source * EXTRACT_TARGET_COUNT + targetSlot + 1;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (blockEntity == null || !stillValid(player)) return false;
if (id == PREVIOUS_TABS || id == NEXT_TABS) {
firstVisibleTab = Math.clamp(firstVisibleTab + (id == NEXT_TABS ? VISIBLE_TABS : -VISIBLE_TABS),
0, WritingDeskBlockEntity.COLLECTIONS - VISIBLE_TABS);
return true;
} else if (id >= 0 && id < WritingDeskBlockEntity.COLLECTIONS) {
if (id < firstVisibleTab || id >= firstVisibleTab + VISIBLE_TABS) return false;
blockEntity.setActiveCollection(id);
selectedPage = 0;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= EXTRACT_PAGE_BUTTON_BASE
&& id < EXTRACT_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * EXTRACT_TARGET_COUNT) {
int offset = id - EXTRACT_PAGE_BUTTON_BASE;
int source = offset / EXTRACT_TARGET_COUNT;
int targetSlot = offset % EXTRACT_TARGET_COUNT - 1;
if (!extractPage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
targetSlot)) return false;
} else if (id >= MOVE_PAGE_BUTTON_BASE && id < MOVE_PAGE_BUTTON_BASE + PAGE_ADDRESS_COUNT * PAGE_ADDRESS_COUNT) {
int offset = id - MOVE_PAGE_BUTTON_BASE;
int source = offset / PAGE_ADDRESS_COUNT;
int target = offset % PAGE_ADDRESS_COUNT;
if (!movePage(source / NotebookContents.MAX_PAGES, source % NotebookContents.MAX_PAGES,
target / NotebookContents.MAX_PAGES, target % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PLACE_PAGE_BUTTON_BASE
&& id < PLACE_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - PLACE_PAGE_BUTTON_BASE;
if (!placeCarriedPage(offset / NotebookContents.MAX_PAGES, offset % NotebookContents.MAX_PAGES)) return false;
} else if (id >= PAGE_BUTTON_BASE && id < PAGE_BUTTON_BASE + NotebookContents.MAX_PAGES) {
selectedPage = id - PAGE_BUTTON_BASE;
selectedCollection = -1;
selectedSymbol = null;
} else if (id >= ALL_PAGE_BUTTON_BASE && id < ALL_PAGE_BUTTON_BASE + WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES) {
int offset = id - ALL_PAGE_BUTTON_BASE;
int collection = offset / NotebookContents.MAX_PAGES;
int page = offset % NotebookContents.MAX_PAGES;
if (pageAt(collection, page).isEmpty()) return false;
selectedCollection = collection;
selectedPage = page;
selectedSymbol = null;
} else if (id >= CATALOG_BUTTON_BASE && id < CATALOG_BUTTON_BASE + catalog.size()) {
List<SymbolDisplayMetadata> available = WritingDeskCatalog.available(blockEntity);
if (!catalog.equals(available)) {
refreshCatalog(available);
return false;
}
selectedSymbol = available.get(id - CATALOG_BUTTON_BASE).id();
} else return false;
refreshOutput();
return true;
}
private boolean placeCarriedPage(int collectionIndex, int pageIndex) {
// Resolve the actual storage position on the server, never a filtered list index.
ItemStack carried = getCarried();
if (clientSide || !storablePage(carried)) return false;
ItemStack collection = desk.getItem(collectionIndex);
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex < 0 || pageIndex >= CollationFolderItem.capacity(collection)) return false;
var pages = new java.util.ArrayList<>(collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
if (!pages.get(pageIndex).isEmpty()) return false;
pages.set(pageIndex, carried.copyWithCount(1));
ItemStack updated = collection.copy();
updated.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
desk.setItem(collectionIndex, updated);
} else return false;
carried.shrink(1);
setCarried(carried.isEmpty() ? ItemStack.EMPTY : carried);
selectedCollection = collectionIndex;
selectedPage = pageIndex;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean movePage(int sourceCollection, int sourcePage, int targetCollection, int targetPage) {
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
ItemStack targetBook = desk.getItem(targetCollection);
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
boolean targetNotebook = CollationFolderItem.isCollection(targetBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)
|| targetNotebook && targetPage >= CollationFolderItem.capacity(targetBook)) return false;
if ((!sourceNotebook && (!sourceBook.is(Rivencraft.PAGE.get()) || sourcePage != 0))
|| (!targetNotebook && (!targetBook.is(Rivencraft.PAGE.get()) || targetPage != 0))) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage) : sourceBook;
if (moving.getCount() != 1) return false;
if (!storablePage(moving)) return false;
if (!targetNotebook && targetBook.getCount() != 1) return false;
var targetPages = targetNotebook ? sourceCollection == targetCollection ? sourcePages
: new java.util.ArrayList<>(targetBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (targetNotebook) while (targetPages.size() <= targetPage) targetPages.add(ItemStack.EMPTY);
ItemStack displaced = targetNotebook ? targetPages.get(targetPage) : targetBook;
if (!displaced.isEmpty() && !storablePage(displaced)) return false;
if (targetNotebook) targetPages.set(targetPage, moving);
if (sourceNotebook) {
sourcePages.set(sourcePage, displaced);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, displaced.isEmpty() ? ItemStack.EMPTY : displaced.copyWithCount(1));
if (targetNotebook && sourceCollection != targetCollection) {
ItemStack updatedTarget = targetBook.copy();
updatedTarget.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
desk.setItem(targetCollection, updatedTarget);
} else if (!targetNotebook) desk.setItem(targetCollection, moving.copyWithCount(1));
selectedCollection = targetCollection;
selectedPage = targetPage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)) return false;
if (!sourceNotebook && (!sourceBook.is(Rivencraft.PAGE.get()) || sourcePage != 0)) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
if (sourceNotebook && (sourcePage >= sourcePages.size() || sourcePages.get(sourcePage).isEmpty())) return false;
ItemStack moving = sourceNotebook ? sourcePages.get(sourcePage).copyWithCount(1) : sourceBook.copyWithCount(1);
if (!sourceNotebook && sourceBook.getCount() != 1) return false;
if (targetSlot == -1) {
setCarried(moving);
} else {
if (targetSlot >= WritingDeskBlockEntity.COLLECTIONS && targetSlot < PLAYER_START) return false;
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && !storablePage(moving)) return false;
Slot target = slots.get(targetSlot);
if (!target.isActive() || !target.mayPlace(moving)) return false;
ItemStack current = target.getItem();
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
var targetPages = targetSlot == sourceCollection ? sourcePages
: new java.util.ArrayList<>(current.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
int position = -1;
for (int i = 0; i < CollationFolderItem.capacity(current); i++) {
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
position = i;
break;
}
}
if (position < 0) return false;
while (targetPages.size() <= position) targetPages.add(ItemStack.EMPTY);
targetPages.set(position, moving);
if (targetSlot != sourceCollection) {
ItemStack updatedTarget = current.copy();
updatedTarget.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(targetPages));
target.setByPlayer(updatedTarget);
}
} else if (current.isEmpty()) {
target.setByPlayer(moving);
} else if (targetSlot >= PLAYER_START && ItemStack.isSameItemSameComponents(current, moving)
&& current.getCount() < target.getMaxStackSize(moving)) {
target.setByPlayer(current.copyWithCount(current.getCount() + 1));
} else return false;
}
if (sourceNotebook) {
sourcePages.set(sourcePage, ItemStack.EMPTY);
ItemStack updatedSource = sourceBook.copy();
updatedSource.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(sourcePages));
desk.setItem(sourceCollection, updatedSource);
} else desk.setItem(sourceCollection, ItemStack.EMPTY);
selectedCollection = sourceCollection;
selectedPage = sourcePage;
selectedSymbol = null;
refreshCatalog();
broadcastChanges();
return true;
}
@Override public boolean stillValid(Player player) {
return blockEntity != null && blockEntity.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY;
if (index == COPY_RESULT) {
refreshOutput();
if (!canCopy()) return ItemStack.EMPTY;
ItemStack copy = selectedSource().copyWithCount(1);
ItemStack movingCopy = copy.copy();
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
refreshOutput();
return copy;
}
Slot slot = slots.get(index);
if (!slot.isActive()) return ItemStack.EMPTY;
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack moving = slot.getItem();
ItemStack original = moving.copy();
if (index < COPY_RESULT) {
if (!moveItemStackTo(moving, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveIntoDesk(moving)) {
return ItemStack.EMPTY;
}
if (moving.isEmpty()) slot.setByPlayer(ItemStack.EMPTY);
else slot.setChanged();
return original;
}
private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(Rivencraft.INK_VIAL.get())) return moveItemStackTo(moving,
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
if (CollationFolderItem.isCollection(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false);
if (storablePage(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
return false;
}
private static boolean storablePage(ItemStack stack) {
return stack.is(Rivencraft.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
}
private ItemStack selectedSource() {
if (selectedSymbol != null) {
SymbolReference reference = new SymbolReference(selectedSymbol);
return PortSymbolPages.find(reference.id()) != null
&& blockEntity != null
&& WritingDeskCatalog.available(blockEntity).stream()
.anyMatch(entry -> entry.id().equals(selectedSymbol))
? PageItem.createSymbolPage(reference) : ItemStack.EMPTY;
}
int collectionIndex = selectedCollection >= 0 ? selectedCollection
: blockEntity == null ? syncedActiveCollection : blockEntity.activeCollection();
return pageAt(collectionIndex, selectedPage);
}
private ItemStack pageAt(int collectionIndex, int pageIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(Rivencraft.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex >= CollationFolderItem.capacity(collection)) return ItemStack.EMPTY;
List<ItemStack> pages = collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
}
return ItemStack.EMPTY;
}
private boolean canCopy() {
ItemStack source = selectedSource();
return blockEntity != null && source.is(Rivencraft.PAGE.get())
&& PageItem.content(source).kind() != PageContent.Kind.BLANK
&& PageItem.content(source).kind() != PageContent.Kind.LINK_PANEL
&& PageItem.isUsableWriting(source) && blockEntity.canConsumeCopyMaterials();
}
private void refreshOutput() {
if (clientSide) return;
if (blockEntity != null) blockEntity.setDisplayPage(selectedSource());
ItemStack preview = canCopy() ? selectedSource().copyWithCount(1) : ItemStack.EMPTY;
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
private void refreshCatalog() {
if (clientSide || blockEntity == null) return;
refreshCatalog(WritingDeskCatalog.available(blockEntity));
}
private void refreshCatalog(List<SymbolDisplayMetadata> current) {
if (clientSide || blockEntity == null) return;
if (!current.equals(catalog)) {
catalog = current;
if (serverViewer != null && serverViewer.containerMenu == this) {
PacketDistributor.sendToPlayer(serverViewer, new WritingDeskCatalogPayload(containerId, catalog));
}
}
}
@Override public void broadcastChanges() {
refreshCatalog();
refreshOutput();
super.broadcastChanges();
}
@Override public void clicked(int slotId, int button, net.minecraft.world.inventory.ContainerInput clickType,
Player player) {
if (!stillValid(player)) {
player.closeContainer();
return;
}
refreshCatalog();
refreshOutput();
if (slotId >= 0 && slotId < slots.size() && !slots.get(slotId).isActive()) return;
super.clicked(slotId, button, clickType, player);
}
private final class CopySlot extends Slot {
private CopySlot(Container container, int index, int x, int y) { super(container, index, x, y); }
@Override public boolean mayPlace(ItemStack stack) { return false; }
@Override public boolean mayPickup(Player player) {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@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);
}
}
private final class DeskSlot extends Slot {
private final int limit;
private DeskSlot(Container container, int index, int x, int y, int limit) {
super(container, index, x, y);
this.limit = limit;
}
@Override public boolean isActive() {
int slot = getContainerSlot();
return slot >= WritingDeskBlockEntity.COLLECTIONS
|| slot >= firstVisibleTab && slot < firstVisibleTab + VISIBLE_TABS;
}
@Override public boolean mayPickup(Player player) { return isActive(); }
@Override public boolean mayPlace(ItemStack stack) { return isActive() && container.canPlaceItem(getContainerSlot(), stack); }
@Override public int getMaxStackSize() { return limit; }
}
}
@@ -33,10 +33,15 @@ public final class PortBookNetworking {
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
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);
@@ -72,6 +77,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);
@@ -82,7 +88,8 @@ public final class PortBookNetworking {
private static void use(ServerPlayer player, UseLinkingBookPayload payload) {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (!stack.is(Rivencraft.LINKING_BOOK.get())) return;
String failure = LinkingBookItem.travel(player, stack);
String failure = net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player,
hand(payload.offhand()), stack, () -> LinkingBookItem.travel(player, stack));
if (failure != null) player.sendOverlayMessage(Component.literal(failure));
}
@@ -94,6 +101,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);
@@ -110,8 +118,20 @@ public final class PortBookNetworking {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
long seed = player.getRandom().nextLong();
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
PortAgeTerrain terrain;
List<Identifier> generationPages;
try {
biomePlan = PortAuthoredWriting.biomePlan(pages, player.level().getServer(), seed);
if (pages.isEmpty()) {
var random = PortRandomAgeSelection.select(player.level().getServer(), seed);
terrain = random.terrain();
biomePlan = random.biomes();
generationPages = pages;
} else {
var selected = net.dnorberg.rivencraft.writing.PortAuthoredTerrainSelection.select(player.level().getServer(), pages, seed);
terrain = selected.terrain();
biomePlan = selected.biomes();
generationPages = selected.generationPages();
}
} catch (IllegalArgumentException exception) {
player.sendOverlayMessage(Component.literal("Age writing unavailable: " + exception.getMessage()));
return null;
@@ -128,25 +148,22 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
PortAgeTerrain terrain = PortAuthoredWriting.terrain(pages);
if (pages.isEmpty()) {
PortRandomAgeSelection.Selection random = PortRandomAgeSelection.select(server, seed);
terrain = random.terrain();
biomePlan = random.biomes();
}
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain);
var randomFeatures = pages.isEmpty()
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
randomFeatures == null ? PortAuthoredWriting.rockPlan(pages, seed) : randomFeatures.rocks(),
PortAuthoredWriting.vegetationPlan(pages),
randomFeatures == null ? PortAuthoredWriting.dungeonCount(pages) : randomFeatures.dungeons(),
randomFeatures == null ? PortAuthoredWriting.lakePlan(pages, seed) : randomFeatures.lakes(),
randomFeatures == null ? PortAuthoredWriting.terrainFeaturePlan(pages, seed) : randomFeatures.terrain(),
var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
PortAuthoredWriting.vegetationPlan(generationPages),
randomFeatures == null ? PortAuthoredWriting.dungeonCount(generationPages) : randomFeatures.dungeons(),
randomFeatures == null ? PortAuthoredWriting.lakePlan(generationPages, seed) : randomFeatures.lakes(),
randomFeatures == null ? PortAuthoredWriting.terrainFeaturePlan(generationPages, seed) : randomFeatures.terrain(),
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;
@@ -157,16 +174,55 @@ 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 = PortAgeTravel.enter(player, id);
java.util.function.Supplier<String> travel = () -> {
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(), 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 LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
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) {
@@ -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,26 @@
package net.dnorberg.rivencraft.network;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Current server-approved symbols from this open Writing Desk menu. */
public record WritingDeskCatalogPayload(int containerId, List<SymbolDisplayMetadata> symbols)
implements CustomPacketPayload {
public static final Type<WritingDeskCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "writing_desk_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, WritingDeskCatalogPayload> STREAM_CODEC =
StreamCodec.of(
(buffer, payload) -> {
buffer.writeVarInt(payload.containerId());
WritingDeskCatalog.write(buffer, payload.symbols());
},
buffer -> new WritingDeskCatalogPayload(buffer.readVarInt(), WritingDeskCatalog.read(buffer)));
@Override public Type<? extends CustomPacketPayload> 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,80 @@
package net.dnorberg.rivencraft.portal;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.item.LinkDisarmService;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.LinkVisualEffects;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.portal.TeleportTransition;
import net.minecraft.world.phys.Vec3;
/** Portal transport uses the book's arrival rules; no independent portal exit offset. */
public final class PortalBookTravel {
private PortalBookTravel() {}
public static boolean available(ServerLevel source, ItemStack book) {
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
: book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null;
return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null;
}
public static void travel(Entity entity, ServerLevel source, ItemStack book) {
if (entity instanceof ServerPlayer player) {
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
net.dnorberg.rivencraft.network.PortBookNetworking.travelDescriptive(player, book, null, true);
} else {
String failure = LinkingBookItem.travel(player, book, true);
if (failure != null) player.sendOverlayMessage(net.minecraft.network.chat.Component.literal(failure));
else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
}
return;
}
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
if (descriptive) {
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) return;
var landing = PortAgeTravel.resolveLanding(level);
if (landing == null) return;
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
entity.getYRot(), entity.getXRot());
}
if (destination == null || !destination.valid()) return;
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null || !entity.canTeleport(source, target) || !descriptive && target == source && !properties.has(LinkProperties.INTRA_LINKING)) return;
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) return;
Vec3 arrival = new Vec3(destination.x(), destination.y(), destination.z());
target.getChunkAt(feet);
if (!target.noCollision(entity, entity.getBoundingBox().move(arrival.subtract(entity.position())))) return;
Vec3 incoming = entity.getDeltaMovement();
Vec3 velocity = Vec3.ZERO;
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z), yaw = Math.toRadians(destination.yaw());
velocity = new Vec3(speed * Math.cos(yaw), incoming.y + 0.2, speed * Math.sin(yaw));
}
Vec3 departure = entity.position();
// Disarm only after a successful transfer, leaving equipment in the source world.
Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(),
destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET));
if (arrived == null) return;
if (properties.has(LinkProperties.DISARM)) {
LinkDisarmService.disarmAt(arrived, source, departure);
}
arrived.resetFallDistance();
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
var sound = LinkVisualEffects.sound(properties, true);
source.playSound(null, departure.x, departure.y, departure.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
target.playSound(null, arrival.x, arrival.y, arrival.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1);
}
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.portal;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.world.entity.Entity;
/** Session-only portal cooldowns: tick counters must never be saved with players. */
public final class PortalTravelCooldown {
public static final long TICKS = 40;
private static final Map<Entity, Long> LAST_USE = new WeakHashMap<>();
private PortalTravelCooldown() {}
public static boolean blocked(long now, long previous) {
return previous >= 0 && now >= previous && now - previous < TICKS;
}
public static boolean blocked(Entity entity, long now) {
return blocked(now, LAST_USE.getOrDefault(entity, -1L));
}
public static void mark(Entity entity, long now) {
LAST_USE.put(entity, now);
}
public static void forget(Entity entity) {
LAST_USE.remove(entity);
}
public static void clear() {
LAST_USE.clear();
}
}
@@ -0,0 +1,54 @@
package net.dnorberg.rivencraft.portal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
/** Collision callbacks enqueue once; transport occurs after the entering movement packet finishes. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
public final class PortalTravelQueue {
private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long tick) {}
private static final Map<Entity, Pending> PENDING = new IdentityHashMap<>();
private PortalTravelQueue() {}
public static void enqueue(Entity entity, ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long now) {
PENDING.putIfAbsent(entity, new Pending(source, portal.immutable(), receptacle.immutable(), book.copyWithCount(1), now));
}
@SubscribeEvent public static void tick(ServerTickEvent.Post event) {
var server = event.getServer();
var ready = new ArrayList<Map.Entry<Entity, Pending>>();
var iterator = PENDING.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
if (server.getTickCount() <= entry.getValue().tick()) continue;
ready.add(Map.entry(entry.getKey(), entry.getValue()));
iterator.remove();
}
for (var entry : ready) {
Entity entity = entry.getKey();
Pending pending = entry.getValue();
if (entity.isRemoved() || !entity.isAlive() || entity.level() != pending.source()
|| entity instanceof ServerPlayer player && server.getPlayerList().getPlayer(player.getUUID()) != player
|| !pending.source().hasChunkAt(pending.portal()) || !pending.source().hasChunkAt(pending.receptacle())
|| !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal)
|| !pending.receptacle().equals(portal.receptacle())
|| !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder)
|| !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())
|| CrystalPortalNetwork.cellStatus(pending.source(), pending.portal(), pending.receptacle())
!= CrystalPortalNetwork.CellStatus.VALID) continue;
PortalBookTravel.travel(entity, pending.source(), pending.book());
}
}
@SubscribeEvent public static void stopped(ServerStoppedEvent event) { PENDING.clear(); PortalTravelCooldown.clear(); }
}
@@ -23,6 +23,7 @@ public final class ModCreativeTabs {
output.accept(Rivencraft.DESCRIPTIVE_BOOK.get());
output.accept(Rivencraft.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,11 @@ 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.WRITING_DESK_ITEM.get());
output.accept(Rivencraft.NOTEBOOK.get());
output.accept(Rivencraft.COLLATION_FOLDER.get());
output.accept(Rivencraft.SEALED_NOTEBOOK.get());
creativeSymbolPortfolios(parameters.holders()).forEach(output::accept);
})
.build());
TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder()
@@ -65,6 +71,15 @@ public final class ModCreativeTabs {
private ModCreativeTabs() {
}
public static java.util.List<net.minecraft.world.item.ItemStack> creativeSymbolPortfolios(
net.minecraft.core.HolderLookup.Provider registries) {
var portfolios = new java.util.ArrayList<net.minecraft.world.item.ItemStack>();
for (String category : PortSymbolPages.categories()) portfolios.addAll(
net.dnorberg.rivencraft.item.SymbolPortfolioItem.createVolumes(
net.minecraft.resources.Identifier.parse(category), PortSymbolPages.pages(category, registries)));
return java.util.List.copyOf(portfolios);
}
public static void register(IEventBus modEventBus) {
TABS.register(modEventBus);
}
@@ -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();
}
}
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more