Public Access
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2904eaa8d | ||
|
|
35980338e1 | ||
|
|
be8440ddb5 | ||
|
|
f13bedd26f | ||
|
|
40770841ee |
No files matched your search
@@ -15,11 +15,18 @@ 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. Books now have Legacy-style spreads,
|
||||
animated fallback panels and saved destination labels; lectern stands, open-book
|
||||
drops, page collections, stations and basic progression are also ported. The latest
|
||||
beta adds Age libraries, crystal portals with animated fallback surfaces, saved
|
||||
instability scoring/provider selection, potion providers and four-color decay.
|
||||
Full writing recovery, live material profiling and other hazards remain pending.
|
||||
The beta does not yet provide full
|
||||
1.21.1 gameplay or optional-integration parity.
|
||||
See the [26.3 beta guide](ports/mc263/README.md) and
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.78-beta.1.md).
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md).
|
||||
Keep the builds and their saves separate, and back up worlds before updating.
|
||||
|
||||
## Getting started
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
File diff suppressed because it is too large.
Load diff
+1177
-3
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.106-beta.1
|
||||
|
||||
Collects the player-validated port slices .99–.106 since 1.0.1.98-beta.1.
|
||||
This beta is not yet feature-complete with Minecraft 1.21.1.
|
||||
|
||||
## Books and travel
|
||||
|
||||
- Fallback Age platforms now use the dedicated non-collectible platform block
|
||||
from 1.21.1: opaque crystal texture, glass-like breaking strength, no drops
|
||||
or pick-block item. Existing ordinary Crystal Block platforms are unchanged.
|
||||
- Ported automatic Return Links for successful handheld Descriptive Book
|
||||
travel. `descriptive_books.return_link_mode` in `config/rivencraft-local.toml`
|
||||
defaults to **DISABLED**. `ON_CREATION` issues one on the first arrival per
|
||||
Age/world with that mode enabled; `ALWAYS` issues one on each arrival.
|
||||
- Return Links preserve departure dimension, position and facing. They are
|
||||
issued after Disarm and drop at the destination if inventory is full.
|
||||
First-arrival records persist across reload, reseeding and restart.
|
||||
- Successful handheld Linking and Descriptive Book travel now leaves the
|
||||
active book at departure as a recoverable open-book entity. Both hands are
|
||||
supported. Failed travel or canceled entity spawning restores the book.
|
||||
- Dropped books reuse the existing textures and skyward-facing model. They
|
||||
have a one-second pickup delay, no ordinary five-minute expiry, slow decay
|
||||
and water/rain damage, matching 1.21.1. Their components and decay counter
|
||||
persist through restart.
|
||||
|
||||
## Page collections and binding
|
||||
|
||||
- Ported Notebooks and Collation Folders with their existing editor and assets,
|
||||
capacities of 252 and 27 positions, sparse page storage, search, scrolling,
|
||||
page movement and inventory transfers. Link Panels are rejected by the editor.
|
||||
- Notebook crafting uses the existing leather/string pattern. The held
|
||||
collection is locked against ordinary menu movement. Escape closes the menu
|
||||
with search focused; search still consumes inventory/hotbar shortcuts.
|
||||
- Corrected the port's keyboard and mouse-button mappings, including arrow
|
||||
movement, right-click handling and scrollbar dragging.
|
||||
- The Book Binder accepts collection imports: shift-click to append pages or
|
||||
click a carried collection on the strip to insert them. Empty positions are
|
||||
skipped and occupied page order is retained. Imports validate all inputs and
|
||||
capacity before committing; successful imports return the empty collection.
|
||||
- Added category Symbol Portfolios to Creative, including registry-derived
|
||||
biome, material and fluid pages. Large categories split into volumes of up
|
||||
to 252 pages. Portfolios use the editor and Binder import flow.
|
||||
- Sealed Notebooks open into Folders containing 16 random pages using the
|
||||
1.21.1 pool/exclusions. Survival consumes one; Creative retains the item.
|
||||
Full inventories receive a dropped Folder. No new recipes are invented for
|
||||
Folders, Portfolios or Sealed Notebooks; discovery/library loot is pending.
|
||||
|
||||
## Writing Desk
|
||||
|
||||
- Ported the two-block Writing Desk, its existing model/textures, selected-page
|
||||
and white paper-stack display, and Legacy GUI atlases.
|
||||
- Ported 88 tabs cycled four at a time, category/search/sort/ALL views, collection
|
||||
and loose-page storage, page movement/extraction, and server-owned catalog
|
||||
synchronization. Link Panels cannot be stored or copied in the desk.
|
||||
- Copy previews are virtual. Taking a copy consumes one paper and one ink by
|
||||
default while retaining the original page. `writing.symbol_ink_cost` is
|
||||
configurable in `rivencraft-local.toml`.
|
||||
- Vials fill a 16-unit meter and return vanilla glass bottles. Refills are
|
||||
event-driven and require bottle space. Full inventory prevents shift-copy
|
||||
consumption. Unknown pages are retained but cannot be copied unresolved.
|
||||
- Ported crafting and adapted its recipe-unlock advancement to 26.3.
|
||||
Removing either desk half removes its partner and drops contents once.
|
||||
- Further corrections to the desk model, focused on page/paper placement,
|
||||
remain tracked for both versions in issue #113.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
|
||||
Dimensions and Bad Packets are not required. The 1.21.1 implementation is
|
||||
unchanged. Use matching client/server builds; Age catalog protocol remains 10.
|
||||
- Player in-game checks passed. Both build targets passed. Targeted disposable
|
||||
server fixtures covered ownership recovery, collection/copy transactions,
|
||||
save/network codecs, block removal, reload/reseed and restart persistence.
|
||||
Servers were stopped and test worlds removed. This was not a full GameTest run.
|
||||
- Full writing recovery, remaining discovery/stations, instability, crystal
|
||||
portals, progression awards and optional integration parity remain unfinished.
|
||||
JEI guides and discovery loot for the newly ported interactions are pending.
|
||||
Unsupported effects remain marked red; Creative availability is not parity.
|
||||
- Back up saves. New Native Cave profiles from .97 onward cannot be read by
|
||||
earlier betas, and newly ported items/entities also require the newer build.
|
||||
Keep 1.21.1 and 26.3 saves separate. Machine acceleration (#112) and Native
|
||||
Cave default biome-pool policy (#111) remain separate planned work.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.115-beta.1
|
||||
|
||||
Collects the player-validated port slices .107–.115 since 1.0.1.106-beta.1.
|
||||
Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. This remains a beta with
|
||||
incomplete Minecraft 1.21.1 gameplay and optional-integration parity.
|
||||
|
||||
## Book stands and dropped books
|
||||
|
||||
- Place a Linking Book, Descriptive Book or Page on an empty vanilla lectern
|
||||
to convert it into a Rivencraft book stand, preserving facing. Occupied
|
||||
vanilla lecterns are left alone.
|
||||
- Books use the existing open-book model and textures and show their name
|
||||
above the stand. Pages use their item artwork without a hover label,
|
||||
retaining the 1.21.1 orientation corrections.
|
||||
- Open a bound book with an empty hand, then click its destination panel to
|
||||
travel while leaving the book on its stand. Existing link modifiers,
|
||||
same-dimension rules, Return Link configuration and Age-entry awards apply.
|
||||
- Shift-right-click with an empty hand retrieves the stored item and restores
|
||||
a vanilla lectern. Breaking returns one lectern and its contents. Unwritten
|
||||
books can be displayed; retrieve them to bind in hand in this beta.
|
||||
- Ordinary dropped Linking/Descriptive Books now become recoverable open-book
|
||||
entities, including unwritten books, inventory/player drops, container and
|
||||
stand drops, death/Disarm drops and ordinary book entities loaded from disk.
|
||||
- Conversion preserves the stack and components, position, motion, facing,
|
||||
pickup delay and ownership/thrower metadata. It uses the existing open-book
|
||||
lifetime, slow decay and water/rain behavior. Already-custom books and
|
||||
non-book items are skipped.
|
||||
- Conversion runs after registration without world scans or force-loading.
|
||||
The original is removed only after replacement succeeds; a canceled
|
||||
replacement leaves the ordinary item intact and eligible on a later reload.
|
||||
- Stand travel validates source dimension, distance, loaded chunk and the exact
|
||||
currently stored book, rejecting stale screens after removal/replacement.
|
||||
|
||||
## Book GUIs and names
|
||||
|
||||
- Descriptive Books reuse the Legacy gilded cover, name/author front spread,
|
||||
and one authored symbol at a time on following right-hand pages. Saved page
|
||||
order and repetitions are retained, with runtime glyphs and no extra item outline.
|
||||
- New Descriptive Books record their author server-side. Older books without
|
||||
that metadata use the unknown-author label.
|
||||
- Linking Books use the plain Legacy cover, wrapped title, destination name
|
||||
and block coordinates, plus the blank final spread. Return Links suppress
|
||||
the redundant title. Bottom corners or arrow keys turn pages with sound.
|
||||
- Both GUIs share the Legacy artwork/text/counter/corner helpers. Naming
|
||||
controls hide on later pages and remain correct after resizing. Escape closes
|
||||
even with naming focused; normal text input does not trigger inventory keys.
|
||||
- Ported the existing 1.21.1 fallback's animated teal spiral panel to both GUIs,
|
||||
including lecterns. Removed placeholder labels. The Legacy frame draws over
|
||||
the animation; travel is active only on the front panel.
|
||||
- The animated fallback is not a live destination view. Destination tint,
|
||||
snapshot renderer integrations and random-Age glyph descriptions remain pending.
|
||||
- New Ages save their entered display name independently of their ID, folder
|
||||
and seed. Newly bound Linking Books and automatic Return Links capture their
|
||||
destination/departure label for both GUI and tooltip. Older Ages/books retain
|
||||
vanilla-name or dimension-ID fallbacks; existing labels are not rewritten.
|
||||
- Corrected negative tooltip coordinates to floor consistently with the GUI.
|
||||
|
||||
## Progression and recipe discovery
|
||||
|
||||
- Ported the Rivencraft advancement tree: root, obtaining a Page/Linking Book,
|
||||
taking a Link Panel from the Ink Mixer, copying a Page, binding an Age and
|
||||
successfully travelling into an Age. Action awards occur after committed
|
||||
server actions, including normal and shift pickup; previews earn nothing.
|
||||
- The advancement background uses the existing blue Crystal Block texture,
|
||||
with the corrected 26.3 resource-path format. The portal node waits for the
|
||||
Receptacle/portal port.
|
||||
- Added recipe-book discovery for Ink Vials (Black Dye), Ink Mixers (Glass
|
||||
Bottle), Book Binders (Iron Ingot) and Crystal Blocks (Crystal Shard), alongside
|
||||
the Notebook and Writing Desk unlocks. Three-by-three recipes appear in a
|
||||
Crafting Table's recipe book, not the inventory's two-by-two book.
|
||||
- Ingredients and costs are unchanged. Blank Pages remain uncraftable.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
- Player in-game checks passed each slice and the final dropped-book changes.
|
||||
- The 26.3 and independent 1.21.1 builds passed. Gradle test tasks have no
|
||||
standalone test sources; targeted disposable-server fixtures covered real
|
||||
station actions, advancements/recipe unlocks, stand ownership/drops and
|
||||
persistence, book author/page/name persistence, and drop conversion/state.
|
||||
- Temporary test servers were stopped and their worlds deleted. A no-player
|
||||
entity fixture required one loaded fixture chunk for inspection; further
|
||||
visual/restart validation was handed to the player.
|
||||
- 1.21.1 gameplay code is unchanged. Shared issue #114 remains open for its
|
||||
matching universal dropped-book fix; Writing Desk model placement stays in #113.
|
||||
- Instability, full writing recovery, discovery/library loot, crystal portals,
|
||||
remaining stations/progression and optional integrations still need port work.
|
||||
|
||||
Keep 26.3 and 1.21.1 instances/saves separate and back up beta worlds before updating.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.120-beta.1
|
||||
|
||||
Collects the player-validated .116–.120 port slices since 1.0.1.115-beta.1.
|
||||
For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent 1.21.1
|
||||
release is unchanged; full gameplay and optional-integration parity remain incomplete.
|
||||
|
||||
## Libraries and writing
|
||||
|
||||
- Ported Legacy library generation, shared schematic, chest loot and five correctly
|
||||
oriented book/page stands. Libraries generate after decoration in eligible new
|
||||
non-Cave Age chunks, using the 1.21.1 spacing, terrain and clearance checks.
|
||||
- Library loot defaults to `LEGACY`; optional `AGE_ONLY` limits new library Pages
|
||||
to known symbols originally written into that Age. Original writing is saved
|
||||
separately and restored on reload. Older Ages without it have an empty Age-only
|
||||
pool. Ordinary Books replace stand Pages when that pool is empty.
|
||||
- Added operator library placement, candidate/probe/loot diagnostics, session-only
|
||||
test spacing and a locator limited to nearby already-loaded chunks.
|
||||
- Hand-prepared Link Panel ink cost is server-configurable (default two). The
|
||||
Ink Mixer still consumes one ink unit and one paper per panel. Writing Desk
|
||||
ink handling now uses the shared service.
|
||||
|
||||
## Crystal portals
|
||||
|
||||
- Ported craftable Book Receptacles with all six mounting orientations and the
|
||||
existing 1.21.1 model/book transforms. A bound Linking or Descriptive Book
|
||||
activates a valid connected crystal frame, including supported irregular frames.
|
||||
- Reused the bounded crystal flood/tension algorithm, placement protection events,
|
||||
rollback, owner persistence, restart repair, frame cleanup and traversal cooldown.
|
||||
- Empty-hand removal closes the portal and returns the book. Breaking the holder
|
||||
or its crystal support closes it and drops a recoverable open-book entity.
|
||||
- Player and non-player travel reuse book destinations, safe arrival, Intra-Linking,
|
||||
Disarm and momentum rules. Descriptive Books share the same landing resolver
|
||||
with portals; no separate portal exit offset is added. Player Return Link policy
|
||||
and progression remain in force.
|
||||
- The animated fallback surface renders its facing sides and uses destination sky
|
||||
tint. The Open Portal advancement and receptacle recipe unlock are restored.
|
||||
Live destination views are still pending optional integration work.
|
||||
|
||||
## Instability and colored decay
|
||||
|
||||
- New Ages save operator-only creation-time score contributions and deterministic
|
||||
provider selections using all five Legacy decks. Existing Ages remain neutral;
|
||||
reloads/config changes do not reroll stored plans.
|
||||
- Initial scoring covers Bright Lighting, Accelerated, Corrupted Pages, ineffective
|
||||
Cave sky/weather modifiers, excess and biome-forced structures, and extra suns.
|
||||
Corrupted Pages now bind successfully and retain their animated redacted text.
|
||||
- Ported 18 potion providers: local/global Blindness, Fatigue, Hunger, Nausea,
|
||||
Poison, Slowness, Weakness and Wither, plus hostile Regeneration/Resistance.
|
||||
General effects affect eligible Survival/Adventure players and living entities;
|
||||
Creative/Spectator remain exempt. Local variants require sky exposure.
|
||||
- Added a bounded occupied-Age scheduler, owned-effect cleanup, provider controls
|
||||
and operator score/status, enable/disable, session test-score and pulse commands.
|
||||
Effects supplied or extended by another source are preserved.
|
||||
- Ported Blue, Purple, Red and White decay with the existing 16x16 textures,
|
||||
hardness/air conversion odds, saved per-color frontiers and attempt sequence.
|
||||
White retains stepping-contact magic damage. Decay has no item form or drops.
|
||||
- Spread uses only already-loaded candidate chunks, bounded attempts and action
|
||||
budgets. Fluids, block entities, critical/unbreakable blocks, infrastructure,
|
||||
protected tags and claim events prevent conversion.
|
||||
- The first resolved book/portal arrival is saved for spawn protection. Re-enter
|
||||
an older Age once to record it. Global/Age/provider and world-mutation controls
|
||||
gate spread; existing decay blocks remain when spreading is disabled.
|
||||
- Test a color with `/rivencraft_port instability decay blue 32` (also `purple`,
|
||||
`red`, `white`); output includes actual changes and nearby tracked coordinates.
|
||||
Scores are not added to ordinary book or Binder displays.
|
||||
|
||||
## Validation and remaining work
|
||||
|
||||
Player in-game checks passed .116–.120. Both the 26.3 and 1.21.1 builds passed;
|
||||
Gradle has no standalone test sources. Targeted disposable dedicated-server checks
|
||||
covered library placement, portal entity travel and restart ownership/cleanup,
|
||||
potion application/cleanup and persistence, all four decay colors, saved spread
|
||||
continuation, and bedrock/fluid protection. Test servers were stopped and their
|
||||
worlds removed.
|
||||
|
||||
Full writing recovery/penalties, live material-abundance profiling, other instability
|
||||
hazards, remaining discovery/stations and optional integrations still need port work.
|
||||
Unported hazard cards remain saved and are reported as unavailable, rather than
|
||||
being replaced with different cards. Book previews remain the animated fallback.
|
||||
Machine acceleration (#112), Writing Desk model placement (#113), and the matching
|
||||
1.21.1 universal dropped-book fix (#114) remain tracked separately.
|
||||
|
||||
Use matching client/server beta builds (catalog protocol 10). Keep Minecraft tracks
|
||||
and saves separate, back up beta worlds, and do not downgrade saves containing new
|
||||
blocks or data to earlier betas.
|
||||
@@ -0,0 +1,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).
|
||||
@@ -193,6 +193,10 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'data/rivencraft/worldgen/structure_set/bastions.json'
|
||||
include 'assets/rivencraft/models/block/star_fissure.json'
|
||||
include 'assets/rivencraft/blockstates/star_fissure.json'
|
||||
include 'assets/rivencraft/blockstates/decay.json'
|
||||
include 'assets/rivencraft/models/block/decay_*.json'
|
||||
include 'assets/rivencraft/textures/block/decay_*.png'
|
||||
include 'data/rivencraft/tags/block/decay_protected.json'
|
||||
include 'assets/rivencraft/sounds.json'
|
||||
include 'assets/rivencraft/sounds/**/*.ogg'
|
||||
include 'assets/rivencraft/textures/item/crystal_shard.png'
|
||||
@@ -203,6 +207,25 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/textures/gui/symbolcomponents.png'
|
||||
include 'assets/rivencraft/models/item/page_link_panel.json'
|
||||
include 'assets/rivencraft/textures/item/ink_vial.png'
|
||||
include 'assets/rivencraft/textures/item/notebook.png'
|
||||
include 'assets/rivencraft/textures/item/collation_folder.png'
|
||||
include 'assets/rivencraft/models/item/notebook.json'
|
||||
include 'assets/rivencraft/models/item/collation_folder.json'
|
||||
include 'assets/rivencraft/textures/item/sealed_notebook.png'
|
||||
include 'assets/rivencraft/textures/item/symbol_portfolio.png'
|
||||
include 'assets/rivencraft/models/item/sealed_notebook.json'
|
||||
include 'assets/rivencraft/models/item/symbol_portfolio.json'
|
||||
include 'assets/rivencraft/textures/item/writing_desk.png'
|
||||
include 'assets/rivencraft/textures/block/writing_desk.png'
|
||||
include 'assets/rivencraft/textures/gui/writing_desk_legacy.png'
|
||||
include 'assets/rivencraft/textures/gui/scrollbar.png'
|
||||
include 'assets/rivencraft/models/item/writing_desk.json'
|
||||
include 'assets/rivencraft/models/block/writing_desk.json'
|
||||
include 'assets/rivencraft/models/block/writing_desk_extension.json'
|
||||
include 'assets/rivencraft/blockstates/writing_desk.json'
|
||||
include 'data/rivencraft/loot_table/blocks/writing_desk.json'
|
||||
include 'data/rivencraft/advancement/progression/*.json'
|
||||
exclude 'data/rivencraft/advancement/progression/root.json'
|
||||
include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta'
|
||||
include 'assets/rivencraft/models/item/ink_vial.json'
|
||||
include 'assets/rivencraft/models/item/crystal_shard.json'
|
||||
@@ -214,7 +237,10 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/textures/gui/bookui_cover.png'
|
||||
include 'assets/rivencraft/textures/gui/notebook_pages.png'
|
||||
include 'assets/rivencraft/textures/gui/bookui_pager.png'
|
||||
include 'assets/rivencraft/textures/gui/bookui_rpage_full.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_block.png'
|
||||
include 'assets/rivencraft/textures/entity/linking_book.png'
|
||||
include 'assets/rivencraft/textures/entity/descriptive_book.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder_press.png'
|
||||
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
|
||||
@@ -230,9 +256,24 @@ tasks.named('processResources', ProcessResources).configure {
|
||||
include 'assets/rivencraft/models/block/book_binder.json'
|
||||
include 'assets/rivencraft/models/item/book_binder.json'
|
||||
include 'assets/rivencraft/blockstates/book_binder.json'
|
||||
include 'assets/rivencraft/blockstates/book_receptacle.json'
|
||||
include 'assets/rivencraft/blockstates/crystal_portal.json'
|
||||
include 'assets/rivencraft/models/block/book_receptacle.json'
|
||||
include 'assets/rivencraft/models/block/crystal_portal.json'
|
||||
include 'assets/rivencraft/models/item/book_receptacle.json'
|
||||
include 'assets/rivencraft/textures/block/book_receptacle.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_portal.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_portal.png.mcmeta'
|
||||
include 'data/rivencraft/loot_table/blocks/book_receptacle.json'
|
||||
include 'assets/rivencraft/blockstates/book_stand.json'
|
||||
include 'data/rivencraft/loot_table/blocks/book_stand.json'
|
||||
include 'data/rivencraft/loot_table/chests/mystcraft_library.json'
|
||||
include 'data/rivencraft/structure/mystcraft_library.nbt'
|
||||
include 'data/rivencraft/loot_table/blocks/book_binder.json'
|
||||
include 'assets/rivencraft/models/block/crystal_block.json'
|
||||
include 'assets/rivencraft/blockstates/crystal_block.json'
|
||||
include 'assets/rivencraft/models/block/cloud_platform.json'
|
||||
include 'assets/rivencraft/blockstates/cloud_platform.json'
|
||||
include 'assets/rivencraft/blockstates/budding_crystal.json'
|
||||
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
|
||||
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
|
||||
|
||||
@@ -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.120-beta.1
|
||||
+31
-26
@@ -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,46 @@
|
||||
package net.dnorberg.rivencraft;
|
||||
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** Server-owned gameplay policies adapted from 1.21.1. */
|
||||
public final class PortConfig {
|
||||
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
|
||||
public static final ModConfigSpec SPEC;
|
||||
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
public static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
|
||||
|
||||
static {
|
||||
var builder = new ModConfigSpec.Builder();
|
||||
builder.push("descriptive_books");
|
||||
RETURN_LINK_MODE = builder.comment("Controls automatic Return Links for successful Descriptive Book travel.",
|
||||
"ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.")
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
|
||||
builder.pop();
|
||||
builder.push("libraries");
|
||||
LIBRARY_LOOT_MODE = builder.comment("LEGACY uses writing supplies/Sealed Notebooks and random lectern pages; AGE_ONLY uses only pages written into this Age.")
|
||||
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
|
||||
builder.pop();
|
||||
builder.push("writing");
|
||||
SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.")
|
||||
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||
LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline Link Panel by hand with paper.",
|
||||
"The Ink Mixer uses one ink unit per panel independently of this setting.")
|
||||
.defineInRange("link_panel_ink_cost", 2, 1, 16);
|
||||
builder.pop();
|
||||
builder.push("accelerated_age");
|
||||
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
|
||||
.defineInRange("max_loaded_chunks", 8, 1, 64);
|
||||
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
|
||||
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
|
||||
builder.pop();
|
||||
net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder);
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
private PortConfig() {}
|
||||
}
|
||||
@@ -57,6 +57,15 @@ public final class Rivencraft {
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID);
|
||||
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
|
||||
private static final DeferredRegister<net.minecraft.world.entity.EntityType<?>> ENTITIES =
|
||||
DeferredRegister.create(Registries.ENTITY_TYPE, MOD_ID);
|
||||
public static final Supplier<net.minecraft.world.entity.EntityType<net.dnorberg.rivencraft.entity.DroppedBookEntity>>
|
||||
DROPPED_BOOK = ENTITIES.register("dropped_book", () -> net.minecraft.world.entity.EntityType.Builder
|
||||
.<net.dnorberg.rivencraft.entity.DroppedBookEntity>of(
|
||||
net.dnorberg.rivencraft.entity.DroppedBookEntity::new, net.minecraft.world.entity.MobCategory.MISC)
|
||||
.sized(0.65F, 0.4F).clientTrackingRange(10).updateInterval(20)
|
||||
.build(net.minecraft.resources.ResourceKey.create(Registries.ENTITY_TYPE,
|
||||
Identifier.fromNamespaceAndPath(MOD_ID, "dropped_book"))));
|
||||
private static final DeferredRegister<SoundEvent> SOUNDS =
|
||||
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
|
||||
public static final Supplier<SoundEvent> LINK = sound("linking.link");
|
||||
@@ -82,6 +91,12 @@ public final class Rivencraft {
|
||||
public static final Supplier<DataComponentType<Identifier>> AGE_BOOK_ID = COMPONENTS.registerComponentType(
|
||||
"age_book_id", builder -> builder.persistent(Identifier.CODEC)
|
||||
.networkSynchronized(Identifier.STREAM_CODEC));
|
||||
public static final Supplier<DataComponentType<net.dnorberg.rivencraft.writing.NotebookContents>> NOTEBOOK_CONTENTS =
|
||||
COMPONENTS.registerComponentType("notebook_contents", builder -> builder
|
||||
.persistent(net.dnorberg.rivencraft.writing.NotebookContents.CODEC)
|
||||
.networkSynchronized(net.dnorberg.rivencraft.writing.NotebookContents.STREAM_CODEC));
|
||||
public static final Supplier<DataComponentType<String>> LINK_DESTINATION_NAME = COMPONENTS.registerComponentType(
|
||||
"link_destination_name", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
|
||||
public static final Supplier<DataComponentType<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
|
||||
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
|
||||
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
|
||||
@@ -93,6 +108,8 @@ public final class Rivencraft {
|
||||
"symbol_page_id", builder -> builder.persistent(Identifier.CODEC)
|
||||
.networkSynchronized(Identifier.STREAM_CODEC));
|
||||
/** Ordered authored page IDs remain on the finished book for later port slices. */
|
||||
public static final Supplier<DataComponentType<String>> BOOK_AUTHOR = COMPONENTS.registerComponentType(
|
||||
"book_author", builder -> builder.persistent(Codec.STRING).networkSynchronized(ByteBufCodecs.STRING_UTF8));
|
||||
public static final Supplier<DataComponentType<java.util.List<Identifier>>> BOOK_SYMBOLS = COMPONENTS.registerComponentType(
|
||||
"book_symbols", builder -> builder.persistent(Identifier.CODEC.listOf())
|
||||
.networkSynchronized(ByteBufCodecs.<io.netty.buffer.ByteBuf, Identifier>list(252)
|
||||
@@ -110,12 +127,39 @@ public final class Rivencraft {
|
||||
|
||||
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
|
||||
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.CloudPlatformBlock> CLOUD_PLATFORM =
|
||||
BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)
|
||||
.mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable());
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookReceptacleBlock> BOOK_RECEPTACLE =
|
||||
BLOCKS.registerBlock("book_receptacle", net.dnorberg.rivencraft.block.BookReceptacleBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).noOcclusion().mapColor(MapColor.COLOR_BLUE));
|
||||
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE_ITEM = ITEMS.registerSimpleBlockItem(BOOK_RECEPTACLE);
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.CrystalPortalBlock> CRYSTAL_PORTAL =
|
||||
BLOCKS.registerBlock("crystal_portal", net.dnorberg.rivencraft.block.CrystalPortalBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL)
|
||||
.noCollision().noOcclusion().lightLevel(state -> 11).noLootTable());
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity>> BOOK_RECEPTACLE_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("book_receptacle", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity::new, BOOK_RECEPTACLE.get()));
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity>> CRYSTAL_PORTAL_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("crystal_portal", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity::new, CRYSTAL_PORTAL.get()));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.DecayBlock> DECAY =
|
||||
BLOCKS.registerBlock("decay", net.dnorberg.rivencraft.block.DecayBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).strength(5.0F, 10.0F).noLootTable());
|
||||
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE =
|
||||
BLOCKS.registerBlock("star_fissure", StarFissureBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6));
|
||||
public static final Supplier<BlockEntityType<StarFissureBlockEntity>>
|
||||
STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>(
|
||||
StarFissureBlockEntity::new, STAR_FISSURE.get()));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.BookStandBlock> BOOK_STAND =
|
||||
BLOCKS.registerBlock("book_stand", net.dnorberg.rivencraft.block.BookStandBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LECTERN));
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.BookStandBlockEntity>> BOOK_STAND_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("book_stand", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.BookStandBlockEntity::new, BOOK_STAND.get()));
|
||||
public static final DeferredBlock<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
|
||||
"book_binder", PortBookBinderBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
@@ -134,6 +178,25 @@ public final class Rivencraft {
|
||||
new BlockEntityType<>(InkMixerBlockEntity::new, INK_MIXER.get()));
|
||||
public static final Supplier<MenuType<InkMixerMenu>> INK_MIXER_MENU =
|
||||
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
|
||||
public static final DeferredBlock<net.dnorberg.rivencraft.block.WritingDeskBlock> WRITING_DESK =
|
||||
BLOCKS.registerBlock("writing_desk", net.dnorberg.rivencraft.block.WritingDeskBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
public static final DeferredItem<BlockItem> WRITING_DESK_ITEM = ITEMS.registerSimpleBlockItem(WRITING_DESK);
|
||||
public static final Supplier<BlockEntityType<net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity>> WRITING_DESK_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("writing_desk", () -> new BlockEntityType<>(
|
||||
net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity::new, WRITING_DESK.get()));
|
||||
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.WritingDeskMenu>> WRITING_DESK_MENU =
|
||||
MENUS.register("writing_desk", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.WritingDeskMenu::new));
|
||||
public static final Supplier<MenuType<net.dnorberg.rivencraft.menu.NotebookMenu>> NOTEBOOK_MENU =
|
||||
MENUS.register("notebook", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.NotebookMenu::new));
|
||||
public static final DeferredItem<net.dnorberg.rivencraft.item.NotebookItem> NOTEBOOK =
|
||||
ITEMS.registerItem("notebook", net.dnorberg.rivencraft.item.NotebookItem::new, properties -> properties.stacksTo(1));
|
||||
public static final DeferredItem<net.dnorberg.rivencraft.item.CollationFolderItem> COLLATION_FOLDER =
|
||||
ITEMS.registerItem("collation_folder", net.dnorberg.rivencraft.item.CollationFolderItem::new, properties -> properties.stacksTo(1));
|
||||
public static final DeferredItem<net.dnorberg.rivencraft.item.SymbolPortfolioItem> SYMBOL_PORTFOLIO =
|
||||
ITEMS.registerItem("symbol_portfolio", net.dnorberg.rivencraft.item.SymbolPortfolioItem::new, properties -> properties.stacksTo(1));
|
||||
public static final DeferredItem<net.dnorberg.rivencraft.item.SealedNotebookItem> SEALED_NOTEBOOK =
|
||||
ITEMS.registerItem("sealed_notebook", net.dnorberg.rivencraft.item.SealedNotebookItem::new, properties -> properties.stacksTo(16));
|
||||
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
|
||||
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
|
||||
"budding_crystal", BuddingCrystalBlock::new,
|
||||
@@ -166,10 +229,12 @@ public final class Rivencraft {
|
||||
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
|
||||
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
|
||||
|
||||
public Rivencraft(IEventBus modEventBus) {
|
||||
public Rivencraft(IEventBus modEventBus, net.neoforged.fml.ModContainer container) {
|
||||
container.registerConfig(net.neoforged.fml.config.ModConfig.Type.LOCAL, PortConfig.SPEC);
|
||||
BLOCKS.register(modEventBus);
|
||||
SOUNDS.register(modEventBus);
|
||||
ITEMS.register(modEventBus);
|
||||
ENTITIES.register(modEventBus);
|
||||
BLOCK_ENTITY_TYPES.register(modEventBus);
|
||||
MENUS.register(modEventBus);
|
||||
COMPONENTS.register(modEventBus);
|
||||
@@ -179,12 +244,16 @@ public final class Rivencraft {
|
||||
PortChunkGenerators.register(modEventBus);
|
||||
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.event.BookStandConversionHandler::onRightClickBlock);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
||||
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
||||
modEventBus.addListener(PortBookNetworking::registerPayloads);
|
||||
net.dnorberg.rivencraft.age.PortStatusEffectProviders.register();
|
||||
net.dnorberg.rivencraft.age.PortDecayProviders.register();
|
||||
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package net.dnorberg.rivencraft.advancement;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Awards action-only progression after the server has committed the action. */
|
||||
public final class ProgressionAdvancements {
|
||||
public static final String OPEN_PORTAL = "open_portal";
|
||||
public static final String LINK_PANEL = "link_panel";
|
||||
public static final String COPY_PAGE = "copy_page";
|
||||
public static final String BIND_AGE = "bind_age";
|
||||
public static final String ENTER_AGE = "enter_age";
|
||||
|
||||
private ProgressionAdvancements() {}
|
||||
|
||||
public static void award(ServerPlayer player, String path) {
|
||||
var advancement = player.level().getServer().getAdvancements().get(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||
if (advancement != null) player.getAdvancements().award(advancement, "completed");
|
||||
}
|
||||
|
||||
public static void awardAgeTravel(ServerPlayer player) {
|
||||
if (RuntimeAgeBackend.savedAges(player.level().getServer()).containsKey(player.level().dimension().identifier()))
|
||||
award(player, ENTER_AGE);
|
||||
}
|
||||
}
|
||||
@@ -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) -> {
|
||||
|
||||
@@ -42,7 +42,9 @@ final class AgeLevelIndex extends SavedData {
|
||||
Codec.unboundedMap(Identifier.CODEC, PortAgeColors.CODEC)
|
||||
.optionalFieldOf("colors", Map.of()).forGetter(index -> index.colors),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortDayCycle.CODEC)
|
||||
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles)
|
||||
.optionalFieldOf("day_cycles", Map.of()).forGetter(index -> index.dayCycles),
|
||||
Codec.unboundedMap(Identifier.CODEC, Codec.STRING)
|
||||
.optionalFieldOf("display_names", Map.of()).forGetter(index -> index.displayNames)
|
||||
).apply(instance, AgeLevelIndex::new));
|
||||
|
||||
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
|
||||
@@ -65,6 +67,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
private final Map<Identifier, PortSkyStyle> sky;
|
||||
private final Map<Identifier, PortAgeColors> colors;
|
||||
private final Map<Identifier, PortDayCycle> dayCycles;
|
||||
private final Map<Identifier, String> displayNames;
|
||||
|
||||
private AgeLevelIndex() {
|
||||
this.ages = new LinkedHashMap<>();
|
||||
@@ -81,6 +84,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
this.sky = new LinkedHashMap<>();
|
||||
this.colors = new LinkedHashMap<>();
|
||||
this.dayCycles = new LinkedHashMap<>();
|
||||
this.displayNames = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
|
||||
@@ -94,7 +98,7 @@ final class AgeLevelIndex extends SavedData {
|
||||
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
|
||||
Map<Identifier, PortLighting> lighting,
|
||||
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
|
||||
Map<Identifier, PortDayCycle> dayCycles) {
|
||||
Map<Identifier, PortDayCycle> dayCycles, Map<Identifier, String> displayNames) {
|
||||
this.ages = new LinkedHashMap<>(ages);
|
||||
this.retired = new LinkedHashSet<>(retired);
|
||||
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
|
||||
@@ -109,6 +113,16 @@ final class AgeLevelIndex extends SavedData {
|
||||
this.sky = new LinkedHashMap<>(sky);
|
||||
this.colors = new LinkedHashMap<>(colors);
|
||||
this.dayCycles = new LinkedHashMap<>(dayCycles);
|
||||
this.displayNames = new LinkedHashMap<>(displayNames);
|
||||
}
|
||||
|
||||
String displayName(Identifier id) { return ages.containsKey(id) ? displayNames.get(id) : null; }
|
||||
|
||||
void setDisplayName(Identifier id, String name) {
|
||||
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
if (name == null || name.isBlank() || name.length() > 32) throw new IllegalArgumentException("Invalid Age display name");
|
||||
displayNames.put(id, name.strip());
|
||||
setDirty();
|
||||
}
|
||||
|
||||
Map<Identifier, Long> entries() {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */
|
||||
public final class InstabilityProviderCatalog {
|
||||
public enum Deck {
|
||||
BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000);
|
||||
|
||||
private final int entryCost;
|
||||
|
||||
Deck(int entryCost) { this.entryCost = entryCost; }
|
||||
public int entryCost() { return entryCost; }
|
||||
}
|
||||
|
||||
public record Card(Identifier id, int cost) {
|
||||
public Card {
|
||||
if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private static final List<List<Card>> DECKS = createDecks();
|
||||
|
||||
private InstabilityProviderCatalog() {}
|
||||
|
||||
public static List<Card> cards(Deck deck) { return DECKS.get(deck.ordinal()); }
|
||||
|
||||
public static boolean contains(Identifier id) {
|
||||
return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id));
|
||||
}
|
||||
|
||||
private static List<List<Card>> createDecks() {
|
||||
List<List<Card>> decks = new ArrayList<>();
|
||||
for (Deck ignored : Deck.values()) decks.add(new ArrayList<>());
|
||||
add(decks, "blindness", 1000, 0, 0, 0, 1, 0);
|
||||
add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1);
|
||||
add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0);
|
||||
add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0);
|
||||
add(decks, "fatigue", 500, 5, 0, 0, 0, 0);
|
||||
add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "hunger", 500, 8, 2, 0, 0, 0);
|
||||
add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "nausea", 1000, 0, 0, 0, 1, 0);
|
||||
add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1);
|
||||
add(decks, "poison", 500, 9, 3, 0, 0, 0);
|
||||
add(decks, "poison_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "slow", 500, 6, 1, 0, 0, 0);
|
||||
add(decks, "slow_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "weakness", 500, 8, 2, 0, 0, 0);
|
||||
add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0);
|
||||
add(decks, "wither", 1000, 0, 1, 1, 2, 0);
|
||||
add(decks, "wither_global", 2000, 0, 0, 1, 1, 1);
|
||||
add(decks, "burning", 500, 0, 1, 0, 0, 0);
|
||||
add(decks, "crumble", 2000, 0, 0, 6, 0, 0);
|
||||
add(decks, "decayblue", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decayred", 2000, 0, 0, 0, 2, 1);
|
||||
add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3);
|
||||
add(decks, "explosions", 1000, 0, 0, 8, 0, 0);
|
||||
add(decks, "lightning", 1000, 0, 4, 4, 0, 0);
|
||||
add(decks, "meteors", 1000, 0, 0, 4, 0, 0);
|
||||
return decks.stream().map(List::copyOf).toList();
|
||||
}
|
||||
|
||||
private static void add(List<List<Card>> decks, String path, int cost, int... copies) {
|
||||
Card card = new Card(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost);
|
||||
for (Deck deck : Deck.values()) {
|
||||
for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.util.RandomSource;
|
||||
|
||||
/** Extra random block ticks from authored Accelerated pages, bounded to loaded player chunks. */
|
||||
public final class PortAcceleratedTicks {
|
||||
public static final Identifier PAGE = Identifier.fromNamespaceAndPath("rivencraft", "environment/accelerated");
|
||||
|
||||
private PortAcceleratedTicks() {}
|
||||
|
||||
public static int pageCount(List<Identifier> pages) {
|
||||
return (int) pages.stream().filter(PAGE::equals).count();
|
||||
}
|
||||
|
||||
public static void configure(ServerLevel level, int pages) {
|
||||
if (!(level instanceof PortAgeLevel)) throw new IllegalArgumentException("Not a Rivencraft Age");
|
||||
level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).setPages(pages);
|
||||
level.getDataStorage().saveAndJoin();
|
||||
}
|
||||
|
||||
public static int pages(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(PortAccelerationSettings.TYPE).pages();
|
||||
}
|
||||
|
||||
public static void onLevelTickPost(LevelTickEvent.Post event) {
|
||||
if (event.getLevel() instanceof PortAgeLevel level) tick(level);
|
||||
}
|
||||
|
||||
static void tick(ServerLevel level) {
|
||||
int maximumAttempts = PortConfig.ACCELERATED_MAX_ATTEMPTS.get();
|
||||
if (maximumAttempts == 0) return;
|
||||
int pages = pages(level);
|
||||
if (pages == 0) return;
|
||||
|
||||
List<ChunkPos> chunks = loadedPlayerChunks(level);
|
||||
if (chunks.isEmpty()) return;
|
||||
int maximumChunks = Math.min(chunks.size(), PortConfig.ACCELERATED_MAX_CHUNKS.get());
|
||||
int first = Math.floorMod(level.getServer().getTickCount(), chunks.size());
|
||||
List<TickSection> eligible = new ArrayList<>();
|
||||
for (int offset = 0; offset < maximumChunks; offset++) {
|
||||
ChunkPos pos = chunks.get((first + offset) % chunks.size());
|
||||
LevelChunk chunk = level.getChunkSource().getChunkNow(pos.x(), pos.z());
|
||||
if (chunk == null) continue;
|
||||
LevelChunkSection[] sections = chunk.getSections();
|
||||
for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
|
||||
LevelChunkSection section = sections[sectionIndex];
|
||||
if (!section.isRandomlyTickingBlocks()) continue;
|
||||
int baseY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex));
|
||||
eligible.add(new TickSection(pos, baseY, section));
|
||||
}
|
||||
}
|
||||
if (eligible.isEmpty()) return;
|
||||
|
||||
// Legacy attempts three positions per random-ticking section per page. Divide
|
||||
// the bounded total between sections before ticking any one of them, so a
|
||||
// lower section cannot exhaust the entire budget ahead of surface crops.
|
||||
int total = attemptBudget(pages, eligible.size(), maximumAttempts);
|
||||
int perSection = total / eligible.size();
|
||||
int remainder = total % eligible.size();
|
||||
int firstSection = Math.floorMod(level.getServer().getTickCount(), eligible.size());
|
||||
RandomSource random = level.getRandom();
|
||||
for (int offset = 0; offset < eligible.size(); offset++) {
|
||||
TickSection candidate = eligible.get((firstSection + offset) % eligible.size());
|
||||
int samples = perSection + (offset < remainder ? 1 : 0);
|
||||
for (int sample = 0; sample < samples; sample++) {
|
||||
int x = random.nextInt(16);
|
||||
int y = random.nextInt(16);
|
||||
int z = random.nextInt(16);
|
||||
BlockState state = candidate.section().getBlockState(x, y, z);
|
||||
if (state.isRandomlyTicking()) {
|
||||
state.randomTick(level, new BlockPos(candidate.chunk().getMinBlockX() + x,
|
||||
candidate.baseY() + y, candidate.chunk().getMinBlockZ() + z), random);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int attemptBudget(int pages, int sections, int maximumAttempts) {
|
||||
if (pages < 0 || sections < 0 || maximumAttempts < 0)
|
||||
throw new IllegalArgumentException("Negative accelerated tick budget input");
|
||||
if (sections == 0) return 0;
|
||||
long perSection = 3L * pages;
|
||||
return perSection > maximumAttempts / (long) sections
|
||||
? maximumAttempts : (int) (perSection * sections);
|
||||
}
|
||||
|
||||
private static List<ChunkPos> loadedPlayerChunks(ServerLevel level) {
|
||||
Set<ChunkPos> chunks = new LinkedHashSet<>();
|
||||
for (var player : level.players()) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
int chunkX = center.x() + x;
|
||||
int chunkZ = center.z() + z;
|
||||
if (level.getChunkSource().hasChunk(chunkX, chunkZ)) {
|
||||
chunks.add(new ChunkPos(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(chunks);
|
||||
}
|
||||
|
||||
private record TickSection(ChunkPos chunk, int baseY, LevelChunkSection section) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Age-local authored page count, retained across unload, restart and reseeding. */
|
||||
final class PortAccelerationSettings extends SavedData {
|
||||
private static final Codec<PortAccelerationSettings> CODEC = Codec.intRange(0, Integer.MAX_VALUE)
|
||||
.fieldOf("pages").codec().xmap(PortAccelerationSettings::new, settings -> settings.pages);
|
||||
static final SavedDataType<PortAccelerationSettings> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "acceleration"),
|
||||
() -> new PortAccelerationSettings(0), CODEC);
|
||||
private int pages;
|
||||
|
||||
private PortAccelerationSettings(int pages) { this.pages = pages; }
|
||||
int pages() { return pages; }
|
||||
void setPages(int pages) {
|
||||
if (pages < 0) throw new IllegalArgumentException("Negative Accelerated page count");
|
||||
this.pages = pages;
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -23,6 +23,14 @@ public final class PortAgeTravel {
|
||||
if (!AgeCatalogSync.ready(player, id)) {
|
||||
return "Age catalog not acknowledged by client; remaining in source dimension";
|
||||
}
|
||||
BlockPos landing = resolveLanding(target);
|
||||
if (landing == null) return "No safe landing available; remaining in source dimension";
|
||||
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||
return moved ? null : "Teleportation failed; remaining in source dimension";
|
||||
}
|
||||
|
||||
public static BlockPos resolveLanding(ServerLevel target) {
|
||||
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
|
||||
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
|
||||
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
|
||||
@@ -30,14 +38,13 @@ public final class PortAgeTravel {
|
||||
? findFootedDestination(target, landing).orElse(null)
|
||||
: findSurfaceLanding(target, landing.getX(), landing.getZ());
|
||||
if (landing == null) landing = findLanding(target);
|
||||
if (landing == null) return "No safe test landing available; remaining in source dimension";
|
||||
if (landing == null) return null;
|
||||
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
|
||||
boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
|
||||
Set.of(), player.getYRot(), player.getXRot(), true);
|
||||
return moved ? null : "Teleportation failed; remaining in source dimension";
|
||||
if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing);
|
||||
return landing;
|
||||
}
|
||||
|
||||
private static BlockPos findLanding(ServerLevel target) {
|
||||
static BlockPos findLanding(ServerLevel target) {
|
||||
if (isCaveTerrain(target)) {
|
||||
// The cave roof is not a valid arrival surface. Search down from
|
||||
// Legacy's Y=64 start, then construct the normal safety platform
|
||||
@@ -112,7 +119,7 @@ public final class PortAgeTravel {
|
||||
}
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
target.setBlock(foot.offset(x, -1, z), Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3);
|
||||
}
|
||||
}
|
||||
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** World-wide record of Ages for which an ON_CREATION Return Link was issued. */
|
||||
final class PortAgeVisits extends SavedData {
|
||||
static final Codec<PortAgeVisits> CODEC = Identifier.CODEC.listOf().fieldOf("ages").codec()
|
||||
.xmap(PortAgeVisits::new, visits -> visits.ages.stream().sorted().toList());
|
||||
static final SavedDataType<PortAgeVisits> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "age_visits"),
|
||||
() -> new PortAgeVisits(List.of()), CODEC);
|
||||
private final Set<Identifier> ages;
|
||||
|
||||
PortAgeVisits(List<Identifier> ages) { this.ages = new HashSet<>(ages); }
|
||||
boolean hasVisited(Identifier age) { return ages.contains(age); }
|
||||
boolean markFirstVisit(Identifier age) {
|
||||
if (!ages.add(age)) return false;
|
||||
setDirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Disposable fixture for atomic collection imports and real Binder assembly. */
|
||||
final class PortBinderCollectionProbe {
|
||||
private static final BlockPos BINDER = new BlockPos(0, 64, 0);
|
||||
private static final BlockPos CHEST = new BlockPos(1, 64, 0);
|
||||
private static final List<Identifier> EXPECTED = List.of(id("weather/off"), id("lighting/bright"),
|
||||
id("terrain/flat"), id("biome/single"));
|
||||
private PortBinderCollectionProbe() {}
|
||||
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
|
||||
private static ItemStack page(String path) { return PageItem.createSymbolPage(id(path)); }
|
||||
private static ItemStack collection(boolean folder, List<ItemStack> pages) {
|
||||
ItemStack result = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
|
||||
result.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
|
||||
return result;
|
||||
}
|
||||
|
||||
static void create(ServerLevel level) {
|
||||
PortAgeTravel.findLanding(level);
|
||||
level.setBlock(BINDER, Rivencraft.BOOK_BINDER.get().defaultBlockState(), 3);
|
||||
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
|
||||
var binder = (PortBookBinderBlockEntity) level.getBlockEntity(BINDER);
|
||||
var chest = (Container) level.getBlockEntity(CHEST);
|
||||
ItemStack folder = collection(true, List.of(page("weather/off"), ItemStack.EMPTY, page("lighting/bright")));
|
||||
ItemStack before = folder.copy();
|
||||
for (int i = 0; i < PortBookBinderBlockEntity.PAGE_COUNT; i++)
|
||||
binder.setItem(PortBookBinderBlockEntity.FIRST_PAGE + i, page("terrain/flat"));
|
||||
if (!binder.importCollection(folder, 0).isEmpty() || binder.nextPageIndex() != 252
|
||||
|| !ItemStack.isSameItemSameComponents(before, folder)
|
||||
|| binder.symbolPages().stream().anyMatch(symbol -> !symbol.equals(id("terrain/flat"))))
|
||||
throw new IllegalStateException("Full Binder import was not atomic");
|
||||
binder.clearSymbolPages();
|
||||
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
|
||||
if (!binder.importCollection(collection(true, List.of(page("weather/off"), PageItem.createLinkPanelPage())), 0).isEmpty()
|
||||
|| binder.hasSymbolPages() || binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
|
||||
throw new IllegalStateException("Conflicting Link Panel import was not atomic");
|
||||
binder.setItem(PortBookBinderBlockEntity.PANEL, ItemStack.EMPTY);
|
||||
if (!binder.importCollection(folder, -1).isEmpty() || !binder.importCollection(folder, 1).isEmpty())
|
||||
throw new IllegalStateException("Invalid collection insertion point accepted");
|
||||
binder.insertPage(0, page("terrain/flat"));
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("5225a1ed-5ead-44b5-a63c-c78e7dca86f5"), "RivenBinderProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
var menu = new PortBookBinderMenu(1, player.getInventory(), binder);
|
||||
try {
|
||||
menu.setCarried(folder);
|
||||
if (!menu.clickMenuButton(player, PortBookBinderMenu.STRIP_CLICK_BASE + 1)
|
||||
|| menu.getCarried().get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|
||||
|| !binder.symbolPages().equals(EXPECTED.subList(0, 3)))
|
||||
throw new IllegalStateException("Cursor collection order/empty-cover regression");
|
||||
chest.setItem(0, menu.getCarried().copy());
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
player.getInventory().setItem(9, collection(false, List.of(page("biome/single"))));
|
||||
menu.quickMoveStack(player, PortBookBinderBlockEntity.SIZE);
|
||||
ItemStack emptied = player.getInventory().getItem(9);
|
||||
if (!emptied.is(Rivencraft.NOTEBOOK.get())
|
||||
|| emptied.get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0
|
||||
|| !binder.symbolPages().equals(EXPECTED))
|
||||
throw new IllegalStateException("Shift collection import regression");
|
||||
chest.setItem(1, emptied.copy());
|
||||
binder.setItem(PortBookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
binder.setItem(PortBookBinderBlockEntity.PANEL, PageItem.createLinkPanelPage());
|
||||
menu.assemble(player, "Collection Fixture104");
|
||||
ItemStack book = menu.getCarried();
|
||||
if (!book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) || !EXPECTED.equals(book.get(Rivencraft.BOOK_SYMBOLS.get()))
|
||||
|| !book.has(Rivencraft.AGE_BOOK_ID.get()) || binder.hasSymbolPages()
|
||||
|| !binder.getItem(PortBookBinderBlockEntity.COVER).isEmpty()
|
||||
|| !binder.getItem(PortBookBinderBlockEntity.PANEL).isEmpty())
|
||||
throw new IllegalStateException("Collection-backed Binder assembly regression");
|
||||
if (!PortProgressionProbe.isComplete(player, "bind_age"))
|
||||
throw new IllegalStateException("Missing bind_age award");
|
||||
if (!player.getGameProfile().name().equals(book.get(Rivencraft.BOOK_AUTHOR.get())))
|
||||
throw new IllegalStateException("Binder author was not server assigned");
|
||||
var pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
|
||||
if (pages.lastPage() != EXPECTED.size() || pages.symbolAt(0).isPresent()
|
||||
|| !pages.symbolAt(1).orElseThrow().equals(EXPECTED.getFirst())
|
||||
|| !pages.symbolAt(EXPECTED.size()).orElseThrow().equals(EXPECTED.getLast())
|
||||
|| pages.symbolAt(EXPECTED.size() + 1).isPresent()
|
||||
|| pages.turn(0, -1) != 0 || pages.turn(pages.lastPage(), 1) != pages.lastPage())
|
||||
throw new IllegalStateException("Authored book page indexing failed");
|
||||
var old = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()));
|
||||
if (old.lastPage() != 1 || !old.author().isEmpty() || old.symbolAt(1).isPresent())
|
||||
throw new IllegalStateException("Older panel-only book fallback failed");
|
||||
var repeated = new net.dnorberg.rivencraft.writing.DescriptiveBookPages(
|
||||
List.of(EXPECTED.getFirst(), EXPECTED.getFirst()), "Test");
|
||||
if (repeated.lastPage() != 2 || !repeated.symbolAt(1).equals(repeated.symbolAt(2)))
|
||||
throw new IllegalStateException("Repeated symbol display was collapsed");
|
||||
var ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
|
||||
var server = level.getServer();
|
||||
if (!RuntimeAgeBackend.savedAgeName(server, ageId).orElseThrow().equals("Collection Fixture104"))
|
||||
throw new IllegalStateException("Created Age lost its display name");
|
||||
var namedLevel = server.getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, ageId));
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel).equals("Collection Fixture104"))
|
||||
throw new IllegalStateException("Link binding name lookup did not use Age title");
|
||||
RuntimeAgeBackend.setAgeDisplayName(server, ageId, "Crystal Garden's Dawn");
|
||||
var target = new net.dnorberg.rivencraft.item.LinkBookTarget(ageId, -0.5, 64, -1.25, 90, 0);
|
||||
var link = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
link.set(Rivencraft.LINK_BOOK_TARGET.get(), target);
|
||||
link.set(Rivencraft.LINK_DESTINATION_NAME.get(), net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(namedLevel));
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(link, target).getString().equals("Crystal Garden's Dawn"))
|
||||
throw new IllegalStateException("Link destination label ignored saved name");
|
||||
var mode = net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.get();
|
||||
player.getInventory().clearContent();
|
||||
try {
|
||||
net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(net.dnorberg.rivencraft.PortConfig.ReturnLinkMode.ALWAYS);
|
||||
PortReturnLinks.afterSuccessfulTravel(player, ageId, target);
|
||||
var returned = java.util.stream.IntStream.range(0, player.getInventory().getContainerSize())
|
||||
.mapToObj(i -> player.getInventory().getItem(i)).filter(i -> i.is(Rivencraft.LINKING_BOOK.get()))
|
||||
.findFirst().orElseThrow();
|
||||
if (!"Crystal Garden's Dawn".equals(returned.get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Automatic Return Link lost departure name");
|
||||
chest.setItem(4, returned.copy());
|
||||
} finally { net.dnorberg.rivencraft.PortConfig.RETURN_LINK_MODE.set(mode); }
|
||||
chest.setItem(3, link);
|
||||
chest.setItem(2, book.copy());
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
} finally { player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
|
||||
static void verify(ServerLevel level) {
|
||||
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
|
||||
if (!chest.getItem(0).is(Rivencraft.COLLATION_FOLDER.get()) || !chest.getItem(1).is(Rivencraft.NOTEBOOK.get()))
|
||||
throw new IllegalStateException("Empty collection covers were not preserved");
|
||||
for (int i = 0; i < 2; i++) if (chest.getItem(i).get(Rivencraft.NOTEBOOK_CONTENTS.get()).pageCount() != 0)
|
||||
throw new IllegalStateException("Imported pages remained in a collection");
|
||||
if (!"RivenBinderProbe".equals(chest.getItem(2).get(Rivencraft.BOOK_AUTHOR.get())))
|
||||
throw new IllegalStateException("Authored book lost its author");
|
||||
var ageId = chest.getItem(2).get(Rivencraft.AGE_BOOK_ID.get());
|
||||
if (!RuntimeAgeBackend.savedAgeName(level.getServer(), ageId).orElseThrow().equals("Crystal Garden's Dawn")
|
||||
|| !"Crystal Garden's Dawn".equals(chest.getItem(3).get(Rivencraft.LINK_DESTINATION_NAME.get()))
|
||||
|| !"Crystal Garden's Dawn".equals(chest.getItem(4).get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Age/Linking/Return destination names did not survive persistence");
|
||||
var old = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
var overworld = new net.dnorberg.rivencraft.item.LinkBookTarget(net.minecraft.world.level.Level.OVERWORLD.identifier(), 0, 64, 0, 0, 0);
|
||||
if (!net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(old, overworld).getString()
|
||||
.equals(net.minecraft.network.chat.Component.translatable("dimension.minecraft.overworld").getString()))
|
||||
throw new IllegalStateException("Older book vanilla name fallback changed");
|
||||
if (!EXPECTED.equals(chest.getItem(2).get(Rivencraft.BOOK_SYMBOLS.get())))
|
||||
throw new IllegalStateException("Authored book page order was not preserved");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.TickTask;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.ProblemReporter;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.storage.TagValueOutput;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Join conversion runs on queued server tasks: create and inspect on separate commands. */
|
||||
final class PortBookDropConversionProbe {
|
||||
private static final UUID OWNER = UUID.fromString("cc5909e5-aafa-43d6-b841-a917ec9c0115");
|
||||
private static final AABB AREA = new AABB(-16, -64, -16, 32, 320, 32);
|
||||
private static ItemStack book(boolean descriptive, String label) {
|
||||
var stack = new ItemStack(descriptive ? Rivencraft.DESCRIPTIVE_BOOK.get() : Rivencraft.LINKING_BOOK.get());
|
||||
stack.set(DataComponents.CUSTOM_NAME, Component.literal(label));
|
||||
return stack;
|
||||
}
|
||||
static void create(ServerLevel level) {
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) level.setBlock(new BlockPos(x, 63, z), Blocks.STONE.defaultBlockState(), 2);
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
new com.mojang.authlib.GameProfile(UUID.fromString("84f38604-48fe-48d3-a4fb-f858a7200115"), "RivenDropProbe"));
|
||||
player.setPos(8.5, 64, 8.5);
|
||||
// Test exact factory state before physics can advance position or pickup timers.
|
||||
var sample = new ItemEntity(level, 8.5, 66, 8.5, book(false, "state_copy"));
|
||||
sample.getItem().set(Rivencraft.LINK_BOOK_TARGET.get(), new net.dnorberg.rivencraft.item.LinkBookTarget(
|
||||
level.dimension().identifier(), -0.5, 64, -1.25, 135, -20));
|
||||
sample.getItem().set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
|
||||
sample.getItem().set(Rivencraft.LINK_PROPERTIES.get(), new net.dnorberg.rivencraft.item.LinkProperties(net.dnorberg.rivencraft.item.LinkProperties.DISARM
|
||||
| net.dnorberg.rivencraft.item.LinkProperties.MAINTAIN_MOMENTUM));
|
||||
sample.setTarget(OWNER);
|
||||
sample.setThrower(player);
|
||||
sample.setPickUpDelay(80);
|
||||
sample.setDeltaMovement(new Vec3(0.2, 0.1, -0.3));
|
||||
sample.setYRot(37);
|
||||
sample.setXRot(12);
|
||||
sample.setNoGravity(true);
|
||||
var copy = DroppedBookEntity.fromDrop(sample);
|
||||
if (!copy.position().equals(sample.position()) || !copy.getDeltaMovement().equals(sample.getDeltaMovement())
|
||||
|| copy.getYRot() != 37 || copy.getXRot() != 12 || !copy.isNoGravity()
|
||||
|| !ItemStack.matches(copy.getItem(), sample.getItem()) || !OWNER.equals(copy.getTarget())
|
||||
|| copy.getUUID().equals(sample.getUUID()) || copy.getAge() != -32768)
|
||||
throw new IllegalStateException("Drop conversion lost placement, ownership or item components");
|
||||
var originalTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
var copiedTag = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
sample.saveWithoutId(originalTag); copy.saveWithoutId(copiedTag);
|
||||
for (String key : List.of("PickupDelay", "Owner", "Thrower", "Item"))
|
||||
if (!java.util.Objects.equals(originalTag.buildResult().get(key), copiedTag.buildResult().get(key)))
|
||||
throw new IllegalStateException("Drop conversion lost metadata: " + key);
|
||||
// Four ordinary book variants must all convert through the actual event route.
|
||||
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description")) {
|
||||
var stack = book(name.endsWith("description"), name);
|
||||
if (name.equals("manual_link")) {
|
||||
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), sample.getItem().get(Rivencraft.LINK_BOOK_TARGET.get()));
|
||||
stack.set(Rivencraft.LINK_DESTINATION_NAME.get(), "Named destination");
|
||||
}
|
||||
if (name.equals("manual_description")) stack.set(Rivencraft.AGE_BOOK_ID.get(), level.dimension().identifier());
|
||||
var item = new ItemEntity(level, 8.5, 66, 8.5, stack);
|
||||
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO); item.setTarget(OWNER);
|
||||
level.addFreshEntity(item);
|
||||
}
|
||||
player.drop(book(false, "player_toss"), false, net.minecraft.util.Prediction.SERVER_ONLY);
|
||||
net.minecraft.world.Containers.dropItemStack(level, 8.5, 65, 8.5, book(true, "container"));
|
||||
var standPos = new BlockPos(10, 64, 8);
|
||||
level.setBlock(standPos, Rivencraft.BOOK_STAND.get().defaultBlockState(), 3);
|
||||
((net.dnorberg.rivencraft.block.entity.BookStandBlockEntity) level.getBlockEntity(standPos)).setBook(book(false, "stand"));
|
||||
level.destroyBlock(standPos, true);
|
||||
var custom = new DroppedBookEntity(level, new Vec3(8.5, 66, 8.5), book(false, "already_custom"));
|
||||
custom.setNoGravity(true);
|
||||
level.addFreshEntity(custom);
|
||||
level.addFreshEntity(new ItemEntity(level, 8.5, 66, 8.5, net.dnorberg.rivencraft.item.PageItem.createLinkPanelPage()));
|
||||
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
|
||||
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity dropped
|
||||
&& dropped.getItem().getHoverName().getString().equals("cancel_preserve")) event.setCanceled(true);
|
||||
};
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
|
||||
try {
|
||||
var item = new ItemEntity(level, 8.5, 66, 8.5, book(false, "cancel_preserve"));
|
||||
item.setNoGravity(true); item.setDeltaMovement(Vec3.ZERO);
|
||||
level.addFreshEntity(item);
|
||||
} finally {
|
||||
// Queued after that source's conversion task; no fixture listener remains installed.
|
||||
level.getServer().schedule(new TickTask(0, () -> net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel)));
|
||||
}
|
||||
}
|
||||
static void verify(ServerLevel level) {
|
||||
var drops = level.getEntitiesOfClass(ItemEntity.class, AREA);
|
||||
for (String name : List.of("manual_link", "manual_description", "unwritten_link", "unwritten_description",
|
||||
"player_toss", "container", "stand", "already_custom", "cancel_preserve")) {
|
||||
var found = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals(name)).toList();
|
||||
if (found.size() != 1 || !name.equals("cancel_preserve") && !(found.getFirst() instanceof DroppedBookEntity))
|
||||
throw new IllegalStateException("Expected one open book for " + name + ", found " + found.size());
|
||||
}
|
||||
var named = drops.stream().filter(e -> e.getItem().getHoverName().getString().equals("manual_link")).findFirst().orElseThrow();
|
||||
if (!OWNER.equals(named.getTarget()) || !"Named destination".equals(named.getItem().get(Rivencraft.LINK_DESTINATION_NAME.get())))
|
||||
throw new IllegalStateException("Converted book lost owner or destination");
|
||||
if (drops.stream().filter(e -> e.getItem().is(Rivencraft.PAGE.get())).anyMatch(e -> e instanceof DroppedBookEntity))
|
||||
throw new IllegalStateException("Non-book item was converted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.event.BookStandConversionHandler;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.dnorberg.rivencraft.network.UseBookStandPayload;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Isolated conversion, ownership, stale-screen and persistence checks. */
|
||||
final class PortBookStandProbe {
|
||||
private static final BlockPos POS = new BlockPos(0, 64, 0);
|
||||
static void create(ServerLevel level) {
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("a5c323a1-3b58-4dd6-927f-e08000800110"), "RivenStandProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
try {
|
||||
for (Direction facing : Direction.Plane.HORIZONTAL) for (InteractionHand hand : InteractionHand.values())
|
||||
for (var sample : java.util.List.of(new ItemStack(Rivencraft.LINKING_BOOK.get()),
|
||||
new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), PageItem.createLinkPanelPage().copyWithCount(3))) {
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.FACING, facing), 3);
|
||||
player.setItemInHand(hand, sample.copy());
|
||||
if (!BookStandConversionHandler.convert(player, hand, POS)) throw new IllegalStateException("Conversion failed");
|
||||
var stand = (BookStandBlockEntity) level.getBlockEntity(POS);
|
||||
var stored = sample.copyWithCount(1);
|
||||
if (!ItemStack.matches(stand.getBook(), stored) || player.getItemInHand(hand).getCount() != sample.getCount() - 1
|
||||
|| stand.getBlockState().getValue(BookStandBlock.FACING) != facing)
|
||||
throw new IllegalStateException("Conversion lost ownership or facing");
|
||||
var packet = new UseBookStandPayload(level.dimension().identifier(), POS, stored.copy());
|
||||
if (PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Valid source rejected");
|
||||
player.setPos(100, 64, 100);
|
||||
if (!PortBookNetworking.validatedStandBook(player, packet).isEmpty()) throw new IllegalStateException("Distant source accepted");
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("minecraft", "overworld"), POS, stored)).isEmpty())
|
||||
throw new IllegalStateException("Wrong dimension accepted");
|
||||
if (!PortBookNetworking.validatedStandBook(player, new UseBookStandPayload(packet.dimension(), POS, new ItemStack(Items.DIRT))).isEmpty())
|
||||
throw new IllegalStateException("Replaced book accepted");
|
||||
var loaded = net.minecraft.world.level.block.entity.BlockEntity.loadStatic(POS, stand.getBlockState(),
|
||||
stand.saveWithFullMetadata(level.registryAccess()), level.registryAccess());
|
||||
if (!(loaded instanceof BookStandBlockEntity copy) || !ItemStack.matches(copy.getBook(), stored))
|
||||
throw new IllegalStateException("Stand serialization lost content");
|
||||
player.getInventory().clearContent();
|
||||
player.setShiftKeyDown(true);
|
||||
stand.getBlockState().useWithoutItem(level, player, new BlockHitResult(Vec3.atCenterOf(POS), Direction.UP, POS, false));
|
||||
player.setShiftKeyDown(false);
|
||||
if (!level.getBlockState(POS).is(Blocks.LECTERN)
|
||||
|| level.getBlockState(POS).getValue(LecternBlock.FACING) != facing
|
||||
|| player.getInventory().countItem(stored.getItem()) != 1
|
||||
|| !PortBookNetworking.validatedStandBook(player, packet).isEmpty())
|
||||
throw new IllegalStateException("Retrieval did not restore lectern or recover item exactly once");
|
||||
player.getInventory().clearContent();
|
||||
player.setItemInHand(hand, stored.copy());
|
||||
BookStandConversionHandler.convert(player, hand, POS);
|
||||
level.destroyBlock(POS, true);
|
||||
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
|
||||
new net.minecraft.world.phys.AABB(-2, 62, -2, 3, 68, 3));
|
||||
int items = drops.stream().filter(e -> e.getItem().is(stored.getItem())).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
int lecterns = drops.stream().filter(e -> e.getItem().is(Items.LECTERN)).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
if (items != 1 || lecterns != 1) throw new IllegalStateException("Break drops duplicated/lost: " + items + "/" + lecterns);
|
||||
drops.forEach(net.minecraft.world.entity.Entity::discard);
|
||||
}
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState().setValue(LecternBlock.HAS_BOOK, true), 3);
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, PageItem.createLinkPanelPage());
|
||||
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Occupied lectern converted");
|
||||
level.setBlock(POS, Blocks.LECTERN.defaultBlockState(), 3);
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(Items.DIRT));
|
||||
if (BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Unrelated item accepted");
|
||||
var named = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("Stand persistence"));
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, named);
|
||||
if (!BookStandConversionHandler.convert(player, InteractionHand.MAIN_HAND, POS)) throw new IllegalStateException("Final stand missing");
|
||||
} finally { player.setShiftKeyDown(false); player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
static void verify(ServerLevel level) {
|
||||
if (!(level.getBlockEntity(POS) instanceof BookStandBlockEntity stand)
|
||||
|| !stand.getBook().is(Rivencraft.LINKING_BOOK.get())
|
||||
|| !stand.getBook().getHoverName().getString().equals("Stand persistence"))
|
||||
throw new IllegalStateException("Stand restart persistence failed");
|
||||
}
|
||||
}
|
||||
@@ -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,202 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.util.BlockSnapshot;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
|
||||
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
|
||||
public final class PortDecayProviders {
|
||||
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
|
||||
private static final Map<Identifier, DecayBlock.Color> COLORS = Map.of(
|
||||
id("decayblue"), DecayBlock.Color.BLUE,
|
||||
id("decaypurple"), DecayBlock.Color.PURPLE,
|
||||
id("decayred"), DecayBlock.Color.RED,
|
||||
id("decaywhite"), DecayBlock.Color.WHITE);
|
||||
private static final Direction[] DIRECTIONS = Direction.values();
|
||||
|
||||
private PortDecayProviders() {}
|
||||
|
||||
public static void register() {
|
||||
COLORS.keySet().forEach(id -> PortInstabilityRuntime.register(id, true, PortDecayProviders::apply));
|
||||
}
|
||||
|
||||
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
|
||||
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
|
||||
ServerLevel level = player.level();
|
||||
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|
||||
|| !PortInstabilityRuntime.isAge(level)
|
||||
|| level.getChunkSource().getChunkNow(player.chunkPosition().x(), player.chunkPosition().z()) == null) return -1;
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
Identifier id = id("decay" + color.getSerializedName());
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled()
|
||||
|| !PortInstabilityConfig.instabilityProviderEnabled(id)) return -1;
|
||||
int changed = 0;
|
||||
for (int i = 0; i < Math.min(count, 32); i++) {
|
||||
PortInstabilityRuntime.Budget budget = new PortInstabilityRuntime.Budget(1);
|
||||
apply(new PortInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
|
||||
level.getServer().getTickCount(), budget), id, 1);
|
||||
changed += budget.applied();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation()
|
||||
|| !PortInstabilityRuntime.isAge(context.level())
|
||||
|| context.loadedChunks().isEmpty()) return;
|
||||
DecayBlock.Color color = COLORS.get(id);
|
||||
if (color == null) return;
|
||||
PortDecaySavedData data = PortDecaySavedData.get(context.level());
|
||||
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
|
||||
^ id.hashCode());
|
||||
List<BlockPos> frontier = data.frontier(color);
|
||||
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
|
||||
if (source == null) {
|
||||
seed(context, color, data, random);
|
||||
return;
|
||||
}
|
||||
BlockState sourceState = context.level().getBlockState(source);
|
||||
if (!sourceState.is(Rivencraft.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
|
||||
data.remove(color, source);
|
||||
return;
|
||||
}
|
||||
for (int attempt = 0; attempt < PortInstabilityConfig.decayMaxAttempts(); attempt++) {
|
||||
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
|
||||
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|
||||
|| isSpawnProtected(context.age(), target)) continue;
|
||||
BlockState prior = context.level().getBlockState(target);
|
||||
if (!canReplace(context.level(), target, prior, color)) continue;
|
||||
int difficulty = conversionDifficulty(context.level(), target, prior, color);
|
||||
if (random.nextInt(difficulty) != 0) continue;
|
||||
if (replace(context, target, prior, color)) data.add(color, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void seed(PortInstabilityRuntime.Context context, DecayBlock.Color color,
|
||||
PortDecaySavedData data, Random random) {
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
int x = chunk.getMinBlockX() + random.nextInt(16);
|
||||
int z = chunk.getMinBlockZ() + random.nextInt(16);
|
||||
BlockPos pos = new BlockPos(x,
|
||||
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
|
||||
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|
||||
|| isSpawnProtected(context.age(), pos)) return;
|
||||
BlockState prior = context.level().getBlockState(pos);
|
||||
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
|
||||
data.add(color, pos);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
|
||||
List<BlockPos> frontier, Random random) {
|
||||
if (frontier.isEmpty()) return null;
|
||||
int start = random.nextInt(frontier.size());
|
||||
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
|
||||
BlockPos pos = frontier.get((start + i) % frontier.size());
|
||||
if (isLoadedTarget(level, chunks, pos)) return pos;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) != null
|
||||
&& chunks.contains(ChunkPos.containing(pos));
|
||||
}
|
||||
|
||||
static boolean isSpawnProtected(PortInstabilityData.Age age, BlockPos pos) {
|
||||
BlockPos spawn = age.spawn().orElse(null);
|
||||
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
|
||||
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
|
||||
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
|
||||
}
|
||||
|
||||
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
|
||||
if (!PortInstabilityRuntime.isAge(level)
|
||||
|| !level.isInWorldBounds(pos) || level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) == null
|
||||
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|
||||
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|
||||
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|
||||
|| isCritical(prior)) return false;
|
||||
return !prior.is(Rivencraft.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
|
||||
}
|
||||
|
||||
private static boolean isCritical(BlockState prior) {
|
||||
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|
||||
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|
||||
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|
||||
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|
||||
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|
||||
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|
||||
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|
||||
|| prior.getBlock() != Rivencraft.DECAY.get()
|
||||
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
|
||||
.getNamespace().equals(Rivencraft.MOD_ID);
|
||||
}
|
||||
|
||||
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
|
||||
if (state.isAir()) return switch (color) {
|
||||
case BLUE, RED -> 20;
|
||||
case PURPLE -> 8;
|
||||
case WHITE -> 50;
|
||||
};
|
||||
if (state.is(Rivencraft.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
|
||||
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
|
||||
return switch (color) {
|
||||
case BLUE -> Math.max(1, (int) hardness * 2);
|
||||
case RED -> Math.max(1, (int) hardness);
|
||||
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
|
||||
case WHITE -> 1;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean replace(PortInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
|
||||
DecayBlock.Color color) {
|
||||
ServerLevel level = context.level();
|
||||
// A fake player gives claim/protection listeners the Age and exact block position.
|
||||
var actor = FakePlayerFactory.getMinecraft(level);
|
||||
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||
if (!prior.isAir()) {
|
||||
var breakEvent = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(breakEvent);
|
||||
if (breakEvent.isCanceled()) return false;
|
||||
}
|
||||
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
|
||||
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
|
||||
BlockState decay = Rivencraft.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
|
||||
if (!level.setBlock(pos, decay, 3)) return false;
|
||||
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||
NeoForge.EVENT_BUS.post(placeEvent);
|
||||
if (placeEvent.isCanceled()) {
|
||||
snapshot.restore();
|
||||
return false;
|
||||
}
|
||||
context.budget().recordApplied();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.block.DecayBlock;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.StringRepresentable;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Persisted per-Age frontier and attempt sequence, bounded to 1,024 positions per color. */
|
||||
public final class PortDecaySavedData extends SavedData {
|
||||
private static final int MAX_FRONTIER_PER_COLOR = 1024;
|
||||
private static final Codec<PortDecaySavedData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts),
|
||||
Codec.unboundedMap(StringRepresentable.fromEnum(DecayBlock.Color::values),
|
||||
BlockPos.CODEC.listOf(0, MAX_FRONTIER_PER_COLOR)).fieldOf("frontiers").forGetter(data -> data.frontiers)
|
||||
).apply(instance, PortDecaySavedData::new));
|
||||
private static final SavedDataType<PortDecaySavedData> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "decay"), () -> new PortDecaySavedData(0, Map.of()), CODEC);
|
||||
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
|
||||
private long attempts;
|
||||
private PortDecaySavedData(long attempts, Map<DecayBlock.Color, List<BlockPos>> positions) {
|
||||
this.attempts = attempts;
|
||||
for (var color : DecayBlock.Color.values()) frontiers.put(color,
|
||||
new ArrayList<>(positions.getOrDefault(color, List.of()).stream().distinct().map(BlockPos::immutable).toList()));
|
||||
}
|
||||
public static PortDecaySavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); }
|
||||
public long nextAttempt() { setDirty(); return attempts++; }
|
||||
public List<BlockPos> frontier(DecayBlock.Color color) { return frontiers.get(color); }
|
||||
public void add(DecayBlock.Color color, BlockPos pos) {
|
||||
var frontier = frontiers.get(color);
|
||||
if (frontier.contains(pos)) return;
|
||||
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.removeFirst();
|
||||
frontier.add(pos.immutable()); setDirty();
|
||||
}
|
||||
public void remove(DecayBlock.Color color, BlockPos pos) { if (frontiers.get(color).remove(pos)) setDirty(); }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.dnorberg.rivencraft.item.PortHandheldTravel;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.storage.TagValueInput;
|
||||
import net.minecraft.world.level.storage.TagValueOutput;
|
||||
import net.minecraft.util.ProblemReporter;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.common.util.FakePlayerFactory;
|
||||
|
||||
/** Disposable-world checks for book ownership and the new entity save format. */
|
||||
final class PortDroppedBookProbe {
|
||||
private PortDroppedBookProbe() {}
|
||||
|
||||
static void create(ServerLevel level) {
|
||||
PortAgeTravel.findLanding(level);
|
||||
var player = FakePlayerFactory.get(level, new com.mojang.authlib.GameProfile(
|
||||
UUID.fromString("4db5c544-0a0c-421d-a128-c520bda64642"), "RivenBookProbe"));
|
||||
player.setPos(0.5, 64, 0.5);
|
||||
try {
|
||||
for (var hand : InteractionHand.values()) {
|
||||
ItemStack book = new ItemStack(hand == InteractionHand.MAIN_HAND
|
||||
? Rivencraft.LINKING_BOOK.get() : Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
book.set(DataComponents.CUSTOM_NAME, Component.literal("Dropped fixture " + hand));
|
||||
book.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(
|
||||
Identifier.fromNamespaceAndPath("minecraft", "overworld"), 12.25, 80.5, -42.75, 135, -20));
|
||||
player.setItemInHand(hand, book);
|
||||
String failure = PortHandheldTravel.travel(player, hand, book, () -> "expected failure");
|
||||
if (!"expected failure".equals(failure) || player.getItemInHand(hand) != book)
|
||||
throw new IllegalStateException("Failed travel consumed the book");
|
||||
try {
|
||||
PortHandheldTravel.travel(player, hand, book, () -> { throw new IllegalStateException("fixture"); });
|
||||
throw new AssertionError("Fixture exception was swallowed");
|
||||
} catch (IllegalStateException expected) {
|
||||
if (!"fixture".equals(expected.getMessage()) || player.getItemInHand(hand) != book) throw expected;
|
||||
}
|
||||
java.util.function.Consumer<net.neoforged.neoforge.event.entity.EntityJoinLevelEvent> cancel = event -> {
|
||||
if (event.getLevel() == level && event.getEntity() instanceof DroppedBookEntity) event.setCanceled(true);
|
||||
};
|
||||
net.neoforged.neoforge.common.NeoForge.EVENT_BUS.addListener(cancel);
|
||||
try {
|
||||
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
|
||||
if (failure != null || player.getItemInHand(hand) != book)
|
||||
throw new IllegalStateException("Rejected dropped entity consumed the book");
|
||||
} finally { net.neoforged.neoforge.common.NeoForge.EVENT_BUS.unregister(cancel); }
|
||||
failure = PortHandheldTravel.travel(player, hand, book, () -> null);
|
||||
if (failure != null || !player.getItemInHand(hand).isEmpty())
|
||||
throw new IllegalStateException("Successful travel retained the active book");
|
||||
}
|
||||
} finally { player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
|
||||
static void verify(ServerLevel level) {
|
||||
var books = level.getEntitiesOfClass(DroppedBookEntity.class, new AABB(-2, 60, -2, 3, 68, 3));
|
||||
if (books.size() != 2) throw new IllegalStateException("Expected two dropped books, found " + books.size());
|
||||
for (var entity : books) {
|
||||
var output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
entity.saveWithoutId(output);
|
||||
var copy = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), level);
|
||||
copy.load(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), output.buildResult()));
|
||||
if (!ItemStack.isSameItemSameComponents(copy.getItem(), entity.getItem())
|
||||
|| copy.getAge() != -32768 || copy.getItem().getCount() != 1)
|
||||
throw new IllegalStateException("Dropped book persistence regression");
|
||||
var copied = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, level.registryAccess());
|
||||
copy.saveWithoutId(copied);
|
||||
if (copied.buildResult().getIntOr("BookDecayTicks", -1)
|
||||
!= output.buildResult().getIntOr("BookDecayTicks", -2))
|
||||
throw new IllegalStateException("Dropped book decay counter was not preserved");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -70,10 +70,12 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
.forGetter(generator -> Optional.ofNullable(generator.lakes)),
|
||||
PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features")
|
||||
.forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)),
|
||||
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed)
|
||||
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) ->
|
||||
com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
||||
com.mojang.serialization.Codec.BOOL.optionalFieldOf("libraries", false).forGetter(generator -> generator.libraries),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("library_symbols", List.of()).forGetter(generator -> generator.librarySymbols)
|
||||
).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed, libraries, symbols) ->
|
||||
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
|
||||
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
|
||||
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed, libraries, symbols)));
|
||||
|
||||
private final ChunkGenerator base;
|
||||
private final PortRockPlan rocks;
|
||||
@@ -82,10 +84,19 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
private final PortLakePlan lakes;
|
||||
private final PortTerrainFeaturePlan terrainFeatures;
|
||||
private final long ageSeed;
|
||||
private final boolean libraries;
|
||||
private final List<Identifier> librarySymbols;
|
||||
|
||||
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
|
||||
this(base, rocks, vegetation, dungeons, lakes, terrainFeatures, ageSeed, false, List.of());
|
||||
}
|
||||
|
||||
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, long ageSeed,
|
||||
boolean libraries, List<Identifier> librarySymbols) {
|
||||
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
|
||||
this.base = base;
|
||||
this.rocks = rocks;
|
||||
@@ -94,6 +105,8 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
this.lakes = lakes;
|
||||
this.terrainFeatures = terrainFeatures;
|
||||
this.ageSeed = ageSeed;
|
||||
this.libraries = libraries;
|
||||
this.librarySymbols = List.copyOf(librarySymbols);
|
||||
}
|
||||
|
||||
public ChunkGenerator base() { return base; }
|
||||
@@ -113,7 +126,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 +137,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) {
|
||||
@@ -286,5 +299,6 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
|
||||
}
|
||||
}
|
||||
if (libraries) PortLibraryFeature.generate(level, chunk, ageSeed, librarySymbols);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
|
||||
/** Scores and test overrides are deliberately operator-only, never book tooltip data. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortInstabilityCommands {
|
||||
private PortInstabilityCommands() {}
|
||||
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft_port")
|
||||
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
|
||||
.then(Commands.literal("instability")
|
||||
.then(Commands.literal("status").executes(context -> {
|
||||
var source = context.getSource(); var level = requireAge(source);
|
||||
var age = PortInstabilityData.get(source.getServer()).age(level);
|
||||
source.sendSuccess(() -> Component.literal("Age " + level.dimension().identifier()
|
||||
+ ": frozen score=" + age.score() + ", enabled=" + age.enabled()
|
||||
+ ", session test score=" + PortInstabilityRuntime.testScore(level)
|
||||
+ ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false);
|
||||
for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal(
|
||||
" " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false);
|
||||
for (var provider : PortInstabilityRuntime.plan(level).providers())
|
||||
source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level()
|
||||
+ " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(context -> {
|
||||
var level = requireAge(context.getSource());
|
||||
boolean enabled = BoolArgumentType.getBool(context, "enabled");
|
||||
PortInstabilityData.get(level.getServer()).enabled(level, enabled);
|
||||
if (!enabled) PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Age instability enabled=" + enabled
|
||||
+ "; saved score and provider selection unchanged"), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("test_score").then(Commands.argument("score", IntegerArgumentType.integer(0, 1_000_000))
|
||||
.executes(context -> {
|
||||
var level = requireAge(context.getSource());
|
||||
PortInstabilityRuntime.testScore(level, IntegerArgumentType.getInteger(context, "score"));
|
||||
PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Session-only score override set; frozen score unchanged. Restart or clear_test_score removes it."), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear_test_score").executes(context -> {
|
||||
var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null);
|
||||
PortStatusEffectProviders.clearAge(level);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Restored saved provider selection"), false); return 1;
|
||||
}))
|
||||
.then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (var color : net.dnorberg.rivencraft.block.DecayBlock.Color.values())
|
||||
if (color.getSerializedName().startsWith(builder.getRemaining())) builder.suggest(color.getSerializedName());
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> decay(context, 1))
|
||||
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
|
||||
.executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts"))))))
|
||||
.then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word())
|
||||
.suggests((context, builder) -> {
|
||||
for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck))
|
||||
if (card.id().getPath().startsWith(builder.getRemaining())) builder.suggest(card.id().getPath());
|
||||
return builder.buildFuture();
|
||||
}).executes(context -> {
|
||||
var source = context.getSource(); var level = requireAge(source);
|
||||
String name = StringArgumentType.getString(context, "provider");
|
||||
Identifier id = Identifier.tryParse(name.contains(":") ? name : "rivencraft:" + name);
|
||||
if (id == null || !InstabilityProviderCatalog.contains(id)) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Unknown instability provider")).create();
|
||||
var chunk = net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(source.getPosition()));
|
||||
if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null)
|
||||
throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Current chunk must already be loaded")).create();
|
||||
var budget = new PortInstabilityRuntime.Budget(1);
|
||||
var effect = new PortInstabilityRuntime.Context(level, PortInstabilityData.get(level.getServer()).age(level),
|
||||
java.util.List.of(chunk), source.getServer().getTickCount(), budget);
|
||||
boolean attempted = PortInstabilityRuntime.invoke(effect, id, 1);
|
||||
boolean applied = attempted && budget.applied() > 0;
|
||||
if (applied) source.sendSuccess(() -> Component.literal("Applied " + id + " (" + budget.applied() + " successful action in this chunk)"), false);
|
||||
else source.sendFailure(Component.literal("No effect applied. Check eligible targets/terrain, sky exposure, existing effects and protection. Provider status: "
|
||||
+ PortInstabilityRuntime.providerStatus(level, id)));
|
||||
return applied ? 1 : 0;
|
||||
})))));
|
||||
}
|
||||
private static int decay(com.mojang.brigadier.context.CommandContext<CommandSourceStack> context, int attempts)
|
||||
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
|
||||
requireAge(context.getSource());
|
||||
var color = java.util.Arrays.stream(net.dnorberg.rivencraft.block.DecayBlock.Color.values())
|
||||
.filter(value -> value.getSerializedName().equalsIgnoreCase(StringArgumentType.getString(context, "color")))
|
||||
.findFirst().orElse(null);
|
||||
if (color == null) {
|
||||
context.getSource().sendFailure(Component.literal("Choose blue, purple, red or white")); return 0;
|
||||
}
|
||||
int changed = PortDecayProviders.pulseForTesting(context.getSource().getPlayerOrException(), color, attempts);
|
||||
if (changed < 0) {
|
||||
context.getSource().sendFailure(Component.literal("Decay is disabled by config, Age or provider policy")); return 0;
|
||||
}
|
||||
context.getSource().sendSuccess(() -> Component.literal("Decay " + color.getSerializedName() + ": " + changed
|
||||
+ " block changes from " + attempts + " bounded attempts; saved score/plan unchanged"), false);
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var nearby = PortDecaySavedData.get(player.level()).frontier(color).reversed().stream()
|
||||
.filter(pos -> net.minecraft.world.level.ChunkPos.containing(pos).equals(player.chunkPosition()))
|
||||
.limit(4).map(BlockPos::toShortString).toList();
|
||||
if (!nearby.isEmpty()) context.getSource().sendSuccess(() -> Component.literal("Nearby tracked decay: " + nearby), false);
|
||||
return changed;
|
||||
}
|
||||
private static net.minecraft.server.level.ServerLevel requireAge(CommandSourceStack source)
|
||||
throws com.mojang.brigadier.exceptions.CommandSyntaxException {
|
||||
if (!PortInstabilityRuntime.isAge(source.getLevel())) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(
|
||||
Component.literal("Use this command inside a Rivencraft Age")).create();
|
||||
return source.getLevel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** The 1.21.1 scheduler and status-provider defaults, in the port's server-owned config. */
|
||||
public final class PortInstabilityConfig {
|
||||
private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay;
|
||||
private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts;
|
||||
private static ModConfigSpec.ConfigValue<List<? extends String>> disabledProviders;
|
||||
private PortInstabilityConfig() {}
|
||||
public static void define(ModConfigSpec.Builder builder) {
|
||||
builder.push("instability");
|
||||
enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.")
|
||||
.define("enabled", true);
|
||||
worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.")
|
||||
.define("world_mutation", true);
|
||||
cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200);
|
||||
ages = builder.defineInRange("max_ages_per_tick", 4, 1, 16);
|
||||
providers = builder.defineInRange("max_providers_per_age", 2, 1, 16);
|
||||
actions = builder.defineInRange("max_actions_per_age", 1, 1, 16);
|
||||
level = builder.defineInRange("max_effect_level", 3, 1, 16);
|
||||
chunks = builder.defineInRange("max_loaded_chunks", 16, 1, 64);
|
||||
disabledProviders = builder.comment("Disable particular providers without changing saved selections. Accepts poison or rivencraft:poison.")
|
||||
.defineListAllowEmpty("disabled_providers", List.of(), () -> "poison", value -> value instanceof String text
|
||||
&& Identifier.tryParse(text) != null);
|
||||
builder.pop();
|
||||
builder.push("instability_status_effects");
|
||||
status = builder.define("enabled", true);
|
||||
players = builder.comment("Affect Survival/Adventure players; Creative and Spectator remain exempt.")
|
||||
.define("affect_players", true);
|
||||
nonplayers = builder.define("affect_nonplayers", true);
|
||||
hostiles = builder.define("buff_hostiles", true);
|
||||
active = builder.defineInRange("max_active_per_age", 32, 1, 128);
|
||||
candidates = builder.defineInRange("max_candidates_per_attempt", 8, 1, 16);
|
||||
duration = builder.defineInRange("max_duration_ticks", 200, 20, 200);
|
||||
builder.pop();
|
||||
builder.push("instability_decay");
|
||||
decay = builder.comment("Allow colored decay in loaded Ages. Also requires instability.world_mutation.")
|
||||
.define("enabled", true);
|
||||
decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.")
|
||||
.defineInRange("max_attempts_per_call", 4, 1, 6);
|
||||
builder.pop();
|
||||
}
|
||||
public static boolean instabilityWorldMutation() { return worldMutation.get(); }
|
||||
public static boolean decayEnabled() { return decay.get(); }
|
||||
public static int decayMaxAttempts() { return decayAttempts.get(); }
|
||||
public static boolean instabilityEnabled() { return enabled.get(); }
|
||||
public static boolean instabilityProviderEnabled(Identifier id) {
|
||||
return disabledProviders.get().stream().noneMatch(value -> value.equals(id.toString())
|
||||
|| id.getNamespace().equals("rivencraft") && value.equals(id.getPath()));
|
||||
}
|
||||
public static int instabilityCooldown() { return cooldown.get(); }
|
||||
public static int instabilityMaxAges() { return ages.get(); }
|
||||
public static int instabilityMaxProviders() { return providers.get(); }
|
||||
public static int instabilityMaxActions() { return actions.get(); }
|
||||
public static int instabilityMaxLevel() { return level.get(); }
|
||||
public static int instabilityMaxChunks() { return chunks.get(); }
|
||||
public static boolean statusEffectsEnabled() { return status.get(); }
|
||||
public static boolean statusEffectsPlayers() { return players.get(); }
|
||||
public static boolean statusEffectsNonplayers() { return nonplayers.get(); }
|
||||
public static boolean statusEffectsHostiles() { return hostiles.get(); }
|
||||
public static int statusEffectsMaxActive() { return active.get(); }
|
||||
public static int statusEffectsMaxCandidates() { return candidates.get(); }
|
||||
public static int statusEffectsMaxDuration() { return duration.get(); }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Frozen creation-time contributions and Legacy deck selections. Never silently rescores older Ages. */
|
||||
public final class PortInstabilityData extends SavedData {
|
||||
public record Contribution(String reason, int page, int points) {
|
||||
public static final Codec<Contribution> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.STRING.fieldOf("reason").forGetter(Contribution::reason),
|
||||
Codec.INT.fieldOf("page").forGetter(Contribution::page),
|
||||
Codec.intRange(0, Integer.MAX_VALUE).fieldOf("points").forGetter(Contribution::points)
|
||||
).apply(instance, Contribution::new));
|
||||
}
|
||||
public record Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled, java.util.Optional<net.minecraft.core.BlockPos> spawn) {
|
||||
public static final Codec<Age> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.LONG.fieldOf("seed").forGetter(Age::seed),
|
||||
Contribution.CODEC.listOf().fieldOf("contributions").forGetter(Age::contributions),
|
||||
PortInstabilityPlan.CODEC.fieldOf("plan").forGetter(Age::plan),
|
||||
Codec.BOOL.optionalFieldOf("enabled", true).forGetter(Age::enabled),
|
||||
net.minecraft.core.BlockPos.CODEC.optionalFieldOf("spawn").forGetter(Age::spawn)
|
||||
).apply(instance, Age::new));
|
||||
public Age { contributions = List.copyOf(contributions); spawn = spawn.map(net.minecraft.core.BlockPos::immutable); }
|
||||
public Age(long seed, List<Contribution> contributions, PortInstabilityPlan plan, boolean enabled) {
|
||||
this(seed, contributions, plan, enabled, java.util.Optional.empty());
|
||||
}
|
||||
public int score() { return contributions.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); }
|
||||
}
|
||||
private static final SavedDataType<PortInstabilityData> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "instability"), () -> new PortInstabilityData(Map.of()),
|
||||
Codec.unboundedMap(Identifier.CODEC, Age.CODEC).fieldOf("ages").codec()
|
||||
.xmap(PortInstabilityData::new, data -> Map.copyOf(data.ages)));
|
||||
private final Map<Identifier, Age> ages = new HashMap<>();
|
||||
private PortInstabilityData(Map<Identifier, Age> values) { ages.putAll(values); }
|
||||
public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
public Age age(ServerLevel level) {
|
||||
Age age = ages.get(level.dimension().identifier());
|
||||
return age != null ? age : new Age(level.getSeed(),
|
||||
List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true);
|
||||
}
|
||||
public void enabled(ServerLevel level, boolean enabled) {
|
||||
Age previous = age(level);
|
||||
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn()));
|
||||
setDirty();
|
||||
}
|
||||
/** First resolved arrival remains protected without rescoring or rerolling the Age. */
|
||||
public void recordSpawn(ServerLevel level, net.minecraft.core.BlockPos spawn) {
|
||||
Age previous = age(level);
|
||||
if (previous.spawn().isPresent()) return;
|
||||
ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(),
|
||||
previous.enabled(), java.util.Optional.of(spawn.immutable())));
|
||||
setDirty();
|
||||
}
|
||||
public void create(ServerLevel level, List<Identifier> pages, PortAgeTerrain terrain,
|
||||
PortLighting lighting, PortDayCycle cycle) {
|
||||
Identifier id = level.dimension().identifier();
|
||||
if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id);
|
||||
List<Contribution> costs = new ArrayList<>();
|
||||
costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0));
|
||||
boolean authoredBright = false;
|
||||
Map<Identifier, Integer> structures = new HashMap<>();
|
||||
var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream()
|
||||
.flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).toList();
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
Identifier page = pages.get(i);
|
||||
if (!page.getNamespace().equals("rivencraft")) continue;
|
||||
String path = page.getPath();
|
||||
if (path.equals("lighting/bright")) { costs.add(new Contribution("bright_lighting", i, 500)); authoredBright = true; }
|
||||
if (page.equals(PortAcceleratedTicks.PAGE)) costs.add(new Contribution("accelerated", i, 1000));
|
||||
if (isCorrupted(page)) costs.add(new Contribution("corrupted_" + path.substring(10), i,
|
||||
path.endsWith("small") ? 2000 : path.endsWith("medium") ? 1000 : 0));
|
||||
// Legacy charges for sky/weather controllers that Cave terrain cannot apply.
|
||||
if (terrain == PortAgeTerrain.CAVE && (PortWeatherMode.forPage(page) != null
|
||||
|| PortDayCycle.isController(page) || PortDayCycle.isPeriod(page)
|
||||
|| PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page)))
|
||||
costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100));
|
||||
if (PortStructurePages.supports(page)) {
|
||||
int count = structures.merge(page, 1, Integer::sum);
|
||||
if (count > 3) costs.add(new Contribution("excess_" + path, i, 100));
|
||||
var allowed = PortStructurePages.compatibleBiomes(page, level.registryAccess());
|
||||
if (!allowed.isEmpty() && biomes.stream().anyMatch(biome -> !allowed.contains(biome)))
|
||||
costs.add(new Contribution("forced_biome_" + path, i, 100));
|
||||
}
|
||||
}
|
||||
if (lighting == PortLighting.BRIGHT && !authoredBright) costs.add(new Contribution("seeded_bright_lighting", -1, 500));
|
||||
int suns = 0;
|
||||
for (var body : cycle.bodies()) if (body.kind() == PortDayCycle.SUN && ++suns > 2)
|
||||
costs.add(new Contribution("extra_sun_" + suns, -1, 1000 * (suns - 2)));
|
||||
int score = costs.stream().mapToInt(Contribution::points).reduce(0, Math::addExact);
|
||||
ages.put(id, new Age(level.getSeed(), costs, PortInstabilityPlan.select(score, level.getSeed()), true));
|
||||
setDirty();
|
||||
}
|
||||
public static boolean isCorrupted(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "corrupted/large", "corrupted/medium", "corrupted/small" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Frozen provider selection; handler availability and server settings never reroll this plan. */
|
||||
public record PortInstabilityPlan(int version, List<ProviderLevel> providers) {
|
||||
public static final com.mojang.serialization.Codec<PortInstabilityPlan> CODEC =
|
||||
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
|
||||
com.mojang.serialization.Codec.INT.fieldOf("version").forGetter(PortInstabilityPlan::version),
|
||||
ProviderLevel.CODEC.listOf().fieldOf("providers").forGetter(PortInstabilityPlan::providers)
|
||||
).apply(instance, PortInstabilityPlan::new));
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
public PortInstabilityPlan {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version);
|
||||
providers = List.copyOf(providers);
|
||||
if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) {
|
||||
throw new IllegalArgumentException("Duplicate provider ID in saved Age plan");
|
||||
}
|
||||
}
|
||||
|
||||
public static PortInstabilityPlan empty() { return new PortInstabilityPlan(CURRENT_VERSION, List.of()); }
|
||||
|
||||
public static PortInstabilityPlan select(int score, long ageSeed) {
|
||||
int roundedScore = score / 500 * 500;
|
||||
Map<Identifier, Integer> levels = new LinkedHashMap<>();
|
||||
for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) {
|
||||
int remaining = roundedScore - deck.entryCost();
|
||||
if (remaining <= 0) continue;
|
||||
List<InstabilityProviderCatalog.Card> cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck));
|
||||
Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1))));
|
||||
for (InstabilityProviderCatalog.Card card : cards) {
|
||||
if (remaining < card.cost()) break;
|
||||
remaining -= card.cost();
|
||||
levels.merge(card.id(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return new PortInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream()
|
||||
.map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList());
|
||||
}
|
||||
|
||||
public record ProviderLevel(Identifier id, int level) {
|
||||
public static final com.mojang.serialization.Codec<ProviderLevel> CODEC =
|
||||
com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.fieldOf("id").forGetter(ProviderLevel::id),
|
||||
com.mojang.serialization.Codec.intRange(1, 255).fieldOf("level").forGetter(ProviderLevel::level)
|
||||
).apply(instance, ProviderLevel::new));
|
||||
public ProviderLevel {
|
||||
Objects.requireNonNull(id, "id");
|
||||
if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortInstabilityRuntime {
|
||||
private static final Map<Identifier, Handler> HANDLERS = new HashMap<>();
|
||||
private static final Map<ServerLevel, Map<Identifier, Long>> FAILURES = new WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, Map<Identifier, Integer>> TEST_SCORES = new WeakHashMap<>();
|
||||
private static final Map<MinecraftServer, TickState> TICKS = new WeakHashMap<>();
|
||||
private PortInstabilityRuntime() {}
|
||||
public static void register(Identifier id, ProviderEffect handler) {
|
||||
register(id, false, handler);
|
||||
}
|
||||
public static void register(Identifier id, boolean worldMutating, ProviderEffect handler) {
|
||||
if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, handler)) != null)
|
||||
throw new IllegalArgumentException("Duplicate instability provider " + id);
|
||||
}
|
||||
public static boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; }
|
||||
public static PortInstabilityPlan plan(ServerLevel level) {
|
||||
var age = PortInstabilityData.get(level.getServer()).age(level);
|
||||
Integer test = TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
|
||||
return test == null ? age.plan() : PortInstabilityPlan.select(test, age.seed());
|
||||
}
|
||||
public static Integer testScore(ServerLevel level) {
|
||||
return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier());
|
||||
}
|
||||
public static void testScore(ServerLevel level, Integer score) {
|
||||
var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>());
|
||||
if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score);
|
||||
}
|
||||
@SubscribeEvent public static void tick(LevelTickEvent.Post event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level) || !isAge(level) || level.players().isEmpty()
|
||||
|| !PortInstabilityConfig.instabilityEnabled()) return;
|
||||
var server = level.getServer();
|
||||
long tick = server.getTickCount();
|
||||
int cooldown = PortInstabilityConfig.instabilityCooldown();
|
||||
if (tick % cooldown != 0) return;
|
||||
TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState());
|
||||
if (state.tick != tick) {
|
||||
state.tick = tick;
|
||||
var eligible = new ArrayList<Identifier>();
|
||||
for (var loaded : server.getAllLevels()) if (isAge(loaded) && !loaded.players().isEmpty()
|
||||
&& PortInstabilityData.get(server).age(loaded).enabled() && !plan(loaded).providers().isEmpty())
|
||||
eligible.add(loaded.dimension().identifier());
|
||||
eligible.sort(Comparator.comparing(Identifier::toString));
|
||||
state.eligible = List.copyOf(eligible);
|
||||
}
|
||||
int index = state.eligible.indexOf(level.dimension().identifier());
|
||||
if (index < 0 || state.eligible.size() > PortInstabilityConfig.instabilityMaxAges()
|
||||
&& Math.floorMod(index - (int) Math.floorMod(tick / cooldown, state.eligible.size()), state.eligible.size())
|
||||
>= PortInstabilityConfig.instabilityMaxAges()) return;
|
||||
var age = PortInstabilityData.get(server).age(level);
|
||||
if (!age.enabled()) return;
|
||||
var providers = plan(level).providers();
|
||||
var chunks = loadedChunks(level);
|
||||
if (providers.isEmpty() || chunks.isEmpty()) return;
|
||||
Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions()));
|
||||
int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size());
|
||||
int ran = 0;
|
||||
for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders()
|
||||
&& context.budget().remaining() > 0; offset++) {
|
||||
var provider = providers.get((first + offset) % providers.size());
|
||||
if (invoke(context, provider.id(), provider.level())) ran++;
|
||||
}
|
||||
}
|
||||
public static boolean invoke(Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.instabilityProviderEnabled(id)
|
||||
|| !PortInstabilityData.get(context.level().getServer()).age(context.level()).enabled()) return false;
|
||||
Handler handler = HANDLERS.get(id);
|
||||
if (handler == null || handler.worldMutating() && !PortInstabilityConfig.instabilityWorldMutation()) return false;
|
||||
var failures = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>());
|
||||
if (context.serverTick() < failures.getOrDefault(id, 0L)) return false;
|
||||
try {
|
||||
handler.effect().apply(context, id, Math.min(level, PortInstabilityConfig.instabilityMaxLevel()));
|
||||
} catch (RuntimeException exception) {
|
||||
failures.put(id, context.serverTick() + Math.max(200L, PortInstabilityConfig.instabilityCooldown() * 10L));
|
||||
Rivencraft.LOGGER.error("Instability provider {} failed in {}; temporarily quarantined", id,
|
||||
context.level().dimension().identifier(), exception);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static String providerStatus(ServerLevel level, Identifier id) {
|
||||
if (!HANDLERS.containsKey(id)) return "not ported yet";
|
||||
if (!PortInstabilityConfig.instabilityEnabled()) return "global config off";
|
||||
if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled";
|
||||
if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off";
|
||||
if (HANDLERS.get(id).worldMutating()) {
|
||||
if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off";
|
||||
if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off";
|
||||
} else {
|
||||
if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off";
|
||||
if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled";
|
||||
if (!id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !PortInstabilityConfig.statusEffectsNonplayers()) return "all general targets disabled";
|
||||
}
|
||||
if (level.getServer().getTickCount() < FAILURES.getOrDefault(level, Map.of()).getOrDefault(id, 0L)) return "quarantined";
|
||||
return level.players().isEmpty() ? "no players in Age" : "eligible; sky, targets and budgets still apply";
|
||||
}
|
||||
static List<ChunkPos> loadedChunks(ServerLevel level) {
|
||||
var chunks = new LinkedHashSet<ChunkPos>();
|
||||
for (var player : level.players()) {
|
||||
ChunkPos center = player.chunkPosition();
|
||||
for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) {
|
||||
if (level.getChunkSource().getChunkNow(center.x() + x, center.z() + z) != null)
|
||||
chunks.add(new ChunkPos(center.x() + x, center.z() + z));
|
||||
if (chunks.size() >= PortInstabilityConfig.instabilityMaxChunks()) return List.copyOf(chunks);
|
||||
}
|
||||
}
|
||||
return List.copyOf(chunks);
|
||||
}
|
||||
@SubscribeEvent public static void stopping(ServerStoppingEvent event) {
|
||||
TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear();
|
||||
}
|
||||
public interface ProviderEffect { void apply(Context context, Identifier id, int level); }
|
||||
public record Context(ServerLevel level, PortInstabilityData.Age age, List<ChunkPos> loadedChunks,
|
||||
long serverTick, Budget budget) { public Context { loadedChunks = List.copyOf(loadedChunks); } }
|
||||
public static final class Budget {
|
||||
private int remaining;
|
||||
private int applied;
|
||||
public Budget(int maximum) { remaining = maximum; }
|
||||
public boolean claim() { if (remaining <= 0) return false; remaining--; return true; }
|
||||
public int remaining() { return remaining; }
|
||||
public void recordApplied() { applied++; }
|
||||
public int applied() { return applied; }
|
||||
}
|
||||
private record Handler(boolean worldMutating, ProviderEffect effect) {}
|
||||
private static final class TickState { long tick = Long.MIN_VALUE; List<Identifier> eligible = List.of(); }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
|
||||
/** Explicit operator tools; normal generation does not search or force-load remote chunks. */
|
||||
@EventBusSubscriber(modid = "rivencraft")
|
||||
public final class PortLibraryCommands {
|
||||
@SubscribeEvent public static void register(RegisterCommandsEvent event) {
|
||||
var root = Commands.literal("rivencraft_port").requires(Commands.hasPermission(Commands.LEVEL_ADMINS));
|
||||
root.then(Commands.literal("library")
|
||||
.then(Commands.literal("test_place").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var direction = context.getSource().getEntity() instanceof net.minecraft.world.entity.player.Player player
|
||||
? player.getDirection() : net.minecraft.core.Direction.NORTH;
|
||||
var result = PortLibraryFeature.testPlace(level, BlockPos.containing(context.getSource().getPosition()), direction,
|
||||
level.getSeed(), PortLibraryWriting.get(level.getServer()).symbols(level.dimension().identifier()));
|
||||
if (result.isEmpty()) { context.getSource().sendFailure(Component.literal("No clear placement nearby; use open ground with headroom.")); return 0; }
|
||||
context.getSource().sendSuccess(() -> Component.literal("Library placed. Chest: " + result.get().toShortString()), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("test_spacing").then(Commands.argument("chunks", IntegerArgumentType.integer(1, 32))
|
||||
.executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
if (!RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier()))
|
||||
throw new IllegalArgumentException("Use this test override inside a Rivencraft Age");
|
||||
int spacing = IntegerArgumentType.getInteger(context, "chunks");
|
||||
PortLibraryFeature.setTestSpacing(level.getSeed(), spacing);
|
||||
context.getSource().sendSuccess(() -> Component.literal("Session-only library spacing: " + spacing
|
||||
+ " chunks. Applies to newly generated chunks; 32 restores normal spacing."), false);
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("candidate").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var origin = ChunkPos.containing(BlockPos.containing(context.getSource().getPosition()));
|
||||
ChunkPos best = null;
|
||||
long distance = Long.MAX_VALUE;
|
||||
for (int spacing : new int[]{32, PortLibraryFeature.spacing(level.getSeed())}) {
|
||||
int rx = Math.floorDiv(origin.x(), spacing), rz = Math.floorDiv(origin.z(), spacing);
|
||||
for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) {
|
||||
var candidate = PortLibraryFeature.candidateChunk(level.getSeed(), rx + dx, rz + dz, spacing);
|
||||
long x = candidate.x() - origin.x(), z = candidate.z() - origin.z();
|
||||
long d = x * x + z * z;
|
||||
if (d < distance) { best = candidate; distance = d; }
|
||||
}
|
||||
}
|
||||
var candidate = best;
|
||||
context.getSource().sendSuccess(() -> Component.literal("Nearest candidate chunk " + candidate
|
||||
+ ", center X=" + (candidate.getMinBlockX() + 8) + " Z=" + (candidate.getMinBlockZ() + 8)
|
||||
+ ". A candidate is not a guaranteed library: footing, slope and clear space must pass."), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("probe").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
|
||||
var chest = PortLibraryFeature.libraryChest(chunk);
|
||||
if (chest.isPresent()) context.getSource().sendSuccess(() -> Component.literal("Generated library chest: " + chest.get().toShortString()), false);
|
||||
else {
|
||||
var check = PortLibraryFeature.check(level, chunk.getPos(), level.getSeed());
|
||||
context.getSource().sendSuccess(() -> Component.literal(check.eligible() ? "Eligible library candidate, floor Y=" + check.floorY()
|
||||
: "No library in this chunk: " + check.reason()), false);
|
||||
}
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("inspect_loot").executes(context -> {
|
||||
var level = context.getSource().getLevel();
|
||||
var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition()));
|
||||
var pos = PortLibraryFeature.libraryChest(chunk);
|
||||
if (pos.isEmpty()) { context.getSource().sendFailure(Component.literal("No marked library chest in this chunk.")); return 0; }
|
||||
var chest = (net.minecraft.world.level.block.entity.ChestBlockEntity) level.getBlockEntity(pos.get());
|
||||
var items = new java.util.ArrayList<String>();
|
||||
for (int i = 0; i < chest.getContainerSize(); i++) {
|
||||
var item = chest.getItem(i); // Materializes loot exactly as opening the chest does.
|
||||
if (!item.isEmpty()) items.add(item.getCount() + "x " + item.getHoverName().getString());
|
||||
}
|
||||
long stands = chunk.getBlockEntities().values().stream()
|
||||
.filter(be -> be instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity).count();
|
||||
context.getSource().sendSuccess(() -> Component.literal("Library at " + pos.get().toShortString()
|
||||
+ "; stands in this chunk=" + stands + "; chest=" + items), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("nearest_loaded").executes(context -> {
|
||||
var player = context.getSource().getPlayerOrException();
|
||||
var level = player.level();
|
||||
var origin = player.chunkPosition();
|
||||
BlockPos best = null; double distance = Double.MAX_VALUE;
|
||||
for (int x = origin.x() - 16; x <= origin.x() + 16; x++) for (int z = origin.z() - 16; z <= origin.z() + 16; z++) {
|
||||
var chunk = level.getChunkSource().getChunkNow(x, z);
|
||||
if (chunk == null) continue;
|
||||
var found = PortLibraryFeature.libraryChest(chunk);
|
||||
if (found.isPresent()) {
|
||||
double d = player.distanceToSqr(Vec3.atCenterOf(found.get()));
|
||||
if (d < distance) { best = found.get(); distance = d; }
|
||||
}
|
||||
}
|
||||
if (best == null) { context.getSource().sendFailure(Component.literal("No library in loaded chunks within 16 chunks. Use candidate or test_spacing to explore new terrain.")); return 0; }
|
||||
var facing = level.getBlockState(best).getValue(ChestBlock.FACING);
|
||||
var feet = best.relative(facing, 2).relative(facing.getCounterClockWise());
|
||||
var target = Vec3.atBottomCenterOf(feet);
|
||||
if (!level.getBlockState(feet.below()).isFaceSturdy(level, feet.below(), net.minecraft.core.Direction.UP)
|
||||
|| !level.getFluidState(feet).isEmpty() || !level.getFluidState(feet.above()).isEmpty()
|
||||
|| !level.noCollision(player, player.getBoundingBox().move(target.subtract(player.position())))) {
|
||||
context.getSource().sendFailure(Component.literal("Library found at " + best.toShortString() + " but its aisle is obstructed.")); return 0;
|
||||
}
|
||||
player.teleportTo(level, target.x, target.y, target.z, java.util.Set.of(), facing.getOpposite().toYRot(), 0, true);
|
||||
return 1;
|
||||
})));
|
||||
event.getDispatcher().register(root);
|
||||
}
|
||||
@SubscribeEvent public static void stopped(net.neoforged.neoforge.event.server.ServerStoppedEvent event) {
|
||||
PortLibraryFeature.clearTestSpacing();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
|
||||
public final class PortLibraryFeature {
|
||||
public static final int REGION = 32;
|
||||
static final int FOOTPRINT = 11;
|
||||
private static final int OFFSET_BOUND = 24;
|
||||
private static final int MAX_SUPPORT_DEPTH = 24;
|
||||
private static final int SALT = 14357617;
|
||||
private static final int TEST_SALT = 0x4C494252;
|
||||
private static final String CHEST_MARKER = "RivencraftLibrary";
|
||||
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
|
||||
// x, y, z, clockwise turns relative to the left stand. The middle row
|
||||
// faces across the room; the far stand faces back toward the entrance.
|
||||
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
|
||||
{8, 1, 6, 1}, {6, 1, 8, 2}};
|
||||
private static final Identifier TEMPLATE = Identifier.fromNamespaceAndPath(
|
||||
"rivencraft", "mystcraft_library");
|
||||
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
|
||||
|
||||
private PortLibraryFeature() {}
|
||||
|
||||
record Site(int x, int z, int rotation) {}
|
||||
|
||||
public static int spacing(long seed) {
|
||||
return TEST_SPACING.getOrDefault(seed, REGION);
|
||||
}
|
||||
|
||||
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
|
||||
public static void setTestSpacing(long seed, int chunks) {
|
||||
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
|
||||
if (chunks == REGION) TEST_SPACING.remove(seed);
|
||||
else TEST_SPACING.put(seed, chunks);
|
||||
}
|
||||
|
||||
public static void clearTestSpacing() {
|
||||
TEST_SPACING.clear();
|
||||
}
|
||||
|
||||
/** The single deterministic candidate in one Legacy-sized region. */
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
|
||||
return candidateChunk(seed, regionX, regionZ, REGION);
|
||||
}
|
||||
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
|
||||
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
return new ChunkPos(regionX * spacing + random.nextInt(bound),
|
||||
regionZ * spacing + random.nextInt(bound));
|
||||
}
|
||||
|
||||
static Site site(long seed, ChunkPos chunk) {
|
||||
Site normal = site(seed, chunk, REGION);
|
||||
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
|
||||
}
|
||||
|
||||
private static Site site(long seed, ChunkPos chunk, int spacing) {
|
||||
int rx = Math.floorDiv(chunk.x(), spacing);
|
||||
int rz = Math.floorDiv(chunk.z(), spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, rx, rz,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
int candidateX = rx * spacing + random.nextInt(bound);
|
||||
int candidateZ = rz * spacing + random.nextInt(bound);
|
||||
if (chunk.x() != candidateX || chunk.z() != candidateZ) return null;
|
||||
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
|
||||
}
|
||||
|
||||
/** A saved marker distinguishes a generated library from player-built chests. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
|
||||
return chunk.getBlockEntities().entrySet().stream()
|
||||
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
|
||||
&& chest.getPersistentData().getBooleanOr(CHEST_MARKER, false))
|
||||
.map(java.util.Map.Entry::getKey).findFirst();
|
||||
}
|
||||
|
||||
/** Recognize libraries generated before the chest marker was introduced. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
|
||||
Optional<BlockPos> marked = libraryChest(chunk);
|
||||
if (marked.isPresent()) return marked;
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return Optional.empty();
|
||||
for (var entry : chunk.getBlockEntities().entrySet()) {
|
||||
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
|
||||
int y = entry.getKey().getY();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
|
||||
continue;
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
return Optional.of(entry.getKey());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Open interior aisle, directly above the cobblestone floor. */
|
||||
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
|
||||
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
|
||||
}
|
||||
|
||||
public record PlacementCheck(String reason, int floorY, int[][] heights) {
|
||||
public boolean eligible() { return reason == null; }
|
||||
}
|
||||
|
||||
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
|
||||
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) return rejected("not a candidate chunk");
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int low = Integer.MAX_VALUE;
|
||||
int high = Integer.MIN_VALUE;
|
||||
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
int wx = site.x() + x;
|
||||
int wz = site.z() + z;
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
|
||||
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
|
||||
while (y > level.getMinY() + 2) {
|
||||
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!below.isAir() && !isVegetation(below)) break;
|
||||
y--;
|
||||
}
|
||||
if (y < level.getMinY() + 2) return rejected("no surface footing");
|
||||
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
|
||||
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
|
||||
return rejected("ground is not sturdy terrain");
|
||||
heights[x][z] = y;
|
||||
low = Math.min(low, y);
|
||||
high = Math.max(high, y);
|
||||
}
|
||||
if (high + 10 >= level.getMaxY()) return rejected("insufficient build height");
|
||||
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
|
||||
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
|
||||
// Check the entire raised foundation and house. Foliage can be cleared, but not
|
||||
// structures, fluids, or block entities already occupying this volume.
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = heights[x][z]; y <= high + 10; y++) {
|
||||
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
|
||||
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
|
||||
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
|
||||
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
|
||||
return rejected("solid obstruction inside footprint: "
|
||||
+ state.getBlock().getDescriptionId());
|
||||
}
|
||||
return new PlacementCheck(null, high, heights);
|
||||
}
|
||||
|
||||
private static PlacementCheck rejected(String reason) {
|
||||
return new PlacementCheck(reason, 0, null);
|
||||
}
|
||||
|
||||
private static boolean isVegetation(BlockState state) {
|
||||
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|
||||
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
|
||||
List<Identifier> ageSymbols) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return false;
|
||||
PlacementCheck check = check(level, chunk.getPos(), seed);
|
||||
if (!check.eligible()) return false;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
|
||||
}
|
||||
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
|
||||
}
|
||||
|
||||
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
|
||||
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
|
||||
long seed, List<Identifier> ageSymbols) {
|
||||
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
|
||||
BlockPos center = playerPos.relative(horizontal, 13);
|
||||
int rotation = switch (horizontal) {
|
||||
case NORTH -> 2;
|
||||
case EAST -> 3;
|
||||
case WEST -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
|
||||
int floorY = playerPos.getY() + 2;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
|
||||
site.x() + x, site.z() + z) + 1);
|
||||
if (floorY + 10 >= level.getMaxY() || floorY - playerPos.getY() > 20)
|
||||
return Optional.empty();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = floorY; y <= floorY + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
|
||||
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
|
||||
return Optional.empty();
|
||||
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
|
||||
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
|
||||
}
|
||||
|
||||
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
|
||||
long seed, List<Identifier> ageSymbols) {
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
var template = level.getLevel().getStructureTemplateManager().get(TEMPLATE).orElse(null);
|
||||
if (template == null || template.getSize().getX() != FOOTPRINT
|
||||
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
|
||||
return false;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
|
||||
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
|
||||
}
|
||||
Rotation rotation = switch (site.rotation()) {
|
||||
case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180;
|
||||
case 3 -> Rotation.COUNTERCLOCKWISE_90;
|
||||
default -> Rotation.NONE;
|
||||
};
|
||||
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
|
||||
var settings = new StructurePlaceSettings().setRotation(rotation)
|
||||
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
|
||||
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).pack();
|
||||
if (!template.placeInWorld(level, origin, origin, settings,
|
||||
RandomSource.create(seed ^ positionSalt), 2)) return false;
|
||||
|
||||
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
|
||||
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
|
||||
Direction.from2DDataValue(site.rotation())), 2);
|
||||
PortConfig.LibraryLootMode mode = PortConfig.LIBRARY_LOOT_MODE.get();
|
||||
boolean ageOnly = mode == PortConfig.LibraryLootMode.AGE_ONLY;
|
||||
List<Identifier> symbols = symbolsForLoot(ageSymbols, mode);
|
||||
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
|
||||
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
if (ageOnly) {
|
||||
// Pages are frozen in the Age generator, then saved as real chest items.
|
||||
boolean[] occupied = new boolean[chest.getContainerSize()];
|
||||
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
|
||||
int slot;
|
||||
do slot = random.nextInt(occupied.length); while (occupied[slot]);
|
||||
occupied[slot] = true;
|
||||
chest.setItem(slot, page(symbols, random));
|
||||
}
|
||||
} else {
|
||||
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
|
||||
}
|
||||
}
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
|
||||
if (!level.getBlockState(pos).isAir()) continue;
|
||||
level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
|
||||
.setValue(BookStandBlock.HAS_BOOK, true), 2);
|
||||
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
|
||||
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Direction standFacing(Site site, int clockwiseTurns) {
|
||||
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
|
||||
}
|
||||
|
||||
/** Correct older generated stands in place, retaining their block entities and pages. */
|
||||
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
|
||||
int changed = 0;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (!state.is(Rivencraft.BOOK_STAND.get())) continue;
|
||||
Direction facing = standFacing(site, standCoords[3]);
|
||||
if (state.getValue(BookStandBlock.FACING) != facing) {
|
||||
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
|
||||
return mode == PortConfig.LibraryLootMode.AGE_ONLY
|
||||
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
|
||||
: PortSymbolPages.sealedCandidateIds();
|
||||
}
|
||||
|
||||
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
|
||||
return PageItem.createSymbolPage(symbols.get(random.nextInt(symbols.size())));
|
||||
}
|
||||
|
||||
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
|
||||
return switch (site.rotation()) {
|
||||
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
|
||||
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
|
||||
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
|
||||
default -> cursor.set(site.x() + x, y, site.z() + z);
|
||||
};
|
||||
}
|
||||
|
||||
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
|
||||
int x, int y, int z, BlockState state) {
|
||||
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Written pages for library Age-only loot, kept outside the legacy backend index codec. */
|
||||
final class PortLibraryWriting extends SavedData {
|
||||
static final Codec<PortLibraryWriting> CODEC = Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC.listOf())
|
||||
.fieldOf("ages").codec().xmap(PortLibraryWriting::new, data -> Map.copyOf(data.pages));
|
||||
static final SavedDataType<PortLibraryWriting> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "library_writing"), () -> new PortLibraryWriting(Map.of()), CODEC);
|
||||
private final Map<Identifier, List<Identifier>> pages = new HashMap<>();
|
||||
private PortLibraryWriting(Map<Identifier, List<Identifier>> pages) { pages.forEach((id, list) -> this.pages.put(id, List.copyOf(list))); }
|
||||
static PortLibraryWriting get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); }
|
||||
List<Identifier> symbols(Identifier id) { return pages.getOrDefault(id, List.of()); }
|
||||
void put(Identifier id, List<Identifier> symbols) {
|
||||
if (symbols.isEmpty()) pages.remove(id); else pages.put(id, List.copyOf(symbols));
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
/** Disposable-world menu, ownership, codec and chest-persistence fixtures. */
|
||||
final class PortNotebookProbe {
|
||||
private static final BlockPos CHEST = new BlockPos(0, 64, 0);
|
||||
private PortNotebookProbe() {}
|
||||
|
||||
static void create(ServerLevel level) {
|
||||
PortAgeTravel.findLanding(level);
|
||||
level.setBlock(CHEST, Blocks.CHEST.defaultBlockState(), 3);
|
||||
Container chest = (Container) level.getBlockEntity(CHEST);
|
||||
var player = net.neoforged.neoforge.common.util.FakePlayerFactory.get(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("1043b9f3-f348-4a73-b585-e0de13fd5c27"), "RivenNotebookProbe"));
|
||||
int output = 0;
|
||||
try {
|
||||
for (boolean folder : List.of(false, true)) for (var hand : InteractionHand.values()) {
|
||||
player.getInventory().clearContent();
|
||||
player.getInventory().setSelectedSlot(0);
|
||||
ItemStack collection = new ItemStack(folder ? Rivencraft.COLLATION_FOLDER.get() : Rivencraft.NOTEBOOK.get());
|
||||
player.setItemInHand(hand, collection);
|
||||
var menu = new NotebookMenu(1, player.getInventory(), hand);
|
||||
int capacity = CollationFolderItem.capacity(collection);
|
||||
if (!menu.stillValid(player) || menu.pageCapacity() != capacity
|
||||
|| menu.getSlot(0).mayPlace(PageItem.createLinkPanelPage())
|
||||
|| menu.getSlot(0).mayPlace(new ItemStack(net.minecraft.world.item.Items.DIAMOND)))
|
||||
throw new IllegalStateException("Notebook capacity/page acceptance regression");
|
||||
ItemStack page = PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", "terrain/flat"));
|
||||
player.getInventory().setItem(9, page.copyWithCount(3));
|
||||
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS, 0, ContainerInput.QUICK_MOVE, player);
|
||||
if (!player.getInventory().getItem(9).isEmpty() || contents(collection).pageCount() != 3)
|
||||
throw new IllegalStateException("Notebook shift insertion regression");
|
||||
for (int i = 0; i < 3; i++) menu.clicked(i, 0, ContainerInput.QUICK_MOVE, player);
|
||||
if (contents(collection).pageCount() != 0) throw new IllegalStateException("Notebook shift extraction regression");
|
||||
menu.getSlot(capacity - 1).set(page.copy());
|
||||
if (contents(collection).pageCount() != 1 || !contents(collection).pages().get(0).isEmpty()
|
||||
|| contents(collection).pages().get(capacity - 1).isEmpty())
|
||||
throw new IllegalStateException("Notebook sparse position regression");
|
||||
if (!menu.clickMenuButton(player, NotebookMenu.MOVE_PREVIOUS_BASE + capacity - 1)
|
||||
|| !contents(collection).pages().get(capacity - 1).isEmpty())
|
||||
throw new IllegalStateException("Notebook page movement regression");
|
||||
if (folder && menu.getSlot(capacity).mayPlace(page))
|
||||
throw new IllegalStateException("Folder exceeded 27 positions");
|
||||
menu.clicked(NotebookMenu.NOTEBOOK_SLOTS + 27, 0, ContainerInput.PICKUP, player);
|
||||
if (player.getItemInHand(hand) != collection || !menu.getCarried().isEmpty())
|
||||
throw new IllegalStateException("Notebook active item was movable");
|
||||
var ops = RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, level.registryAccess());
|
||||
var encoded = NotebookContents.CODEC.encodeStart(ops, contents(collection)).getOrThrow();
|
||||
var decoded = NotebookContents.CODEC.parse(ops, encoded).getOrThrow();
|
||||
var buffer = new RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(), level.registryAccess());
|
||||
try {
|
||||
NotebookContents.STREAM_CODEC.encode(buffer, decoded);
|
||||
decoded = NotebookContents.STREAM_CODEC.decode(buffer);
|
||||
if (decoded.pageCount() != 1 || decoded.pages().get(capacity - 2).isEmpty())
|
||||
throw new IllegalStateException("Notebook save/network codec regression");
|
||||
} finally { buffer.release(); }
|
||||
chest.setItem(output++, collection.copy());
|
||||
player.setItemInHand(hand, ItemStack.EMPTY);
|
||||
}
|
||||
} finally { player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
|
||||
private static NotebookContents contents(ItemStack stack) {
|
||||
return stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||
}
|
||||
|
||||
static void verify(ServerLevel level) {
|
||||
if (!(level.getBlockEntity(CHEST) instanceof Container chest)) throw new IllegalStateException("Fixture chest missing");
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var collection = chest.getItem(i);
|
||||
int capacity = i < 2 ? 252 : 27;
|
||||
if (CollationFolderItem.capacity(collection) != capacity || contents(collection).pageCount() != 1
|
||||
|| contents(collection).pages().get(capacity - 2).isEmpty())
|
||||
throw new IllegalStateException("Notebook chest persistence regression: " + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.neoforged.neoforge.common.util.FakePlayer;
|
||||
|
||||
/** FakePlayer normally discards all advancement progress; fixtures need vanilla tracking. */
|
||||
final class PortProgressionPlayer extends FakePlayer {
|
||||
private PlayerAdvancements progress;
|
||||
|
||||
PortProgressionPlayer(ServerLevel level, GameProfile profile) { super(level, profile); }
|
||||
|
||||
@Override public PlayerAdvancements getAdvancements() {
|
||||
if (progress == null) {
|
||||
var server = level().getServer();
|
||||
progress = new PlayerAdvancements(DataFixers.getDataFixer(), server.getPlayerList(),
|
||||
server.getAdvancements(), server.getWorldPath(LevelResource.PLAYER_ADVANCEMENTS_DIR)
|
||||
.resolve(getUUID() + ".json"), this);
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Disposable operator fixture for loaded advancement data and committed action awards. */
|
||||
final class PortProgressionProbe {
|
||||
/** Operator-fixture assertion; deliberately requires the real loaded advancement. */
|
||||
static boolean isComplete(net.minecraft.server.level.ServerPlayer player, String path) {
|
||||
var advancement = player.level().getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||
if (advancement == null) throw new IllegalStateException("Missing progression advancement: " + path);
|
||||
return player.getAdvancements().getOrStartProgress(advancement).isDone();
|
||||
}
|
||||
|
||||
|
||||
static void recipeDiscovery(ServerLevel level) {
|
||||
var materials = java.util.Map.of(
|
||||
"ink_vial", Items.DYE.black(),
|
||||
"ink_mixer", Items.GLASS_BOTTLE,
|
||||
"book_binder", Items.IRON_INGOT,
|
||||
"crystal_block", Rivencraft.CRYSTAL_SHARD.get(),
|
||||
"notebook", Items.LEATHER,
|
||||
"writing_desk", Items.FEATHER);
|
||||
for (var entry : materials.entrySet()) {
|
||||
var id = net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", entry.getKey());
|
||||
var key = net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE, id);
|
||||
var player = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
|
||||
java.util.UUID.nameUUIDFromBytes(id.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)),
|
||||
"RivenRecipe"));
|
||||
var advancement = level.getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "recipes/misc/" + entry.getKey()));
|
||||
if (advancement == null || level.getServer().getRecipeManager().byKey(key).isEmpty())
|
||||
throw new IllegalStateException("Missing recipe/discovery data: " + id);
|
||||
var progress = player.getAdvancements();
|
||||
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player,
|
||||
player.getInventory(), new ItemStack(Items.COBBLESTONE));
|
||||
if (player.getRecipeBook().contains(key))
|
||||
throw new IllegalStateException("Wrong material unlocked " + id);
|
||||
var material = new ItemStack(entry.getValue());
|
||||
player.getInventory().setItem(0, material);
|
||||
net.minecraft.advancements.triggers.CriteriaTriggers.INVENTORY_CHANGED.trigger(player, player.getInventory(), material);
|
||||
if (!player.getRecipeBook().contains(key) || !progress.getOrStartProgress(advancement).isDone())
|
||||
throw new IllegalStateException("Material did not unlock " + id);
|
||||
// The second criterion supports /recipe give without first obtaining its material.
|
||||
var other = new PortProgressionPlayer(level, new com.mojang.authlib.GameProfile(
|
||||
java.util.UUID.randomUUID(), "RivenRecipeGive"));
|
||||
if (other.getRecipeBook().contains(key)) throw new IllegalStateException("Recipe leaked between players");
|
||||
other.awardRecipes(java.util.List.of(level.getServer().getRecipeManager().byKey(key).orElseThrow()));
|
||||
if (!other.getAdvancements().getOrStartProgress(advancement).isDone())
|
||||
throw new IllegalStateException("Recipe unlock criterion did not complete " + id);
|
||||
}
|
||||
}
|
||||
|
||||
static void run(ServerLevel level) {
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("b428c3ef-57a6-43dc-95e8-441b6a171107"), "RivenProgression"));
|
||||
var pos = new BlockPos(4, 64, 0);
|
||||
player.setPos(4.5, 64, 2.5);
|
||||
level.setBlock(pos, Rivencraft.INK_MIXER.get().defaultBlockState(), 3);
|
||||
var mixer = (InkMixerBlockEntity) level.getBlockEntity(pos);
|
||||
try {
|
||||
// Every tree node must decode; portal advancement waits for its missing item/feature port.
|
||||
for (String path : new String[]{"root", "page", "linking_book", "link_panel", "copy_page", "bind_age", "enter_age"})
|
||||
if (isComplete(player, path))
|
||||
throw new IllegalStateException("Fixture requires fresh player advancement data");
|
||||
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
|
||||
if (isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Empty Mixer awarded progression");
|
||||
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
|
||||
menu.broadcastChanges();
|
||||
if (isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer preview awarded progression");
|
||||
menu.clicked(InkMixerMenu.RESULT, 0, ContainerInput.PICKUP, player);
|
||||
if (!isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer pickup did not award progression");
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
var advancement = level.getServer().getAdvancements().get(
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath("rivencraft", "progression/link_panel"));
|
||||
player.getAdvancements().revoke(advancement, "completed");
|
||||
menu.quickMoveStack(player, InkMixerMenu.RESULT);
|
||||
if (!isComplete(player, "link_panel"))
|
||||
throw new IllegalStateException("Mixer shift pickup did not award progression");
|
||||
ProgressionAdvancements.awardAgeTravel(player);
|
||||
if (!isComplete(player, "enter_age"))
|
||||
throw new IllegalStateException("Registered Age did not award entry");
|
||||
} finally {
|
||||
player.getInventory().clearContent();
|
||||
level.removeBlock(pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,30 +19,18 @@ public final class PortRandomAgeSelection {
|
||||
public static Selection select(MinecraftServer server, long seed) {
|
||||
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
|
||||
// the other terrain families are rarer. The sorted order is deliberate.
|
||||
PortAgeTerrain terrain = weighted(new Random(seed ^ 0x5445525241494E4CL),
|
||||
List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
|
||||
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID),
|
||||
new int[]{1, 1, 5, 1, 6, 1});
|
||||
PortAgeTerrain terrain = terrain(seed);
|
||||
if (terrain == PortAgeTerrain.VOID)
|
||||
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
|
||||
List.of(Identifier.withDefaultNamespace("the_void"))));
|
||||
|
||||
PortBiomePlan.Distribution distribution = weighted(new Random(seed ^ 0x524956454E435241L),
|
||||
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
|
||||
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
|
||||
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
|
||||
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
|
||||
PortBiomePlan.Distribution.HUGE),
|
||||
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
|
||||
PortBiomePlan.Distribution distribution = distribution(seed);
|
||||
List<Identifier> eligible = eligibleBiomes(server, terrain);
|
||||
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
|
||||
distribution = PortBiomePlan.Distribution.SINGLE;
|
||||
// 26.3 has no native multi-cave biome source; choose a seeded Cave
|
||||
// biome rather than silently using Lush Caves for every random Age.
|
||||
if (terrain == PortAgeTerrain.CAVE && distribution == PortBiomePlan.Distribution.NATIVE)
|
||||
distribution = PortBiomePlan.Distribution.SINGLE;
|
||||
if (distribution == PortBiomePlan.Distribution.NATIVE)
|
||||
return new Selection(terrain, new PortBiomePlan(distribution, List.of()));
|
||||
return new Selection(terrain, new PortBiomePlan(distribution,
|
||||
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
|
||||
|
||||
int count = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
|
||||
if (count > 1) {
|
||||
@@ -54,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;
|
||||
}
|
||||
}
|
||||
+69
@@ -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,48 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.PortConfig;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Called only after successful Descriptive Book travel and its Disarm effect. */
|
||||
public final class PortReturnLinks {
|
||||
private PortReturnLinks() {}
|
||||
|
||||
public static void afterSuccessfulTravel(ServerPlayer player, Identifier age, LinkBookTarget departure) {
|
||||
var mode = PortConfig.RETURN_LINK_MODE.get();
|
||||
if (mode == PortConfig.ReturnLinkMode.DISABLED) return;
|
||||
var storage = player.level().getServer().overworld().getDataStorage();
|
||||
boolean issue = shouldIssue(mode, storage.computeIfAbsent(PortAgeVisits.TYPE), age);
|
||||
if (!issue) return;
|
||||
ItemStack book = createBook(departure);
|
||||
var source = player.level().getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, departure.dimension()));
|
||||
if (source != null) book.set(Rivencraft.LINK_DESTINATION_NAME.get(),
|
||||
net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(source));
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
|
||||
// Persist the once-per-Age marker before the next arrival can issue another.
|
||||
if (mode == PortConfig.ReturnLinkMode.ON_CREATION)
|
||||
storage.saveAndJoin();
|
||||
}
|
||||
|
||||
static boolean shouldIssue(PortConfig.ReturnLinkMode mode, PortAgeVisits visits, Identifier age) {
|
||||
return switch (mode) {
|
||||
case DISABLED -> false;
|
||||
case ALWAYS -> true;
|
||||
case ON_CREATION -> visits.markFirstVisit(age);
|
||||
};
|
||||
}
|
||||
|
||||
static ItemStack createBook(LinkBookTarget departure) {
|
||||
ItemStack book = new ItemStack(Rivencraft.LINKING_BOOK.get());
|
||||
book.set(Rivencraft.LINK_BOOK_TARGET.get(), departure);
|
||||
book.set(DataComponents.CUSTOM_NAME, Component.translatable("item.rivencraft.linking_book.return"));
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.WeakHashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.effect.MobEffects;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.monster.Monster;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.entity.EntityTypeTest;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Legacy potion cards adapted to bounded, Age-local server effects. */
|
||||
@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class PortStatusEffectProviders {
|
||||
private static final int MAX_TRACKED_AGES = 64;
|
||||
private static final Map<Identifier, Spec> SPECS = createSpecs();
|
||||
private static final Map<ServerLevel, List<Lease>> LEASES = new WeakHashMap<>();
|
||||
|
||||
private PortStatusEffectProviders() {}
|
||||
|
||||
public static void register() {
|
||||
SPECS.keySet().forEach(id -> PortInstabilityRuntime.register(id, PortStatusEffectProviders::apply));
|
||||
}
|
||||
|
||||
static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) {
|
||||
if (!PortInstabilityConfig.statusEffectsEnabled()
|
||||
|| !PortInstabilityRuntime.isAge(context.level())) return;
|
||||
Spec spec = SPECS.get(id);
|
||||
if (spec == null || spec.target() == Target.HOSTILE && !PortInstabilityConfig.statusEffectsHostiles()
|
||||
|| spec.target() == Target.GENERAL && !PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !PortInstabilityConfig.statusEffectsNonplayers()) return;
|
||||
List<Lease> leases = LEASES.get(context.level());
|
||||
if (leases == null) {
|
||||
if (LEASES.size() >= MAX_TRACKED_AGES) return;
|
||||
leases = new ArrayList<>();
|
||||
LEASES.put(context.level(), leases);
|
||||
}
|
||||
prune(context.level(), leases, context.serverTick(), false);
|
||||
if (leases.size() >= PortInstabilityConfig.statusEffectsMaxActive()) return;
|
||||
|
||||
Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L)
|
||||
^ id.hashCode());
|
||||
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
|
||||
AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinY(), chunk.getMinBlockZ(),
|
||||
chunk.getMaxBlockX() + 1, context.level().getMaxY(), chunk.getMaxBlockZ() + 1);
|
||||
int duration = Math.min(spec.legacyDuration(), PortInstabilityConfig.statusEffectsMaxDuration());
|
||||
int amplifier = Math.max(0, level - 1);
|
||||
MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier);
|
||||
List<LivingEntity> candidates = new ArrayList<>();
|
||||
context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds,
|
||||
entity -> eligible(context.level(), entity, spec, instance), candidates,
|
||||
PortInstabilityConfig.statusEffectsMaxCandidates());
|
||||
if (candidates.isEmpty()) return;
|
||||
candidates.sort(Comparator.comparing(Entity::getUUID));
|
||||
LivingEntity target = candidates.get(random.nextInt(candidates.size()));
|
||||
if (!context.budget().claim() || !target.addEffect(instance)) return;
|
||||
context.budget().recordApplied();
|
||||
leases.add(new Lease(id, new WeakReference<>(target), instance,
|
||||
context.serverTick() + duration, spec.target()));
|
||||
}
|
||||
|
||||
private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec,
|
||||
MobEffectInstance instance) {
|
||||
if (!entity.isAlive() || entity.isRemoved() || entity.level() != level
|
||||
|| entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance)
|
||||
|| !spec.global() && !level.canSeeSky(entity.blockPosition())) return false;
|
||||
return allowedByPolicy(spec.target(), entity);
|
||||
}
|
||||
|
||||
private static boolean allowedByPolicy(Target target, LivingEntity entity) {
|
||||
if (entity instanceof Player player) {
|
||||
return target == Target.GENERAL && PortInstabilityConfig.statusEffectsPlayers()
|
||||
&& !player.isCreative() && !player.isSpectator();
|
||||
}
|
||||
return target == Target.GENERAL ? PortInstabilityConfig.statusEffectsNonplayers()
|
||||
: entity instanceof Monster && PortInstabilityConfig.statusEffectsHostiles();
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
if (event.getServer().getTickCount() % 20 != 0) return;
|
||||
boolean disabled = !PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.statusEffectsEnabled();
|
||||
long tick = event.getServer().getTickCount();
|
||||
for (var entry : LEASES.entrySet()) {
|
||||
prune(entry.getKey(), entry.getValue(), tick, disabled
|
||||
|| !PortInstabilityData.get(entry.getKey().getServer()).age(entry.getKey()).enabled());
|
||||
}
|
||||
LEASES.values().removeIf(List::isEmpty);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onEntityTravel(EntityTravelToDimensionEvent event) {
|
||||
Entity entity = event.getEntity();
|
||||
if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) {
|
||||
clearEntity(living);
|
||||
}
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) clearEntity(player);
|
||||
}
|
||||
|
||||
@net.neoforged.bus.api.SubscribeEvent
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
long tick = event.getServer().getTickCount();
|
||||
for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true);
|
||||
LEASES.clear();
|
||||
}
|
||||
|
||||
public static void clearAge(ServerLevel level) {
|
||||
var leases = LEASES.remove(level);
|
||||
if (leases != null) prune(level, leases, level.getServer().getTickCount(), true);
|
||||
}
|
||||
|
||||
private static void clearEntity(LivingEntity entity) {
|
||||
for (List<Lease> leases : LEASES.values()) {
|
||||
Iterator<Lease> iterator = leases.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Lease lease = iterator.next();
|
||||
if (lease.entity().get() != entity) continue;
|
||||
removeOwnedEffect(entity, lease);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void prune(ServerLevel level, List<Lease> leases, long tick, boolean removeAll) {
|
||||
Iterator<Lease> iterator = leases.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Lease lease = iterator.next();
|
||||
LivingEntity entity = lease.entity().get();
|
||||
if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
boolean revoke = removeAll || !PortInstabilityConfig.instabilityProviderEnabled(lease.provider())
|
||||
|| entity.level() != level
|
||||
|| !allowedByPolicy(lease.target(), entity);
|
||||
if (revoke || tick >= lease.expiresAt()) {
|
||||
removeOwnedEffect(entity, lease);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void removeOwnedEffect(LivingEntity entity, Lease lease) {
|
||||
// addEffect stores the supplied instance when no effect exists. Do not remove
|
||||
// a replacement from another mod (or a stronger/extended effect).
|
||||
if (entity.getEffect(lease.instance().getEffect()) == lease.instance()
|
||||
&& lease.instance().getAmplifier() == lease.amplifier()
|
||||
&& lease.instance().getDuration() <= lease.originalDuration()) {
|
||||
entity.removeEffect(lease.instance().getEffect());
|
||||
}
|
||||
}
|
||||
|
||||
static int legacyDuration(Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec == null ? -1 : spec.legacyDuration();
|
||||
}
|
||||
|
||||
static boolean localRequiresSky(Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec != null && !spec.global();
|
||||
}
|
||||
|
||||
static boolean eligibleTarget(ServerLevel level, LivingEntity entity, Identifier id) {
|
||||
Spec spec = SPECS.get(id);
|
||||
return spec != null && eligible(level, entity, spec,
|
||||
new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0));
|
||||
}
|
||||
|
||||
private static Map<Identifier, Spec> createSpecs() {
|
||||
Map<Identifier, Spec> specs = new HashMap<>();
|
||||
addPair(specs, "blindness", MobEffects.BLINDNESS, 60);
|
||||
addPair(specs, "fatigue", MobEffects.MINING_FATIGUE, 80);
|
||||
addPair(specs, "hunger", MobEffects.HUNGER, 80);
|
||||
addPair(specs, "nausea", MobEffects.NAUSEA, 60);
|
||||
addPair(specs, "poison", MobEffects.POISON, 80);
|
||||
addPair(specs, "slow", MobEffects.SLOWNESS, 80);
|
||||
addPair(specs, "weakness", MobEffects.WEAKNESS, 80);
|
||||
addPair(specs, "wither", MobEffects.WITHER, 30);
|
||||
specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE));
|
||||
specs.put(id("enemyresist_global"), new Spec(MobEffects.RESISTANCE, 200, true, Target.HOSTILE));
|
||||
return Map.copyOf(specs);
|
||||
}
|
||||
|
||||
private static void addPair(Map<Identifier, Spec> specs, String name,
|
||||
Holder<MobEffect> effect, int duration) {
|
||||
specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL));
|
||||
specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL));
|
||||
}
|
||||
|
||||
private static Identifier id(String path) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
private enum Target { GENERAL, HOSTILE }
|
||||
private record Spec(Holder<MobEffect> effect, int legacyDuration, boolean global, Target target) {}
|
||||
private record Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target, int amplifier, int originalDuration) {
|
||||
private Lease(Identifier provider, WeakReference<LivingEntity> entity, MobEffectInstance instance, long expiresAt,
|
||||
Target target) {
|
||||
this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,100 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.WritingDeskBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Disposable server fixture for copying transactions, page moves and two-part removal. */
|
||||
final class PortWritingDeskProbe {
|
||||
private static final BlockPos POS = new BlockPos(0, 64, 0);
|
||||
private PortWritingDeskProbe() {}
|
||||
private static ItemStack page(String path) { return PageItem.createSymbolPage(Identifier.fromNamespaceAndPath("rivencraft", path)); }
|
||||
|
||||
static void create(ServerLevel level) {
|
||||
PortAgeTravel.findLanding(level);
|
||||
var player = new PortProgressionPlayer(level,
|
||||
new com.mojang.authlib.GameProfile(java.util.UUID.fromString("f92ad9ee-1ff0-4fe2-bcd3-994d4c035933"), "RivenDeskProbe"));
|
||||
player.setPos(0.5, 64, 2.5);
|
||||
// New Minecraft drops Container block-entity contents during removal.
|
||||
// Removing either half must remove its partner and drop contents once.
|
||||
for (Direction facing : Direction.Plane.HORIZONTAL) for (boolean extension : List.of(false, true)) {
|
||||
var state = Rivencraft.WRITING_DESK.get().defaultBlockState().setValue(WritingDeskBlock.FACING, facing);
|
||||
level.setBlock(POS, state, 3);
|
||||
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
|
||||
BlockPos other = POS.relative(facing.getClockWise());
|
||||
if (!level.getBlockState(other).getValue(WritingDeskBlock.EXTENSION))
|
||||
throw new IllegalStateException("Desk extension not placed");
|
||||
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
|
||||
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 3));
|
||||
level.destroyBlock(extension ? other : POS, true);
|
||||
if (level.getBlockState(POS).is(Rivencraft.WRITING_DESK.get()) || level.getBlockState(other).is(Rivencraft.WRITING_DESK.get()))
|
||||
throw new IllegalStateException("Desk partner survived removal");
|
||||
var drops = level.getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,
|
||||
new net.minecraft.world.phys.AABB(-3, 60, -3, 4, 69, 4));
|
||||
int paper = drops.stream().filter(e -> e.getItem().is(Items.PAPER)).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
int desks = drops.stream().filter(e -> e.getItem().is(Rivencraft.WRITING_DESK_ITEM.get())).mapToInt(e -> e.getItem().getCount()).sum();
|
||||
if (paper != 3 || desks != 1) throw new IllegalStateException("Desk removal duplicated/lost drops: " + paper + "/" + desks);
|
||||
drops.forEach(net.minecraft.world.entity.Entity::discard);
|
||||
}
|
||||
var state = Rivencraft.WRITING_DESK.get().defaultBlockState();
|
||||
level.setBlock(POS, state, 3);
|
||||
Rivencraft.WRITING_DESK.get().setPlacedBy(level, POS, state, player, new ItemStack(Rivencraft.WRITING_DESK_ITEM.get()));
|
||||
var desk = (WritingDeskBlockEntity) level.getBlockEntity(POS);
|
||||
try {
|
||||
if (desk.canPlaceItem(0, PageItem.createLinkPanelPage())) throw new IllegalStateException("Desk accepted Link Panel");
|
||||
desk.setItem(0, page("terrain/flat"));
|
||||
var folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
|
||||
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(List.of(page("weather/off"))));
|
||||
desk.setItem(1, folder);
|
||||
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER, 2));
|
||||
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(Rivencraft.INK_VIAL.get()));
|
||||
if (desk.inkRemaining() != 16 || !desk.getItem(WritingDeskBlockEntity.RETURN).is(Items.GLASS_BOTTLE))
|
||||
throw new IllegalStateException("Desk ink exchange regression");
|
||||
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||
menu.broadcastChanges();
|
||||
if (menu.getSlot(WritingDeskMenu.COPY_RESULT).getItem().isEmpty() || desk.inkRemaining() != 16)
|
||||
throw new IllegalStateException("Desk virtual result consumed inputs");
|
||||
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ContainerInput.PICKUP, player);
|
||||
if (menu.getCarried().isEmpty() || desk.inkRemaining() != 15
|
||||
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(0).isEmpty())
|
||||
throw new IllegalStateException("Desk copy did not consume exactly one paper/ink");
|
||||
if (!PortProgressionProbe.isComplete(player, "copy_page"))
|
||||
throw new IllegalStateException("Missing copy_page award");
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++)
|
||||
player.getInventory().setItem(slot, new ItemStack(Items.COBBLESTONE, 64));
|
||||
if (!menu.quickMoveStack(player, WritingDeskMenu.COPY_RESULT).isEmpty() || desk.inkRemaining() != 15)
|
||||
throw new IllegalStateException("Full inventory consumed copy inputs");
|
||||
player.getInventory().clearContent();
|
||||
if (!menu.clickMenuButton(player, WritingDeskMenu.movePageButtonId(1, 0, 1, 26)))
|
||||
throw new IllegalStateException("Desk sparse page movement failed");
|
||||
if (!menu.clickMenuButton(player, WritingDeskMenu.extractPageButtonId(1, 26, -1)) || menu.getCarried().isEmpty())
|
||||
throw new IllegalStateException("Desk page extraction failed");
|
||||
menu.setCarried(ItemStack.EMPTY);
|
||||
for (int i = 0; i < 21; i++) menu.clickMenuButton(player, WritingDeskMenu.NEXT_TABS);
|
||||
if (menu.firstVisibleTab() != 84 || !menu.clickMenuButton(player, 87)) throw new IllegalStateException("88 desk tabs not reachable");
|
||||
desk.setItem(87, page("lighting/bright"));
|
||||
menu.clickMenuButton(player, WritingDeskMenu.PAGE_BUTTON_BASE);
|
||||
menu.broadcastChanges();
|
||||
} finally { player.getInventory().clearContent(); }
|
||||
verify(level);
|
||||
}
|
||||
|
||||
static void verify(ServerLevel level) {
|
||||
if (!(level.getBlockEntity(POS) instanceof WritingDeskBlockEntity desk)
|
||||
|| desk.activeCollection() != 87 || desk.inkRemaining() != 15
|
||||
|| desk.getItem(WritingDeskBlockEntity.PAPER).getCount() != 1 || desk.getItem(87).isEmpty()
|
||||
|| desk.displayPage().isEmpty()) throw new IllegalStateException("Desk persistence regression");
|
||||
}
|
||||
}
|
||||
@@ -149,14 +149,26 @@ public final class RuntimeAgeBackend {
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, lighting, sky, colors, dayCycle, List.of());
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
|
||||
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
|
||||
List<Identifier> librarySymbols) {
|
||||
checkServerThread(server);
|
||||
ageName(id);
|
||||
AgeLevelIndex index = index(server);
|
||||
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
|
||||
PortLibraryWriting.get(server).put(id, librarySymbols);
|
||||
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
((PortAgeLevel) level).configureWeather(weather);
|
||||
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle);
|
||||
// A newly bound book may be used immediately. Persist its Age identity
|
||||
// before publishing the catalog so a later crash cannot leave that
|
||||
// book pointing at a world omitted from the on-disk index.
|
||||
@@ -270,6 +282,18 @@ public final class RuntimeAgeBackend {
|
||||
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
||||
}
|
||||
|
||||
/** Display metadata never changes the dimension identity, seed or filesystem path. */
|
||||
public static java.util.Optional<String> savedAgeName(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
return java.util.Optional.ofNullable(index(server).displayName(id));
|
||||
}
|
||||
|
||||
public static void setAgeDisplayName(MinecraftServer server, Identifier id, String name) {
|
||||
checkServerThread(server);
|
||||
index(server).setDisplayName(id, name);
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
}
|
||||
|
||||
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
return index(server).entries();
|
||||
@@ -366,15 +390,14 @@ public final class RuntimeAgeBackend {
|
||||
}
|
||||
|
||||
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
|
||||
if (plan.distribution() == PortBiomePlan.Distribution.NATIVE)
|
||||
throw new IllegalArgumentException("Native biome distribution has no authored source");
|
||||
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
List<Holder<Biome>> biomes = plan.biomes().stream().map(id ->
|
||||
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, id))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + id)))
|
||||
.toList();
|
||||
return switch (plan.distribution()) {
|
||||
case NATIVE -> throw new IllegalStateException("Native is not an override");
|
||||
case NATIVE -> biomes.size() == 1 ? new FixedBiomeSource(biomes.getFirst())
|
||||
: new PortScaledBiomeSource(HolderSet.direct(biomes), seed, 0);
|
||||
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
|
||||
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
|
||||
plan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
|
||||
@@ -399,9 +422,12 @@ public final class RuntimeAgeBackend {
|
||||
PortAgeColors colors, PortDayCycle dayCycle) {
|
||||
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
|
||||
if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id);
|
||||
if (biomePlan != null && biomePlan.distribution() == PortBiomePlan.Distribution.NATIVE
|
||||
&& !biomePlan.biomes().isEmpty() && terrain != PortAgeTerrain.CAVE)
|
||||
throw new IllegalArgumentException("Only Native Cave terrain may carry an explicit native biome pool");
|
||||
|
||||
// Noise terrains reuse the Overworld biome source unless a
|
||||
// non-Native authored distribution replaces it.
|
||||
// non-Native authored distribution or a frozen Native Cave pool replaces it.
|
||||
// LevelStem.seedOverride controls chunk random state and getSeed().
|
||||
LevelStem overworldStem = server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM)
|
||||
.getValue(LevelStem.OVERWORLD);
|
||||
@@ -453,7 +479,8 @@ public final class RuntimeAgeBackend {
|
||||
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
|
||||
}
|
||||
}
|
||||
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
|
||||
if (biomePlan != null && (biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE
|
||||
|| terrain == PortAgeTerrain.CAVE && !biomePlan.biomes().isEmpty())) {
|
||||
source = authoredBiomeSource(server, biomePlan, seed);
|
||||
}
|
||||
var settings = switch (terrain) {
|
||||
@@ -465,10 +492,10 @@ 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);
|
||||
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed,
|
||||
terrain != PortAgeTerrain.CAVE, PortLibraryWriting.get(server).symbols(id));
|
||||
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
|
||||
generator, OptionalLong.of(seed));
|
||||
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.PortalBookTravel;
|
||||
|
||||
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityTicker;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
|
||||
/** A book-holding crystal attachment, with server-authoritative frame activation. */
|
||||
public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.FACING;
|
||||
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
|
||||
|
||||
public BookReceptacleBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
|
||||
}
|
||||
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos,
|
||||
CollisionContext context) {
|
||||
return switch (state.getValue(FACING)) {
|
||||
case NORTH -> Block.box(0, 0, 10, 16, 16, 16);
|
||||
case SOUTH -> Block.box(0, 0, 0, 16, 16, 6);
|
||||
case EAST -> Block.box(0, 0, 0, 6, 16, 16);
|
||||
case WEST -> Block.box(10, 0, 0, 16, 16, 16);
|
||||
case UP -> Block.box(0, 0, 0, 16, 6, 16);
|
||||
case DOWN -> Block.box(0, 10, 0, 16, 16, 16);
|
||||
};
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(FACING, HAS_BOOK);
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new BookReceptacleBlockEntity(pos, state);
|
||||
}
|
||||
@Override public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state,
|
||||
BlockEntityType<T> type) {
|
||||
return level instanceof ServerLevel
|
||||
? createTickerHelper(type, Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), (world, pos, current, holder) -> BookReceptacleBlockEntity.serverTick((ServerLevel) world, pos, holder))
|
||||
: null;
|
||||
}
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
Direction face = context.getClickedFace();
|
||||
if (!context.getLevel().getBlockState(
|
||||
context.getClickedPos().relative(face.getOpposite())).is(Rivencraft.CRYSTAL_BLOCK.get())) return null;
|
||||
return defaultBlockState().setValue(FACING, face);
|
||||
}
|
||||
|
||||
@Override protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos,
|
||||
Player player, InteractionHand hand, BlockHitResult hit) {
|
||||
if (!isBoundBook(stack)) return InteractionResult.TRY_WITH_EMPTY_HAND;
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (!(level instanceof ServerLevel server) ||
|
||||
!(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return InteractionResult.FAIL;
|
||||
if (!holder.getBook().isEmpty()) return InteractionResult.FAIL;
|
||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos);
|
||||
if (!plan.valid()) {
|
||||
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.portal.invalid_frame"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
if (!PortalBookTravel.available(server, stack)) {
|
||||
if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"));
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
holder.setBook(stack.copyWithCount(1));
|
||||
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
|
||||
holder.takeBook();
|
||||
return InteractionResult.FAIL;
|
||||
}
|
||||
if (!player.getAbilities().instabuild) stack.shrink(1);
|
||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder
|
||||
&& !holder.getBook().isEmpty()) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
ItemStack book = holder.takeBook();
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, net.minecraft.util.Prediction.SERVER_ONLY);
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
return InteractionResult.PASS;
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
|
||||
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
|
||||
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
|
||||
if (!level.isClientSide() && level.hasChunkAt(support) && !level.getBlockState(support)
|
||||
.is(Rivencraft.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
|
||||
}
|
||||
|
||||
private static boolean isBoundBook(ItemStack stack) {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) && stack.has(Rivencraft.LINK_BOOK_TARGET.get())
|
||||
|| stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && stack.has(Rivencraft.AGE_BOOK_ID.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
/** Vanilla lectern geometry with Rivencraft storage instead of a vanilla book menu. */
|
||||
public final class BookStandBlock extends LecternBlock {
|
||||
public BookStandBlock(Properties properties) { super(properties); }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { return new BookStandBlockEntity(pos, state); }
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
|
||||
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return InteractionResult.PASS;
|
||||
if (player.isShiftKeyDown() && !level.isClientSide()) {
|
||||
var book = stand.takeBook();
|
||||
level.setBlock(pos, Blocks.LECTERN.defaultBlockState().setValue(FACING, state.getValue(FACING)), 3);
|
||||
if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1);
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
@Override protected net.minecraft.world.MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) { return null; }
|
||||
@Override protected int getAnalogOutputSignal(BlockState state, Level level, BlockPos pos, net.minecraft.core.Direction direction) { return 0; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.LevelReader;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Solid crystal-textured arrival footing that cannot be collected. */
|
||||
public final class CloudPlatformBlock extends Block {
|
||||
public CloudPlatformBlock(Properties properties) { super(properties); }
|
||||
|
||||
@Override
|
||||
protected ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state, boolean includeData) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.level.material.Fluid;
|
||||
|
||||
/** Non-solid linked surface; all travel is delegated to the held book's rules. */
|
||||
public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
// Retained so portal blocks already saved with this state still deserialize.
|
||||
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
|
||||
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
|
||||
|
||||
public CrystalPortalBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
|
||||
}
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(LIVE_VIEW, AXIS);
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||
// Keep the animated fallback until the optional destination view has
|
||||
// actually been established, including after a world restart.
|
||||
return RenderShape.MODEL;
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new CrystalPortalBlockEntity(pos, state);
|
||||
}
|
||||
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
||||
|
||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, net.minecraft.world.entity.InsideBlockEffectApplier effects, boolean precise) {
|
||||
if (!(level instanceof ServerLevel server) || !entity.canUsePortal(false)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
if (PortalTravelCooldown.blocked(entity, now)) return;
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||
ItemStack book = holder.getBook();
|
||||
if (book.isEmpty()) return;
|
||||
PortalTravelCooldown.mark(entity, now);
|
||||
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
net.minecraft.world.level.redstone.Orientation orientation, boolean moved) {
|
||||
super.neighborChanged(state, level, pos, neighbor, orientation, moved);
|
||||
if (!(level instanceof ServerLevel server)
|
||||
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null) return;
|
||||
// Like a vanilla portal, validate when the frame changes, not on a
|
||||
// repeating timer that can race block entities loading after restart.
|
||||
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
|
||||
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
|
||||
}
|
||||
|
||||
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||
// Ignore validation ticks saved by older builds. Frame edits are
|
||||
// handled by neighborChanged, and the receptacle maintains the view.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.age.PortInstabilityRuntime;
|
||||
import net.dnorberg.rivencraft.age.PortInstabilityConfig;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.StringRepresentable;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
|
||||
/** The four colored decay variants selected by the current instability deck. */
|
||||
public final class DecayBlock extends Block {
|
||||
public static final EnumProperty<Color> COLOR = EnumProperty.create("color", Color.class);
|
||||
|
||||
public DecayBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||
builder.add(COLOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) {
|
||||
if (level instanceof net.minecraft.server.level.ServerLevel server && state.getValue(COLOR) == Color.WHITE
|
||||
&& PortInstabilityConfig.decayEnabled() && PortInstabilityConfig.instabilityWorldMutation()
|
||||
&& PortInstabilityRuntime.isAge(server)) {
|
||||
entity.hurtServer(server, level.damageSources().magic(), 1.0F);
|
||||
}
|
||||
super.stepOn(level, pos, state, entity);
|
||||
}
|
||||
|
||||
public enum Color implements StringRepresentable {
|
||||
BLUE, PURPLE, RED, WHITE;
|
||||
|
||||
@Override
|
||||
public String getSerializedName() {
|
||||
return name().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||
import net.dnorberg.rivencraft.writing.WritingDeskCatalog;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.Containers;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.SimpleMenuProvider;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
public final class WritingDeskBlock extends BaseEntityBlock {
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||
public static final BooleanProperty EXTENSION = BooleanProperty.create("extension");
|
||||
|
||||
public WritingDeskBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(EXTENSION, false));
|
||||
}
|
||||
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
|
||||
builder.add(FACING, EXTENSION);
|
||||
}
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
Direction facing = context.getHorizontalDirection().getOpposite();
|
||||
BlockPos extension = context.getClickedPos().relative(facing.getClockWise());
|
||||
return context.getLevel().getBlockState(extension).canBeReplaced(context)
|
||||
? defaultBlockState().setValue(FACING, facing) : null;
|
||||
}
|
||||
@Override public void setPlacedBy(Level level, BlockPos pos, BlockState state,
|
||||
@Nullable LivingEntity placer, ItemStack stack) {
|
||||
super.setPlacedBy(level, pos, state, placer, stack);
|
||||
level.setBlock(pos.relative(state.getValue(FACING).getClockWise()),
|
||||
state.setValue(EXTENSION, true), 3);
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return state.getValue(EXTENSION) ? null : new WritingDeskBlockEntity(pos, state);
|
||||
}
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
BlockPos controller = state.getValue(EXTENSION)
|
||||
? pos.relative(state.getValue(FACING).getCounterClockWise()) : pos;
|
||||
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
|
||||
&& level.getBlockEntity(controller) instanceof WritingDeskBlockEntity desk) {
|
||||
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||
(id, inventory, ignored) -> new WritingDeskMenu(id, inventory, desk),
|
||||
Component.translatable("container.rivencraft.writing_desk")),
|
||||
buffer -> {
|
||||
buffer.writeBlockPos(controller);
|
||||
WritingDeskCatalog.write(buffer, WritingDeskCatalog.available(desk));
|
||||
});
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
@Override protected void affectNeighborsAfterRemoval(BlockState state, net.minecraft.server.level.ServerLevel level,
|
||||
BlockPos pos, boolean movedByPiston) {
|
||||
BlockPos other = pos.relative(state.getValue(FACING).getClockWise(), state.getValue(EXTENSION) ? -1 : 1);
|
||||
BlockState otherState = level.getBlockState(other);
|
||||
if (otherState.is(this) && otherState.getValue(EXTENSION) != state.getValue(EXTENSION)
|
||||
&& otherState.getValue(FACING) == state.getValue(FACING))
|
||||
level.setBlock(other, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), 35);
|
||||
Containers.updateNeighboursAfterDestroy(state, level, pos);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One persisted book owns the whole crystal network. Portal cells retain only its position. */
|
||||
public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
private boolean restore = true;
|
||||
private int skyColor = 0xffffff;
|
||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
public ItemStack getBook() { return book; }
|
||||
public int skyColor() { return skyColor; }
|
||||
public void setBook(ItemStack value) {
|
||||
book = value.copyWithCount(1);
|
||||
skyColor = 0xffffff;
|
||||
if (level instanceof ServerLevel source) {
|
||||
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get())
|
||||
: destination == null ? null : destination.dimension();
|
||||
var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.DIMENSION, id));
|
||||
if (target != null) {
|
||||
var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target)
|
||||
: BlockPos.containing(destination.x(), destination.y(), destination.z());
|
||||
if (pos != null) {
|
||||
int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky();
|
||||
String type = target.dimensionTypeRegistration().unwrapKey()
|
||||
.map(key -> key.identifier().getPath()).orElse("");
|
||||
skyColor = written >= 0 ? written
|
||||
: target.dimension() == net.minecraft.world.level.Level.NETHER || type.endsWith("_sky_nether")
|
||||
? 0x6b2725
|
||||
: target.dimension() == net.minecraft.world.level.Level.END || type.endsWith("_sky_end")
|
||||
? 0x211835
|
||||
: net.minecraft.util.ARGB.colorFromVector3f(target.environmentAttributes().getValue(
|
||||
net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos)) & 0xffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
sync();
|
||||
}
|
||||
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; }
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) {
|
||||
BlockState current = level.getBlockState(worldPosition);
|
||||
if (current.is(Rivencraft.BOOK_RECEPTACLE.get())) {
|
||||
level.setBlock(worldPosition, current.setValue(BookReceptacleBlock.HAS_BOOK, !book.isEmpty()), 2);
|
||||
level.sendBlockUpdated(worldPosition, current, level.getBlockState(worldPosition), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void serverTick(ServerLevel level, BlockPos pos, BookReceptacleBlockEntity holder) {
|
||||
if (holder.book.isEmpty()) { holder.restore = false; return; }
|
||||
int interval = holder.restore ? 20 : 200;
|
||||
if (level.getGameTime() % interval != Math.floorMod(pos.asLong(), interval)) return;
|
||||
var plan = CrystalPortalNetwork.plan(level, pos);
|
||||
if (plan.waitingForChunks()) return;
|
||||
boolean restoring = holder.restore;
|
||||
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
|
||||
plan = CrystalPortalNetwork.plan(level, pos);
|
||||
CrystalPortalNetwork.reconcile(level, pos, plan);
|
||||
holder.restore = false;
|
||||
if (restoring) holder.sync();
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
|
||||
skyColor = input.getIntOr("SkyColor", 0xffffff);
|
||||
restore = true;
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
|
||||
output.putInt("SkyColor", skyColor);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
|
||||
if (level instanceof ServerLevel server) {
|
||||
CrystalPortalNetwork.shutdown(server, pos);
|
||||
ItemStack removed = book;
|
||||
book = ItemStack.EMPTY;
|
||||
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, removed);
|
||||
}
|
||||
super.preRemoveSideEffects(pos, oldState);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One stored book or page, synchronized independently of the player's inventory. */
|
||||
public final class BookStandBlockEntity extends BlockEntity {
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
public BookStandBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), pos, state); }
|
||||
public ItemStack getBook() { return book; }
|
||||
public void setBook(ItemStack stack) { book = stack; sync(); }
|
||||
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; sync(); return result; }
|
||||
private void sync() {
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY);
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
@Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) {
|
||||
if (level != null && !level.isClientSide())
|
||||
net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5, takeBook());
|
||||
super.preRemoveSideEffects(pos, oldState);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
|
||||
/** One stored book or page, synchronized independently of the player's inventory. */
|
||||
public final class CrystalPortalBlockEntity extends BlockEntity {
|
||||
private BlockPos receptacle;
|
||||
public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.CRYSTAL_PORTAL_BLOCK_ENTITY.get(), pos, state); }
|
||||
public BlockPos receptacle() { return receptacle; }
|
||||
public void setReceptacle(BlockPos pos) {
|
||||
receptacle = pos.immutable();
|
||||
setChanged();
|
||||
if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
receptacle = input.read("Receptacle", BlockPos.CODEC).orElse(null);
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
if (receptacle != null) output.store("Receptacle", BlockPos.CODEC, receptacle);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); }
|
||||
@Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); }
|
||||
}
|
||||
+30
@@ -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;
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
||||
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.ContainerHelper;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
/** Collection tabs, paper, ink vials and empty-vial return are owned by the block. */
|
||||
public final class WritingDeskBlockEntity extends BlockEntity implements Container {
|
||||
public static final int COLLECTIONS = 88;
|
||||
public static final int INK = COLLECTIONS;
|
||||
public static final int RETURN = INK + 1;
|
||||
public static final int PAPER = INK + 2;
|
||||
/** Unused by new desks; retained so old saves cannot silently lose slot contents. */
|
||||
public static final int LEGACY_OUTPUT = INK + 3;
|
||||
public static final int SIZE = INK + 4;
|
||||
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
private int activeCollection;
|
||||
private int inkRemaining;
|
||||
private ItemStack displayPage = ItemStack.EMPTY;
|
||||
|
||||
public WritingDeskBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
|
||||
public int activeCollection() { return activeCollection; }
|
||||
public int inkRemaining() { return inkRemaining; }
|
||||
public ItemStack displayPage() { return displayPage; }
|
||||
public void setDisplayPage(ItemStack page) {
|
||||
ItemStack next = page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1);
|
||||
if (!ItemStack.matches(displayPage, next)) {
|
||||
displayPage = next;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
public void setActiveCollection(int index) {
|
||||
if (index >= 0 && index < COLLECTIONS && activeCollection != index) {
|
||||
activeCollection = index;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public int getContainerSize() { return SIZE; }
|
||||
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
|
||||
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||
@Override public ItemStack removeItem(int slot, int amount) {
|
||||
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||
if (!removed.isEmpty()) {
|
||||
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
|
||||
setChanged();
|
||||
if (slot == RETURN) refillEmptyInkBar();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@Override public ItemStack removeItemNoUpdate(int slot) { return ContainerHelper.takeItem(items, slot); }
|
||||
@Override public void setItem(int slot, ItemStack stack) {
|
||||
items.set(slot, stack);
|
||||
if (stack.getCount() > getMaxStackSize()) stack.setCount(getMaxStackSize());
|
||||
if (slot < COLLECTIONS) displayPage = ItemStack.EMPTY;
|
||||
setChanged();
|
||||
if (slot == INK || slot == RETURN) refillEmptyInkBar();
|
||||
}
|
||||
@Override public boolean stillValid(Player player) {
|
||||
return level != null && level.isLoaded(worldPosition)
|
||||
&& level.getBlockEntity(worldPosition) == this
|
||||
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||
worldPosition.getZ() + 0.5) <= 64.0;
|
||||
}
|
||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
|
||||
&& stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
|
||||
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|
||||
|| stack.is(Rivencraft.PAGE.get())
|
||||
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
|
||||
return switch (slot) {
|
||||
case INK -> stack.is(Rivencraft.INK_VIAL.get());
|
||||
case RETURN -> false;
|
||||
case PAPER -> stack.is(Items.PAPER);
|
||||
case LEGACY_OUTPUT -> false;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/** Consume one vial only when the empty meter can return its glass bottle. */
|
||||
private void refillEmptyInkBar() {
|
||||
if (level != null && !level.isClientSide() && inkRemaining == 0) {
|
||||
int addedInk = InkVialExchange.exchange(items, INK, RETURN);
|
||||
if (addedInk > 0) {
|
||||
inkRemaining = addedInk;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The output is virtual; inputs are committed only after a result can be given to a player. */
|
||||
public boolean canConsumeCopyMaterials() {
|
||||
ItemStack paper = items.get(PAPER);
|
||||
if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false;
|
||||
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
|
||||
return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost);
|
||||
}
|
||||
|
||||
public boolean consumeCopyMaterials() {
|
||||
if (!canConsumeCopyMaterials()) return false;
|
||||
int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost();
|
||||
if (inkRemaining < cost) {
|
||||
inkRemaining += InkVialExchange.exchange(items, INK, RETURN);
|
||||
}
|
||||
inkRemaining -= cost;
|
||||
removeItem(PAPER, 1);
|
||||
setChanged();
|
||||
if (inkRemaining == 0) refillEmptyInkBar();
|
||||
return true;
|
||||
}
|
||||
@Override public void setLevel(Level level) {
|
||||
super.setLevel(level);
|
||||
refillEmptyInkBar();
|
||||
}
|
||||
@Override public void clearContent() {
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
inkRemaining = 0;
|
||||
displayPage = ItemStack.EMPTY;
|
||||
setChanged();
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
ContainerHelper.loadAllItems(input, items);
|
||||
activeCollection = Math.clamp(input.getIntOr("ActiveCollection", 0), 0, COLLECTIONS - 1);
|
||||
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
|
||||
displayPage = input.read("DisplayPage", ItemStack.CODEC).orElse(ItemStack.EMPTY);
|
||||
refillEmptyInkBar();
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
ContainerHelper.saveAllItems(output, items);
|
||||
output.putInt("ActiveCollection", activeCollection);
|
||||
output.putInt("InkRemaining", inkRemaining);
|
||||
if (!displayPage.isEmpty()) output.store("DisplayPage", ItemStack.CODEC, displayPage);
|
||||
}
|
||||
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||
return ClientboundBlockEntityDataPacket.create(this);
|
||||
}
|
||||
@Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
|
||||
return saveWithoutMetadata(registries);
|
||||
}
|
||||
@Override public void setChanged() {
|
||||
super.setChanged();
|
||||
if (level != null && !level.isClientSide()) {
|
||||
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public final class AgeCatalogClient {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void register(RegisterClientPayloadHandlersEvent event) {
|
||||
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
|
||||
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
|
||||
if (!(context.listener() instanceof ClientPacketListener listener)) return;
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.model.object.book.BookModel;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
|
||||
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** The 1.21.1 receptacle book orientation in the 26.3 extracted renderer. */
|
||||
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity, BookReceptacleRenderer.State> {
|
||||
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
|
||||
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
}
|
||||
public static final class State extends BlockEntityRenderState {
|
||||
boolean present, descriptive;
|
||||
Direction facing = Direction.NORTH;
|
||||
}
|
||||
@Override public State createRenderState() { return new State(); }
|
||||
@Override public void extractRenderState(BookReceptacleBlockEntity holder, State state, float partialTick,
|
||||
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
|
||||
BlockEntityRenderer.super.extractRenderState(holder, state, partialTick, camera, overlay);
|
||||
state.present = !holder.getBook().isEmpty() && holder.getBlockState().getValue(BookReceptacleBlock.HAS_BOOK);
|
||||
state.descriptive = holder.getBook().is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
state.facing = holder.getBlockState().getValue(BookReceptacleBlock.FACING);
|
||||
}
|
||||
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
|
||||
if (!state.present) return;
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 0.5F, 0.5F);
|
||||
switch (state.facing) {
|
||||
case UP -> {
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
pose.rotateDegrees(Axis.XP, -90);
|
||||
pose.rotateDegrees(Axis.YP, 90);
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
}
|
||||
case DOWN -> {
|
||||
pose.rotateDegrees(Axis.XP, -90);
|
||||
pose.rotateDegrees(Axis.YP, 90);
|
||||
pose.rotateDegrees(Axis.ZP, 180);
|
||||
}
|
||||
case NORTH -> pose.rotateDegrees(Axis.YP, -90);
|
||||
case SOUTH -> pose.rotateDegrees(Axis.YP, 90);
|
||||
case EAST -> pose.rotateDegrees(Axis.YP, 180);
|
||||
case WEST -> { }
|
||||
}
|
||||
pose.scale(0.8F, 0.8F, 0.8F);
|
||||
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.1F, 0.005F), pose,
|
||||
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
pose.popPose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.mojang.math.Axis;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.client.gui.Font;
|
||||
import net.minecraft.client.model.geom.ModelLayers;
|
||||
import net.minecraft.client.model.object.book.BookModel;
|
||||
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
|
||||
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
|
||||
import net.minecraft.client.renderer.item.ItemModelResolver;
|
||||
import net.minecraft.client.renderer.item.ItemStackRenderState;
|
||||
import net.minecraft.client.renderer.state.level.CameraRenderState;
|
||||
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemDisplayContext;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Extracted version of the 1.21.1 lectern display, retaining its final page orientation. */
|
||||
public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlockEntity, BookStandRenderer.State> {
|
||||
private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png");
|
||||
private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png");
|
||||
private final BookModel model;
|
||||
private final ItemModelResolver resolver;
|
||||
private final Font font;
|
||||
public BookStandRenderer(BlockEntityRendererProvider.Context context) {
|
||||
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||
resolver = context.itemModelResolver();
|
||||
font = context.font();
|
||||
}
|
||||
public static final class State extends BlockEntityRenderState {
|
||||
final ItemStackRenderState page = new ItemStackRenderState();
|
||||
boolean present, isPage, descriptive;
|
||||
float rotation;
|
||||
Component name;
|
||||
}
|
||||
@Override public State createRenderState() { return new State(); }
|
||||
@Override public void extractRenderState(BookStandBlockEntity stand, State state, float partialTick,
|
||||
Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) {
|
||||
BlockEntityRenderer.super.extractRenderState(stand, state, partialTick, camera, overlay);
|
||||
var book = stand.getBook();
|
||||
state.present = !book.isEmpty();
|
||||
state.isPage = book.is(Rivencraft.PAGE.get());
|
||||
state.descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
state.name = state.present && !state.isPage ? book.getHoverName().copy() : null;
|
||||
Direction facing = stand.getBlockState().getValue(BookStandBlock.FACING);
|
||||
state.rotation = state.isPage ? facing.toYRot() - 180 + (facing.getAxis() == Direction.Axis.X ? 180 : 0)
|
||||
: -facing.getClockWise().toYRot();
|
||||
resolver.updateForTopItem(state.page, state.isPage ? book : net.minecraft.world.item.ItemStack.EMPTY,
|
||||
ItemDisplayContext.FIXED, stand.getLevel(), null, 0);
|
||||
}
|
||||
@Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
|
||||
if (!state.present) return;
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 1.0625F, 0.5F);
|
||||
pose.rotateDegrees(Axis.YP, state.rotation);
|
||||
if (state.isPage) {
|
||||
pose.rotateDegrees(Axis.XP, 67.5F);
|
||||
pose.scale(0.7F, 0.7F, 0.7F);
|
||||
state.page.submit(pose, collector, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
} else {
|
||||
pose.rotateDegrees(Axis.ZP, 67.5F);
|
||||
pose.translate(0, -0.125F, 0);
|
||||
collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.9F, 1.2F), pose,
|
||||
state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0);
|
||||
}
|
||||
pose.popPose();
|
||||
if (state.name != null) {
|
||||
pose.pushPose();
|
||||
pose.translate(0.5F, 1.6F, 0.5F);
|
||||
pose.rotate(camera.orientation);
|
||||
pose.scale(0.025F, -0.025F, 0.025F);
|
||||
collector.submitText(pose, -font.width(state.name) / 2.0F, 0, state.name.getVisualOrderText(),
|
||||
false, Font.DisplayMode.NORMAL, state.lightCoords, 0xFFFFFFFF, 0, 0);
|
||||
pose.popPose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -18,12 +18,33 @@ public final class PortBookBinderClientEvents {
|
||||
public static void registerBlockEntityRenderers(
|
||||
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
|
||||
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
|
||||
event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new);
|
||||
event.registerBlockEntityRenderer(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), BookReceptacleRenderer::new);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void portalTint(net.neoforged.neoforge.client.event.RegisterColorHandlersEvent.BlockTintSources event) {
|
||||
event.register(java.util.List.of(new net.minecraft.client.color.block.BlockTintSource() {
|
||||
@Override public int color(net.minecraft.world.level.block.state.BlockState state) { return 0xffffffff; }
|
||||
@Override public int colorInWorld(net.minecraft.world.level.block.state.BlockState state,
|
||||
net.minecraft.client.renderer.block.BlockAndTintGetter level, net.minecraft.core.BlockPos pos) {
|
||||
if (level.getBlockEntity(pos) instanceof net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity portal
|
||||
&& portal.receptacle() != null
|
||||
&& level.getBlockEntity(portal.receptacle()) instanceof net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity holder)
|
||||
return 0xff000000 | holder.skyColor();
|
||||
return 0xffffffff;
|
||||
}
|
||||
}), Rivencraft.CRYSTAL_PORTAL.get());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerScreens(RegisterMenuScreensEvent event) {
|
||||
event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new);
|
||||
event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new);
|
||||
event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new);
|
||||
event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
|
||||
+15
@@ -26,4 +26,19 @@ public final class PortBookInteractionHandler {
|
||||
? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
|
||||
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
|
||||
}
|
||||
@SubscribeEvent
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
if (!event.getLevel().isClientSide() || event.getEntity().isShiftKeyDown()
|
||||
|| !event.getItemStack().isEmpty()
|
||||
|| !(event.getLevel().getBlockEntity(event.getPos()) instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity stand)) return;
|
||||
var book = stand.getBook();
|
||||
if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
var source = new net.dnorberg.rivencraft.network.UseBookStandPayload(
|
||||
event.getLevel().dimension().identifier(), event.getPos(), book.copy());
|
||||
Minecraft.getInstance().setScreenAndShow(book.is(Rivencraft.LINKING_BOOK.get())
|
||||
? new PortLinkingBookScreen(source) : new PortDescriptiveBookScreen(source));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
-36
@@ -10,81 +10,111 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Temporary 26.3 descriptive-book interaction; authored pages and live previews remain unported. */
|
||||
/** Legacy book layout and authored glyph pages; destination rendering remains a port placeholder. */
|
||||
public final class PortDescriptiveBookScreen extends Screen {
|
||||
private static final int WIDTH = 327;
|
||||
private static final int HEIGHT = 199;
|
||||
private static final int TEXT_COLOR = 0xFF3B281B;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final boolean bound;
|
||||
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
|
||||
private final net.dnorberg.rivencraft.writing.DescriptiveBookPages pages;
|
||||
private int pageIndex;
|
||||
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
|
||||
private EditBox titleInput;
|
||||
|
||||
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
|
||||
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
|
||||
}
|
||||
|
||||
public PortDescriptiveBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
|
||||
this(InteractionHand.MAIN_HAND, source.book());
|
||||
standSource = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound) return;
|
||||
if (bound || standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
Component.literal("Age name"));
|
||||
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
|
||||
titleInput.setHint(Component.literal("Name this Age"));
|
||||
titleInput.setHint(Component.literal(standSource == null ? "Name this Age" : "Remove book to bind"));
|
||||
addRenderableWidget(titleInput);
|
||||
setInitialFocus(titleInput);
|
||||
addRenderableWidget(Button.builder(Component.literal("Bind"), button -> bind())
|
||||
.bounds(left + 40, top + 100, 53, 20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
|
||||
.bounds(left + 97, top + 100, 53, 20).build());
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
|
||||
graphics.text(font, Component.literal("Age preview unavailable"), left + 181, top + 47,
|
||||
0xFFE9DFCB, false);
|
||||
graphics.text(font, Component.literal("Click to enter"), left + 205, top + 65,
|
||||
0xFFE9DFCB, false);
|
||||
} else {
|
||||
graphics.text(font, Component.literal("Bind a prototype Age"), left + 183, top + 39,
|
||||
TEXT_COLOR, false);
|
||||
PortLegacyBookArtwork.drawCover(graphics, left, top, true);
|
||||
if (pageIndex > 0) PortLegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||
if (pageIndex == 0) {
|
||||
if (bound) {
|
||||
graphics.nextStratum();
|
||||
linkPanel.render(graphics, left, top, partialTick);
|
||||
graphics.nextStratum();
|
||||
}
|
||||
PortLegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||
if (bound) {
|
||||
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||
int textY = PortLegacyBookArtwork.drawText(graphics, font, title, left + 40, top + 40, 110, TEXT_COLOR);
|
||||
Component author = pages.author().isBlank()
|
||||
? Component.translatable("screen.rivencraft.age.unknown_author") : Component.literal(pages.author());
|
||||
PortLegacyBookArtwork.drawText(graphics, font, author, left + 50, textY + 2, 100, TEXT_COLOR);
|
||||
graphics.disableScissor();
|
||||
} else {
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
graphics.text(font, Component.literal(standSource == null ? "Bind a prototype Age" : "Remove book to bind"),
|
||||
left + 183, top + 39, TEXT_COLOR, false);
|
||||
}
|
||||
} else if (pages.symbolAt(pageIndex).isPresent()) {
|
||||
PortLegacyBookArtwork.drawFullPage(graphics, left, top);
|
||||
graphics.nextStratum();
|
||||
var symbol = pages.symbolAt(pageIndex).orElseThrow();
|
||||
ItemStack glyph = new ItemStack(Rivencraft.PAGE.get());
|
||||
glyph.set(Rivencraft.SYMBOL_PAGE_ID.get(), symbol);
|
||||
glyph.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
|
||||
graphics.enableScissor(left + 174, top + 26, left + 306, top + 172);
|
||||
graphics.pose().pushMatrix();
|
||||
graphics.pose().translate(left + 177, top + 29);
|
||||
graphics.pose().scale(8.0F, 8.0F);
|
||||
graphics.fakeItem(glyph, 0, 0);
|
||||
graphics.pose().popMatrix();
|
||||
graphics.disableScissor();
|
||||
}
|
||||
graphics.nextStratum();
|
||||
PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, pages.lastPage());
|
||||
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
if (event.x() >= left + 173 && event.x() < left + 305
|
||||
&& event.y() >= top + 20 && event.y() < top + 103) {
|
||||
ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, pages.lastPage());
|
||||
if (direction != 0) { turnPage(direction); return true; }
|
||||
if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) {
|
||||
if (standSource != null) ClientPacketDistributor.sendToServer(standSource);
|
||||
else ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
@@ -92,6 +122,36 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
private void turnPage(int direction) {
|
||||
int next = pages.turn(pageIndex, direction);
|
||||
if (next == pageIndex) return;
|
||||
pageIndex = next;
|
||||
minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI(
|
||||
net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
private void updatePageControls() {
|
||||
for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control)
|
||||
control.visible = pageIndex == 0;
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
|
||||
if (event.isEscape()) return super.keyPressed(event);
|
||||
if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) {
|
||||
if (event.isLeft()) { turnPage(-1); return true; }
|
||||
if (event.isRight()) { turnPage(1); return true; }
|
||||
} else {
|
||||
// Keep inventory/hotbar actions out of a focused name field.
|
||||
return titleInput.keyPressed(event);
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override public void tick() {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
@@ -105,12 +165,4 @@ public final class PortDescriptiveBookScreen extends Screen {
|
||||
onClose();
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Original Mystcraft Legacy book atlases and page composition (LGPL-3.0,
|
||||
* copyright XCompWiz, revision 9bc8ddc061845df0cd5ea47f4fdef773cff786ae). */
|
||||
final class PortLegacyBookArtwork {
|
||||
private static final int ATLAS_SIZE = 256;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
private static final Identifier RIGHT_FULL = texture("bookui_rpage_full.png");
|
||||
|
||||
private PortLegacyBookArtwork() {}
|
||||
|
||||
static void drawCover(GuiGraphicsExtractor graphics, int left, int top, boolean gilded) {
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
if (gilded) {
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 186, 0);
|
||||
blit(graphics, COVER, left + 293, top + 7, 34, 192, 186, 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void drawLeftPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static void drawPanelPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static void drawFullPage(GuiGraphicsExtractor graphics, int left, int top) {
|
||||
blit(graphics, RIGHT_FULL, left + 163, top, 156, 195, 0, 0);
|
||||
}
|
||||
|
||||
static int drawText(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font,
|
||||
net.minecraft.network.chat.Component text, int x, int y, int width, int color) {
|
||||
for (var line : font.split(text, width)) {
|
||||
graphics.text(font, line, x, y, color, false);
|
||||
y += font.lineHeight;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
static void drawCounter(GuiGraphicsExtractor graphics, net.minecraft.client.gui.Font font,
|
||||
int left, int top, int page, int last) {
|
||||
String counter = page + "/" + last;
|
||||
graphics.text(font, counter, left + 165 - font.width(counter) / 2, top + 185, 0xFF000000, false);
|
||||
}
|
||||
|
||||
static int turnDirection(double x, double y, int page, int last) {
|
||||
if (y < 164 || y >= 195) return 0;
|
||||
if (page > 0 && x >= 7 && x < 35) return -1;
|
||||
return page < last && x >= 293 && x < 320 ? 1 : 0;
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, ATLAS_SIZE, ATLAS_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
|
||||
/**
|
||||
* Built-in linking-panel animation inspired by Mystcraft Legacy's portal bloom.
|
||||
*
|
||||
* <p>The original effect used a GLSL noise shader over an optional live world
|
||||
* view. This port retains the 1.21.1 fallback's two noisy spirals and default teal
|
||||
* tint, using the normal GUI API without a renderer mod.</p>
|
||||
*/
|
||||
final class PortLegacyLinkPanel {
|
||||
static final int X = 173, Y = 20, WIDTH = 132, HEIGHT = 83;
|
||||
private static final int CELL_SIZE = 3;
|
||||
private static final int BORDER_COLOR = 0xFF7B6AA8;
|
||||
private int ticks;
|
||||
private static final int LINK_COLOR = 0xFF006666;
|
||||
|
||||
static boolean contains(double x, double y) {
|
||||
return x >= X && x < X + WIDTH && y >= Y && y < Y + HEIGHT;
|
||||
}
|
||||
|
||||
void render(GuiGraphicsExtractor graphics, int bookLeft, int bookTop, float partialTick) {
|
||||
render(graphics, bookLeft + X, bookTop + Y, WIDTH, HEIGHT, partialTick);
|
||||
}
|
||||
|
||||
void tick() {
|
||||
ticks++;
|
||||
}
|
||||
|
||||
private void render(GuiGraphicsExtractor graphics, int left, int top, int width, int height, float partialTick) {
|
||||
graphics.fill(left, top, left + width, top + height, 0xFF000011);
|
||||
|
||||
float time = (ticks + partialTick) * 0.035F;
|
||||
float reveal = Math.min(1.0F, time / 1.8F);
|
||||
for (int y = 1; y < height - 1; y += CELL_SIZE) {
|
||||
for (int x = 1; x < width - 1; x += CELL_SIZE) {
|
||||
float normalizedX = (x - width * 0.5F) / height;
|
||||
float normalizedY = (y - height * 0.5F) / height;
|
||||
float first = spiral(normalizedX, normalizedY, time, 0.0F);
|
||||
float second = spiral(normalizedX, normalizedY, time, 2.35F);
|
||||
float intensity = Math.max(first, second) * reveal;
|
||||
if (intensity <= 0.035F) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int color = scaledColor(LINK_COLOR, Math.min(1.0F, 0.12F + intensity));
|
||||
graphics.fill(left + x, top + y,
|
||||
left + Math.min(width - 1, x + CELL_SIZE),
|
||||
top + Math.min(height - 1, y + CELL_SIZE), color);
|
||||
}
|
||||
}
|
||||
graphics.outline(left, top, width, height, BORDER_COLOR);
|
||||
}
|
||||
|
||||
private static float spiral(float x, float y, float time, float phase) {
|
||||
float distance = (float)Math.sqrt(x * x + y * y);
|
||||
float angle = (float)Math.atan2(y, x);
|
||||
float arm = 0.5F + 0.5F * (float)Math.cos(angle * 3.0F - distance * 25.0F + time * 4.0F + phase);
|
||||
float pulse = 0.72F + 0.28F * noise(x * 31.0F, y * 31.0F, time + phase);
|
||||
float core = Math.max(0.0F, 1.0F - distance * 2.15F);
|
||||
return core * (0.28F + arm * 0.72F) * pulse;
|
||||
}
|
||||
|
||||
private static float noise(float x, float y, float time) {
|
||||
double value = Math.sin(x * 12.9898D + y * 78.233D + time * 19.19D) * 43758.5453D;
|
||||
return (float)(value - Math.floor(value));
|
||||
}
|
||||
|
||||
private static int scaledColor(int color, float scale) {
|
||||
int red = Math.min(255, Math.round(((color >> 16) & 0xFF) * scale + 18.0F * scale));
|
||||
int green = Math.min(255, Math.round(((color >> 8) & 0xFF) * scale + 24.0F * scale));
|
||||
int blue = Math.min(255, Math.round((color & 0xFF) * scale + 42.0F * scale));
|
||||
return 0xFF000000 | red << 16 | green << 8 | blue;
|
||||
}
|
||||
}
|
||||
+82
-33
@@ -10,35 +10,48 @@ import net.minecraft.client.gui.components.Button;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.Screen;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Minimal 26.3 book interaction using the Legacy artwork; live preview is not ported yet. */
|
||||
/** Legacy Linking Book front and final spreads with the shared fallback panel. */
|
||||
public final class PortLinkingBookScreen extends Screen {
|
||||
private static final int WIDTH = 327;
|
||||
private static final int HEIGHT = 199;
|
||||
private static final int TEXT_COLOR = 0xFF3B281B;
|
||||
private static final Identifier COVER = texture("bookui_cover.png");
|
||||
private static final Identifier LEFT_PAGE = texture("notebook_pages.png");
|
||||
private static final Identifier RIGHT_PANEL = texture("bookui_pager.png");
|
||||
|
||||
|
||||
private final InteractionHand hand;
|
||||
private final boolean bound;
|
||||
private final net.dnorberg.rivencraft.item.LinkBookTarget target;
|
||||
private final boolean returnLink;
|
||||
private final Component destination;
|
||||
private int pageIndex;
|
||||
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
|
||||
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
|
||||
private EditBox titleInput;
|
||||
|
||||
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
|
||||
super(book.getHoverName());
|
||||
this.hand = hand;
|
||||
bound = book.has(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
bound = target != null;
|
||||
destination = bound ? net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(book, target) : Component.empty();
|
||||
Component customName = book.get(net.minecraft.core.component.DataComponents.CUSTOM_NAME);
|
||||
returnLink = customName != null
|
||||
&& customName.getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents translated
|
||||
&& translated.getKey().equals("item.rivencraft.linking_book.return");
|
||||
}
|
||||
|
||||
public PortLinkingBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
|
||||
this(InteractionHand.MAIN_HAND, source.book());
|
||||
standSource = source;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
if (bound) return;
|
||||
if (bound || standSource != null) return;
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
|
||||
@@ -51,38 +64,55 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
.bounds(left + 40, top + 100, 53, 20).build());
|
||||
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> onClose())
|
||||
.bounds(left + 97, top + 100, 53, 20).build());
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
blit(graphics, COVER, left, top + 7, 34, 192, 152, 0);
|
||||
blit(graphics, COVER, left + 34, top + 7, 103, 192, 49, 0);
|
||||
blit(graphics, COVER, left + 137, top + 7, 4, 192, 45, 0);
|
||||
blit(graphics, COVER, left + 141, top + 7, 186, 192, 0, 0);
|
||||
if (bound) blit(graphics, LEFT_PAGE, left + 7, top, 156, 195, 0, 0);
|
||||
blit(graphics, RIGHT_PANEL, left + 163, top, 156, 195, 0, 0);
|
||||
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.fill(left + 173, top + 20, left + 305, top + 103, 0xFF15141D);
|
||||
graphics.text(font, Component.literal("Destination preview"), left + 185, top + 47, 0xFFE9DFCB, false);
|
||||
graphics.text(font, Component.literal("Click to link"), left + 205, top + 65, 0xFFE9DFCB, false);
|
||||
PortLegacyBookArtwork.drawCover(graphics, left, top, false);
|
||||
if (pageIndex > 0) {
|
||||
PortLegacyBookArtwork.drawLeftPage(graphics, left, top);
|
||||
} else {
|
||||
graphics.text(font, Component.literal("Name this Linking Book"), left + 183, top + 39,
|
||||
TEXT_COLOR, false);
|
||||
if (bound) {
|
||||
graphics.nextStratum();
|
||||
linkPanel.render(graphics, left, top, partialTick);
|
||||
graphics.nextStratum();
|
||||
}
|
||||
PortLegacyBookArtwork.drawPanelPage(graphics, left, top);
|
||||
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
|
||||
if (bound) {
|
||||
int y = top + 40;
|
||||
if (!returnLink) y = PortLegacyBookArtwork.drawText(graphics, font, title,
|
||||
left + 40, y, 110, TEXT_COLOR) + 4;
|
||||
y = PortLegacyBookArtwork.drawText(graphics, font, destination, left + 50, y, 100, TEXT_COLOR) + 10;
|
||||
var pos = net.minecraft.core.BlockPos.containing(target.x(), target.y(), target.z());
|
||||
PortLegacyBookArtwork.drawText(graphics, font,
|
||||
Component.literal(pos.getX() + ", " + pos.getY() + ", " + pos.getZ()),
|
||||
left + 40, y, 110, TEXT_COLOR);
|
||||
} else {
|
||||
PortLegacyBookArtwork.drawText(graphics, font, Component.literal(
|
||||
standSource == null ? "Name this Linking Book" : "Remove book to bind"),
|
||||
left + 40, top + 43, 110, TEXT_COLOR);
|
||||
}
|
||||
graphics.disableScissor();
|
||||
}
|
||||
graphics.nextStratum();
|
||||
PortLegacyBookArtwork.drawCounter(graphics, font, left, top, pageIndex, 1);
|
||||
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if (bound && event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
|
||||
int left = (width - WIDTH) / 2;
|
||||
int top = (height - HEIGHT) / 2;
|
||||
if (event.x() >= left + 173 && event.x() < left + 305
|
||||
&& event.y() >= top + 20 && event.y() < top + 103) {
|
||||
ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
int direction = PortLegacyBookArtwork.turnDirection(event.x() - left, event.y() - top, pageIndex, 1);
|
||||
if (direction != 0) { turnPage(direction); return true; }
|
||||
if (bound && pageIndex == 0 && PortLegacyLinkPanel.contains(event.x() - left, event.y() - top)) {
|
||||
if (standSource != null) ClientPacketDistributor.sendToServer(standSource);
|
||||
else ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(hand == InteractionHand.OFF_HAND));
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
@@ -90,6 +120,33 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
private void turnPage(int direction) {
|
||||
int next = Math.clamp(pageIndex + direction, 0, 1);
|
||||
if (next == pageIndex) return;
|
||||
pageIndex = next;
|
||||
minecraft.getSoundManager().play(net.minecraft.client.resources.sounds.SimpleSoundInstance.forUI(
|
||||
net.minecraft.sounds.SoundEvents.BOOK_PAGE_TURN, 1.0F, 0.65F));
|
||||
updatePageControls();
|
||||
}
|
||||
|
||||
private void updatePageControls() {
|
||||
for (var widget : renderables) if (widget instanceof net.minecraft.client.gui.components.AbstractWidget control)
|
||||
control.visible = pageIndex == 0;
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
|
||||
if (event.isEscape()) return super.keyPressed(event);
|
||||
if (titleInput == null || !titleInput.isFocused() || !titleInput.visible) {
|
||||
if (event.isLeft()) { turnPage(-1); return true; }
|
||||
if (event.isRight()) { turnPage(1); return true; }
|
||||
} else return titleInput.keyPressed(event);
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
|
||||
@Override public void tick() {
|
||||
if (bound) linkPanel.tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPauseScreen() {
|
||||
return false;
|
||||
@@ -103,12 +160,4 @@ public final class PortLinkingBookScreen extends Screen {
|
||||
onClose();
|
||||
}
|
||||
|
||||
private static Identifier texture(String filename) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/gui/" + filename);
|
||||
}
|
||||
|
||||
private static void blit(GuiGraphicsExtractor graphics, Identifier texture, int x, int y,
|
||||
int width, int height, int u, int v) {
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, texture, x, y, u, v, width, height, 256, 256);
|
||||
}
|
||||
}
|
||||
@@ -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,62 @@
|
||||
package net.dnorberg.rivencraft.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.storage.ValueInput;
|
||||
import net.minecraft.world.level.storage.ValueOutput;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Recoverable open-book entity, with the 1.21.1 decay and weather behavior. */
|
||||
public final class DroppedBookEntity extends ItemEntity {
|
||||
private static final int SLOW_DECAY_INTERVAL = 10_000;
|
||||
private int decayTicks;
|
||||
|
||||
public DroppedBookEntity(EntityType<? extends DroppedBookEntity> type, Level level) { super(type, level); }
|
||||
|
||||
public DroppedBookEntity(Level level, Vec3 position, ItemStack book) {
|
||||
this(Rivencraft.DROPPED_BOOK.get(), level);
|
||||
setPos(position.x, position.y + 0.25, position.z);
|
||||
setDeltaMovement(Vec3.ZERO);
|
||||
setItem(book);
|
||||
setUnlimitedLifetime();
|
||||
setPickUpDelay(20);
|
||||
}
|
||||
|
||||
/** Conversion preserves ordinary toss/drop state; the travel constructor has different placement rules. */
|
||||
public static DroppedBookEntity fromDrop(ItemEntity source) {
|
||||
var book = new DroppedBookEntity(Rivencraft.DROPPED_BOOK.get(), source.level());
|
||||
book.restoreFrom(source);
|
||||
// The original remains registered until replacement succeeds, so this needs a new identity.
|
||||
book.setUUID(java.util.UUID.randomUUID());
|
||||
book.setItem(source.getItem().copy());
|
||||
book.setUnlimitedLifetime();
|
||||
return book;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
super.tick();
|
||||
if (!(level() instanceof ServerLevel serverLevel) || isRemoved()) return;
|
||||
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
|
||||
decayTicks = 0;
|
||||
hurtServer(serverLevel, damageSources().starve(), 1.0F);
|
||||
}
|
||||
if (!isRemoved() && isInWaterOrRain()) hurtServer(serverLevel, damageSources().drown(), 1.0F);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addAdditionalSaveData(ValueOutput output) {
|
||||
super.addAdditionalSaveData(output);
|
||||
output.putInt("BookDecayTicks", decayTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readAdditionalSaveData(ValueInput input) {
|
||||
super.readAdditionalSaveData(input);
|
||||
decayTicks = Math.clamp(input.getIntOr("BookDecayTicks", 0), 0, SLOW_DECAY_INTERVAL - 1);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.LecternBlock;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
|
||||
|
||||
public final class BookStandConversionHandler {
|
||||
private BookStandConversionHandler() {}
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
var book = event.getItemStack();
|
||||
if (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get())) return;
|
||||
var state = event.getLevel().getBlockState(event.getPos());
|
||||
if (!state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK)) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
if (!event.getLevel().isClientSide()) convert(event.getEntity(), event.getHand(), event.getPos());
|
||||
}
|
||||
public static boolean convert(Player player, InteractionHand hand, BlockPos pos) {
|
||||
var level = player.level();
|
||||
var state = level.getBlockState(pos);
|
||||
var book = player.getItemInHand(hand);
|
||||
if (level.isClientSide() || !state.is(Blocks.LECTERN) || state.getValue(LecternBlock.HAS_BOOK)
|
||||
|| (!book.is(Rivencraft.LINKING_BOOK.get()) && !book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && !book.is(Rivencraft.PAGE.get()))) return false;
|
||||
if (!level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, state.getValue(LecternBlock.FACING)).setValue(BookStandBlock.HAS_BOOK, true), 3)) return false;
|
||||
if (!(level.getBlockEntity(pos) instanceof BookStandBlockEntity stand)) return false;
|
||||
stand.setBook(book.consumeAndReturn(1, player));
|
||||
level.playSound(null, pos, SoundEvents.BOOK_PUT, SoundSource.BLOCKS, 1, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.minecraft.server.TickTask;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.bus.api.EventPriority;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent;
|
||||
|
||||
/** Converts ordinary book drops from every source, including entities restored from disk. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID)
|
||||
public final class DroppedBookConversionHandler {
|
||||
private DroppedBookConversionHandler() {}
|
||||
|
||||
@SubscribeEvent(priority = EventPriority.LOWEST)
|
||||
public static void onJoin(EntityJoinLevelEvent event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel level)
|
||||
|| !(event.getEntity() instanceof ItemEntity item) || item instanceof DroppedBookEntity
|
||||
|| !isBook(item.getItem())) return;
|
||||
// Join events can run before chunk promotion. Do not add another entity inside that callback.
|
||||
// Keep the original until the queued replacement has actually been accepted.
|
||||
level.getServer().schedule(new TickTask(0, () -> {
|
||||
if (level.getServer().getLevel(level.dimension()) != level || item.isRemoved()
|
||||
|| !item.isAddedToLevel() || item.level() != level || !isBook(item.getItem())) return;
|
||||
var replacement = DroppedBookEntity.fromDrop(item);
|
||||
if (level.addFreshEntity(replacement)) item.discard();
|
||||
}));
|
||||
}
|
||||
|
||||
private static boolean isBook(ItemStack stack) {
|
||||
return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Legacy's compact page collection, using the existing Notebook page editor. */
|
||||
public final class CollationFolderItem extends NotebookItem {
|
||||
public static final int MAX_PAGES = 27;
|
||||
|
||||
public CollationFolderItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
public static boolean isCollection(ItemStack stack) {
|
||||
return stack.is(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.COLLATION_FOLDER.get())
|
||||
|| stack.is(Rivencraft.SYMBOL_PORTFOLIO.get());
|
||||
}
|
||||
|
||||
public static int capacity(ItemStack stack) {
|
||||
return stack.is(Rivencraft.COLLATION_FOLDER.get()) ? MAX_PAGES
|
||||
: stack.is(Rivencraft.NOTEBOOK.get()) || stack.is(Rivencraft.SYMBOL_PORTFOLIO.get())
|
||||
? NotebookContents.MAX_PAGES : 0;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package net.dnorberg.rivencraft.item;
|
||||
import java.util.function.Consumer;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
@@ -17,8 +17,6 @@ import net.minecraft.world.level.Level;
|
||||
/** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */
|
||||
public final class InkVialItem extends Item {
|
||||
public static final int CAPACITY = 16;
|
||||
// Match the 1.21.1 default; port the configurable cost with the full configuration system.
|
||||
public static final int LINK_PANEL_COST = 2;
|
||||
|
||||
public InkVialItem(Properties properties) {
|
||||
super(properties);
|
||||
@@ -30,37 +28,16 @@ public final class InkVialItem extends Item {
|
||||
|
||||
@Override
|
||||
public InteractionResult use(Level level, Player player, InteractionHand hand) {
|
||||
InteractionHand paperHand = hand == InteractionHand.MAIN_HAND
|
||||
? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
ItemStack vial = player.getItemInHand(hand);
|
||||
ItemStack paper = player.getItemInHand(paperHand);
|
||||
int ink = remaining(vial);
|
||||
if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER)
|
||||
|| ink < LINK_PANEL_COST || ink > CAPACITY) {
|
||||
return InteractionResult.PASS;
|
||||
if (level.isClientSide()) {
|
||||
// LOCAL gameplay config is not synchronized. Predict only item eligibility;
|
||||
// the server decides affordability using its own configured cost.
|
||||
var paperHand = hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND;
|
||||
int ink = remaining(player.getItemInHand(hand));
|
||||
return player.getItemInHand(hand).is(Rivencraft.INK_VIAL.get())
|
||||
&& player.getItemInHand(paperHand).is(Items.PAPER) && ink > 0 && ink <= CAPACITY
|
||||
? InteractionResult.SUCCESS : InteractionResult.PASS;
|
||||
}
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
int newInk = ink - LINK_PANEL_COST;
|
||||
ItemStack spentVial = newInk == 0
|
||||
? new ItemStack(Items.GLASS_BOTTLE)
|
||||
: new ItemStack(Rivencraft.INK_VIAL.get());
|
||||
if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk);
|
||||
|
||||
if (paper.getCount() == 1) {
|
||||
player.setItemInHand(paperHand, panel);
|
||||
} else {
|
||||
paper.shrink(1);
|
||||
giveOrDrop(player, panel);
|
||||
}
|
||||
if (vial.getCount() == 1) {
|
||||
player.setItemInHand(hand, spentVial);
|
||||
} else {
|
||||
vial.shrink(1);
|
||||
giveOrDrop(player, spentVial);
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
return WritingInkService.prepareLinkPanel(player, hand) ? InteractionResult.SUCCESS : InteractionResult.PASS;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -70,7 +47,4 @@ public final class InkVialItem extends Item {
|
||||
tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage"));
|
||||
}
|
||||
|
||||
private static void giveOrDrop(Player player, ItemStack stack) {
|
||||
if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.animal.equine.AbstractHorse;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
|
||||
public final class LinkDisarmService {
|
||||
private LinkDisarmService() {}
|
||||
|
||||
public static boolean mayTravel(Entity entity) {
|
||||
// Legacy refuses loose items and dropped Link Books on Disarm links.
|
||||
return !(entity instanceof ItemEntity);
|
||||
}
|
||||
|
||||
public static void disarmAt(Entity entity, ServerLevel source, Vec3 departure) {
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
eject(player.getInventory(), source, departure);
|
||||
player.containerMenu.broadcastChanges();
|
||||
}
|
||||
if (entity instanceof Container container) eject(container, source, departure);
|
||||
if (entity instanceof AbstractHorse horse) eject(horse.getInventory(), source, departure);
|
||||
if (entity instanceof Mob mob) dropEquipment(mob, source, departure);
|
||||
}
|
||||
|
||||
private static void eject(Container inventory, ServerLevel source, Vec3 departure) {
|
||||
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
||||
ItemStack stack = inventory.getItem(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
inventory.setItem(slot, ItemStack.EMPTY);
|
||||
drop(stack, source, departure);
|
||||
}
|
||||
inventory.setChanged();
|
||||
}
|
||||
|
||||
private static void dropEquipment(Mob mob, ServerLevel source, Vec3 departure) {
|
||||
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||
ItemStack stack = mob.getItemBySlot(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
float chance = mob.getDropChances().byEquipment(slot);
|
||||
mob.setItemSlot(slot, ItemStack.EMPTY);
|
||||
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
|
||||
if (chance <= 1 && stack.isDamageableItem()) {
|
||||
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
|
||||
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
|
||||
1 + mob.getRandom().nextInt(minimum));
|
||||
stack.setDamageValue(Math.clamp(damage, 1, minimum));
|
||||
}
|
||||
drop(stack, source, departure);
|
||||
}
|
||||
}
|
||||
private static void drop(ItemStack stack, ServerLevel source, Vec3 position) {
|
||||
var dropped = new ItemEntity(source, position.x, position.y, position.z, stack);
|
||||
dropped.setDefaultPickUpDelay();
|
||||
source.addFreshEntity(dropped);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/** Handheld Linking Book; destination binding and travel are server-authoritative packets. */
|
||||
@@ -40,6 +39,10 @@ public final class LinkingBookItem extends Item {
|
||||
}
|
||||
|
||||
public static String travel(ServerPlayer player, ItemStack book) {
|
||||
return travel(player, book, false);
|
||||
}
|
||||
|
||||
public static String travel(ServerPlayer player, ItemStack book, boolean portal) {
|
||||
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (destination == null) return "Unbound Linking Book (26.3 port preview)";
|
||||
@@ -71,14 +74,20 @@ public final class LinkingBookItem extends Item {
|
||||
Vec3 incoming = player.getDeltaMovement();
|
||||
boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(),
|
||||
Set.of(), destination.yaw(), destination.pitch(), true);
|
||||
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw());
|
||||
if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal);
|
||||
return moved ? null : "Teleportation failed";
|
||||
}
|
||||
|
||||
/** Link Panel effects are shared by Linking and Descriptive Books. */
|
||||
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
|
||||
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
|
||||
LinkVisualEffects.play(source, departure, player, properties, false);
|
||||
applySuccessfulLink(player, properties, source, departure, incoming, destinationYaw, false);
|
||||
}
|
||||
|
||||
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
|
||||
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw,
|
||||
boolean portal) {
|
||||
LinkVisualEffects.play(source, departure, player, properties, portal);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
|
||||
double speed = Math.hypot(incoming.x, incoming.z);
|
||||
double yaw = Math.toRadians(destinationYaw);
|
||||
@@ -88,27 +97,41 @@ public final class LinkingBookItem extends Item {
|
||||
player.setDeltaMovement(Vec3.ZERO);
|
||||
}
|
||||
if (properties.has(LinkProperties.DISARM)) {
|
||||
for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) {
|
||||
ItemStack carried = player.getInventory().getItem(slot);
|
||||
if (carried.isEmpty()) continue;
|
||||
player.getInventory().setItem(slot, ItemStack.EMPTY);
|
||||
ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z,
|
||||
carried.copy());
|
||||
dropped.setDefaultPickUpDelay();
|
||||
source.addFreshEntity(dropped);
|
||||
}
|
||||
player.getInventory().setChanged();
|
||||
player.containerMenu.broadcastChanges();
|
||||
LinkDisarmService.disarmAt(player, source, departure);
|
||||
}
|
||||
}
|
||||
|
||||
public static String destinationName(ServerLevel level) {
|
||||
var id = level.dimension().identifier();
|
||||
return net.dnorberg.rivencraft.age.RuntimeAgeBackend.savedAgeName(level.getServer(), id)
|
||||
.orElseGet(() -> {
|
||||
if (id.equals(Level.OVERWORLD.identifier())) return "Overworld";
|
||||
if (id.equals(Level.NETHER.identifier())) return "The Nether";
|
||||
if (id.equals(Level.END.identifier())) return "The End";
|
||||
return id.toString();
|
||||
});
|
||||
}
|
||||
|
||||
public static Component destinationName(ItemStack book, LinkBookTarget target) {
|
||||
String name = book.get(Rivencraft.LINK_DESTINATION_NAME.get());
|
||||
if (name != null && !name.isBlank()) return Component.literal(name);
|
||||
var id = target.dimension();
|
||||
if (id.equals(Level.OVERWORLD.identifier())) return Component.translatable("dimension.minecraft.overworld");
|
||||
if (id.equals(Level.NETHER.identifier())) return Component.translatable("dimension.minecraft.the_nether");
|
||||
if (id.equals(Level.END.identifier())) return Component.translatable("dimension.minecraft.the_end");
|
||||
return Component.literal(id.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
LinkBookTarget target = stack.get(Rivencraft.LINK_BOOK_TARGET.get());
|
||||
tooltip.accept(Component.literal(target == null ? "Unbound (26.3 port preview)"
|
||||
: "Destination: " + target.dimension() + " (" + (int) target.x() + ", "
|
||||
+ (int) target.y() + ", " + (int) target.z() + ")"));
|
||||
if (target == null) tooltip.accept(Component.literal("Unbound (26.3 port preview)"));
|
||||
else {
|
||||
BlockPos pos = BlockPos.containing(target.x(), target.y(), target.z());
|
||||
tooltip.accept(Component.translatable("item.rivencraft.linking_book.destination",
|
||||
destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ()));
|
||||
}
|
||||
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM))
|
||||
tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm"));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.menu.NotebookMenu;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.SimpleMenuProvider;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.item.component.TooltipDisplay;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** The 1.21.1 collection editor opened through a server-owned menu. */
|
||||
public class NotebookItem extends Item {
|
||||
public NotebookItem(Properties properties) { super(properties); }
|
||||
|
||||
@Override
|
||||
public InteractionResult use(Level level, Player player, InteractionHand hand) {
|
||||
ItemStack notebook = player.getItemInHand(hand);
|
||||
if (player instanceof ServerPlayer serverPlayer) {
|
||||
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||
(id, inventory, ignored) -> new NotebookMenu(id, inventory, hand), notebook.getHoverName()),
|
||||
buffer -> buffer.writeBoolean(hand == InteractionHand.OFF_HAND));
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
|
||||
Consumer<Component> tooltip, TooltipFlag flag) {
|
||||
var contents = stack.getOrDefault(Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||
tooltip.accept(Component.translatable("item.rivencraft.notebook.pages", contents.pageCount(),
|
||||
CollationFolderItem.capacity(stack)).withStyle(ChatFormatting.GRAY));
|
||||
if (contents.unresolvedCount() > 0) tooltip.accept(Component.translatable(
|
||||
"item.rivencraft.notebook.unresolved", contents.unresolvedCount()).withStyle(ChatFormatting.RED));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,22 @@ public final class PageItem extends Item {
|
||||
return stack;
|
||||
}
|
||||
|
||||
public static ItemStack createSymbolPage(net.dnorberg.rivencraft.writing.SymbolReference symbol) {
|
||||
return createSymbolPage(symbol.id());
|
||||
}
|
||||
|
||||
/** Resolved view of the port's existing components; no second saved schema. */
|
||||
public static net.dnorberg.rivencraft.writing.PageContent content(ItemStack stack) {
|
||||
var id = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
|
||||
if (isLinkPanel(stack)) return net.dnorberg.rivencraft.writing.PageContent.LINK_PANEL;
|
||||
return id == null ? net.dnorberg.rivencraft.writing.PageContent.BLANK
|
||||
: net.dnorberg.rivencraft.writing.PageContent.symbol(
|
||||
new net.dnorberg.rivencraft.writing.SymbolReference(id)).resolve();
|
||||
}
|
||||
|
||||
public static boolean isUsableWriting(ItemStack stack) { return content(stack).isResolvedSymbol(); }
|
||||
public static boolean isCorrupted(net.dnorberg.rivencraft.writing.SymbolReference symbol) { return isCorrupted(symbol.id()); }
|
||||
|
||||
@Override
|
||||
public Component getName(ItemStack stack) {
|
||||
Component customName = stack.get(DataComponents.CUSTOM_NAME);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Leaves only the active book behind after successful handheld travel. */
|
||||
public final class PortHandheldTravel {
|
||||
private PortHandheldTravel() {}
|
||||
|
||||
public static String travel(ServerPlayer player, InteractionHand hand, ItemStack book,
|
||||
Supplier<String> travel) {
|
||||
if (book.isEmpty() || player.getItemInHand(hand) != book) return "Book is no longer in the active hand";
|
||||
var source = player.level();
|
||||
var departure = player.position();
|
||||
player.setItemInHand(hand, ItemStack.EMPTY);
|
||||
boolean dropped = false;
|
||||
try {
|
||||
String failure = travel.get();
|
||||
if (failure != null) return failure;
|
||||
net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player);
|
||||
dropped = source.addFreshEntity(new DroppedBookEntity(source, departure, book));
|
||||
return null;
|
||||
} finally {
|
||||
// Rejected travel or a cancelled entity spawn must not consume the book.
|
||||
if (!dropped) {
|
||||
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, book);
|
||||
else if (!player.getInventory().add(book)) player.drop(book, false, Prediction.SERVER_ONLY);
|
||||
}
|
||||
player.containerMenu.broadcastChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.PortSymbolPages;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.Prediction;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** The 1.21.1 booster item, opened into a 16-page Collation Folder. */
|
||||
public final class SealedNotebookItem extends Item {
|
||||
public SealedNotebookItem(Properties properties) { super(properties); }
|
||||
|
||||
@Override
|
||||
public InteractionResult use(Level level, Player player, InteractionHand hand) {
|
||||
if (level.isClientSide()) return InteractionResult.SUCCESS;
|
||||
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS;
|
||||
ItemStack sealed = player.getItemInHand(hand);
|
||||
ItemStack folder = open(serverPlayer);
|
||||
if (folder.isEmpty()) return InteractionResult.FAIL;
|
||||
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
|
||||
if (sealed.isEmpty()) player.setItemInHand(hand, folder);
|
||||
else if (!player.getInventory().add(folder)) player.drop(folder, false, Prediction.SERVER_ONLY);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
public static ItemStack open(ServerPlayer player) {
|
||||
var candidates = PortSymbolPages.sealedCandidateIds();
|
||||
if (candidates.isEmpty()) return ItemStack.EMPTY;
|
||||
var pages = new ArrayList<ItemStack>(16);
|
||||
for (int i = 0; i < 16; i++) pages.add(PageItem.createSymbolPage(
|
||||
candidates.get(player.getRandom().nextInt(candidates.size()))));
|
||||
ItemStack folder = new ItemStack(Rivencraft.COLLATION_FOLDER.get());
|
||||
folder.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.dnorberg.rivencraft.item;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
|
||||
public final class SymbolPortfolioItem extends NotebookItem {
|
||||
public SymbolPortfolioItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
public static List<ItemStack> createVolumes(Identifier category, List<ItemStack> pages) {
|
||||
if (pages.isEmpty()) return List.of();
|
||||
int capacity = NotebookContents.MAX_PAGES;
|
||||
int volumeCount = (pages.size() + capacity - 1) / capacity;
|
||||
List<ItemStack> volumes = new ArrayList<>(volumeCount);
|
||||
for (int volume = 0; volume < volumeCount; volume++) {
|
||||
ItemStack portfolio = new ItemStack(Rivencraft.SYMBOL_PORTFOLIO.get());
|
||||
int first = volume * capacity;
|
||||
int last = Math.min(first + capacity, pages.size());
|
||||
portfolio.set(Rivencraft.NOTEBOOK_CONTENTS.get(),
|
||||
new NotebookContents(pages.subList(first, last)));
|
||||
Component categoryName = Component.translatableWithFallback(
|
||||
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
|
||||
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
|
||||
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
|
||||
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
|
||||
categoryName, volume + 1, volumeCount));
|
||||
volumes.add(portfolio);
|
||||
}
|
||||
return List.copyOf(volumes);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.world.Container;
|
||||
@@ -110,6 +112,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
||||
return ItemStack.EMPTY;
|
||||
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
return output;
|
||||
}
|
||||
@@ -150,6 +154,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
||||
}
|
||||
@Override public void onTake(Player player, ItemStack stack) {
|
||||
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
|
||||
if (player instanceof ServerPlayer serverPlayer)
|
||||
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||
refreshResult();
|
||||
}
|
||||
super.onTake(player, stack);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package net.dnorberg.rivencraft.menu;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.inventory.Slot;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
public final class NotebookMenu extends AbstractContainerMenu {
|
||||
public static final int NOTEBOOK_SLOTS = NotebookContents.MAX_PAGES;
|
||||
public static final int MOVE_PREVIOUS_BASE = 0;
|
||||
public static final int MOVE_NEXT_BASE = NOTEBOOK_SLOTS;
|
||||
private static final int PLAYER_INVENTORY_START = NOTEBOOK_SLOTS;
|
||||
private final Inventory playerInventory;
|
||||
private final InteractionHand hand;
|
||||
private final int openedHotbarSlot;
|
||||
private final NotebookContainer contents;
|
||||
private final int pageCapacity;
|
||||
|
||||
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
|
||||
}
|
||||
|
||||
public NotebookMenu(int containerId, Inventory inventory, InteractionHand hand) {
|
||||
super(Rivencraft.NOTEBOOK_MENU.get(), containerId);
|
||||
this.playerInventory = inventory;
|
||||
this.hand = hand;
|
||||
this.openedHotbarSlot = inventory.getSelectedSlot();
|
||||
this.pageCapacity = CollationFolderItem.capacity(inventory.player.getItemInHand(hand));
|
||||
this.contents = new NotebookContainer(inventory, hand);
|
||||
|
||||
for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
|
||||
// The client draws these pages on a filtered, scrollable surface instead.
|
||||
addSlot(new PageSlot(contents, page, -1000, -1000, page < pageCapacity));
|
||||
}
|
||||
for (int row = 0; row < 3; row++) {
|
||||
for (int column = 0; column < 9; column++) {
|
||||
int inventoryIndex = column + row * 9 + 9;
|
||||
addSlot(playerSlot(inventoryIndex, 8 + column * 18, 135 + row * 18));
|
||||
}
|
||||
}
|
||||
for (int column = 0; column < 9; column++) {
|
||||
addSlot(playerSlot(column, 8 + column * 18, 193));
|
||||
}
|
||||
}
|
||||
|
||||
private Slot playerSlot(int inventoryIndex, int x, int y) {
|
||||
boolean locked = hand == InteractionHand.MAIN_HAND && inventoryIndex == openedHotbarSlot;
|
||||
return locked ? new LockedSlot(playerInventory, inventoryIndex, x, y)
|
||||
: new Slot(playerInventory, inventoryIndex, x, y);
|
||||
}
|
||||
|
||||
public int pageCapacity() { return pageCapacity; }
|
||||
|
||||
@Override
|
||||
public boolean stillValid(Player player) {
|
||||
return player.isAlive()
|
||||
&& (hand == InteractionHand.OFF_HAND || player.getInventory().getSelectedSlot() == openedHotbarSlot)
|
||||
&& CollationFolderItem.isCollection(player.getItemInHand(hand));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack quickMoveStack(Player player, int index) {
|
||||
Slot slot = slots.get(index);
|
||||
if (!slot.hasItem()) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
ItemStack source = slot.getItem();
|
||||
ItemStack original = source.copy();
|
||||
if (index < PLAYER_INVENTORY_START) {
|
||||
if (!moveItemStackTo(source, PLAYER_INVENTORY_START, slots.size(), true)) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
} else {
|
||||
if (!source.is(Rivencraft.PAGE.get())
|
||||
|| PageItem.isLinkPanel(source)
|
||||
|| !moveItemStackTo(source, 0, PLAYER_INVENTORY_START, false)) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
}
|
||||
if (source.isEmpty()) {
|
||||
slot.setByPlayer(ItemStack.EMPTY);
|
||||
} else {
|
||||
slot.setChanged();
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clickMenuButton(Player player, int id) {
|
||||
int source;
|
||||
int target;
|
||||
if (id >= MOVE_PREVIOUS_BASE && id < MOVE_NEXT_BASE) {
|
||||
source = id;
|
||||
target = source - 1;
|
||||
} else if (id >= MOVE_NEXT_BASE && id < MOVE_NEXT_BASE + NOTEBOOK_SLOTS) {
|
||||
source = id - MOVE_NEXT_BASE;
|
||||
target = source + 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (source < 0 || source >= pageCapacity || target < 0 || target >= pageCapacity
|
||||
|| contents.getItem(source).isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
contents.swap(source, target);
|
||||
broadcastChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clicked(int slotId, int button, ContainerInput clickType, Player player) {
|
||||
if (!stillValid(player)) {
|
||||
player.closeContainer();
|
||||
return;
|
||||
}
|
||||
if (clickType == ContainerInput.SWAP
|
||||
&& (button == openedHotbarSlot && hand == InteractionHand.MAIN_HAND
|
||||
|| button == 40 && hand == InteractionHand.OFF_HAND)) {
|
||||
return;
|
||||
}
|
||||
super.clicked(slotId, button, clickType, player);
|
||||
}
|
||||
|
||||
private static final class PageSlot extends Slot {
|
||||
private final boolean enabled;
|
||||
|
||||
private PageSlot(Container container, int slot, int x, int y, boolean enabled) {
|
||||
super(container, slot, x, y);
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
@Override public boolean mayPlace(ItemStack stack) {
|
||||
return enabled && stack.is(Rivencraft.PAGE.get())
|
||||
&& !PageItem.isLinkPanel(stack);
|
||||
}
|
||||
@Override public boolean mayPickup(Player player) { return enabled; }
|
||||
@Override public int getMaxStackSize() { return 1; }
|
||||
}
|
||||
|
||||
private static final class LockedSlot extends Slot {
|
||||
private LockedSlot(Container container, int slot, int x, int y) {
|
||||
super(container, slot, x, y);
|
||||
}
|
||||
|
||||
@Override public boolean mayPickup(Player player) { return false; }
|
||||
@Override public boolean mayPlace(ItemStack stack) { return false; }
|
||||
}
|
||||
|
||||
private static final class NotebookContainer extends SimpleContainer {
|
||||
private final Inventory inventory;
|
||||
private final InteractionHand hand;
|
||||
private boolean loading;
|
||||
|
||||
private NotebookContainer(Inventory inventory, InteractionHand hand) {
|
||||
super(NOTEBOOK_SLOTS);
|
||||
this.inventory = inventory;
|
||||
this.hand = hand;
|
||||
ItemStack notebook = inventory.player.getItemInHand(hand);
|
||||
loading = true;
|
||||
List<ItemStack> saved = notebook.getOrDefault(
|
||||
Rivencraft.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||
for (int index = 0; index < saved.size(); index++) {
|
||||
super.setItem(index, saved.get(index).copy());
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private void swap(int first, int second) {
|
||||
loading = true;
|
||||
ItemStack held = getItem(first);
|
||||
setItem(first, getItem(second));
|
||||
setItem(second, held);
|
||||
loading = false;
|
||||
setChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChanged() {
|
||||
super.setChanged();
|
||||
ItemStack notebook = inventory.player.getItemInHand(hand);
|
||||
if (loading || !CollationFolderItem.isCollection(notebook)) {
|
||||
return;
|
||||
}
|
||||
int capacity = CollationFolderItem.capacity(notebook);
|
||||
NonNullList<ItemStack> saved = NonNullList.withSize(capacity, ItemStack.EMPTY);
|
||||
for (int index = 0; index < capacity; index++) {
|
||||
ItemStack page = getItem(index);
|
||||
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
|
||||
}
|
||||
notebook.set(Rivencraft.NOTEBOOK_CONTENTS.get(), new NotebookContents(saved));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user