Compare commits

..
Author SHA1 Message Date
dnorberg be8440ddb5 Port collections, dropped books, return links and Writing Desk to 26.3
Build / build (push) Waiting to run
2026-11-05 22:04:24 -05:00
dnorberg f13bedd26f Port seeded Age completion and Accelerated pages to 26.3 beta
Build / build (push) Waiting to run
2026-11-05 15:19:11 -05:00
dnorberg 40770841ee Port celestial systems and seeded Age completion to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-04 18:08:12 -05:00
90 changed files with 7577 additions and 148 deletions

No files matched your search

+5 -4
View File
@@ -15,11 +15,12 @@ incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
supports authored terrain/biomes, structure and vegetation pages, independent
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
Normal Time length/phase/direction/bearing and sun-size controls,
and a growing set of generation features,
but does not yet provide full 1.21.1 gameplay or optional-integration parity.
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
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.78-beta.1.md).
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.106-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: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+707
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),
@@ -1303,6 +1374,642 @@ unchanged moon, and travel isolation still require the user's client check.
The server stopped cleanly and its temporary world was deleted. Nothing is
committed, pushed or published without an explicit request.
## Normal Time shared celestial color
`1.0.1.79-beta.1` ports the root `AgeSkyProfile` behavior that consumes a
pending shared color group at Normal Time and applies it to both paired
bodies. `PortAgeColors` now saves an optional `celestial` RGB value, default
-1 (native/untinted), with catalog protocol 7 for synchronization. Existing
sky/ambient fields and old profiles retain their values. Color accumulation
passes through non-color celestial modifiers, clamps at the target, resets
after consumption, and leaves trailing colors assigned to sky as before.
Extraction copies the saved tint into `SkyRenderState`. A narrowly scoped
native sun/moon uniform modification multiplies RGB while retaining alpha;
there are no live world reads during drawing, new textures, pipelines or GPU
buffers. Native weather fading, transparent halos, moon phases, stars and
sunrise glow remain unchanged. Time and effective illumination are not tied
to RGB. State reset/defaults prevent cross-dimension tint leakage. Independent
Sun/Moon pages, multiple bodies and per-body colors remain unported.
The `create_celestial_color` fixture checks three separate target groups,
completed-group clamping, modifier passthrough, consumed-group reset, seeded
unnumbered colors, old codec defaults and catalog round trips. Client checks
must cover a frozen Zenith sun, Nadir moon, mixed colors, transparent halos,
restart and return to an untinted dimension. Server probes validate saved
profiles and attributes, not rendered output. Build passed; disposable-server
fixtures retained separate sky/ambient/body colors through reload, reseed and
restart, including zero/black colors and untouched native defaults. Sampled
time/light remained unchanged. The server stopped cleanly and its temporary
world was deleted. No commit/push/publication is performed without an explicit
request.
## Standalone Sun and Moon controllers
`1.0.1.80-beta.1` extends the existing cycle profile with optional `body_mode`
(paired=0, Sun=1, Moon=2); old profiles default to the paired renderer.
Catalog protocol 8 includes this field. One celestial controller is accepted
per book, with existing Length/Phase/Direction/Angle/Size and shared Color
groups. Multiple controllers and dangling/duplicate modifiers retain the
interim strict rejection rather than losing written pages.
Standalone bodies use a linear angular orbit as in root `AgeSkyProfile.Body`.
Both native body-angle attributes point at that orbit; render-state mode
suppresses the absent counterpart. The selected Moon consumes Size itself;
Normal Time retains its original sun-only sizing. Moon-only sunrise glow is
suppressed before any horizon pose is pushed. Shared RGB/alpha handling and
native buffers/pipelines are reused, with extraction/reset defaults protecting
other dimensions. Missing Sky completion uses Overworld, as before. Explicit
Nether/End/Static still does not draw bodies in this native-renderer slice.
The gameplay skylight attribute follows root `lightAt` and
`LevelSkyBrightnessMixin`: positive cosine altitude, 0.18 for a moon, weather
attenuation, then the 11-level sky-darkening range. It does not infer light
from color or size, and does not bypass Bright/Dark or no-skylight terrain.
Native background/star/moon-phase schedules remain, rather than claiming a
full custom sky port. Seeded default phase/bearing completion, multiple
independent bodies and eclipses remain future work.
The op `create_body` fixture checks all named phases for both bodies, light
weights, numeric size, reverse motion, order rejection, old-profile defaults,
compatible missing Sky and catalog synchronization. Actual body visibility,
moon size, glow transparency and travel isolation require manual client
validation. Build and disposable-server probes passed: Zenith sun/moon
skylight was 15/6, Nadir and horizon were 4; named phase angles were correct.
The paired fixture retained its opposite moon and native lighting. Reload,
reseed and full restart preserved body mode, color, size, bearing and phase.
The test server stopped and its temporary world was deleted. No commit/push/
publication is performed without an explicit request.
## Multiple independent authored celestials
`1.0.1.81-beta.1` adds an optional immutable `PortCelestialBody` list to the
cycle profile and catalog protocol 9. Existing profiles default to an empty
list and use their validated native paired/single path. Books with multiple
controllers compile ordered modifier groups into independent body orbits;
Normal Time expands into opposite-phase sun/moon entries. Colors are consumed
at each controller, with original page indices retained for seeded amounts.
Uncolored bodies reset to native white rather than inheriting the previous
body's tint. Size/phase/length/bearing/direction also reset per group.
The profile constructor and authoring gate enforce the root ten-sun/ten-moon
limit, with Normal Time counting toward both. Duplicate modifiers within a
group and dangling modifiers remain strict errors until grammar recovery is
ported. The first controller continues to govern the shared day timeline and
native background/star/moon-phase schedules. Positive-altitude sunlight sums
to at most 1; only if no sunlight contributes is summed 0.18-weight moonlight
used. Weather and Bright/Dark remain separate. No eclipse attenuation is
included yet.
Extraction resolves body angles into immutable presentation snapshots. The
native body pass is replaced only for a nonempty multi-body list: draw suns
first, then moons with their own color, size and bearing, then native stars
once. Native buffers, pipelines and alpha are reused. Temporary renderer
parameters and pose transforms restore in finally blocks; no world lookup is
performed during drawing. Explicit Nether/End/Static rendering remains a
separate limitation. Eclipse disc occlusion, corona and sky/light attenuation
will be the following slice, not a claim of this implementation.
`create_celestials` checks independent motion/color/size, modifier reset, Normal
Time expansion, ten-pair acceptance/overflow rejection, profile codecs and
catalog round trips. Manual checks cover body placement/alpha, multiple
controllers, restart and ordinary-dimension isolation. Build passed, as did
disposable-server profile/catalog checks, combined-light invariants, reload,
reseed and full restart. Single-body, paired and tinted fixtures retained
their earlier behavior. The test server stopped and its temporary world was
deleted. No commit/push/release is performed without explicit instruction.
## Eclipse effects
`1.0.1.82-beta.1` adapts root `AgeSkyProfile.eclipseFor`, `eclipseAt` and
`lightAt`, plus `RivencraftSkyEffects.drawBody`. The dot-product separation
of normalized orbit directions is compared with the sum of quarter-disc
angular radii; each body takes the strongest opposite-kind contact. This is
root's radial proximity formula, not a new physical disc-area model. Bearing,
phase, size, motion and direction all affect contact. Same-kind and isolated
bodies have no eclipse; only an above-horizon sun contributes global shading.
Sunlight is multiplied by `1 - 0.85 * overlap` before the existing light
aggregation and weather handling. Extraction shades sky RGB by
`1 - 0.65 * strongestVisibleSolarOverlap`. Moon RGB scales by `1 - overlap`;
sun RGB moves toward white by `pow(overlap, 8) * 0.8`. Values are derived from
saved profiles, requiring neither a new saved schema nor packet fields.
Existing multi-body Ages gain the effects on update; single/paired native
paths stay unchanged. Overlap calculations are bounded by the 20-body limit,
and extracted presentation values are reused without live-world drawing reads.
Native transparent glows draw before each body's central ordinary-alpha disc.
A registered pipeline cloned from native Celestial uses conventional alpha
blending; cached center-cropped atlas meshes retain native UV orientation.
An eclipsed moon uses the full-moon core to avoid transparent phase pixels
exposing the sun (root always drew that full-moon center). Weather alpha is
retained. The helper drops meshes on atlas-generation change and closes them
with the renderer. There is no generated art or per-frame mesh allocation.
Explicit Nether/End/Static body rendering remains outside this slice.
`create_eclipse` supplies aligned, partial, clear, below-horizon and moving
fixtures; it checks overlap/color/light constants, same-kind exclusion and
motion out of contact. The multi-body op fixture is renamed
`create_celestials` to avoid merging with the unrelated existing terrain
`create_multi` command. Manual checks must confirm disc opacity, halo,
corona/sky darkening, resource reload and travel isolation. Build passed.
Disposable-server fixtures passed with overlap 1/0.293/0 for aligned/partial/
clear cases and no global shading for below-horizon alignment. Aligned
sunlight was 0.15, clear sunlight 1; sampled skylight was 6/12/15 for aligned/
partial/clear. Moving contact ended as expected. Reload, reseed, catalog
round trips and full restart retained profiles and derived light behavior;
earlier standalone/paired fixtures remained unchanged. The test server stopped
and its temporary world was deleted. No commit/push/publication is performed
without an explicit request.
## Authored moon sprite correction
`1.0.1.83-beta.1` removes the mixed native-phase/full-core adaptation introduced
with the opaque eclipse pass. Standalone and multi-body authored moons now
draw full-moon glow and center consistently, matching root's custom renderer,
which always samples the first (full-moon) atlas cell. Under ordinary alpha,
native dark phase pixels can produce black opaque centers; the old overlay
glow had made those dark pixels effectively invisible. The ordinary native
Normal Time pair retains its calendar phases. No eclipse math, saved profile,
catalog protocol or world data changes are needed.
The user's `celest` Age was read without modification. Its body colors are
all -1 (native white), not corrupted black values. Its first Moon and second
Sun both have period 1, phase 0, origin 0, clockwise direction and native
bearing -1; they remain exactly coincident. That moon's full eclipse and its
occlusion of the sun are expected under the newly added root overlap rules,
independent of the phase-sprite correction. Do not silently alter player-authored
orbits to conceal the intentional alignment. Manual checks cover nonaligned
moons, the known permanent eclipse, Native Normal Time, resource reload and
cross-dimension isolation. No user save was modified, and nothing is committed,
pushed or published without explicit instruction.
## Celestials over explicit non-Overworld skies
`1.0.1.84-beta.1` removes the presentation exclusion that hid authored bodies
over Nether/End/Static. Root `RivencraftSkyEffects` draws its body list after
any chosen background. The native 26.3 paths instead returned after End or
skipped a NONE sky entirely. An extracted overlay-mode flag now schedules an
authored body layer without changing the actual dimension type or gameplay.
Single Sun/Moon and Normal Time profiles expand into temporary presentation
bodies on these backgrounds; saved profiles are not rewritten. Existing
Overworld single/paired/multi-body paths stay unchanged.
End uses a RETURN hook that includes the native early return, after its pass
is closed. Static closes its own background pass before drawing the overlay.
Fog-only Nether presentation schedules an extracted END sky pass, suppresses
its End background/flash drawing, and retains the fog clear color. Overlay
passes load existing targets without clearing, reuse native body resources
and suppress additional stars/horizon glow on non-Overworld backgrounds.
Weather alpha is extracted even when the native NONE/END branch omits it.
All presentation flags reset when the render state is reused.
Eclipse shading modulates End/Static background tint; Nether-style fog color
uses the same root shade factor through an environment attribute layer.
Native untinted fog and authored sky colors are both supported; other
backgrounds retain their existing fog behavior. Body lighting and eclipse
geometry do not depend on style. Missing Sky selection still chooses
Overworld for authored celestials. No schema/protocol changes or new assets
are required. The eclipse fixture accepts an optional explicit Sky; server
checks cover saved choices, fog/light behavior and persistence, while visual
layering, no duplication, resource reload and travel isolation require manual
client checks. Build and disposable-server checks passed for all four sky
styles: aligned eclipse skylight stayed 6, clear stayed 15, and Nether fog
scaled by 0.35 only during overlap. No-celestial and tinted-Nether fixtures
retained their expected colors. Saved profiles, style, light and fog survived
reload/reseed and full restart. The test server stopped and its temporary
world was deleted. No commit/push/publication is performed without explicit
request.
## Bare celestial seeded randomization
`1.0.1.85-beta.1` adds per-controller randomization specifically for bare
Sun/Moon groups, as requested for easier testing. This deliberately uses the
root **random-Age** ranges rather than its authored compile defaults (which
reuse shared seeded phase/bearing and default size/color). A dedicated stream
mixes Age seed with controller ordinal, independent of unrelated page indices,
terrain/weather/lighting RNG and sky selection. Palette/mixing and 0.75–1.75
size come from `AgeSkyProfile.random`; 0.2–2 periods with 1/7 stopped chance
come from `AgeTraits.select`. Bearing and phase use the port's existing degree/
tick granularity. Direction is random per body. Counts and body-kind limits
still follow explicitly written controller pages.
The compiler recognizes whether a controller has any written celestial
modifier. Colors already consumed by Sky/Ambient do not suppress randomization.
Any written celestial modifier retains the previous authored path as a whole,
not just individual field overrides; partially authored groups do not gain
new random missing fields in this slice. Normal Time and absent controllers
are unchanged. Bare controllers materialize immutable full body profiles,
including a one-body list for standalone cases. Multi-body compilation carries
their colors/orbits without replacing them with native defaults. No new
saved schema, protocol or client renderer code is needed.
Only newly created Ages compile these defaults. Older saved profiles load
unchanged; GUI opening, restart and terrain reseeding do not reroll them.
`create_random_celestials` checks reproducibility, ordinal independence,
unrelated-page stability, 128-seed bounds/pool coverage and unchanged authored/
Normal Time behavior. The regular fixtures still exercise explicit groups.
Manual checks cover visible variation, moving/stopped bodies, mixed authored
groups and restart. Build and disposable-server checks passed, including
single bare bodies, multi-body/128-seed coverage, catalog round trips,
unrelated-page stability, explicit/eclipsed fixtures, reload, terrain reseed
and full restart. Saved random parameters remained unchanged while moving
orbits continued with the clock. The server stopped and its temporary world
was deleted. No commit/push/publication occurs without explicit request.
## Missing celestial branch completion
`1.0.1.86-beta.1` adds creation-only `PortDayCycle.resolve`. Written controllers
delegate to the validated compiler unchanged. If none exist, the root
`AgeSkyProfile.random` draw positions select 0–3 suns and 0–3 moons: consume
the sky-style draw and mixed sky-color draws, then the two counts. Body
settings reuse .85's independent per-controller streams. This matches root's
unrestricted count model, not bit-identical per-body values; the .85 per-body
length/direction independence is retained. Missing sky tint completion and
random-page policy parity are not claimed by this slice.
The shared resolver is used by player book creation and the `create_random`
operator command. Other exact diagnostic fixtures continue using `compile`
or native profiles to isolate their subject. A saved NONE body mode (3)
represents a resolved zero-body sky. Its sun/moon/horizon drawing is suppressed
without switching to Dark lighting or changing the native background/star
timeline. Old missing profiles still decode to NATIVE rather than being
retroactively completed. The mode-domain extension uses catalog protocol 10.
No generation runs or rerolls happen during GUI rendering, load or reseed.
`create_completed_sky` tests 256-seed count bounds and coverage of empty,
sun-only, moon-only and mixed results; reproducibility and unrelated-page
stability; explicit-controller precedence; old defaults; and catalog round
trips. Manual checks cover varied new books, empty skies, explicit-controller
counts, all backgrounds, restart and Overworld isolation. No commit/push/
publication is performed without explicit instruction.
The .86 build and disposable dedicated-server checks passed: four background
fixtures, the seed-14 empty sky, random Age creation, explicit controllers,
catalog round trips, unload/reload, terrain reseeding and full restart. Saved
body parameters survived unchanged. The test server was stopped and its world
deleted; client visual confirmation remains a manual check.
## Random Age sky-color completion
`1.0.1.87-beta.1` uses creation-only `PortAgeColors.resolve` for player books
and `age create_random`. Empty symbol lists choose a sky tint from root
`AgeSkyProfile.random`'s palette and draw order, including its rounded channel
averages. Nonempty symbol lists delegate to the existing compiler unchanged.
This intentionally distinguishes wholly random Ages from authored books with
an omitted sky-color branch, as 1.21.1 does. The existing .85 per-body random
color truncation is preserved by a shared palette helper; celestial count and
body random streams do not change.
Colors are stored in the existing profile and catalog fields. Old native
colors are not retroactively randomized; reload, restart and terrain reseed
do not rerun completion. No protocol update is necessary. The completed-sky
diagnostic checks six root seed/color vectors, codec persistence, authored
precedence (including explicit black) and old-profile defaults. Random-page
blocklist parity remains separate pending work.
Build and disposable dedicated-server checks passed: root vectors and authored
precedence, color attributes/catalog round trips for all four background
styles, explicit sky/ambient colors, unload/reload, reseed and full restart.
Saved random colors stayed unchanged after reseeding and restart. The test
server was stopped and its disposable world deleted. Client rendering is
still a manual check. No commit, push or publication was performed.
## Authored biome branch completion
`1.0.1.88-beta.1` ports the explicit-controller missing-pool behavior from
1.21.1 `AgeBiomeSelection.compile`: Single requires one biome; other nonnative
controllers require two distinct biomes. An entirely omitted pool uses the
seeded 2:1 continuation/stop draw up to the eligible pool size. Authored pools
fill only to the minimum, preserve order and duplicate weights, and never add
an unrequested third biome once the minimum is met. Native still disallows
biome pages. The binder's availability validation accepts incomplete pools;
the server resolves actual registry eligibility before creating the Age.
Player creation now passes the actual Age seed to biome compilation. Exact
older diagnostics retain the registry-only compiler; the new completion
fixture exercises the production seeded path. Cave uses recognized Cave and
Nether biomes. Void does not inject The Void alongside authored biomes and
reports a clear distinct-biome error when completion is impossible. Pools
are stored in existing `PortBiomePlan` fields and never rerolled on restart
or terrain reseed. No protocol change is needed. Broader writing recovery,
structure-driven missing-biome selection and random-page policies remain
separate parity work.
The build and disposable dedicated-server checks passed. The completion
fixture checked every distribution over 32 seeds, partial/full/weighted pools
and codec round trips. Created Standard Single, Flat Tiny, Cave Tiny and Void
Single plans; Cave included the new 26.3 Sulfur Caves biome. An impossible
controller-only Void Tiny pool was refused with the distinct-biome message.
Generator pools survived unload/reload, reseed and full server restart.
The server was stopped and the temporary world deleted. Binder interaction
and in-game biome layouts still require manual validation. No commit, push
or publication was performed.
## Unpaired material completion
`1.0.1.89-beta.1` adapts 1.21.1 `AgeFeatureSelection.implied`. A shared pure
normalizer expands an unpaired material into an internal material/feature
pair, keyed by Age seed, material ID and its original book index. Rock, lake
and terrain-feature compilers all use the same completed view. Original book
pages stay unchanged and no random selection happens at render/load time.
Explicit feature targets suppress completion, including incompatible targets
that must still be rejected by the current strict grammar.
Candidate ordering and terrain restrictions follow root's material-feature
list: Rocks/Floating Islands/Tendrils/Spheres/Spikes/Obelisks for solids and
the three lake types for fluids, filtered by supported terrain. Cave only
permits the two terrain-independent solid features; Void also permits floating
islands; Flat excludes Rocks. No fallback fluid feature exists on Flat, Cave
or Void. This does not add page-free random features, nor remove the existing
per-feature caps or introduce instability recovery. Resulting plans use their
existing save codecs, so no catalog/protocol change is needed.
The operator fixture checks 64 seeds on each terrain: independent consecutive
materials, deterministic/idempotent completion, explicit precedence, candidate
coverage, fluid retention and incompatible-fluid rejection. Persistence and
actual generator plans are inspected with `feature_plan` without generating
chunks. Client binder interaction and feature visibility remain manual checks.
The .89 build and disposable server checks passed: seeded fixture assertions,
Standard Glowstone Spikes, Cave Diamond Spheres, Void Stone Tendrils, Flat
Glowstone Obelisks and Standard Water Deep Lakes. Cave Water was refused.
Feature plans and materials survived unload/reload, terrain reseed and full
restart. The server was stopped and its temporary world deleted. No commit,
push or publication was performed.
## Page-free random material features
`1.0.1.90-beta.1` adapts the material and rare-formation portion of 1.21.1
`AgeFeatureSelection.randomChoices`. A new creation-only selector shares the
production authored plan compilers rather than duplicating generator logic.
It rolls 0–2 unique material feature types, uses root's candidate ordering and
Legacy rank chooser, removes chosen types, and selects full-collision solids
or source fluids from the registered sorted pools. Terrain eligibility uses
the current port validator. The separate seeded Skylands and Star Fissure
rolls and the 1-in-10 Crystal Formations roll follow root's rules.
Player Link-Panel-only creation and `age create_random` share this selector.
Authored books still use their own feature compilers, including implied
materials from .89. Plans are frozen in existing save fields, without extra
book pages or a protocol change. Old Ages keep their original plans.
Random structures, random-page policies and instability are not claimed by
this slice. Material values can be valuable/hazardous, matching the root pool;
the instability port must account for that before stable release.
`create_random_features` checks 64 seeds across all terrain types: seeded
determinism, counts 0/1/2, unique feature types, all nine candidate families,
terrain compatibility, rare-feature coverage, and plan codec round trips.
`feature_plan` treats an absent feature generator as a valid empty result.
Client feature visibility remains a manual check.
The .90 build and disposable server checks passed. The seeded matrix covered
all six terrains and all nine material feature families. Created surface,
Cave, Flat and Void fixtures, including lakes, rare Skylands and a Star
Fissure, and verified the production `create_random` plan. Seed 0 Standard
correctly produced no features; seed 1 produced a single lake. Saved plans
survived unload/reload, reseed and full restart. The server was stopped and
its temporary world deleted. Nothing was committed, pushed or published.
## Page-free random structures
`1.0.1.91-beta.1` ports `AgeStructureSelection.randomChoices`: an independent
seed stream rolls 0–2 unique candidates using Legacy ranks. Terrain and
registry availability are checked, then the selected biome pool must intersect
the structure set's normal biome eligibility. This is deliberately stricter
than authored pages, which retain their existing biome overrides. Native
Flat uses Plains, Native Cave uses the backend's Lush Caves fallback, and
other native noise terrain uses the Overworld LevelStem biome source. A
non-Native plan uses its saved biome IDs. Abandoned Camps are added after
the root candidate list at rank 3. Cave Dungeons remain excluded by current
port support, whereas root permits that combination; it needs a separate
placement validation before enabling it.
The combined random feature resolver preserves .90 material/rare choices,
adds Dungeons separately, and merges structure pages into the saved terrain
feature plan. A new optional `structure_control` field defaults false for
old profiles and is true for new random Ages, even for an empty selection.
The generator applies its existing controlled-set filter when that flag is
true. This prevents zero choices from accidentally retaining all vanilla
controlled sets. Other native structure families still use their normal
biome gate. Existing authored/old plans are not rewritten, and no catalog
protocol change is needed.
The operator fixture checks seeded counts/uniqueness, all terrains, fixed
Plains/End/Nether biome pools, native defaults, biome eligibility, the empty
controlled-set gate, explicit Village precedence, merge/codec persistence,
and old-profile defaults. Actual placement remains an in-game check.
The .91 build and disposable server checks passed. Fixtures exercised Plains,
Native Flat, Nether-biome Cave, End-biome surface and empty Void selection,
plus the production random-Age command. Village, Fortress and Dungeon plans,
and an intentionally empty controlled plan, survived unload/reload, reseed
and full restart. Namespaced biome arguments were checked. The server was
stopped and its temporary world deleted. No commit, push or publication was
performed; generated structure placement remains a manual client check.
## Cave Dungeon parity
`1.0.1.92-beta.1` closes the Cave Dungeon terrain exclusion documented in .91.
Root `AgeStructureSelection.error` permits Dungeons on noise terrain, which
includes Cave. The 26.3 feature and placement implementation were already in
place; the port's shared lake/dungeon Cave rejection blocked authoring and,
through the availability check, random selection. Splitting that validation
permits Cave Dungeons without allowing Cave lakes or Flat/Void Dungeons.
The two targets' `worldgen/placed_feature/dungeons.json` files compare exactly:
vanilla monster_room, count 8, in-square and full-height uniform placement
using above-bottom/below-top anchors. No legacy Y=0 assumption is introduced.
Vanilla's solid floor/ceiling and opening-count requirements still determine
successful placement. The 32-page cap and per-occurrence deterministic seed
remain unchanged. New random Cave Ages now consider Dungeons automatically;
old frozen plans are not changed.
The extended `create_dungeons` fixture checks Cave stacking/cap and the
remaining lake/Flat/Void restrictions. The random-structure matrix now requires
Cave Dungeon coverage. `dungeons_probe` accepts an optional 1–4 chunk-side
bound for manual tests; it is not run automatically, avoiding a large sync
chunk-generation scan. Actual dungeon placement is handed to the client check.
The .92 build and disposable server checks passed: Cave and surface authored
fixtures, 32-page stacking/overflow, retained lake and Flat/Void rejection,
and random-structure coverage including a Nether Cave Dungeon selection.
Dungeon counts survived unload/reload and full restart; frozen random plans
survived reseed. Placement JSON matched the 1.21.1 file byte-for-byte. The
server was stopped and its temporary world deleted. No commit, push or
publication was performed. No dungeon chunk scan was run automatically.
## Biome-specific Nether and End materials
`1.0.1.93-beta.1` ports root `AgeDimensionService.withBiomeMaterialRules`.
The backend was using `caves.materialRule()` unchanged, which selects
`minecraft:overworld_caves` and can apply Overworld grass to Nether biomes.
The new noise-settings wrapper sequences the five vanilla End biomes' registered
End rule, then the five vanilla Nether biomes' registered Nether rule, then
the original terrain rule. This fixes Cave Nether coverings and the equivalent
End/Nether mismatch in authored Standard, Amplified and Island noise Ages.
It uses the active rule registry rather than copying vanilla material tables.
All non-material settings are preserved. Dry Cave's AIR default fluid, empty
aquifer config and minimum-relative sea level remain intact. Native Nether
surface rules can still place their own lava patches; this does not restore
flooded Cave aquifers. Flat/Void layer materials and custom feature materials
are not rewritten. Old saved Ages pick up the rule when reloaded, but only
newly generated chunks change; no generated blocks are retroactively edited.
No profile field or network protocol change is required.
The creation fixture verifies unchanged geometry/router/default blocks and
fluids, aquifers and sea level for four noise presets, and round-trips the
composed material rule with registry-aware codecs. `material_probe` limits
generation to one terrain-stage chunk and reports actual block counts.
Build, registry-aware rule codec tests and disposable server terrain checks
passed. All five Nether Cave fixtures generated their native material families
without grass/dirt: Nylium, Soul Sand/Soil, Basalt/Blackstone or Netherrack.
Standard The End and Island End Highlands generated End Stone; Lush Cave's
original Overworld materials remained present. Reload retained the rules, and
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
+702 -3
View File
@@ -2,11 +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, 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. Celestial systems, instability, crystal
portals, and much of the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
independent Age weather and lighting, multiple authored or seeded celestial
bodies and eclipses, biome/material completion, and random features/structures.
Instability, crystal portals, remaining discovery/stations, writing recovery and optional
integration parity remain unfinished. The latest published beta is
[1.0.1.106-beta.1](RELEASE_NOTES_1.0.1.106-beta.1.md).
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.
The entries below record how the port was built, including earlier alpha-only
@@ -947,6 +952,700 @@ reload, reseed, restart and unchanged sampled lighting/time. Actual rendered
size remains a manual check. The test server stopped and its world was deleted.
No commit/push/publication is performed until explicitly requested.
The local `1.0.1.79-beta.1` slice adds shared color groups before **Normal
Time**, tinting both its sun and moon as in 1.21.1. Use Number + Red/Green/
Blue/White/Black pages before that controller; Length/Phase/Angle/Size can
appear between the colors and Normal Time without losing the pending group.
Normal Time consumes the group, so it does not tint a later Sky or Ambient
Light Color target. Trailing colors still target the sky. Mixing, seeded
unnumbered amounts and completed-group clamping use the existing color rules.
The tint changes native sun/moon RGB only, retaining sprite transparency,
weather fading, moon phases and existing size/orbit/time settings. It does
not recolor stars, sunrise glow or change gameplay illumination. Older saved
color profiles keep untinted bodies; new profiles save and sync their tint.
Catalog protocol is now 7: use matching client/server builds. Standalone
Sun/Moon controllers and independent per-body colors remain pending.
Test **Link Panel → Zero Length → Zenith Phase → Number 24 → Red → Normal
Time**, optionally with No Weather and Normal Lighting, for a red sun. Use
Nadir Phase to view the same tint on the moon. Mix several colors, add a
separate color group before Sky, and confirm that the Overworld stays normal
after travel. Check glow transparency and persistence after reopening the
world. The op fixture `create_celestial_color <name> <seed>
<rising|zenith|setting|nadir> <red|green|blue|white|black> <0..24>` provides
independently colored sky/ambient plus a frozen body for diagnostics.
Client rendering remains a manual check; nothing is committed or published
without an explicit request.
The local `1.0.1.80-beta.1` slice supports one standalone **Sun** or **Moon**
controller in place of Normal Time. Both accept the existing Color, Length,
Phase, Direction, Angle and Size modifiers (including Number overrides).
Sun omits the paired moon; Moon omits the paired sun and sunrise glow.
Size applies to the selected standalone body, while Normal Time still sizes
only its sun. A missing Sky selects Overworld; explicit Sky remains decisive,
and non-Overworld body rendering is still pending.
Standalone bodies use the root-style linear orbit rather than the native
paired cycle's eased orbit. Zenith puts either body overhead, Nadir puts it
below the world, and Rising/Setting place it at the horizon. Zero Length
freezes that position. Gameplay skylight combines altitude with precipitation
and the root 0.18 moonlight multiplier (overhead sun = 15, overhead moon = 6,
below-horizon body = 4 under clear weather). Bright/Dark rules remain separate.
Native background, stars and moon-phase schedules remain for this slice;
multiple independent bodies, eclipse handling, and full seeded celestial
completion are not yet supported. Catalog protocol is now 8.
Test **Link Panel → Zero Length → Zenith Phase → Large Celestial → Sun**,
then replace Sun with Moon in a new book. Each should show only that body
overhead. Try Number + Color and Size, a moving/reversed cycle, and Nadir;
confirm the paired Normal Time and Overworld are unaffected after travel.
Restart and revisit. The diagnostic `create_body <name> <seed> <sun|moon>
<rising|zenith|setting|nadir>` creates a red, large, east-bearing frozen body.
More than one celestial controller is still rejected, not silently discarded.
Nothing is committed or published without an explicit request.
The local `1.0.1.81-beta.1` slice accepts multiple celestial controllers.
Each Sun/Moon consumes its own preceding Color, Length, Phase, Direction,
Angle and Size group; modifiers reset after that controller. Normal Time
contributes a sun/moon pair with opposite phases and its usual sun-only Size.
The 1.21.1 limit of **ten suns and ten moons** applies, counting pairs toward
both limits. Unsupported duplicate modifiers within one group and dangling
modifiers are still rejected; the full recovery/instability grammar is pending.
Multiple bodies retain independent saved orbits/colors and draw using the
native body resources, with suns before moons. Each has its own linear orbit;
the first controller supplies the shared day timeline and native background/
star/moon-phase schedules. Skylight combines positive-altitude suns (capped at
1), or sums weaker moonlight when no sun contributes, matching the root
aggregation rule. Explicit non-Overworld backgrounds still hide bodies.
Catalog protocol is now 9. Older single-body/paired profiles remain unchanged.
**Eclipse effects are not in this slice:** sky darkening, disc occlusion and
corona effects will be ported next. Visual overlap alone is not an eclipse.
Test two groups in one book: **Zero Length → Zenith Phase → Large Celestial
→ Number 24 → Red → Sun**, then **Zero Length → Rising Phase → Small Celestial
→ Number 24 → Blue → Moon** (with Link Panel first). Each should retain its
own size, color and position. Try a moving/reversed third body, a Normal Time
pair alongside a standalone body, restart, and travel back to an ordinary
Age/Overworld. `/rivencraft_port age create_celestials <name> <seed>` creates a
diagnostic three-body Age. Rendering remains a manual client check. Nothing
is committed or published without an explicit request.
The local `1.0.1.82-beta.1` slice adds eclipse effects to saved multi-body
profiles. Contact is based on the central quarter-size discs, not their
halos. Opposite-kind overlap progressively dims a moon and whitens a sun's
corona. A visible eclipsed sun darkens the sky by up to 65% and reduces its
sunlight contribution by up to 85%, using the 1.21.1 formulas. Other suns can
still supply light; same-kind overlaps do not produce eclipses. Below-horizon
overlap does not darken the visible sky.
Each body's native transparent glow is followed by an ordinary-alpha central
disc, so an eclipsing moon obscures the sun instead of showing it through the
glow. During an eclipse the moon core uses the solid full-moon sprite, matching
root's occluding disc; native phases are retained outside eclipse contact.
Weather fading remains. Meshes are cached, rebuilt after atlas changes and
closed with the sky renderer. No new image assets are needed. Existing Ages
with multiple bodies get these derived effects without rebinding their books.
Test **Link Panel → Zero Length → Zenith Phase → Sun → Zero Length → Zenith
Phase → Large Celestial → Moon** (No Weather and Normal Lighting recommended).
This holds an eclipse overhead. Change the Moon to Half Length in a new book
to watch it pass across the sun. Verify opaque center/transparent glow, sky
and ground darkening, restart and travel back to an ordinary dimension.
The op fixture `/rivencraft_port age create_eclipse <name> <seed>
<aligned|partial|clear|below|moving>` creates controlled comparisons.
The multi-body fixture is now named `create_celestials`, avoiding a collision
with the existing terrain-generation `create_multi` command. Catalog schema
remains protocol 9. Explicit non-Overworld body rendering and broader seeded
completion are still pending. Nothing is committed or published without an
explicit request.
The local `1.0.1.83-beta.1` correction makes standalone and multi-body authored
moons use the **full-moon sprite in both glow and opaque-center passes**, as
the 1.21.1 custom renderer does. Native dark lunar-phase pixels could become
black patches once the new ordinary-alpha center pass made them opaque.
The native Normal Time pair keeps its calendar phases. Eclipse geometry,
darkening and corona formulas are unchanged; genuinely eclipsed moons still
turn dark and obscure their overlapping sun.
Read-only inspection of the reported `celest` save found all four body colors
intact (`-1`, untinted). Its first moon and second sun share period, direction,
phase, origin and bearing, creating a permanent eclipse after eclipse support
was added. This is separate from the phase-sprite rendering correction. No
saved orbits/colors are rewritten, and existing Ages need not be recreated.
Please revisit `celest`, compare a nonaligned authored moon, check an eclipse
and resource reload, then return to Overworld/Normal Time to confirm native
phases are unaffected. This rendering correction still needs a client check.
Nothing is committed or published without an explicit request.
The local `1.0.1.84-beta.1` slice renders authored **Normal Time, Sun, Moon
and multiple bodies over all four Sky styles**, including explicit Nether,
End and animated Static. Backgrounds draw first; the existing authored body
pass follows with its colors, sizes, orbits, opaque centers and eclipse effects.
Nether retains its fog backdrop, End retains its native sky texture, and
Static keeps animating. These are presentation changes only: no Nether/End
terrain, ceiling, coordinate-scale or dragon behavior is introduced.
Eclipses shade End/Static background tint and the Nether-style fog backdrop;
gameplay light still uses the saved body profile. Native Overworld rendering
and no-celestial Ages are unchanged. No new assets, saved fields or catalog
protocol changes are needed. Already-created Ages with explicit backgrounds
and authored celestials gain the rendering on update without rebinding.
Test the same frozen overhead Sun/Moon book with **Nether Sky**, **End Sky**
and **Static Sky**, then test a moving body and eclipse in each. Check body
colors/size, no duplicate bodies, unchanged background style, Static animation,
F3+T, restart and travel to Overworld. The existing diagnostic
`age create_eclipse <name> <seed> <mode> [overworld|nether|end|static]` now
accepts an optional explicit Sky for comparisons. Client rendering remains a
manual check. Nothing is committed or published without an explicit request.
The local `1.0.1.85-beta.1` slice randomizes a **bare Sun or Moon page** when
a new Age is created. Each such controller gets its own deterministic color,
size, bearing, phase, direction and length. Defaults use the root random-Age
palette (mixing 1–3 colors), size 0.75×–1.75×, bearing 0–359°, full-cycle phase,
either direction, and length 0.2–2 days with Legacy's 1-in-7 stopped-cycle
chance. Sun/Moon counts still come from your pages; nothing adds extra bodies.
This applies only when that controller has **no written celestial modifiers**.
If its group includes Color, Size, Angle, Phase, Direction or Length, the
existing explicitly authored behavior is retained, including defaults for
its other fields. Sky/ambient colors consumed by their own target and unrelated
terrain/weather/lighting pages do not make a body authored or reshuffle its
random settings. Normal Time is unchanged. Groups use Age seed plus controller
ordinal; repeated bare pages get independent settings. Choices are saved,
not rerolled on GUI opening, reload/restart or terrain reseeding.
Test a **new** book with **Link Panel → Sun → Moon → Sun**, optionally adding
No Weather and Normal Lighting for visibility. Expect varied colors/sizes/
orbits; some bodies can be below the horizon or stopped. Test an explicitly
modified body in the same book, then restart and revisit. Existing Ages keep
their original saved settings and are not randomized retroactively. The op
fixture `age create_random_celestials <name> <seed>` tests independent seeded
defaults and bounds. Nothing is committed or published without explicit request.
The local `1.0.1.86-beta.1` slice completes the celestial branch for **new
books with no celestial controller pages**, including Link-Panel-only books.
The Age seed chooses **0–3 suns and 0–3 moons**, following the root random
count draws. Each body receives the independent saved settings introduced
in .85. An explicit Sun, Moon or Normal Time suppresses this extra-body
completion entirely; its written controllers remain authoritative.
A valid zero-sun/zero-moon result draws neither body nor sunrise glow, rather
than accidentally falling back to a vanilla pair. This does not force Dark
Lighting: background/star schedules and independent lighting retain their
normal behavior. Random background selection remains independent of body
counts, and all supported Sky styles can render the chosen bodies. Default
sky colors retain their current behavior; this slice completes bodies only.
Choices persist through restart and terrain reseeding. Existing Ages are not
rerolled. Catalog protocol is now **10**, requiring matching client/server
builds. Test several new Link-Panel-only books, plus books that contain only
terrain/weather/lighting/Sky pages. Expect varied counts, including sun-only,
moon-only and empty skies; some bodies may be below the horizon. Compare an
explicit one-Sun book and a Normal Time book, then restart and revisit.
`age create_completed_sky <name> <seed> [overworld|nether|end|static]` provides
clear-weather Flat fixtures and reports a sample zero-body seed. The existing
`age create_random` now also uses the completed celestial plan. Nothing is
committed or published without explicit request.
The local `1.0.1.87-beta.1` slice gives **new Link-Panel-only random Ages**
a saved random sky/fog tint, matching 1.21.1's seeded palette and rounded
mix of 1–3 colors. This completes the sky-color draw already reserved by the
random celestial count selection. Body settings/counts are unchanged.
Books containing symbol pages keep their authored/default color rules; a
black authored sky remains black. Ambient lighting is not randomized by this
change. Existing Ages retain their saved colors. No new saved fields or
catalog protocol change is needed. Create several new Link-Panel-only books,
check sky/fog colors and restart persistence, then compare an explicitly
colored Sky book and an uncolored terrain-only book. Nothing is committed or
published without explicit request.
The local `1.0.1.88-beta.1` slice seed-completes **explicit biome distribution
pages with missing biome pages**. Single Biome alone selects one eligible
biome. Tiled/Grid/region controllers alone choose at least two distinct
biomes, using 1.21.1's seeded continuation rule and the available pool as the
maximum. With one authored biome they fill just the missing second biome;
with two distinct authored biomes they add nothing. Repeated biome pages keep
their weight but do not satisfy the distinct-biome minimum by themselves.
Cave completion draws only recognized Cave/Nether biomes. Void supplies The
Void only when no biome is authored; a multi-biome Void book needs sufficient
explicit biome pages rather than acquiring an unwanted Void biome. Native
still rejects biome pages. No-controller biome behavior is unchanged, and
the broader recovery/instability grammar is not part of this slice.
Test a new Link Panel + Tiny Biome Regions book, Single Biome alone, and Tiny
with one/two specific biome pages. Also try Cave + Tiny and restart/revisit.
The operator fixture is `age create_completed_biomes <name> <seed> <terrain>
<distribution>`; `age biome_pool <name>` inspects the generator without loading
chunks. Existing Age pools do not change. Nothing is committed or published
without explicit request.
The local `1.0.1.89-beta.1` slice supports **unpaired Block/Fluid pages**.
When no feature immediately follows a material, the Age seed and original
page position choose a compatible implied feature, following 1.21.1's model.
Explicit material → feature pairs remain unchanged. Consecutive material
pages each receive their own selection; the resulting choices may repeat.
Only the saved feature plans gain these implied choices, not extra physical
pages in the book.
Solid candidates are Rocks, Floating Islands, Tendrils, Spheres, Spikes and
Obelisks, restricted by terrain. Cave permits Tendrils/Spheres; Void also
permits Floating Islands. Flat excludes Rocks. Fluid candidates are the three
lake types on supported surface noise terrain. An unpaired fluid on Flat,
Void or Cave is still refused because no compatible lake implementation is
available there. Explicit incompatible pairings and existing 32-feature
limits are unchanged; the full recovery/instability grammar remains pending.
Test Link Panel + Glowstone, consecutive Glowstone/Stone pages, and an unpaired
Water page on Standard terrain. Compare an explicit Glowstone → Tendrils
pair, try a solid on Cave/Void, and restart/revisit. Existing Ages retain
their saved features. Operator diagnostics: `age create_implied_material
<name> <seed> <terrain> <material-id>` and `age feature_plan <name>` (no chunk
generation). Nothing is committed or published without explicit request.
The local `1.0.1.90-beta.1` slice adds **random material features to new
Link-Panel-only Ages**. The Age seed rolls 0–2 distinct feature types using
1.21.1's Legacy rank weighting, then chooses a registered compatible solid
or source fluid for each. Candidates are Rocks, Floating Islands, Tendrils,
Spheres, Spikes, Obelisks and the three lake types, filtered by terrain.
The same creation pass rolls rare Skylands (1-in-8 on noise terrain), Crystal
Formations (1-in-10 on exposed surface terrain), and Star Fissures using the
root small-feature grammar. These are additional to the 0–2 material features.
Random structure selection and page-eligibility config remain future port
work. Authored books do not gain unrequested random features, and existing
Age plans are not rerolled. Choices persist through restart and reseed.
Test several new Link-Panel-only books for varied material features, including
Ages with none; compare an explicitly authored book and revisit after restart.
For controlled checks, `age create_random_features <name> <seed> <terrain>`
creates a feature-only fixture and `age feature_plan <name>` reports its
saved generator choices without generating chunks. `age create_random` uses
the same production selector. Nothing is committed or published without
explicit request.
The local `1.0.1.91-beta.1` slice ports **random structure selection** from
1.21.1. New Link-Panel-only Ages roll 0–2 distinct structure families in
addition to .90's material features. Legacy ranks prefer Villages, Strongholds
and Mineshafts, followed by Dungeons/Fortresses; newer structures are rarer.
The 26.3 Abandoned Camps page joins that rare pool. Candidates must support
the terrain and at least one biome in the actual selected biome pool.
A saved structure-control flag distinguishes an intentionally empty selection
from older Ages' native behavior. Unselected page-controlled structure sets
are disabled even when no structures were chosen; other native structure
families remain governed by vanilla biome rules. Authored pages retain their
biome override behavior, and old saved Ages are not changed. Cave Dungeons
remain excluded by the port's existing terrain restriction; this is a known
remaining difference from 1.21.1, not a new root behavior.
Test several new random Ages, including a restart/revisit, and compare an
authored Village book. For focused checks, `age create_random_structures
<name> <seed> <terrain> <biome-id|native>` creates a controlled fixture;
`age feature_plan <name>` reports the frozen selection, including Dungeons.
No catalog protocol change is needed. Nothing is committed or published
without explicit request.
The local `1.0.1.92-beta.1` slice enables **Dungeons on Cave terrain**, including
Nether cave biomes, matching 1.21.1's noise-terrain support. The already-ported
placement JSON is byte-identical to root: eight vanilla monster-room attempts
per page, uniformly from one block above the minimum to one below the maximum
build height. Vanilla still requires a suitable enclosed chamber; an attempt
is not a guaranteed dungeon. Stacking up to 32 pages is unchanged.
Only the combined Cave dungeon/lake rejection was split: lakes remain rejected
there, and Flat/Void still reject Dungeons. New random Cave Ages can now select
Dungeons through .91's normal eligibility/rank rules. Existing saved plans
are unchanged. `age create_dungeons <name> <seed> cave` creates an eight-page
fixture. `age dungeons_probe <name> [chunks]` optionally limits the search to
1–4 chunks per side; omitting it retains the previous 4×4 search. That probe
generates chunks and is reserved for manual placement checks.
Test Cave + Dungeons, optionally stacking pages for visibility; use spectator
mode to find monster rooms and compare a Nether-biome Cave Age. This slice's
server validation checks authoring and persistence without generating a large
region. No saved-field/protocol change is needed. Nothing is committed or
published without explicit request.
The local `1.0.1.93-beta.1` slice fixes **Nether and End biome materials on
noise terrain**, including Nether biomes in Cave Ages. The port had retained
the vanilla terrain's Overworld material rules, which could coat Nether
biomes in grass. It now ports 1.21.1's biome rule layer: the five vanilla End
biomes use the registered End rule, the five vanilla Nether biomes use the
registered Nether rule, and all other biomes retain the terrain's original
rules. Nether coverings include Nylium, Soul Sand/Soil, Basalt and Blackstone
where their native rules select them; End areas use End Stone.
Noise geometry, biome selection, lighting, default fluids, sea level and dry
Cave aquifer settings remain unchanged. This is not a Flat/Void layer rewrite.
The correction applies after reload to **new chunks only** in existing Ages;
already generated grass is not replaced. Test a new Nether-biome Cave Age
and End-biome Standard/Island Age, or explore new chunks after restarting.
Operator fixtures: `age create_biome_surface <name> <seed> <terrain> <biome-id>`
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.
From the repository root:
```sh
@@ -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,83 @@
# Rivencraft 26.3 beta 1.0.1.93-beta.1
Collects the player-validated port slices .79–.93 since 1.0.1.78-beta.1.
This beta is not yet feature-complete with Minecraft 1.21.1.
## Celestial writing and rendering
- Ported standalone Sun and Moon pages and multiple independently authored
bodies, up to ten of each. Each controller consumes its own ordered Color,
Length, Phase, Direction, Angle and Size modifiers; Number pages fine-tune
supported values. Normal Time retains its paired sun/moon behavior.
- Color groups before Normal Time tint both bodies; independent Sun/Moon
groups can use different colors. Authored moons use full-moon sprites as in
1.21.1; native Normal Time retains calendar moon phases.
- Authored bodies render over Overworld, Nether, End and animated Static
backgrounds. Opaque centers retain transparent glows. Eclipse alignment
affects body color, background shading and gameplay sky light.
- Bare Sun/Moon pages receive independent seeded colors, sizes, bearings,
phases, directions and lengths, including stopped cycles. Explicit modifier
groups retain their authored behavior.
- Books without celestial controllers seed-complete 0–3 suns and 0–3 moons.
Empty skies do not accidentally draw a vanilla pair. Explicit controllers
suppress extra-body completion. Link-Panel-only random Ages also receive
seeded sky/fog colors using the 1.21.1 palette.
- All resolved settings are saved, not rerolled by viewing a book, restarting,
or reseeding future terrain. Existing Age profiles remain unchanged.
## Biome and feature completion
- Explicit biome-distribution pages can now seed-complete missing biome
pools. Single selects one; other nonnative controllers require two distinct
biomes. Fully omitted pools use the seeded continuation rule, bounded by the
available biome pool. Authored pools fill only the missing minimum.
- Supplied biome order and duplicate weights are retained. Two distinct
supplied biomes do not acquire an unwanted third biome. Cave pools use
recognized Cave/Nether biomes; Void does not inject The Void alongside
supplied biome pages.
- Unpaired Block/Fluid pages receive seeded terrain-compatible implied
features. Consecutive materials resolve independently; explicit pairs remain
authoritative. Unsupported pairings and the current feature caps remain.
- Link-Panel-only Ages roll 0–2 distinct material feature types with Legacy
rank weighting and compatible registered solids/source fluids. Additional
rare rolls cover Skylands, Crystal Formations and Star Fissures.
- Random Ages also roll 0–2 distinct structure families, filtered by terrain,
registry availability and the chosen biome pool. Newer structures, including
26.3 Abandoned Camps, are rare candidates. An intentionally empty selection
disables page-controlled structure sets rather than enabling all of them.
- Cave terrain now supports authored and randomly selected Dungeons, matching
1.21.1. Dungeon placement retains eight monster-room attempts per page and
modern build-height anchors. Flat/Void Dungeons and Cave lakes remain
unsupported.
## Nether and End material correction
- Ported 1.21.1's biome-specific material-rule layer for noise terrain. Nether
biomes in Cave Ages now use their native Netherrack, Nylium, Soul Sand/Soil,
Basalt and Blackstone rules instead of Overworld grass. End biomes use End
Stone. Other biomes retain the original terrain rules.
- Geometry, biome selection, dry Cave aquifers and other environment settings
are preserved. Flat/Void layers and authored feature materials are unchanged.
- Existing Ages pick up the correction after reload, but only new chunks
change. Already generated grass is not replaced.
## 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 is **10**: use matching client/server builds.
- Player in-game checks passed for all included slices. Builds and targeted
disposable-server checks covered writing, seeded completion, codecs/catalog
round trips, terrain materials, reload, reseed and restart. Temporary servers
and test worlds were removed. This is not a full automated GameTest run.
- Full writing recovery, discovery/stations, instability, portals and optional
integration parity remain unfinished. Random-page eligibility config also
remains pending; valuable/hazardous random materials currently lack the
finished version's instability scoring. Creative availability does not imply
full page support. Number-controlled Static resolution (#109) and time-page
naming (#110) remain separate work.
- Beta save compatibility is not guaranteed. Back up worlds and keep the two
Minecraft version tracks and saves separate.
Report issues and help improve the wiki at
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
@@ -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).
+21
View File
@@ -203,6 +203,23 @@ 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 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
include 'assets/rivencraft/models/item/ink_vial.json'
include 'assets/rivencraft/models/item/crystal_shard.json'
@@ -215,6 +232,8 @@ tasks.named('processResources', ProcessResources).configure {
include 'assets/rivencraft/textures/gui/notebook_pages.png'
include 'assets/rivencraft/textures/gui/bookui_pager.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'
@@ -233,6 +252,8 @@ tasks.named('processResources', ProcessResources).configure {
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.78-beta.1
mod_version=1.0.1.106-beta.1
+31 -26
View File
@@ -9,33 +9,35 @@
"item.rivencraft.page.symbol.implemented": "Implemented in 26.3; book and terrain requirements still apply.",
"symbol.rivencraft.weather.normal.description": "Use an independent normal weather cycle. Cave terrain keeps weather off.",
"symbol.rivencraft.sky.overworld.description": "Use the native Overworld sky. Terrain and lighting remain independently authored.",
"symbol.rivencraft.sky.nether.description": "Use a Nether-style sky and reddish fog, without Nether terrain or gameplay rules.",
"symbol.rivencraft.sky.end.description": "Use the native End sky backdrop, without End terrain or a dragon fight.",
"symbol.rivencraft.sky.static.description": "Animated static surrounds the Age. Preceding color pages tint it; otherwise it is gray.",
"symbol.rivencraft.sky.nether.description": "Use a Nether-style reddish fog backdrop. Authored celestials render over it; terrain and gameplay rules remain independent.",
"symbol.rivencraft.sky.end.description": "Use the native End sky backdrop with authored celestials over it, without End terrain or a dragon fight.",
"symbol.rivencraft.sky.static.description": "Animated static surrounds the Age, behind authored celestials. Preceding colors tint it; otherwise it is gray.",
"symbol.rivencraft.lighting.ambient_color": "Ambient Light Color",
"symbol.rivencraft.lighting.ambient_color.description": "Apply preceding shared colors to ambient light. Use Bright separately for full effective ambient lighting.",
"symbol.rivencraft.number.description": "Before Color: value × 10 per channel. Before Length: value × 0.08 days. Before Phase/Angle: value × 15 degrees. Before Size: 0.25× to 3× (0-24).",
"symbol.rivencraft.size.small.description": "Before Normal Time: render the sun at 0.5× size. Its paired moon stays normal-sized, matching 1.21.1. A preceding Number overrides the size.",
"symbol.rivencraft.size.normal.description": "Before Normal Time: render the sun at 1× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.",
"symbol.rivencraft.size.large.description": "Before Normal Time: render the sun at 2× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.",
"symbol.rivencraft.angle.north.description": "Before Normal Time: set the native celestial orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.east.description": "Before Normal Time: set the native celestial orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.south.description": "Before Normal Time: set the native celestial orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.west.description": "Before Normal Time: set the native celestial orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.phase.rising.description": "Before Normal Time: start this Age's native cycle at sunrise. A preceding Number overrides Phase in 15-degree steps from midnight.",
"symbol.rivencraft.phase.zenith.description": "Before Normal Time: start this Age's native cycle at noon. With Zero Length it remains noon.",
"symbol.rivencraft.phase.setting.description": "Before Normal Time: start this Age's native cycle at sunset. A preceding Number overrides Phase in 15-degree steps from midnight.",
"symbol.rivencraft.phase.nadir.description": "Before Normal Time: start this Age's native cycle at midnight. With Zero Length it remains midnight.",
"symbol.rivencraft.direction.clockwise.description": "Before Normal Time: advance this Age's native day cycle in its usual direction.",
"symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time: run this Age's native day cycle in reverse. Does not change the Overworld clock.",
"symbol.rivencraft.celestial.normal_time.description": "Use the native sun/moon cycle with Length, Phase, Direction, Angle, and sun Size modifiers. Without a Length, use one normal day.",
"symbol.rivencraft.period.zero.description": "Before Normal Time: freeze the Age's day timelines at the written Phase, or a saved seeded time if Phase is omitted. A preceding Number overrides the Length.",
"symbol.rivencraft.period.half.description": "Before Normal Time: a half-length day (twice as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.full.description": "Before Normal Time: a normal-length day. A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.double.description": "Before Normal Time: a double-length day (half as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.color.red.description": "Add red to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.green.description": "Add green to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.blue.description": "Add blue to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.size.small.description": "Before Sun/Moon: 0.5× body size. Before Normal Time: size the sun only. A preceding Number overrides the size.",
"symbol.rivencraft.size.normal.description": "Before Sun/Moon: 1× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).",
"symbol.rivencraft.size.large.description": "Before Sun/Moon: 2× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).",
"symbol.rivencraft.angle.north.description": "Before Normal Time, Sun or Moon: set orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.east.description": "Before Normal Time, Sun or Moon: set orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.south.description": "Before Normal Time, Sun or Moon: set orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.angle.west.description": "Before Normal Time, Sun or Moon: set orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.",
"symbol.rivencraft.phase.rising.description": "Before a celestial controller: start the sun or standalone moon at rising. A preceding Number overrides Phase in 15-degree steps from Nadir.",
"symbol.rivencraft.phase.zenith.description": "Before a celestial controller: start the sun or standalone moon overhead. Zero Length holds this position.",
"symbol.rivencraft.phase.setting.description": "Before a celestial controller: start the sun or standalone moon at setting. A preceding Number overrides Phase in 15-degree steps from Nadir.",
"symbol.rivencraft.phase.nadir.description": "Before a celestial controller: start the sun or standalone moon below the world. Zero Length holds this position.",
"symbol.rivencraft.direction.clockwise.description": "Before Normal Time, Sun or Moon: advance this Age's celestial cycle in its usual direction.",
"symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time, Sun or Moon: reverse this Age's celestial cycle. Does not change the Overworld clock.",
"symbol.rivencraft.celestial.normal_time.description": "Add a sun/moon pair. Preceding modifiers apply to this pair; Size affects its sun only. Colors tint both. Limit ten suns and ten moons per Age.",
"symbol.rivencraft.celestial.sun.description": "Add a sun. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Moon alignment causes eclipses; limit ten suns.",
"symbol.rivencraft.celestial.moon.description": "Add a moon with weaker light. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Sun alignment causes eclipses; limit ten moons.",
"symbol.rivencraft.period.zero.description": "Before Normal Time, Sun or Moon: freeze at the written Phase, or a saved seeded time if omitted. A preceding Number overrides the Length.",
"symbol.rivencraft.period.half.description": "Before Normal Time, Sun or Moon: a half-length cycle (twice as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.full.description": "Before Normal Time, Sun or Moon: a normal-length cycle. A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.period.double.description": "Before Normal Time, Sun or Moon: a double-length cycle (half as fast). A preceding Number sets Length to value × 0.08 days.",
"symbol.rivencraft.color.red.description": "Add red to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.green.description": "Add green to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.blue.description": "Add blue to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.white.description": "Add equally to all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.color.black.description": "Subtract equally from all RGB channels. Precede with a Number, or use a seeded amount.",
"symbol.rivencraft.lighting.normal.description": "Use normal skylight and ambient brightness. Cave terrain remains without skylight.",
@@ -82,9 +84,12 @@
"symbol.rivencraft.feature.red_shrubs.description": "Add red shrub patches to surface terrain with suitable ground.",
"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": "Place immediately before a compatible feature; unpaired materials are not yet supported.",
"symbol.rivencraft.biome.native.description": "Use the terrain's native biome source; do not include Biome pages.",
"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 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,35 @@
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 ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec SPEC;
public static final ModConfigSpec.IntValue SYMBOL_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("writing");
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
.defineInRange("symbol_ink_cost", 1, 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();
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,10 @@ 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<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
@@ -110,6 +123,10 @@ 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<StarFissureBlock> STAR_FISSURE =
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
@@ -134,6 +151,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 +202,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,6 +217,7 @@ 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(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("6");
var registrar = event.registrar("10");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
@@ -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();
}
}
@@ -14,22 +14,27 @@ import org.joml.Vector3f;
import org.joml.Vector3fc;
/** Frozen shared-color writing, adapted from 1.21.1 AgeSkyProfile. -1 means native. */
public record PortAgeColors(int sky, int ambient) {
public record PortAgeColors(int sky, int ambient, int celestial) {
public static final PortAgeColors NATIVE = new PortAgeColors(-1, -1);
public static final Codec<PortAgeColors> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(-1, 0xffffff).fieldOf("sky").forGetter(PortAgeColors::sky),
Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient)
Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient),
Codec.intRange(-1, 0xffffff).optionalFieldOf("celestial", -1).forGetter(PortAgeColors::celestial)
).apply(instance, PortAgeColors::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeColors> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient, PortAgeColors::new);
ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient,
ByteBufCodecs.INT, PortAgeColors::celestial, PortAgeColors::new);
public static final Identifier AMBIENT = Identifier.fromNamespaceAndPath("rivencraft", "lighting/ambient_color");
private static volatile Map<Identifier, PortAgeColors> clientColors = Map.of();
public PortAgeColors {
if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff)
if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff
|| celestial < -1 || celestial > 0xffffff)
throw new IllegalArgumentException("Invalid Age color");
}
public PortAgeColors(int sky, int ambient) { this(sky, ambient, -1); }
public static void updateClient(Map<Identifier, PortAgeColors> colors) { clientColors = Map.copyOf(colors); }
public static PortAgeColors forLevel(Level level) {
@@ -65,7 +70,7 @@ public record PortAgeColors(int sky, int ambient) {
public static PortAgeColors compile(List<Identifier> pages, long seed) {
if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier");
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1;
int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1;
boolean written = false;
for (int i = 0; i < pages.size(); i++) {
Identifier page = pages.get(i);
@@ -81,17 +86,43 @@ public record PortAgeColors(int sky, int ambient) {
case "color/white" -> { red += amount; green += amount; blue += amount; }
case "color/black" -> { red -= amount; green -= amount; blue -= amount; }
}
} else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)) {
} else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)
|| PortDayCycle.isController(page)) {
if (PortDayCycle.isController(page)) celestial = -1;
if (written) {
int rgb = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
if (page.equals(AMBIENT)) ambient = rgb; else sky = rgb;
if (page.equals(AMBIENT)) ambient = rgb;
else if (PortDayCycle.isController(page)) celestial = rgb;
else sky = rgb;
}
red = green = blue = 0;
written = false;
}
}
if (written) sky = clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
return new PortAgeColors(sky, ambient);
return new PortAgeColors(sky, ambient, celestial);
}
/** Only pageless random Ages receive a random sky tint, as in 1.21.1. */
public static PortAgeColors resolve(List<Identifier> pages, long seed) {
if (!pages.isEmpty()) return compile(pages, seed);
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5); // Root sky-style draw precedes the sky-color draw.
return new PortAgeColors(randomColor(random, true), -1);
}
/** Shared palette; retain .85's truncation for already-defined body defaults. */
static int randomColor(Random random, boolean rounded) {
int[] palette = {0xB74A50, 0xD9834B, 0xDBC574, 0x5AAB80, 0x6FA8DF, 0x9870B9, 0xD5DBE1, 0x252531};
int count = 1 + random.nextInt(3), red = 0, green = 0, blue = 0;
for (int i = 0; i < count; i++) {
int color = palette[random.nextInt(palette.length)];
red += color >> 16 & 255;
green += color >> 8 & 255;
blue += color & 255;
}
int offset = rounded ? count / 2 : 0;
return (red + offset) / count << 16 | (green + offset) / count << 8 | (blue + offset) / count;
}
private static int clamp(int value) { return Math.max(0, Math.min(255, value)); }
File diff suppressed because it is too large. Load diff
@@ -37,7 +37,7 @@ public final class PortAgeTravel {
return moved ? null : "Teleportation failed; remaining in source dimension";
}
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 +112,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,99 @@
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 = net.neoforged.neoforge.common.util.FakePlayerFactory.get(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");
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 (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
throw new IllegalStateException("Authored book page order was not preserved");
}
}
@@ -0,0 +1,31 @@
package net.dnorberg.rivencraft.age;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.data.worldgen.material.EndMaterialRules;
import net.minecraft.data.worldgen.material.NetherMaterialRules;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.material.MaterialRules;
/** Root AgeDimensionService.withBiomeMaterialRules adapted to 26.3's rule registry. */
final class PortBiomeMaterialRules {
private PortBiomeMaterialRules() {}
static Holder<NoiseGeneratorSettings> apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) {
var biomes = registries.lookupOrThrow(Registries.BIOME);
var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE);
var materials = MaterialRules.sequence(
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS,
Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS),
MaterialRules.getRule(rules, EndMaterialRules.END)),
MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY,
Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS),
MaterialRules.getRule(rules, NetherMaterialRules.NETHER)),
base.materialRule().value());
return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(),
base.noiseRouter(), Holder.direct(materials), base.spawnTarget(), base.seaLevel(), base.disableMobGeneration(),
base.aquifers(), base.useLegacyRandomSource(), base.debugFunctions()));
}
}
@@ -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,96 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** One frozen authored orbit, using the same angular motion as 1.21.1 Body. */
public record PortCelestialBody(int kind, double period, int stoppedTime, long originTick,
int phaseTick, boolean clockwise, int bearing, float size, int color) {
public static final Codec<PortCelestialBody> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Codec.intRange(1, 2).fieldOf("kind").forGetter(PortCelestialBody::kind),
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortCelestialBody::period),
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortCelestialBody::stoppedTime),
Codec.LONG.fieldOf("origin_tick").forGetter(PortCelestialBody::originTick),
Codec.intRange(0, 23999).fieldOf("phase_tick").forGetter(PortCelestialBody::phaseTick),
Codec.BOOL.fieldOf("clockwise").forGetter(PortCelestialBody::clockwise),
Codec.intRange(-1, 359).fieldOf("bearing").forGetter(PortCelestialBody::bearing),
Codec.floatRange(0.25F, 3F).fieldOf("size").forGetter(PortCelestialBody::size),
Codec.intRange(-1, 0xffffff).fieldOf("color").forGetter(PortCelestialBody::color)
).apply(instance, PortCelestialBody::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortCelestialBody> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.VAR_INT, PortCelestialBody::kind, ByteBufCodecs.DOUBLE, PortCelestialBody::period,
ByteBufCodecs.VAR_INT, PortCelestialBody::stoppedTime, ByteBufCodecs.VAR_LONG, PortCelestialBody::originTick,
ByteBufCodecs.VAR_INT, PortCelestialBody::phaseTick, ByteBufCodecs.BOOL, PortCelestialBody::clockwise,
ByteBufCodecs.INT, PortCelestialBody::bearing, ByteBufCodecs.FLOAT, PortCelestialBody::size,
ByteBufCodecs.INT, PortCelestialBody::color, PortCelestialBody::new);
public PortCelestialBody {
if (kind != PortDayCycle.SUN && kind != PortDayCycle.MOON || !Double.isFinite(period)
|| period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing > 359
|| !Float.isFinite(size) || size < 0.25F || size > 3 || color < -1 || color > 0xffffff)
throw new IllegalArgumentException("Invalid celestial body");
}
public static PortCelestialBody of(PortDayCycle cycle, int kind, int color, boolean pairedMoon) {
int offset = pairedMoon ? 12000 : 0;
return new PortCelestialBody(kind, cycle.period(), (cycle.stoppedTime() + offset) % 24000,
cycle.originTick(), (cycle.phaseTick() + offset) % 24000, cycle.clockwise(),
cycle.bearing(), pairedMoon ? 1F : cycle.sunSize(), color);
}
public float angle(double nativeTicks) {
double time = period == 0 ? stoppedTime
: (clockwise ? 1 : -1) * (nativeTicks - originTick) / period + phaseTick;
return (float) (((time - 6000D) % 24000D) * (360D / 24000D));
}
public float light(long nativeTicks) {
return Math.max(0F, (float) Math.cos(Math.toRadians(angle(nativeTicks))))
* (kind == PortDayCycle.MOON ? 0.18F : 1F);
}
public net.minecraft.world.phys.Vec3 direction(double ticks) {
double orbit = Math.toRadians(angle(ticks)), axis = Math.toRadians(bearing < 0 ? -90 : bearing);
double lateral = Math.sin(orbit);
return new net.minecraft.world.phys.Vec3(Math.sin(axis) * lateral, Math.cos(orbit), Math.cos(axis) * lateral);
}
/** Root AgeSkyProfile.eclipseFor: contact uses the central quarter-size disc, not its halo. */
public float eclipse(java.util.List<PortCelestialBody> bodies, double ticks) {
float strongest = 0;
var direction = direction(ticks);
double radius = Math.atan((kind == PortDayCycle.SUN ? 30D : 20D) * size * 0.25D / 100D);
for (var other : bodies) {
if (other.kind == kind) continue;
double separation = Math.acos(Math.max(-1D, Math.min(1D, direction.dot(other.direction(ticks)))));
double contact = radius + Math.atan((other.kind == PortDayCycle.SUN ? 30D : 20D) * other.size * 0.25D / 100D);
strongest = Math.max(strongest, (float) Math.max(0D, 1D - separation / contact));
}
return strongest;
}
public static float eclipseAt(java.util.List<PortCelestialBody> bodies, double ticks) {
float strongest = 0;
for (var body : bodies) if (body.kind == PortDayCycle.SUN && body.direction(ticks).y > 0D)
strongest = Math.max(strongest, body.eclipse(bodies, ticks));
return strongest;
}
/** Shared client/server-checkable RGB rule; alpha is handled by the renderer. */
public int eclipseColor(float eclipse) {
int rgb = color < 0 ? 0xffffff : color;
float corona = kind == PortDayCycle.SUN ? (float) Math.pow(eclipse, 8D) * 0.8F : 0F;
int red = channel(rgb >> 16 & 255, eclipse, corona);
int green = channel(rgb >> 8 & 255, eclipse, corona);
int blue = channel(rgb & 255, eclipse, corona);
return red << 16 | green << 8 | blue;
}
private int channel(int value, float eclipse, float corona) {
return Math.round(kind == PortDayCycle.MOON ? value * (1F - eclipse) : value + (255 - value) * corona);
}
}
@@ -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));
}
}
@@ -19,7 +19,9 @@ import net.minecraft.world.level.Level;
/** Authored native celestial controls. Never mutates the shared clock. */
public record PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) {
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode,
List<PortCelestialBody> bodies) {
public static final int PAIRED = 0, SUN = 1, MOON = 2, NONE = 3;
public static final PortDayCycle NATIVE = new PortDayCycle(false, 1, 0);
public static final Identifier NORMAL_TIME = Identifier.fromNamespaceAndPath("rivencraft", "celestial/normal_time");
public static final Codec<PortDayCycle> CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -30,20 +32,40 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
Codec.intRange(0, 23999).optionalFieldOf("phase_tick", 0).forGetter(PortDayCycle::phaseTick),
Codec.BOOL.optionalFieldOf("clockwise", true).forGetter(PortDayCycle::clockwise),
Codec.intRange(-1, 359).optionalFieldOf("bearing", -1).forGetter(PortDayCycle::bearing),
Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize)
Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize),
Codec.intRange(0, 3).optionalFieldOf("body_mode", PAIRED).forGetter(PortDayCycle::bodyMode),
PortCelestialBody.CODEC.listOf().optionalFieldOf("bodies", List.of()).forGetter(PortDayCycle::bodies)
).apply(instance, PortDayCycle::new));
public static final StreamCodec<RegistryFriendlyByteBuf, PortDayCycle> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.BOOL, PortDayCycle::authored, ByteBufCodecs.DOUBLE, PortDayCycle::period,
ByteBufCodecs.VAR_INT, PortDayCycle::stoppedTime, ByteBufCodecs.VAR_LONG, PortDayCycle::originTick,
ByteBufCodecs.VAR_INT, PortDayCycle::phaseTick, ByteBufCodecs.BOOL, PortDayCycle::clockwise,
ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize, PortDayCycle::new);
ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize,
ByteBufCodecs.VAR_INT, PortDayCycle::bodyMode,
PortCelestialBody.STREAM_CODEC.apply(ByteBufCodecs.list()), PortDayCycle::bodies, PortDayCycle::new);
private static volatile Map<Identifier, PortDayCycle> clientCycles = Map.of();
public PortDayCycle {
bodies = List.copyOf(bodies);
if (!Double.isFinite(period) || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000
|| phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing >= 360
|| !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F)
|| !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F
|| bodyMode < PAIRED || bodyMode > NONE
|| bodyMode == NONE && (!authored || !bodies.isEmpty()))
throw new IllegalArgumentException("Invalid day cycle");
if (bodies.stream().filter(body -> body.kind() == SUN).count() > 10
|| bodies.stream().filter(body -> body.kind() == MOON).count() > 10)
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, bodyMode, List.of());
}
public PortDayCycle(boolean authored, double period, int stoppedTime,
long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) {
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, PAIRED);
}
public PortDayCycle(boolean authored, double period, int stoppedTime) {
@@ -87,7 +109,14 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static boolean supports(Identifier page) {
return page.equals(NORMAL_TIME) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page);
return isController(page) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page);
}
public static boolean isController(Identifier page) {
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
}
public static boolean isSize(Identifier page) {
@@ -98,25 +127,74 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
}
public static boolean validWriting(List<Identifier> pages) {
int target = pages.indexOf(NORMAL_TIME), periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0;
if (target != pages.lastIndexOf(NORMAL_TIME)) return false;
for (int i = 0; i < pages.size(); i++) {
if (isPeriod(pages.get(i)) && (++periods > 1 || i > target)) return false;
if (isPhase(pages.get(i)) && (++phases > 1 || i > target)) return false;
if (isDirection(pages.get(i)) && (++directions > 1 || i > target)) return false;
if (isAngle(pages.get(i)) && (++angles > 1 || i > target)) return false;
if (isSize(pages.get(i)) && (++sizes > 1 || i > target)) return false;
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
for (Identifier page : pages) {
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|| isSize(page) && ++sizes > 1) return false;
if (isController(page)) {
if (!page.getPath().equals("celestial/moon")) suns++;
if (!page.getPath().equals("celestial/sun")) moons++;
if (suns > 10 || moons > 10) return false;
periods = phases = directions = angles = sizes = 0;
}
}
return true;
return periods + phases + directions + angles + sizes == 0;
}
public static PortDayCycle compile(List<Identifier> pages, long seed) {
return compile(pages, seed, 0);
}
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages) || !PortAgeColors.validNumbers(pages))
throw new IllegalArgumentException("Invalid celestial modifier order");
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
// Match root AgeSkyProfile.random's count draw positions: sky style,
// mixed sky color, then independent 0..3 sun and moon counts.
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5);
randomColor(random);
int suns = random.nextInt(4), moons = random.nextInt(4);
var bodies = new java.util.ArrayList<PortCelestialBody>();
for (int i = 0; i < suns + moons; i++)
bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies);
if (bodies.isEmpty()) return new PortDayCycle(true, 1, 0, 0, 0, true, -1, 1F, NONE);
var first = bodies.getFirst();
return new PortDayCycle(true, first.period(), first.stoppedTime(), first.originTick(), first.phaseTick(),
first.clockwise(), first.bearing(), first.size(), first.kind(), bodies);
}
public static PortDayCycle compile(List<Identifier> pages, long seed, long nativeTicks) {
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one Length, Phase, Direction, Angle, and Size before Normal Time");
if (!pages.contains(NORMAL_TIME)) return NATIVE;
if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons");
if (pages.stream().filter(PortDayCycle::isController).count() <= 1)
return compileOne(pages, seed, nativeTicks, 0);
var bodies = new java.util.ArrayList<PortCelestialBody>();
PortDayCycle first = null;
int start = 0, ordinal = 0;
for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) {
var cycle = compileOne(pages.subList(start, i + 1), seed, nativeTicks, ordinal++);
if (first == null) first = cycle;
// Prefix retains original page indices for seeded unnumbered colors.
int color = PortAgeColors.compile(pages.subList(0, i + 1), seed).celestial();
if (!cycle.bodies.isEmpty()) bodies.addAll(cycle.bodies);
else {
if (cycle.bodyMode != MOON) bodies.add(PortCelestialBody.of(cycle, SUN, color, false));
if (cycle.bodyMode != SUN) bodies.add(PortCelestialBody.of(cycle, MOON, color, cycle.bodyMode == PAIRED));
}
start = i + 1;
}
return new PortDayCycle(first.authored, first.period, first.stoppedTime, first.originTick,
first.phaseTick, first.clockwise, first.bearing, first.sunSize, first.bodyMode, bodies);
}
private static PortDayCycle compileOne(List<Identifier> pages, long seed, long nativeTicks, int ordinal) {
var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null);
if (controller == null) return NATIVE;
int mode = controller.getPath().equals("celestial/sun") ? SUN
: controller.getPath().equals("celestial/moon") ? MOON : PAIRED;
if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode);
double period = 1;
Integer phase = null;
boolean clockwise = true;
@@ -160,7 +238,59 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
int stopped = period == 0 ? phase != null ? phase
: new Random(seed ^ 0x4441594359434C45L).nextInt(24000) : 0;
return new PortDayCycle(true, period, stopped, phase != null ? nativeTicks : 0,
phase == null ? 0 : phase, clockwise, bearing, size);
phase == null ? 0 : phase, clockwise, bearing, size, mode);
}
private static boolean hasBodyModifiers(List<Identifier> pages) {
boolean color = false, controls = false;
for (var page : pages) {
if (isController(page)) return controls || color;
if (PortAgeColors.isColor(page)) color = true;
else if (PortSkyStyle.forPage(page) != null || page.equals(PortAgeColors.AMBIENT)) color = false;
if (isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page)) controls = true;
}
return false;
}
private static PortDayCycle randomUnmodified(long seed, int ordinal, long nativeTicks, int mode) {
// Independent per-controller stream: unrelated terrain/sky/weather pages
// cannot reshuffle the bodies. Use root random-Age bounds, not native defaults.
var random = new Random(seed ^ 0x43454C4553544941L ^ (ordinal + 1L) * 0x9E3779B97F4A7C15L);
double period = random.nextInt(7) == 0 ? 0D : 0.2D + random.nextDouble() * 1.8D;
int phase = random.nextInt(24000), bearing = random.nextInt(360);
float size = 0.75F + random.nextFloat();
boolean clockwise = random.nextBoolean();
int color = randomColor(random);
var cycle = new PortDayCycle(true, period, period == 0 ? phase : 0, nativeTicks,
phase, clockwise, bearing, size, mode);
return new PortDayCycle(true, period, cycle.stoppedTime, nativeTicks, phase, clockwise, bearing, size,
mode, List.of(PortCelestialBody.of(cycle, mode, color, false)));
}
private static int randomColor(Random random) {
return PortAgeColors.randomColor(random, false);
}
public boolean singleBody() { return authored && (bodyMode == SUN || bodyMode == MOON); }
public boolean customLight() { return singleBody() || !bodies.isEmpty(); }
/** Linear authored orbit, as in 1.21.1 Body; zero degrees is Zenith. */
public float bodyAngle(long nativeTicks) {
return (float) (((time(nativeTicks) - 6000D) % 24000D) * (360D / 24000D));
}
public float bodyLight(long nativeTicks) {
if (bodyMode == NONE) return 0;
if (!bodies.isEmpty()) {
float sun = 0, moon = 0;
for (var body : bodies) {
if (body.kind() == SUN) sun += body.light(nativeTicks) * (1F - 0.85F * body.eclipse(bodies, nativeTicks));
else moon += body.light(nativeTicks);
}
return Math.min(1F, sun > 0 ? sun : moon);
}
float altitude = Math.max(0F, (float) Math.cos(Math.toRadians(bodyAngle(nativeTicks))));
return altitude * (bodyMode == MOON ? 0.18F : 1F);
}
public long ticks(long nativeTicks) {
@@ -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");
}
}
}
@@ -113,7 +113,7 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
}
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
RandomState random, long seed) {
if (hasStructurePages()) {
if (controlsStructures()) {
var lookup = PortStructurePages.filtered(sets, terrainFeatures.structures(), getBiomeSource());
var planning = PortStructurePages.planningBiomes(getBiomeSource(), sets, terrainFeatures.structures());
if (base instanceof PortFlatChunkGenerator || base instanceof net.minecraft.world.level.levelgen.FlatLevelSource)
@@ -124,13 +124,13 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
return base.createState(sets, random, seed);
}
private boolean hasStructurePages() {
return terrainFeatures != null && !terrainFeatures.structures().isEmpty();
private boolean controlsStructures() {
return terrainFeatures != null && (terrainFeatures.structureControl() || !terrainFeatures.structures().isEmpty());
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state,
StructureManager structures, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
if (hasStructurePages()) super.createStructures(registries, state, structures, chunk, templates, dimension);
if (controlsStructures()) super.createStructures(registries, state, structures, chunk, templates, dimension);
else base.createStructures(registries, state, structures, chunk, templates, dimension);
}
@Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) {
@@ -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);
}
}
}
@@ -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,13 +42,30 @@ public final class PortRandomAgeSelection {
return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count)));
}
private static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
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())
@@ -68,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;
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.resources.Identifier;
/** Frozen page-free material features and rare formations from 1.21.1. */
public final class PortRandomFeatureSelection {
private PortRandomFeatureSelection() {}
public record Selection(PortRockPlan rocks, PortLakePlan lakes, PortTerrainFeaturePlan terrain, int dungeons) {}
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes) {
var base = select(seed, terrain);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes);
var plan = base.terrain();
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
structures.structures(), plan.carvers(), true);
return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons());
}
public static Selection select(long seed, PortAgeTerrain terrain) {
var random = new Random(seed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT));
var candidates = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks",
"surface_lakes", "deep_lakes", "lava_lakes")) {
var page = id("feature/" + name);
if (PortAuthoredWriting.supports(List.of(terrainPage, page)))
candidates.add(new PortRankedChoice.Option<>(page, rank(name)));
}
var pages = new ArrayList<Identifier>();
pages.add(terrainPage);
List<Identifier> solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds();
for (int selected = 0; selected < count && !candidates.isEmpty();) {
Identifier feature = PortRankedChoice.choose(random, candidates);
candidates.removeIf(option -> option.value().equals(feature));
var materials = PortLakePlan.isLake(feature) ? fluids : solids;
if (materials.isEmpty()) continue;
pages.add(PortRegistryPages.blockPageId(materials.get(random.nextInt(materials.size()))));
pages.add(feature);
selected++;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
&& Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0)
pages.add(PortTerrainFeaturePlan.SKYLANDS);
if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS);
if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0);
}
private static int rank(String name) {
return switch (name) {
case "surface_lakes", "deep_lakes" -> 1;
case "lava_lakes" -> 2;
case "spheres", "spikes", "obelisks" -> 3;
default -> 4;
};
}
private static boolean randomStarFissure(long seed) {
var random = new Random(mix(seed ^ 0x535441524752414DL));
for (int step = 0; step < 27; step++) {
int branch = random.nextInt(5);
if (branch == 0) return false;
if (random.nextInt(9) == 0) return true;
if (branch == 1) return false;
}
return false;
}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,69 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Set;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.dimension.LevelStem;
/** Creation-only structure completion, adapted from 1.21.1 AgeStructureSelection. */
public final class PortRandomStructureSelection {
private static final Identifier DUNGEONS = page("dungeons");
private PortRandomStructureSelection() {}
public record Selection(int dungeons, List<Identifier> structures) {
public Selection { structures = List.copyOf(structures); }
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) {
Set<Identifier> biomes = actualBiomes(server, terrain, plan);
var eligible = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages",
"trial_chambers", "bastions", "buried_treasure", "shipwrecks", "ocean_ruins", "pillager_outposts",
"ruined_portals", "nether_fossils", "ancient_cities", "trail_ruins", "abandoned_camps")) {
Identifier feature = page(name);
boolean allowed = feature.equals(DUNGEONS)
? net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(List.of(
Identifier.fromNamespaceAndPath("rivencraft", "terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT)), feature))
&& server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
.get(ResourceKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent()
: PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess());
if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
}
var random = new Random(seed ^ 0x5354525543545552L);
int count = random.nextInt(3), dungeons = 0;
var structures = new ArrayList<Identifier>();
for (int selected = 0; selected < count && !eligible.isEmpty(); selected++) {
var feature = PortRankedChoice.choose(random, eligible);
if (feature.equals(DUNGEONS)) dungeons++;
else structures.add(feature);
eligible.removeIf(option -> option.value().equals(feature));
}
return new Selection(dungeons, structures);
}
private static Set<Identifier> actualBiomes(MinecraftServer server, PortAgeTerrain terrain, PortBiomePlan plan) {
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"));
if (terrain == PortAgeTerrain.CAVE) return Set.of(Identifier.withDefaultNamespace("lush_caves"));
return server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD)
.value().generator().getBiomeSource().possibleBiomes().stream()
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
private static int rank(String name) {
return switch (name) {
case "villages", "strongholds", "mineshafts" -> 1;
case "dungeons", "nether_fortresses" -> 2;
default -> 3;
};
}
private static Identifier page(String name) { return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name); }
}
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
/** The 1.21.1 LegacyRankChoice rule, retained for seeded grammar completion. */
final class PortRankedChoice {
private PortRankedChoice() {}
record Option<T>(T value, int rank) {}
static <T> T choose(Random random, List<Option<T>> options) {
if (options.isEmpty()) throw new IllegalArgumentException("No ranked choices");
var counts = new HashMap<Integer, Integer>();
int highest = 0;
for (var option : options) {
counts.merge(option.rank(), 1, Integer::sum);
highest = Math.max(highest, option.rank());
}
var weights = new HashMap<Integer, Integer>();
int weight = 1, previousTotal = 0;
for (int rank = highest; rank >= 0; rank--) {
int count = counts.getOrDefault(rank, 0);
if (weight != 1 && count > 0) weight = Math.max(weight, previousTotal / count + 1);
weights.put(rank, weight);
previousTotal = count * weight;
weight++;
}
int ticket = random.nextInt(options.stream().mapToInt(option -> weights.get(option.rank())).sum());
for (var option : options) {
ticket -= weights.get(option.rank());
if (ticket < 0) return option.value();
}
throw new IllegalStateException("Ranked choice ticket was not consumed");
}
}
@@ -0,0 +1,44 @@
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);
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;
}
}
@@ -31,11 +31,20 @@ public enum PortSkyStyle {
? OVERWORLD : random(seed));
}
/** Nether-style Ages use their fog clear color as the visible backdrop. */
public static float fogShade(net.minecraft.world.level.Level level) {
boolean nether = level.dimensionTypeRegistration().unwrapKey().map(key ->
key.identifier().getNamespace().equals("rivencraft")
&& key.identifier().getPath().endsWith("_sky_nether")).orElse(false);
var cycle = PortDayCycle.forLevel(level);
return nether && !cycle.bodies().isEmpty()
? 1F - 0.65F * PortCelestialBody.eclipseAt(cycle.bodies(), PortDayCycle.nativeTicks(level)) : 1F;
}
private static boolean isCelestial(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
return switch (page.getPath()) {
// Only Overworld currently draws bodies in this port. Recognize the
// Sun/Moon intentions too, without marking those pages implemented.
// Preserve the established compatible-default rule for written controllers.
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
default -> false;
};
@@ -88,6 +88,31 @@ public final class PortStructurePages {
.orElseThrow(() -> new IllegalArgumentException("Missing structure set for " + page)));
}
/** Random choices need at least one normally eligible biome; authored pages may override it. */
public static boolean randomEligible(Identifier page, PortAgeTerrain terrain, Set<Identifier> biomes,
HolderLookup.Provider registries) {
if (!supportsTerrain(page, terrain)) return false;
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)));
if (set.isEmpty()) return false;
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.toUnmodifiableSet());
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
Map<Identifier, Integer> result = new HashMap<>();
for (Identifier page : pages) result.merge(SETS.get(page), 1, Integer::sum);
@@ -9,7 +9,8 @@ import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
boolean starFissures, List<Identifier> structures, List<Identifier> carvers,
boolean structureControl) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
@@ -30,7 +31,8 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
.forGetter(PortTerrainFeaturePlan::denseOres),
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers)
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers),
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) {
@@ -55,7 +57,7 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty())
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl)
throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) {
@@ -64,6 +66,11 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
}
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
this(choices, skylands, denseOres, starFissures, structures, carvers, false);
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures) {
this(choices, skylands, denseOres, starFissures, structures, List.of());
@@ -0,0 +1,98 @@
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 = net.neoforged.neoforge.common.util.FakePlayerFactory.get(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");
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");
}
}
@@ -366,15 +366,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 +398,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 +455,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) {
@@ -465,7 +468,7 @@ public final class RuntimeAgeBackend {
case CAVE -> dryCaveSettings(server);
case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource");
};
generator = new NoiseBasedChunkGenerator(source, settings);
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);
@@ -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,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);
}
}
@@ -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.PortConfig.SYMBOL_INK_COST.get();
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
}
public boolean consumeCopyMaterials() {
if (!canConsumeCopyMaterials()) return false;
int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get();
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,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,16 @@ 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);
}
@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
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.vertex.BufferBuilder;
import com.mojang.blaze3d.vertex.ByteBufferBuilder;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.renderpearl.api.buffers.GpuBuffer;
import com.mojang.renderpearl.api.commands.RenderPass;
import com.mojang.renderpearl.api.pipeline.BlendFunction;
import com.mojang.renderpearl.api.pipeline.ColorTargetState;
import com.mojang.renderpearl.api.pipeline.PrimitiveTopology;
import com.mojang.renderpearl.api.pipeline.RenderPipeline;
import java.util.IdentityHashMap;
import java.util.Map;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.TextureAtlas;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.MoonPhase;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterRenderPipelinesEvent;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Legacy's opaque central disc, separate from the native transparent glow. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortCelestialDiscs implements AutoCloseable {
private static final Identifier SUN = Identifier.withDefaultNamespace("sun");
private static final RenderPipeline CORE = RenderPipelines.CELESTIAL.toBuilder()
.withLocation(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "pipeline/celestial_core"))
.withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)).build();
private final Map<TextureAtlasSprite, GpuBuffer> cores = new IdentityHashMap<>();
private TextureAtlasSprite atlasGeneration;
@SubscribeEvent public static void register(RegisterRenderPipelinesEvent event) { event.registerPipeline(CORE); }
public void draw(RenderPass pass, TextureAtlas atlas, boolean moon, MoonPhase phase,
Matrix4f transform, int color, float alpha) {
if (alpha <= 0) return;
var generation = atlas.getSprite(SUN);
if (generation != atlasGeneration) {
close();
atlasGeneration = generation;
}
var sprite = moon ? atlas.getSprite(Identifier.withDefaultNamespace("moon/" + phase.getSerializedName())) : generation;
var buffer = cores.computeIfAbsent(sprite, ignored -> build(sprite, moon));
var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS);
var indexBuffer = indices.getBuffer(6);
var rgb = net.dnorberg.rivencraft.age.PortAgeColors.vector(color);
var uniforms = RenderSystem.getDynamicUniforms().writeTransform(transform, new Vector4f(rgb, alpha));
pass.setPipeline(RenderSystem.getCompiledPipeline(CORE));
RenderSystem.bindDefaultUniforms(pass);
pass.setUniform("DynamicTransforms", uniforms);
pass.setUniform("Sampler0", atlas.getTextureView(), atlas.getSampler());
pass.setVertexBuffer(0, buffer.slice());
pass.setIndexBuffer(indexBuffer, indices.type());
pass.drawIndexed(6, 1, 0, 0, 0);
}
private static GpuBuffer build(TextureAtlasSprite sprite, boolean moon) {
var format = DefaultVertexFormat.POSITION_TEX;
try (var bytes = ByteBufferBuilder.exactlySized(4 * format.getVertexSize())) {
var builder = new BufferBuilder(bytes, PrimitiveTopology.QUADS, format);
// Same 8/32 central area as root's ordinary-alpha second pass.
float u0 = sprite.getU(0.375F), u1 = sprite.getU(0.625F);
float v0 = sprite.getV(0.375F), v1 = sprite.getV(0.625F);
builder.addVertex(-0.25F, 0, -0.25F).setUv(moon ? u1 : u0, moon ? v1 : v0);
builder.addVertex(0.25F, 0, -0.25F).setUv(moon ? u0 : u1, moon ? v1 : v0);
builder.addVertex(0.25F, 0, 0.25F).setUv(moon ? u0 : u1, moon ? v0 : v1);
builder.addVertex(-0.25F, 0, 0.25F).setUv(moon ? u1 : u0, moon ? v0 : v1);
try (var mesh = builder.buildOrThrow()) {
return RenderSystem.getDevice().createBuffer(() -> "Rivencraft celestial core", 40, mesh.vertexBuffer());
}
}
}
@Override public void close() {
cores.values().forEach(GpuBuffer::close);
cores.clear();
atlasGeneration = null;
}
}
@@ -2,6 +2,11 @@ package net.dnorberg.rivencraft.client;
/** Carries extracted sky presentation values without live-world reads during drawing. */
public interface StaticSkyState {
int rivencraft$bodyOverlay();
void rivencraft$bodyOverlay(int mode);
record Body(int kind, float angle, int bearing, float size, int color, float eclipse) {}
java.util.List<Body> rivencraft$bodies();
void rivencraft$bodies(java.util.List<Body> bodies);
long rivencraft$staticFrame();
void rivencraft$staticFrame(long frame);
int rivencraft$skyTint();
@@ -10,4 +15,8 @@ public interface StaticSkyState {
void rivencraft$orbitBearing(int bearing);
float rivencraft$sunSize();
void rivencraft$sunSize(float size);
int rivencraft$celestialTint();
void rivencraft$celestialTint(int color);
int rivencraft$bodyMode();
void rivencraft$bodyMode(int mode);
}
@@ -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,51 @@
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);
}
@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,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;
}
}
@@ -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,35 @@
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;
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);
}
}
@@ -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)
@@ -145,6 +153,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,463 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
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;
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()) {
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; }
}
}
@@ -22,7 +22,9 @@ public class AgeColorAttributesMixin {
});
builder.addTimeBasedLayer(EnvironmentAttributes.FOG_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).sky();
return rgb < 0 ? base : PortAgeColors.vector(rgb);
var color = rgb < 0 ? base : PortAgeColors.vector(rgb);
float shade = net.dnorberg.rivencraft.age.PortSkyStyle.fogShade(level);
return shade == 1F ? color : new org.joml.Vector3f(color).mul(shade);
});
builder.addTimeBasedLayer(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, (base, tick) -> {
int rgb = PortAgeColors.forLevel(level).ambient();
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.age.PortDayCycle;
import net.minecraft.world.attribute.EnvironmentAttributeSystem;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.attribute.WeatherAttributes;
import net.minecraft.world.level.Level;
import org.spongepowered.asm.mixin.Mixin;
@@ -11,6 +12,30 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
@Mixin(EnvironmentAttributeSystem.Builder.class)
public class AgeDayTimelineMixin {
@Inject(method = "addDefaultLayers", at = @At("RETURN"))
private void rivencraft$singleBody(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return;
var builder = cir.getReturnValue();
builder.addTimeBasedLayer(EnvironmentAttributes.SUN_ANGLE, (base, tick) -> {
var cycle = PortDayCycle.forLevel(level);
return cycle.singleBody() ? cycle.bodyAngle(PortDayCycle.nativeTicks(level)) : base;
});
builder.addTimeBasedLayer(EnvironmentAttributes.MOON_ANGLE, (base, tick) -> {
var cycle = PortDayCycle.forLevel(level);
return cycle.singleBody() ? cycle.bodyAngle(PortDayCycle.nativeTicks(level)) : base;
});
builder.addTimeBasedLayer(EnvironmentAttributes.SKY_LIGHT_LEVEL, (base, tick) -> {
var cycle = PortDayCycle.forLevel(level);
if (!cycle.customLight()) return base;
// Match root AgeSkyProfile.lightAt and LevelSkyBrightnessMixin's
// single skylight channel. Bright/Dark and no-skylight geometry remain separate.
float weather = (1F - level.getRainLevel(1F) * 5F / 16F)
* (1F - level.getThunderLevel(1F) * 5F / 16F);
return 15F - (int) ((1F - cycle.bodyLight(PortDayCycle.nativeTicks(level)) * weather) * 11F);
});
}
@Inject(method = "addDynamicLayers", at = @At("HEAD"), cancellable = true)
private void rivencraft$dayCycle(Level level, CallbackInfoReturnable<EnvironmentAttributeSystem.Builder> cir) {
var id = level.dimension().identifier();
@@ -26,6 +26,24 @@ public class StaticSkyRendererMixin {
@Unique private int rivencraft$endTint = -1;
@Unique private int rivencraft$orbitBearing = -1;
@Unique private float rivencraft$sunSize = 1F;
@Unique private int rivencraft$celestialTint = -1;
@Unique private int rivencraft$bodyMode;
@Unique private java.util.List<StaticSkyState.Body> rivencraft$bodies = java.util.List.of();
@Unique private net.dnorberg.rivencraft.client.PortCelestialDiscs rivencraft$discs;
@Shadow @Final private net.minecraft.client.renderer.texture.TextureAtlas celestialsAtlas;
@Shadow private org.joml.Matrix4f applyCelestialBodyTransform(com.mojang.blaze3d.vertex.PoseStack pose,
float height, float size) { throw new AssertionError(); }
@Shadow private void renderSun(com.mojang.renderpearl.api.commands.RenderPass pass, float alpha,
com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); }
@Shadow private void renderMoon(com.mojang.renderpearl.api.commands.RenderPass pass,
net.minecraft.world.level.MoonPhase phase, float alpha,
com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); }
@Shadow private void renderStars(com.mojang.renderpearl.api.commands.RenderPass pass, float brightness,
com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); }
@Shadow private void renderSunMoonAndStars(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float sunAngle,
float moonAngle, float starAngle, net.minecraft.world.level.MoonPhase phase,
float alpha, float stars) { throw new AssertionError(); }
@Inject(method = "extractRenderState", at = @At("TAIL"))
private void rivencraft$extract(ClientLevel level, float partialTicks, Camera camera,
@@ -36,11 +54,66 @@ public class StaticSkyRendererMixin {
((StaticSkyState) state).rivencraft$staticFrame(enabled ? level.getGameTime() / 2 : Long.MIN_VALUE);
((StaticSkyState) state).rivencraft$skyTint(net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).sky());
var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forLevel(level);
// 0=native Overworld/no authored overlay, 1=fog-only backdrop,
// 2=native End backdrop, 3=custom Static backdrop.
int overlay = !cycle.authored() || cycle.bodyMode() == net.dnorberg.rivencraft.age.PortDayCycle.NONE
|| state.skybox == DimensionType.Skybox.OVERWORLD ? 0
: enabled ? 3 : state.skybox == DimensionType.Skybox.NONE ? 1 : 2;
((StaticSkyState) state).rivencraft$bodyOverlay(overlay);
((StaticSkyState) state).rivencraft$orbitBearing(cycle.authored() ? cycle.bearing() : -1);
((StaticSkyState) state).rivencraft$sunSize(cycle.authored() ? cycle.sunSize() : 1F);
((StaticSkyState) state).rivencraft$celestialTint(
net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).celestial());
((StaticSkyState) state).rivencraft$bodyMode(cycle.authored() ? cycle.bodyMode() : 0);
// Root's authored Moon always samples the full-moon sprite. Preserve
// native calendar phases only for the native Normal Time pair.
if (cycle.singleBody() && cycle.bodyMode() == net.dnorberg.rivencraft.age.PortDayCycle.MOON)
state.moonPhase = net.minecraft.world.level.MoonPhase.FULL_MOON;
var authoredBodies = cycle.bodies();
if (authoredBodies.isEmpty() && overlay != 0) {
var bodies = new java.util.ArrayList<net.dnorberg.rivencraft.age.PortCelestialBody>();
int tint = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).celestial();
if (cycle.bodyMode() != net.dnorberg.rivencraft.age.PortDayCycle.MOON)
bodies.add(net.dnorberg.rivencraft.age.PortCelestialBody.of(cycle, 1, tint, false));
if (cycle.bodyMode() != net.dnorberg.rivencraft.age.PortDayCycle.SUN)
bodies.add(net.dnorberg.rivencraft.age.PortCelestialBody.of(cycle, 2, tint, cycle.bodyMode() == 0));
authoredBodies = java.util.List.copyOf(bodies);
}
if (authoredBodies.isEmpty()) ((StaticSkyState) state).rivencraft$bodies(java.util.List.of());
else {
var clock = level.clockManager().getInstance(level.registryAccess().lookupOrThrow(
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD));
double ticks = clock.totalTicks() + clock.partialTick();
var sources = authoredBodies;
var bodies = sources.stream().map(body -> {
float eclipse = body.eclipse(sources, ticks);
return new StaticSkyState.Body(body.kind(), body.angle(ticks), body.bearing(), body.size(),
body.eclipseColor(eclipse), eclipse);
}).toList();
((StaticSkyState) state).rivencraft$bodies(bodies);
float eclipse = 0;
for (var body : bodies) if (body.kind() == 1 && Math.cos(Math.toRadians(body.angle())) > 0)
eclipse = Math.max(eclipse, body.eclipse());
if (state.skybox == DimensionType.Skybox.OVERWORLD) {
state.skyColor = new org.joml.Vector3f(state.skyColor).mul(1F - 0.65F * eclipse);
} else if (eclipse > 0 && overlay >= 2) {
int tint = ((StaticSkyState) state).rivencraft$skyTint();
if (tint < 0) tint = enabled ? 0x929292 : 0xffffff;
((StaticSkyState) state).rivencraft$skyTint(rivencraft$shade(tint, 1F - 0.65F * eclipse));
}
}
// Only the extracted presentation uses END to schedule the sky pass. The actual
// dimension has NONE, so it never acquires End flashes or End gameplay behavior.
if (enabled) state.skybox = DimensionType.Skybox.END;
if (enabled || overlay == 1) {
state.skybox = DimensionType.Skybox.END;
state.endFlashIntensity = 0;
}
if (overlay != 0) state.rainBrightness = 1F - level.getRainLevel(partialTicks);
}
@Unique private static int rivencraft$shade(int rgb, float shade) {
return Math.round((rgb >> 16 & 255) * shade) << 16
| Math.round((rgb >> 8 & 255) * shade) << 8 | Math.round((rgb & 255) * shade);
}
@Inject(method = "render", at = @At("HEAD"), cancellable = true)
@@ -49,9 +122,82 @@ public class StaticSkyRendererMixin {
rivencraft$endTint = ((StaticSkyState) state).rivencraft$skyTint();
rivencraft$orbitBearing = ((StaticSkyState) state).rivencraft$orbitBearing();
rivencraft$sunSize = ((StaticSkyState) state).rivencraft$sunSize();
rivencraft$celestialTint = ((StaticSkyState) state).rivencraft$celestialTint();
rivencraft$bodyMode = ((StaticSkyState) state).rivencraft$bodyMode();
rivencraft$bodies = ((StaticSkyState) state).rivencraft$bodies();
if (((StaticSkyState) state).rivencraft$bodyOverlay() == 1) {
rivencraft$overlay(fog, state);
ci.cancel();
return;
}
if (frame == Long.MIN_VALUE) return;
if (rivencraft$staticSky == null) rivencraft$staticSky = new PortStaticSky();
rivencraft$staticSky.render(renderTarget, fog, frame, rivencraft$endTint);
if (((StaticSkyState) state).rivencraft$bodyOverlay() == 3) rivencraft$overlay(fog, state);
ci.cancel();
}
@Inject(method = "render", at = @At("RETURN"))
private void rivencraft$afterBackground(GpuBufferSlice fog, SkyRenderState state, CallbackInfo ci) {
// RETURN includes the native End branch's early return, after its pass closes.
if (((StaticSkyState) state).rivencraft$bodyOverlay() == 2) rivencraft$overlay(fog, state);
}
@Unique private void rivencraft$overlay(GpuBufferSlice fog, SkyRenderState state) {
if (rivencraft$bodies.isEmpty()) return;
com.mojang.blaze3d.systems.RenderSystem.setShaderFog(fog);
try (var pass = com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().createRenderPass(
() -> "Rivencraft celestial overlay", renderTarget.getColorTextureView(), java.util.Optional.empty(),
renderTarget.getDepthTextureView(), java.util.OptionalDouble.empty())) {
renderSunMoonAndStars(pass, new com.mojang.blaze3d.vertex.PoseStack(), 0, 0, 0,
net.minecraft.world.level.MoonPhase.FULL_MOON, state.rainBrightness, 0);
}
}
@Inject(method = "renderSunMoonAndStars", at = @At("HEAD"), cancellable = true)
private void rivencraft$drawBodies(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float sunAngle,
float moonAngle, float starAngle, net.minecraft.world.level.MoonPhase phase,
float alpha, float stars, CallbackInfo ci) {
if (rivencraft$bodies.isEmpty()) return;
int savedMode = rivencraft$bodyMode, savedTint = rivencraft$celestialTint;
float savedSize = rivencraft$sunSize;
try {
// Stars belong behind opaque discs, not on top of a dark eclipsing moon.
if (stars > 0) {
pose.pushPose();
try {
pose.rotateDegrees(com.mojang.math.Axis.YP, -90);
pose.rotate(com.mojang.math.Axis.XP, starAngle);
renderStars(pass, stars, pose);
} finally { pose.popPose(); }
}
// Same ordering as 1.21.1: all suns, then all moons; each opaque
// central disc is drawn after its transparent glow.
for (int kind = 1; kind <= 2; kind++) for (var body : rivencraft$bodies) {
if (body.kind() != kind) continue;
rivencraft$bodyMode = kind;
rivencraft$celestialTint = body.color();
rivencraft$sunSize = body.size();
pose.pushPose();
try {
pose.rotateDegrees(com.mojang.math.Axis.YP, body.bearing() < 0 ? -90 : body.bearing());
pose.rotateDegrees(com.mojang.math.Axis.XP, body.angle());
float glowAlpha = alpha * (kind == 1 ? 0.84F : 0.88F + 0.12F * body.eclipse());
var bodyPhase = net.minecraft.world.level.MoonPhase.FULL_MOON;
if (kind == 1) renderSun(pass, glowAlpha, pose); else renderMoon(pass, bodyPhase, glowAlpha, pose);
if (rivencraft$discs == null) rivencraft$discs = new net.dnorberg.rivencraft.client.PortCelestialDiscs();
// Both passes must use the same sprite. Native dark phase pixels
// become black patches under ordinary alpha, unlike the old glow-only pass.
var transform = applyCelestialBodyTransform(pose, 100F, (kind == 1 ? 30F : 20F) * body.size());
rivencraft$discs.draw(pass, celestialsAtlas, kind == 2, bodyPhase, transform, body.color(), alpha);
} finally { pose.popPose(); }
}
} finally {
rivencraft$bodyMode = savedMode;
rivencraft$celestialTint = savedTint;
rivencraft$sunSize = savedSize;
}
ci.cancel();
}
@@ -68,10 +214,48 @@ public class StaticSkyRendererMixin {
return nativeSize * rivencraft$sunSize;
}
@Inject(method = "renderSunriseAndSunset", at = @At("HEAD"))
@ModifyArg(method = "renderMoon", at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/SkyRenderer;applyCelestialBodyTransform(Lcom/mojang/blaze3d/vertex/PoseStack;FF)Lorg/joml/Matrix4f;"), index = 2)
private float rivencraft$scaleMoon(float nativeSize) {
return rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON
? nativeSize * rivencraft$sunSize : nativeSize;
}
@Inject(method = "renderSun", at = @At("HEAD"), cancellable = true)
private void rivencraft$hideSun(com.mojang.renderpearl.api.commands.RenderPass pass, float alpha,
com.mojang.blaze3d.vertex.PoseStack pose, CallbackInfo ci) {
if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON
|| rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) ci.cancel();
}
@Inject(method = "renderMoon", at = @At("HEAD"), cancellable = true)
private void rivencraft$hideMoon(com.mojang.renderpearl.api.commands.RenderPass pass,
net.minecraft.world.level.MoonPhase phase, float alpha,
com.mojang.blaze3d.vertex.PoseStack pose, CallbackInfo ci) {
if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.SUN
|| rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) ci.cancel();
}
@ModifyArg(method = {"renderSun", "renderMoon"}, at = @At(value = "INVOKE", target =
"Lnet/minecraft/client/renderer/DynamicGpuData;writeTransform(Lorg/joml/Matrix4f;Lorg/joml/Vector4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"), index = 1)
private org.joml.Vector4f rivencraft$tintCelestial(org.joml.Vector4f nativeColor) {
// Normal Time consumes one shared color group for its sun and moon in 1.21.1.
// Keep native alpha (including weather fading) and the sprite's transparent halo.
if (rivencraft$celestialTint < 0) return nativeColor;
var tint = net.dnorberg.rivencraft.age.PortAgeColors.vector(rivencraft$celestialTint);
return new org.joml.Vector4f(nativeColor.x * tint.x(), nativeColor.y * tint.y(),
nativeColor.z * tint.z(), nativeColor.w);
}
@Inject(method = "renderSunriseAndSunset", at = @At("HEAD"), cancellable = true)
private void rivencraft$rotateHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON
|| rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) {
ci.cancel();
return;
}
if (rivencraft$orbitBearing < 0) return;
pose.pushPose();
pose.rotateDegrees(com.mojang.math.Axis.YP, rivencraft$orbitBearing + 90);
@@ -81,7 +265,9 @@ public class StaticSkyRendererMixin {
private void rivencraft$restoreHorizon(com.mojang.renderpearl.api.commands.RenderPass pass,
com.mojang.blaze3d.vertex.PoseStack pose, float angle,
org.joml.Vector4fc color, CallbackInfo ci) {
if (rivencraft$orbitBearing >= 0) pose.popPose();
if (rivencraft$bodyMode != net.dnorberg.rivencraft.age.PortDayCycle.MOON
&& rivencraft$bodyMode != net.dnorberg.rivencraft.age.PortDayCycle.NONE
&& rivencraft$orbitBearing >= 0) pose.popPose();
}
@Redirect(method = "renderEndSky", at = @At(value = "INVOKE", target =
@@ -94,6 +280,10 @@ public class StaticSkyRendererMixin {
@Inject(method = "close", at = @At("HEAD"))
private void rivencraft$close(CallbackInfo ci) {
if (rivencraft$discs != null) {
rivencraft$discs.close();
rivencraft$discs = null;
}
if (rivencraft$staticSky != null) {
rivencraft$staticSky.close();
rivencraft$staticSky = null;
@@ -14,6 +14,14 @@ public class StaticSkyStateMixin implements StaticSkyState {
@Unique private int rivencraft$tint = -1;
@Unique private int rivencraft$bearing = -1;
@Unique private float rivencraft$sunSize = 1F;
@Unique private int rivencraft$celestialTint = -1;
@Unique private int rivencraft$bodyMode;
@Unique private int rivencraft$bodyOverlay;
public int rivencraft$bodyOverlay() { return rivencraft$bodyOverlay; }
public void rivencraft$bodyOverlay(int mode) { rivencraft$bodyOverlay = mode; }
@Unique private java.util.List<StaticSkyState.Body> rivencraft$bodies = java.util.List.of();
public java.util.List<StaticSkyState.Body> rivencraft$bodies() { return rivencraft$bodies; }
public void rivencraft$bodies(java.util.List<StaticSkyState.Body> bodies) { rivencraft$bodies = java.util.List.copyOf(bodies); }
public long rivencraft$staticFrame() { return rivencraft$frame; }
public void rivencraft$staticFrame(long frame) { rivencraft$frame = frame; }
public int rivencraft$skyTint() { return rivencraft$tint; }
@@ -22,6 +30,10 @@ public class StaticSkyStateMixin implements StaticSkyState {
public void rivencraft$orbitBearing(int bearing) { rivencraft$bearing = bearing; }
public float rivencraft$sunSize() { return rivencraft$sunSize; }
public void rivencraft$sunSize(float size) { rivencraft$sunSize = size; }
public int rivencraft$celestialTint() { return rivencraft$celestialTint; }
public void rivencraft$celestialTint(int color) { rivencraft$celestialTint = color; }
public int rivencraft$bodyMode() { return rivencraft$bodyMode; }
public void rivencraft$bodyMode(int mode) { rivencraft$bodyMode = mode; }
@Inject(method = "reset", at = @At("TAIL"))
private void rivencraft$reset(CallbackInfo ci) {
@@ -29,5 +41,9 @@ public class StaticSkyStateMixin implements StaticSkyState {
rivencraft$tint = -1;
rivencraft$bearing = -1;
rivencraft$sunSize = 1F;
rivencraft$celestialTint = -1;
rivencraft$bodyMode = 0;
rivencraft$bodyOverlay = 0;
rivencraft$bodies = java.util.List.of();
}
}
@@ -33,6 +33,7 @@ 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);
@@ -82,7 +83,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));
}
@@ -108,9 +110,22 @@ public final class PortBookNetworking {
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
long seed = player.getRandom().nextLong();
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
PortAgeTerrain terrain;
List<Identifier> generationPages;
try {
biomePlan = PortAuthoredWriting.biomePlan(pages, player.registryAccess());
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;
@@ -127,24 +142,21 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
long seed = player.getRandom().nextLong();
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);
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
PortAuthoredWriting.rockPlan(pages, seed),
PortAuthoredWriting.vegetationPlan(pages),
PortAuthoredWriting.dungeonCount(pages),
PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed),
var randomFeatures = pages.isEmpty()
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
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.compile(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.compile(pages, seed,
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.PortAcceleratedTicks.configure(level,
net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
@@ -158,13 +170,19 @@ public final class PortBookNetworking {
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);
String failure = net.dnorberg.rivencraft.item.PortHandheldTravel.travel(player,
hand(payload.offhand()), stack, () -> {
String result = PortAgeTravel.enter(player, id);
if (result == null) LinkingBookItem.applySuccessfulLink(player,
stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE),
source, departure, incoming, player.getYRot());
return result;
});
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 net.dnorberg.rivencraft.age.PortReturnLinks.afterSuccessfulTravel(player, id, returnTarget);
}
private static InteractionHand hand(boolean offhand) {
@@ -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; }
}
@@ -32,6 +32,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 +70,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,90 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
// Legacy did not impose a book-page cap. Our fixed-slot binder persists slot IDs
// as unsigned bytes; 252 page positions (after its three input slots) fit safely.
public static final int MAX_PAGES = 252;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
.xmap(NotebookContents::new, NotebookContents::pages);
public static final StreamCodec<RegistryFriendlyByteBuf, NotebookContents> STREAM_CODEC = StreamCodec.of(
NotebookContents::encode, NotebookContents::decode);
public NotebookContents {
if (pages.size() > MAX_PAGES) {
throw new IllegalArgumentException("Notebook exceeds " + MAX_PAGES + " page positions");
}
List<ItemStack> copies = new ArrayList<>(pages.size());
for (ItemStack page : pages) {
validatePage(page).getOrThrow();
copies.add(page.copy());
}
pages = List.copyOf(copies);
}
public int pageCount() {
return (int)pages.stream().filter(page -> !page.isEmpty()).count();
}
public int unresolvedCount() {
return (int)pages.stream()
.filter(page -> !page.isEmpty()
&& page.has(Rivencraft.SYMBOL_PAGE_ID.get())
&& PortSymbolPages.find(page.get(Rivencraft.SYMBOL_PAGE_ID.get())) == null)
.count();
}
private static DataResult<List<ItemStack>> validate(List<ItemStack> pages) {
if (pages.size() > MAX_PAGES) {
return DataResult.error(() -> "Notebook exceeds " + MAX_PAGES + " page positions");
}
for (ItemStack page : pages) {
DataResult<ItemStack> result = validatePage(page);
if (result.error().isPresent()) {
return DataResult.error(() -> result.error().orElseThrow().message());
}
}
return DataResult.success(pages);
}
private static DataResult<ItemStack> validatePage(ItemStack page) {
if (page.isEmpty()) {
return DataResult.success(page);
}
if (!page.is(Rivencraft.PAGE.get())) {
return DataResult.error(() -> "Notebook contains a non-Page item");
}
return page.getCount() == 1
? DataResult.success(page)
: DataResult.error(() -> "Notebook Page stacks must contain exactly one item");
}
private static void encode(RegistryFriendlyByteBuf buffer, NotebookContents contents) {
ByteBufCodecs.VAR_INT.encode(buffer, contents.pages.size());
contents.pages.forEach(page -> ItemStack.OPTIONAL_STREAM_CODEC.encode(buffer, page));
}
private static NotebookContents decode(RegistryFriendlyByteBuf buffer) {
int size = ByteBufCodecs.VAR_INT.decode(buffer);
if (size < 0 || size > MAX_PAGES) {
throw new IllegalArgumentException("Invalid Notebook page count " + size);
}
List<ItemStack> pages = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
pages.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(buffer));
}
return new NotebookContents(pages);
}
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Persistent contents of a Page item. Unknown symbols remain explicit and recoverable. */
public record PageContent(Kind kind, Optional<SymbolReference> symbol) {
public static final PageContent BLANK = new PageContent(Kind.BLANK, Optional.empty());
public static final PageContent LINK_PANEL = new PageContent(Kind.LINK_PANEL, Optional.empty());
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown page content kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<PageContent> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(PageContent::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(PageContent::symbol)
).apply(instance, PageContent::new));
public static final Codec<PageContent> CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate);
public static final StreamCodec<ByteBuf, PageContent> STREAM_CODEC = StreamCodec.of(
PageContent::encode, PageContent::decode);
public PageContent {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
boolean needsSymbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL;
if (needsSymbol != symbol.isPresent()) {
throw new IllegalArgumentException("Page contents do not match kind " + kind);
}
}
public static PageContent symbol(SymbolReference symbol) {
return new PageContent(Kind.SYMBOL, Optional.of(symbol));
}
public static PageContent unresolved(SymbolReference symbol) {
return new PageContent(Kind.UNRESOLVED_SYMBOL, Optional.of(symbol));
}
/** Re-resolves persisted data against the authoritative current server catalog. */
public PageContent resolve() {
if (kind != Kind.SYMBOL && kind != Kind.UNRESOLVED_SYMBOL) {
return this;
}
SymbolReference reference = symbol.orElseThrow();
return PortSymbolPages.find(reference.id()) != null ? symbol(reference) : unresolved(reference);
}
public boolean isResolvedSymbol() {
return kind == Kind.SYMBOL;
}
private static DataResult<PageContent> validate(PageContent content) {
boolean needsSymbol = content.kind == Kind.SYMBOL || content.kind == Kind.UNRESOLVED_SYMBOL;
return needsSymbol == content.symbol.isPresent()
? DataResult.success(content)
: DataResult.error(() -> "Page contents do not match kind " + content.kind);
}
private static void encode(ByteBuf buffer, PageContent content) {
ByteBufCodecs.VAR_INT.encode(buffer, content.kind.ordinal());
content.symbol.ifPresent(reference -> SymbolReference.STREAM_CODEC.encode(buffer, reference));
}
private static PageContent decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown page content kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
Optional<SymbolReference> symbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL
? Optional.of(SymbolReference.STREAM_CODEC.decode(buffer))
: Optional.empty();
return new PageContent(kind, symbol);
}
public enum Kind {
BLANK,
SYMBOL,
UNRESOLVED_SYMBOL,
LINK_PANEL
}
}
@@ -0,0 +1,47 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.age.PortAgeTerrain;
import net.dnorberg.rivencraft.age.PortBiomePlan;
import net.dnorberg.rivencraft.age.PortRandomAgeSelection;
import net.minecraft.resources.Identifier;
import net.minecraft.server.MinecraftServer;
/** Root AgeRuntimeProfiles.selectForWriting, limited to currently supported port combinations. */
public final class PortAuthoredTerrainSelection {
private PortAuthoredTerrainSelection() {}
public record Selection(PortAgeTerrain terrain, List<Identifier> generationPages, PortBiomePlan biomes) {
public Selection { generationPages = List.copyOf(generationPages); }
}
public static Selection select(MinecraftServer server, List<Identifier> pages, long seed) {
var explicit = PortAuthoredWriting.explicitTerrain(pages);
if (explicit != null) return compile(server, pages, seed, explicit);
var preferred = PortRandomAgeSelection.terrain(seed);
try {
return compile(server, pages, seed, preferred);
} catch (IllegalArgumentException ignored) {
// Match root's stable profile-ID fallback rather than rerolling the seed.
}
var compatible = new ArrayList<Selection>();
for (var terrain : PortRandomAgeSelection.terrains()) {
if (terrain == preferred) continue;
try { compatible.add(compile(server, pages, seed, terrain)); }
catch (IllegalArgumentException ignored) { }
}
if (compatible.isEmpty()) throw new IllegalArgumentException("No supported terrain can host this book's pages");
return compatible.get(Math.floorMod(Long.hashCode(seed), compatible.size()));
}
private static Selection compile(MinecraftServer server, List<Identifier> pages, long seed, PortAgeTerrain terrain) {
if (!PortAuthoredWriting.supportsOnTerrain(pages, terrain, seed))
throw new IllegalArgumentException("Unsupported pages for " + terrain + " terrain");
var resolved = PortAuthoredWriting.withTerrain(pages, terrain);
// Also preflight registry-backed biome restrictions, so an automatic Cave
// choice cannot turn supplied surface biome pages into an unusable book.
var biomes = PortAuthoredWriting.biomePlan(resolved, server, seed);
return new Selection(terrain, resolved, biomes);
}
}
@@ -57,6 +57,7 @@ public final class PortAuthoredWriting {
if (entry == null) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true;
if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) return true;
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null
|| PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true;
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
@@ -72,6 +73,36 @@ public final class PortAuthoredWriting {
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
if (pages.stream().noneMatch(PortAuthoredWriting::isTerrain))
return net.dnorberg.rivencraft.age.PortRandomAgeSelection.terrains().stream()
.anyMatch(terrain -> supportsOnTerrain(pages, terrain, 0));
try {
return supportsExplicit(PortMaterialCompletion.complete(pages, 0));
} catch (IllegalArgumentException exception) { return false; }
}
static boolean supportsOnTerrain(List<Identifier> pages, PortAgeTerrain terrain, long seed) {
try {
return supportsExplicit(PortMaterialCompletion.complete(withTerrain(pages, terrain), seed));
} catch (IllegalArgumentException exception) { return false; }
}
static List<Identifier> withTerrain(List<Identifier> pages, PortAgeTerrain terrain) {
if (pages.stream().anyMatch(PortAuthoredWriting::isTerrain)) return List.copyOf(pages);
var resolved = new ArrayList<>(pages);
// Append internally so original material/color/controller indices stay stable.
resolved.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT)));
return List.copyOf(resolved);
}
static PortAgeTerrain explicitTerrain(List<Identifier> pages) {
return pages.stream().filter(PortAuthoredWriting::isTerrain).findFirst()
.map(page -> PortAgeTerrain.valueOf(page.getPath().substring(8).toUpperCase(java.util.Locale.ROOT)))
.orElse(null);
}
private static boolean supportsExplicit(List<Identifier> pages) {
if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false;
if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false;
boolean terrain = false;
@@ -97,7 +128,9 @@ public final class PortAuthoredWriting {
|| pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false;
if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page)
|| net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) {
// Shared color groups can precede a sky/ambient target or tint the sky at the end.
// Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky.
} else if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) {
// Every Accelerated page adds random-tick attempts; it is not a controller.
} else if (isTerrain(page) && !terrain) {
terrain = true;
} else if (PortSkyStyle.forPage(page) != null && sky++ == 0) {
@@ -107,7 +140,7 @@ public final class PortAuthoredWriting {
} else if (PortWeatherMode.forPage(page) != null && weather++ == 0) {
// A single independent weather policy for this Age.
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
// Missing biome slots are seed-completed when the Age is created.
} else if (isBlockPage(page) && pendingMaterial == null
&& PortSymbolPages.find(page) != null
&& (PortSymbolPages.find(page).category().equals("rivencraft:block")
@@ -152,7 +185,8 @@ public final class PortAuthoredWriting {
}
if (pendingMaterial != null || (rocks > 0 || pages.stream().anyMatch(PortVegetationPlan::requiresSurface))
&& (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if ((dungeons > 0 || lakes > 0) && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN)
if (dungeons > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (lakes > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN)
|| pages.contains(CAVE_TERRAIN))) return false;
if (denseOres > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (!carvers.isEmpty() && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
@@ -169,8 +203,8 @@ public final class PortAuthoredWriting {
.filter(value -> value != null).findFirst().orElseThrow();
boolean valid = switch (distribution) {
case NATIVE -> biomes == 0;
case SINGLE -> biomes == 1;
default -> biomes >= 2;
case SINGLE -> biomes <= 1;
default -> true;
};
return valid;
}
@@ -214,13 +248,14 @@ public final class PortAuthoredWriting {
}
public static PortRockPlan rockPlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
pages = completedMaterials(pages, seed);
List<Identifier> materials = solidMaterials(pages, seed, ROCKS);
return materials.isEmpty() ? null : new PortRockPlan(materials);
}
public static PortTerrainFeaturePlan terrainFeaturePlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
pages = completedMaterials(pages, seed);
final List<Identifier> completed = pages;
Map<Identifier, List<Identifier>> materials = new HashMap<>();
Map<Identifier, Integer> occurrences = new HashMap<>();
List<PortTerrainFeaturePlan.Choice> choices = new ArrayList<>();
@@ -230,7 +265,7 @@ public final class PortAuthoredWriting {
choices.add(new PortTerrainFeaturePlan.Choice(page, PortTerrainFeaturePlan.CRYSTAL_MATERIAL));
continue;
}
List<Identifier> selected = materials.computeIfAbsent(page, feature -> solidMaterials(pages, seed, feature));
List<Identifier> selected = materials.computeIfAbsent(page, feature -> solidMaterials(completed, seed, feature));
int occurrence = occurrences.merge(page, 1, Integer::sum) - 1;
choices.add(new PortTerrainFeaturePlan.Choice(page, selected.get(occurrence)));
}
@@ -262,7 +297,7 @@ public final class PortAuthoredWriting {
}
public static PortLakePlan lakePlan(List<Identifier> pages, long seed) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
pages = completedMaterials(pages, seed);
if (pages.stream().noneMatch(PortLakePlan::isLake)) return null;
List<Identifier> fluidPool = PortRegistryPages.fluidMaterialIds();
if (fluidPool.isEmpty()) throw new IllegalStateException("No source-fluid materials are registered");
@@ -288,6 +323,12 @@ public final class PortAuthoredWriting {
return new PortLakePlan(choices);
}
private static List<Identifier> completedMaterials(List<Identifier> pages, long seed) {
var completed = PortMaterialCompletion.complete(pages, seed);
if (!supportsExplicit(completed)) throw new IllegalArgumentException("Unsupported authored Age pages");
return completed;
}
public static PortVegetationPlan vegetationPlan(List<Identifier> pages) {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
List<Identifier> vegetation = pages.stream().filter(PortVegetationPlan::isVegetation).toList();
@@ -307,6 +348,18 @@ public final class PortAuthoredWriting {
/** Resolves all authored biomes from the active registry before creating a level. */
public static PortBiomePlan biomePlan(List<Identifier> pages, HolderLookup.Provider registries) {
return biomePlan(pages, registries, 0, null, null);
}
/** Creation-only completion; the saved plan never rerolls on load or reseed. */
public static PortBiomePlan biomePlan(List<Identifier> pages, net.minecraft.server.MinecraftServer server, long seed) {
return biomePlan(pages, server.registryAccess(), seed,
net.dnorberg.rivencraft.age.PortRandomAgeSelection.eligibleBiomes(server, terrain(pages)),
net.dnorberg.rivencraft.age.PortRandomAgeSelection.distribution(seed));
}
private static PortBiomePlan biomePlan(List<Identifier> pages, HolderLookup.Provider registries,
long seed, List<Identifier> available, PortBiomePlan.Distribution fallback) {
if (!supports(pages)) throw new IllegalArgumentException(
"Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>();
@@ -339,8 +392,44 @@ public final class PortAuthoredWriting {
throw new IllegalArgumentException("Vegetation pages need at least one surface Overworld biome");
if (pages.contains(PortTerrainFeaturePlan.DENSE_ORES) && !biomes.isEmpty() && !hasOreBiome)
throw new IllegalArgumentException("Dense Ores needs at least one biome with registered ore placements");
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new PortBiomePlan(distribution, List.of());
if (distribution == null && fallback != null) {
distribution = fallback;
// Root's authored-pool fallback never discards supplied biome pages.
if (pages.contains(VOID_TERRAIN) && biomes.isEmpty()) distribution = PortBiomePlan.Distribution.SINGLE;
if (!biomes.isEmpty() && (pages.contains(VOID_TERRAIN)
|| distribution == PortBiomePlan.Distribution.NATIVE
|| distribution == PortBiomePlan.Distribution.SINGLE && biomes.size() > 1))
distribution = biomes.size() == 1 ? PortBiomePlan.Distribution.SINGLE : biomes.size() == 2
? PortBiomePlan.Distribution.TILED : PortBiomePlan.Distribution.MEDIUM;
}
if (distribution == PortBiomePlan.Distribution.NATIVE) {
var nativePool = caveTerrain
? (available != null ? available : net.dnorberg.rivencraft.age.PortRandomAgeSelection.caveBiomes(registries))
: List.<Identifier>of();
return new PortBiomePlan(distribution, nativePool);
}
if (distribution != null && available != null) {
int minimum = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
int target = minimum;
if (biomes.isEmpty() && distribution != PortBiomePlan.Distribution.SINGLE) {
var random = new Random(seed ^ 0x42494F4D45434F55L);
while (target < available.size() && random.nextInt(3) != 0) target++;
}
var distinct = new java.util.HashSet<>(biomes);
PortStructureBiomeCompletion.complete(pages, registries, available, !biomes.isEmpty(),
distribution, biomes, distinct, seed);
target = Math.max(target, distinct.size());
// Void may retain authored weather/season biomes, but must not gain
// an unrequested The Void biome just to fill a multi-biome controller.
var filler = new ArrayList<>(pages.contains(VOID_TERRAIN) && !biomes.isEmpty() ? List.<Identifier>of() : available);
Collections.shuffle(filler, new Random(seed ^ 0x42494F4D45504F4FL));
for (Identifier biome : filler) {
if (distinct.size() >= target) break;
if (distinct.add(biome)) biomes.add(biome);
}
if (distinct.size() < minimum) throw new IllegalArgumentException(
distribution + " requires " + minimum + " distinct biomes; supply more biome pages for this terrain");
}
if (biomes.isEmpty()) return null;
if (distribution == null) distribution = biomes.size() == 1
? PortBiomePlan.Distribution.SINGLE : biomes.size() == 2
@@ -0,0 +1,68 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.age.PortCarverPages;
import net.dnorberg.rivencraft.age.PortLakePlan;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
import net.dnorberg.rivencraft.age.PortVegetationPlan;
import net.minecraft.resources.Identifier;
/** Creation-time implied features, adapted from 1.21.1 AgeFeatureSelection. */
public final class PortMaterialCompletion {
private PortMaterialCompletion() {}
public static List<Identifier> complete(List<Identifier> pages, long seed) {
var result = new ArrayList<Identifier>();
for (int index = 0; index < pages.size(); index++) {
Identifier page = pages.get(index);
result.add(page);
var entry = PortSymbolPages.find(page);
if (entry == null || !(entry.category().equals("rivencraft:block")
|| entry.category().equals("rivencraft:fluid"))) continue;
if (index + 1 < pages.size() && isFeature(pages.get(index + 1))) continue;
Identifier material = PortRegistryPages.blockId(page);
var candidates = candidates(pages, entry.category().equals("rivencraft:fluid"));
if (candidates.isEmpty()) throw new IllegalArgumentException(
"No compatible feature for material " + material + " on this terrain");
long mixed = mix(seed ^ (long) material.hashCode() * 0x9E3779B97F4A7C15L ^ index);
result.add(candidates.get(Math.floorMod(mixed, candidates.size())));
}
return List.copyOf(result);
}
private static List<Identifier> candidates(List<Identifier> pages, boolean fluid) {
boolean cave = pages.contains(id("terrain/cave"));
boolean empty = pages.contains(id("terrain/void"));
boolean flat = pages.contains(id("terrain/flat"));
if (fluid) return cave || empty || flat ? List.of()
: List.of(id("feature/surface_lakes"), id("feature/deep_lakes"), id("feature/lava_lakes"));
var choices = new ArrayList<Identifier>();
if (!cave && !empty && !flat) choices.add(id("feature/rocks"));
if (!cave) choices.add(PortTerrainFeaturePlan.FLOATING_ISLANDS);
choices.add(PortTerrainFeaturePlan.TENDRILS);
choices.add(PortTerrainFeaturePlan.SPHERES);
if (!cave && !empty) {
choices.add(PortTerrainFeaturePlan.SPIKES);
choices.add(PortTerrainFeaturePlan.OBELISKS);
}
return choices;
}
private static boolean isFeature(Identifier page) {
return PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page)
|| PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page)
|| PortCarverPages.supports(page) || page.equals(id("feature/rocks"))
|| page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS)
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES) || page.equals(PortTerrainFeaturePlan.STAR_FISSURES);
}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static long mix(long value) {
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
return value ^ value >>> 31;
}
}
@@ -103,6 +103,7 @@ public final class PortRegistryPages {
private static String materialCategory(Block block) {
if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR
|| block == Rivencraft.CLOUD_PLATFORM.get()
|| block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null;
var state = block.defaultBlockState();
var fluid = state.getFluidState();
@@ -0,0 +1,54 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.Set;
import net.dnorberg.rivencraft.age.PortBiomePlan;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.minecraft.core.HolderLookup;
import net.minecraft.resources.Identifier;
/** Root AgeBiomeSelection.completeStructureBiomes, before ordinary minimum filling. */
final class PortStructureBiomeCompletion {
private PortStructureBiomeCompletion() {}
static void complete(List<Identifier> pages, HolderLookup.Provider registries, List<Identifier> available,
boolean hasAuthoredBiomes, PortBiomePlan.Distribution distribution,
List<Identifier> selected, Set<Identifier> distinct, long seed) {
var requirements = pages.stream().filter(PortStructurePages::supports).distinct()
.map(page -> PortStructurePages.compatibleBiomes(page, registries))
.filter(requirement -> !requirement.isEmpty()).toList();
if (requirements.isEmpty()) return;
if (distribution == PortBiomePlan.Distribution.SINGLE) {
if (hasAuthoredBiomes) return;
var candidates = available.stream()
.filter(candidate -> requirements.stream().allMatch(requirement -> requirement.contains(candidate)))
.toList();
if (candidates.isEmpty()) return;
Identifier biome = choose(candidates, seed, 0x5354525543544249L);
selected.clear();
distinct.clear();
selected.add(biome);
distinct.add(biome);
return;
}
for (int index = 0; index < requirements.size(); index++) {
var requirement = requirements.get(index);
if (selected.stream().anyMatch(requirement::contains)) continue;
var candidates = available.stream().filter(requirement::contains)
.filter(candidate -> !distinct.contains(candidate)).toList();
if (candidates.isEmpty()) continue;
Identifier biome = choose(candidates, seed, 0x5354525543544249L + index);
selected.add(biome);
distinct.add(biome);
}
}
private static Identifier choose(List<Identifier> candidates, long seed, long salt) {
var shuffled = new ArrayList<>(candidates);
Collections.shuffle(shuffled, new Random(seed ^ salt));
return shuffled.getFirst();
}
}
@@ -4,6 +4,17 @@ package net.dnorberg.rivencraft.writing;
public final class PortSymbolNumerals {
private PortSymbolNumerals() {}
public static int[] digitsFor(int number) {
if (number < 0) throw new IllegalArgumentException("Numeral must be non-negative");
if (number < 25) return new int[] {number};
int count = 0;
for (int remaining = number; remaining > 0; remaining /= 25) count++;
int[] digits = new int[count];
for (int remaining = number, index = count - 1; index >= 0; index--, remaining /= 25)
digits[index] = remaining % 25;
return digits;
}
public static int[] componentsForDigit(int digit) {
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Base-25 digit must be in [0, 24]");
if (digit == 0) return new int[] {1};
@@ -37,6 +37,15 @@ public final class PortSymbolPages {
return List.copyOf(categories);
}
/** Same bundled, non-biome/material/non-corrupted pool as 1.21.1's booster. */
public static List<Identifier> sealedCandidateIds() {
return ENTRIES.values().stream().filter(entry -> entry.id().getNamespace().equals(Rivencraft.MOD_ID)
&& !entry.id().getPath().startsWith("block/")
&& !entry.id().getPath().startsWith("biome/")
&& !entry.category().equals("rivencraft:corrupted"))
.map(Entry::id).sorted().toList();
}
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
List<ItemStack> pages = new ArrayList<>();
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Client-safe symbol presentation selected by the authoritative server catalog. */
public record SymbolDisplayMetadata(
Identifier id,
String nameTranslationKey,
String descriptionTranslationKey,
Identifier category,
boolean resolved,
String poem
) {
public static final Codec<SymbolDisplayMetadata> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.fieldOf("id").forGetter(SymbolDisplayMetadata::id),
Codec.STRING.fieldOf("name").forGetter(SymbolDisplayMetadata::nameTranslationKey),
Codec.STRING.fieldOf("description").forGetter(SymbolDisplayMetadata::descriptionTranslationKey),
Identifier.CODEC.fieldOf("category").forGetter(SymbolDisplayMetadata::category),
Codec.BOOL.fieldOf("resolved").forGetter(SymbolDisplayMetadata::resolved),
Codec.STRING.optionalFieldOf("poem", "").forGetter(SymbolDisplayMetadata::poem)
).apply(instance, SymbolDisplayMetadata::new));
public static final StreamCodec<ByteBuf, SymbolDisplayMetadata> STREAM_CODEC = StreamCodec.composite(
Identifier.STREAM_CODEC, SymbolDisplayMetadata::id,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::nameTranslationKey,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::descriptionTranslationKey,
Identifier.STREAM_CODEC, SymbolDisplayMetadata::category,
ByteBufCodecs.BOOL, SymbolDisplayMetadata::resolved,
ByteBufCodecs.STRING_UTF8, SymbolDisplayMetadata::poem,
SymbolDisplayMetadata::new
);
public static SymbolDisplayMetadata unresolved(SymbolReference reference) {
return new SymbolDisplayMetadata(
reference.id(),
"symbol.rivencraft.unresolved",
"symbol.rivencraft.unresolved.description",
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "unresolved"),
false,
""
);
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import io.netty.buffer.ByteBuf;
import java.util.Objects;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Stable namespaced identity for one writing symbol. */
public record SymbolReference(Identifier id) {
public static final Codec<SymbolReference> CODEC = Identifier.CODEC.xmap(
SymbolReference::new, SymbolReference::id);
public static final StreamCodec<ByteBuf, SymbolReference> STREAM_CODEC = Identifier.STREAM_CODEC.map(
SymbolReference::new, SymbolReference::id);
public SymbolReference {
Objects.requireNonNull(id, "id");
}
/** Remains useful when the defining data pack has been removed. */
public String unresolvedName() {
return id.toString();
}
}
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.writing;
import net.dnorberg.rivencraft.Rivencraft;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** A bounded, server-derived view of symbols physically present in the desk. */
public final class WritingDeskCatalog {
private static final int MAX_ENTRIES = WritingDeskBlockEntity.COLLECTIONS * NotebookContents.MAX_PAGES;
private WritingDeskCatalog() {}
public static List<SymbolDisplayMetadata> available(WritingDeskBlockEntity desk) {
Map<Identifier, SymbolDisplayMetadata> unique = new HashMap<>();
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot);
if (collection.is(Rivencraft.PAGE.get())) add(unique, collection);
if (CollationFolderItem.isCollection(collection)) {
List<ItemStack> pages = collection.getOrDefault(
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page));
}
}
return unique.values().stream()
.sorted(Comparator.comparing((SymbolDisplayMetadata symbol) -> symbol.category().toString())
.thenComparing(symbol -> symbol.id().toString()))
.toList();
}
private static void add(Map<Identifier, SymbolDisplayMetadata> unique, ItemStack page) {
if (!page.is(Rivencraft.PAGE.get())) return;
PageContent content = PageItem.content(page).resolve();
if (content.kind() != PageContent.Kind.SYMBOL) return;
SymbolReference reference = content.symbol().orElseThrow();
var entry = PortSymbolPages.find(reference.id());
if (entry != null) unique.put(reference.id(), new SymbolDisplayMetadata(entry.id(),
entry.nameKey(), entry.descriptionKey(), Identifier.parse(entry.category()), true, entry.poem()));
}
public static void write(RegistryFriendlyByteBuf buffer, List<SymbolDisplayMetadata> entries) {
if (entries.size() > MAX_ENTRIES) throw new IllegalArgumentException("Desk symbol catalog exceeds limit");
buffer.writeVarInt(entries.size());
entries.forEach(entry -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, entry));
}
public static List<SymbolDisplayMetadata> read(RegistryFriendlyByteBuf buffer) {
int count = buffer.readVarInt();
if (count < 0 || count > MAX_ENTRIES) throw new IllegalArgumentException("Invalid desk symbol count " + count);
List<SymbolDisplayMetadata> entries = new ArrayList<>(count);
for (int index = 0; index < count; index++) {
entries.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
return List.copyOf(entries);
}
}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/collation_folder"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/notebook"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/sealed_notebook"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/symbol_portfolio"}}
@@ -0,0 +1 @@
{"model":{"type":"minecraft:model","model":"rivencraft:item/writing_desk"}}
@@ -0,0 +1,9 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {"trigger":"minecraft:inventory_changed","conditions":{"items":[{"items":"minecraft:leather"}]}},
"has_the_recipe": {"trigger":"minecraft:recipe_unlocked","conditions":{"recipes":["rivencraft:notebook"]}}
},
"requirements": [["has_material", "has_the_recipe"]],
"rewards": {"recipes":["rivencraft:notebook"]}
}
@@ -0,0 +1,9 @@
{
"parent":"minecraft:recipes/root",
"criteria":{
"has_material":{"trigger":"minecraft:inventory_changed","conditions":{"items":[{"items":"minecraft:feather"}]}},
"has_the_recipe":{"trigger":"minecraft:recipe_unlocked","conditions":{"recipes":["rivencraft:writing_desk"]}}
},
"requirements":[["has_material","has_the_recipe"]],
"rewards":{"recipes":["rivencraft:writing_desk"]}
}
@@ -0,0 +1,7 @@
{
"type": "minecraft:crafting_shaped",
"category": "misc",
"key": {"L": "minecraft:leather", "S": "minecraft:string"},
"pattern": [" L ", "S ", " L "],
"result": {"count": 1, "id": "rivencraft:notebook"}
}
@@ -0,0 +1,6 @@
{
"type":"minecraft:crafting_shaped", "category":"misc",
"pattern":["V F", "PPP", "P P"],
"key":{"V":"minecraft:glass_bottle", "F":"minecraft:feather", "P":"#minecraft:planks"},
"result":{"id":"rivencraft:writing_desk", "count":1}
}