Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13bedd26f | ||
|
|
40770841ee | ||
|
|
38e40ddba3 | ||
|
|
a7a73d3c17 | ||
|
|
48fbae9be9 | ||
|
|
c223f3fdca | ||
|
|
d75154fa9b | ||
|
|
9ccec18c18 | ||
|
|
d9464fad76 | ||
|
|
d6e9a58a24 | ||
|
|
fa294be685 | ||
|
|
cb31156584 | ||
|
|
c23b057f51 | ||
|
|
9ab8d3de4a | ||
|
|
56005ac512 | ||
|
|
9ff931afa9 | ||
|
|
eda19de43c | ||
|
|
0fb2f7c1bb | ||
|
|
4f07f675a4 | ||
|
|
b8be99606f | ||
|
|
98595c83ea | ||
|
|
e7e2cf7e5a | ||
|
|
77f15f8599 | ||
|
|
2b2605b277 | ||
|
|
2f1e6bd6f2 | ||
|
|
9d5ff2a2cb | ||
|
|
3ac4c499ab | ||
|
|
9044835ec5 | ||
|
|
7ae41da23e | ||
|
|
9cb63e6994 | ||
|
|
6337b5aa10 | ||
|
|
b9faa28dd5 | ||
|
|
09d2b0800c | ||
|
|
ee905cab68 | ||
|
|
5c08f391a2 | ||
|
|
b8d26a9036 |
No files matched your search
@@ -32,6 +32,10 @@ bin/
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
|
||||
### Python tools ###
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
### Minecraft Modding ###
|
||||
run/
|
||||
logs/
|
||||
|
||||
@@ -1,267 +1,130 @@
|
||||
# Rivencraft
|
||||
|
||||
Rivencraft is a clean, incremental port of the ideas and gameplay of
|
||||
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
||||
1.21.1 on NeoForge.
|
||||
Write a book. Name an Age. Step into a world that did not exist before.
|
||||
|
||||
The bootstrap, books/linking, Ages, writing-system, and Age-grammar milestones are complete.
|
||||
The writing system uses a data-pack symbol catalog and persistent
|
||||
ordered-writing model. Linking Books and Descriptive Books create persistent
|
||||
Ages from either authored supported symbols or seeded random completion.
|
||||
Milestone 6 instability work is in progress: Ages persist frozen instability
|
||||
scores and provider plans, and the bounded runtime implements status effects,
|
||||
colored decay, lightning, explosions, meteors, burning, and erosion. The
|
||||
[Milestone 5 acceptance record](docs/MILESTONE_5_ACCEPTANCE.md) lists the
|
||||
supported grammar, optional integrations, and limits; the
|
||||
[instability runtime policy](docs/INSTABILITY_RUNTIME.md) records the current
|
||||
effects and modern safety boundaries.
|
||||
Rivencraft brings the books, writing stations, symbols, and unpredictable Ages
|
||||
of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to
|
||||
Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing
|
||||
flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft
|
||||
also lets you author modern terrain, biomes, structures, seasons, and celestial
|
||||
bodies. It is an independently developed project, not a byte-for-byte copy of
|
||||
the 1.12.2 mod.
|
||||
|
||||
Milestone 4 now includes stackable blank Pages plus server-created symbol,
|
||||
unresolved-symbol, and baseline link-panel states. Page visuals follow the
|
||||
legacy runtime-composition rules. Craft an Ink Vial from two black dye, a glass
|
||||
bottle, and a water bucket; the bucket is returned. Hold vanilla paper in one
|
||||
hand and use the Ink Vial in the other to prepare a baseline Link Panel Page.
|
||||
Each vial holds 16 ink units, and the default link-panel cost is two units.
|
||||
The spent vial returns a glass bottle. Server owners can adjust the symbol-copy
|
||||
and link-panel costs in the `writing` section of `rivencraft-server.toml`.
|
||||
The Ink Mixer now offers a Legacy-style station for producing Link Panels:
|
||||
insert paper and Ink Vials, then take a Link Panel from the result slot. Each
|
||||
vial fills its 16-unit basin and returns a glass bottle; taking one Link Panel
|
||||
uses the full vial. No server tick is needed. The earlier paper-in-one-hand
|
||||
method still uses the configurable link-panel ink cost. Ink Mixer
|
||||
additives can roll Disarm, Maintain Momentum, and Intra Linking onto a Link
|
||||
Panel. Descriptive Books retain Disarm and Momentum; Intra Linking is only
|
||||
meaningful for same-dimension travel with a Linking Book.
|
||||
TerraBlender 1.21.1 integration is optional: registered modded biomes receive
|
||||
Biome pages, with cave and Nether biomes in a separate Cave Biome creative tab.
|
||||
TerraBlender Overworld/Nether regions extend compatible Age
|
||||
biome pools. See [the compatibility matrix](docs/TERRABLENDER_COMPATIBILITY.md)
|
||||
for supported versions and in-game verification.
|
||||
Serene Seasons 1.21.1 can maintain separate season progress in an Age dimension.
|
||||
Rivencraft automatically whitelists loaded Ages without a restart when Serene is installed;
|
||||
season pages can instead opt an Age out or hold it in one season.
|
||||
See [the optional compatibility guide](docs/SERENE_SEASONS_COMPATIBILITY.md)
|
||||
for the supported manual configuration and current limits; Rivencraft does not
|
||||
require Serene Seasons or GlitchCore.
|
||||
The Writing Desk can copy a selected written page from its collection tabs.
|
||||
The Writing Desk is now craftable from planks, a glass bottle, and a feather.
|
||||
It occupies two adjacent blocks, with one shared inventory; loaded paper and
|
||||
the selected page appear on its Legacy-inspired model.
|
||||
Desks placed before 0.3.6.1 keep their old one-block footprint until broken
|
||||
and placed again; leave an adjacent space when replacing one.
|
||||
Its 88 collection tabs, paper input, ink vial input, empty glass-bottle return,
|
||||
16-unit ink meter, and inventory persist
|
||||
through world saves; breaking it drops stored contents. The desk screen is a
|
||||
functional foundation using Legacy's original desk and scrollbar textures.
|
||||
Four tabs are visible at once; the controls above and below them move by four
|
||||
(1–4, 5–8, through 85–88). Each viewer can browse a different group. Existing
|
||||
four-tab desks retain their collections and supplies when upgraded.
|
||||
With a notebook selected, pick up a Page from your inventory and click an empty
|
||||
position on the left surface to store one page there. Clear search/filter/sort
|
||||
and turn off `ALL` to place pages at specific positions; occupied positions are
|
||||
never overwritten. A notebook tab exposes all 27 notebook positions (scroll to
|
||||
reach the rest); a loose-page tab exposes only its one occupied position.
|
||||
Empty tabs have no page storage until a notebook or page is put in the tab slot.
|
||||
Click a page on the desk surface to select it for copying. Drag a page from one
|
||||
notebook position to an empty position to move it, or onto another page to swap
|
||||
their positions. This also works between notebooks shown in the `ALL` view.
|
||||
Drag a page out of the panel to turn it back into an item. Drop it over a player
|
||||
inventory slot or desk tab to place it there; a tab with a notebook adds it to
|
||||
the first open position. Dropping elsewhere leaves the page on the cursor for
|
||||
normal inventory placement. The source notebook or tab loses the page only after
|
||||
a valid drop.
|
||||
Put vanilla paper in the
|
||||
upper-left slot and Ink Vials in the upper-right; a copy appears in the
|
||||
lower-left result slot when the selected page is valid. Taking it consumes one
|
||||
paper and, by default, one ink. An available vial refills an empty ink meter
|
||||
when inserted or when copying uses its last ink, and returns a vanilla glass
|
||||
bottle to the lower-right slot. Removing a bottle also retries a blocked refill.
|
||||
Refilling waits if that slot is full or contains another item. The `AZ` control sorts
|
||||
visible pages by name; `ALL` shows pages held across every desk tab. Search and
|
||||
category filtering narrow the view without granting new pages, and cross-tab
|
||||
copy selection is checked by the server.
|
||||
Selecting a stored page on the desk surface shows its Legacy-proportioned
|
||||
portrait preview and name above the player inventory.
|
||||
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
|
||||
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,
|
||||
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
|
||||
feature completion, and random structure selection. Nether/End noise-biome
|
||||
materials now follow their native rules. The beta does not yet provide full
|
||||
1.21.1 gameplay or optional-integration parity.
|
||||
See the [26.3 beta guide](ports/mc263/README.md) and
|
||||
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.98-beta.1.md).
|
||||
Keep the builds and their saves separate, and back up worlds before updating.
|
||||
|
||||
## Book Binder
|
||||
## Getting started
|
||||
|
||||
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
|
||||
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
|
||||
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
|
||||
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
|
||||
are optional; without them, the book creates a random Age. To duplicate a Page
|
||||
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
|
||||
and take the copied Page from the output. A Notebook can hold and transfer a
|
||||
larger ordered collection of Pages between desks and the binder.
|
||||
1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket.
|
||||
Craft an Ink Mixer, Writing Desk, and Book Binder.
|
||||
2. Put paper and an Ink Vial in the Ink Mixer and take a Link Panel Page. Add
|
||||
ingredients to the basin first if you want a chance of link modifiers.
|
||||
3. Put leather or an empty Notebook and the Link Panel in the Book Binder.
|
||||
Name the Age and take the Descriptive Book. A book with only a Link Panel
|
||||
creates a seeded random Age; add Symbol Pages to author its features.
|
||||
4. Open the book to create its Age, then click the destination preview to
|
||||
travel. Bring a bound Linking Book for the return trip: automatic Return
|
||||
Links are off by default.
|
||||
|
||||
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
|
||||
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
|
||||
The Link Panel and individual symbol Pages, including registered vanilla biome
|
||||
pages, appear as separately searchable JEI ingredients.
|
||||
Creative inventory separates Link Pages and each symbol category into their own
|
||||
tabs, including distinct Biome and Biome Generation tabs. Blank Pages are hidden
|
||||
from creative inventory and JEI.
|
||||
JEI is optional; vanilla crafting
|
||||
recipes and all writing interactions work without it. For a development client
|
||||
with JEI, run `./gradlew runClient -PwithJei=true`.
|
||||
Find new Symbol Pages in Age libraries, or copy pages you already own with
|
||||
paper and ink at the Writing Desk. The Notebook stores ordered pages, including
|
||||
intentional gaps. Written pages can control terrain, biome distribution,
|
||||
weather, lighting, seasons, sky color, suns and moons, decorations, structures,
|
||||
and more. Incorrect or excessive writing can make an Age unstable; instability
|
||||
may bring decay, lightning, explosions, meteors, burning, erosion, and other
|
||||
hazards. Some combinations are still unsupported and the Binder reports them
|
||||
before consuming ingredients.
|
||||
|
||||
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
|
||||
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
|
||||
the block recipe, and picking up a block unlocks the Receptacle recipe. The
|
||||
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
|
||||
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
|
||||
in JEI when it is installed and can be replaced by a data pack at
|
||||
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
|
||||
world or `/reload`, then check the result in a crafting table and vanilla
|
||||
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
|
||||
book or page is placed on it; this in-world interaction has no JEI guide.
|
||||
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
|
||||
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
|
||||
while hovering either item; Rivencraft does not require Create to run.
|
||||
Place a Rivencraft book or page on a vanilla Lectern to convert it to a Book
|
||||
Stand. Crystal formations yield shards for Crystal Blocks. A connected crystal
|
||||
frame with a Book Receptacle and a bound book forms a portal; frames can stand
|
||||
upright or lie flat. Libraries, Book Stands, and portals use Legacy-inspired
|
||||
assets and sounds. Handheld travel drops the used book at the departure point,
|
||||
so plan how you will retrieve it.
|
||||
|
||||
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
||||
empty Notebook in the left cover slot. Enter a title, then place a Link Panel
|
||||
Page first in the horizontal page strip. Click a page in the strip with a
|
||||
written Page on the cursor to insert it before that position; click it with an
|
||||
empty cursor to take it out. The arrows or mouse wheel scroll through up to
|
||||
27 written Pages. Clicking the strip with a filled Notebook imports its Pages
|
||||
at that position and leaves the emptied Notebook on the cursor. The right slot
|
||||
outputs a Descriptive Book once the title, cover, and Link Panel are present.
|
||||
Inputs are consumed only when the result is taken.
|
||||
## Requirements and optional integrations
|
||||
|
||||
The completed book receives its Age identity when assembled. With only a Link
|
||||
Panel, it describes a randomly generated Age. Authored writing is compiled once
|
||||
into a versioned, server-owned Age plan. The plan freezes the Age seed, resolved
|
||||
symbol meanings, page order, and runtime profile, so later data-pack changes do
|
||||
not reinterpret an existing Age. An authored terrain page selects its runtime
|
||||
profile. Built-in pages cover Standard, Amplified, Cave, Island, Flat, and Void;
|
||||
authored biome, environment, and supported feature pages also affect new Ages.
|
||||
The
|
||||
writing is also stored on the book; the Descriptive Book screen's turned pages
|
||||
show ordered symbols and unresolved IDs. The Descriptive Book screen follows
|
||||
Legacy's book spread: the title and Age details are on the left, the destination
|
||||
preview is on the right, and clicks in the bottom corners turn pages. Ordered
|
||||
symbol pages appear before the final blank spread with their runtime-rendered
|
||||
pictograms. Link-panel-only books use Rivencraft glyphs for their generated
|
||||
Age description.
|
||||
Install the same gameplay mods on the client and server:
|
||||
|
||||
## Linking Book
|
||||
- Minecraft **1.21.1**, NeoForge **21.1.264 or newer in the 21.1 line**, and
|
||||
Java 21.
|
||||
- **Dynamic Dimensions AR Edition 0.9.1** and its **Bad Packets 0.8.2**
|
||||
dependency. These are required for Age dimensions.
|
||||
|
||||
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
|
||||
two crafting-grid positions. An unbound book opens a screen where it can be named and bound to the
|
||||
current location. A bound book opens its destination screen and travels when
|
||||
its preview panel is clicked. The destination is stored on the item and
|
||||
survives inventory and world saves. Travel only succeeds if the target
|
||||
dimension is loaded and a non-fluid, passable landing position can be found
|
||||
nearby.
|
||||
The Linking Book uses the same Legacy page layout and bottom-corner page turns
|
||||
as the Descriptive Book, but without the Descriptive Book's gold cover edges.
|
||||
Both screens play Minecraft's book-page-turn sound when a page is turned.
|
||||
Rivencraft works without any of these optional integrations:
|
||||
|
||||
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
||||
the departure and arrival locations.
|
||||
| Mod | What it adds |
|
||||
| --- | --- |
|
||||
| JEI | Searchable Pages and guides for the Ink Mixer, Writing Desk, Book Binder, and Linking Book crafting. |
|
||||
| Create (bundles Ponder) | Ponder book snapshots and a crystal-portal construction guide. |
|
||||
| Immersive Portals | Live destination views in book previews and crystal portals. In `AUTO` preview mode it takes priority over Ponder. |
|
||||
| TerraBlender | Eligible biomes from mods using its Overworld/Nether regions become Age biome candidates and Pages. Biomes O' Plenty is one example; it is not hard-coded or required. |
|
||||
| Serene Seasons (with GlitchCore) | Independent per-Age seasons, including normal seasons, no seasons, and four eternal-season modes. |
|
||||
| Ruins | Authorable Ruins-template Pages and Ruins structures in Ages. |
|
||||
|
||||
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
|
||||
a Book Stand and place the book on it. Use a Linking Book on the stand by
|
||||
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
|
||||
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
|
||||
the stored book and a vanilla lectern.
|
||||
The optional builds used in the latest development checks are JEI
|
||||
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
|
||||
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
|
||||
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
|
||||
evidence and limits of those combinations.
|
||||
|
||||
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
|
||||
Without Create, they use a built-in animated linking panel tinted by the
|
||||
destination sky. The server independently validates every link.
|
||||
Without Create or Immersive Portals, book previews use Rivencraft's built-in
|
||||
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
|
||||
does not offer the live dimension/season control Rivencraft needs.
|
||||
|
||||
## Descriptive Book
|
||||
## Saves, settings, and current limits
|
||||
|
||||
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
|
||||
assigns the book a permanent Age UUID, selects a deterministic base terrain,
|
||||
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
|
||||
arrival platform at the
|
||||
highest suitable nearby terrain. Solid blocks and tree leaves are valid
|
||||
footing. Candidate columns are scanned from the top down so cave floors are not
|
||||
selected, and fluid-covered columns are excluded. If no suitable surface is
|
||||
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
|
||||
The book cannot reopen while its Age is being generated, whether held or on a
|
||||
Book Stand. Once creation finishes, reopening shows the written book.
|
||||
Copies of the written book continue to resolve to the same persistent Age
|
||||
across server restarts.
|
||||
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
|
||||
before changing mod versions, data packs, or optional biome mods: the Age
|
||||
registry and dimension data must be restored together. Rivencraft preserves
|
||||
known older save formats where possible, but pre-public-release worlds are not
|
||||
promised universal compatibility. Missing custom symbols retain their IDs in
|
||||
books; missing registry entries or dimension providers can still prevent an
|
||||
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
|
||||
for supported behavior and deliberate limits.
|
||||
|
||||
New Age dimension folders use a Minecraft-safe lowercase form of the assigned
|
||||
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
|
||||
Age folders are never renamed.
|
||||
Server settings include `descriptive_books.return_link_mode` (default
|
||||
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
|
||||
season enrollment, and instability controls. The client setting
|
||||
`book_preview.mode` chooses `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
An operator can inspect Ages and instability with `/rivencraft age` and find
|
||||
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
|
||||
can edit link properties and retire an Age; use it carefully and keep backups.
|
||||
Binder status messages are off by default. An OP can run
|
||||
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
|
||||
to control them; only OPs see the diagnostics even when enabled.
|
||||
|
||||
Page-free Ages deterministically select one of six base terrains inspired by
|
||||
Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen
|
||||
profile is saved with the Age and does not change when the server restarts.
|
||||
## Project and development
|
||||
|
||||
Linking creates a bound Return Link to the exact source position and orientation
|
||||
according to the configured Return Link policy.
|
||||
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
|
||||
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
|
||||
and [issues](https://git.paddedcell.org/dnorberg/rivencraft/issues).
|
||||
Help build the [Rivencraft wiki](https://git.paddedcell.org/dnorberg/rivencraft/wiki)
|
||||
with guides for Ages, pages, and crafting, or open an issue with documentation
|
||||
suggestions and corrections.
|
||||
Milestone 7 is complete in 1.0.0.2. Its [acceptance record](docs/MILESTONE_7_ACCEPTANCE.md)
|
||||
and [regression matrix](docs/MILESTONE_7_REGRESSION.md) distinguish automated
|
||||
checks from manual evidence and known limitations.
|
||||
|
||||
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
|
||||
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
|
||||
Age creation, persistence, lifecycle commands, configuration, backups, and
|
||||
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
|
||||
Data-pack runtime profiles are documented in
|
||||
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Java 21 JDK
|
||||
- Minecraft 1.21.1
|
||||
- NeoForge 21.1.264 or newer in the 21.1 line
|
||||
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
|
||||
|
||||
Create is optional. When installed, Rivencraft uses its Ponder renderer for
|
||||
book previews when Immersive Portals is absent. Immersive Portals is also
|
||||
optional and takes priority in the default `AUTO` mode, providing a live
|
||||
rotating destination view. Its Explore button enables WASD and jump/sneak
|
||||
camera movement plus left-drag camera look; right-click returns to the rotating
|
||||
view. Without either integration, Rivencraft uses its built-in animated linking
|
||||
panel. Clients can set `book_preview.mode` in
|
||||
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||
Unavailable optional renderers safely fall back to `LEGACY`.
|
||||
The complete backend matrix, supported combinations, cleanup behavior, and
|
||||
known upstream limits are documented in
|
||||
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```shell
|
||||
```sh
|
||||
./gradlew build
|
||||
./gradlew runClient
|
||||
./gradlew runGameTestServer
|
||||
```
|
||||
|
||||
The built mod is written to `build/libs/`.
|
||||
The default dedicated-server GameTest run is deliberately small. Use
|
||||
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
||||
regression suite before a release or when investigating a related issue. See
|
||||
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
||||
The JAR is written to `build/libs/`. Development runs need the required mods;
|
||||
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
|
||||
Versions use `x.y.z.b`: major release, completed milestone, release slice,
|
||||
then fixes/additional work within that slice.
|
||||
|
||||
## Versioning
|
||||
|
||||
Rivencraft versions use `x.y.z.b`:
|
||||
|
||||
- `x` changes for a major public release.
|
||||
- `y` changes when a development milestone is completed.
|
||||
- `z` changes for each release slice within the current milestone.
|
||||
- `b` changes for bug fixes and additional work within that release slice.
|
||||
|
||||
The build component resets to zero when a new release slice begins. Completing
|
||||
a milestone increments `y` and resets both `z` and `b` to zero. For example,
|
||||
milestone 2 development can progress from `0.1.1.0` to `0.1.1.1`, then
|
||||
`0.1.2.0`; completing the milestone produces `0.2.0.0`.
|
||||
|
||||
## Porting approach
|
||||
|
||||
Mystcraft Legacy targets Forge for Minecraft 1.12.2. Rivencraft therefore
|
||||
ports one subsystem at a time onto current registries, codecs, data packs, and
|
||||
NeoForge APIs instead of importing the legacy source tree wholesale.
|
||||
|
||||
See [docs/PORTING.md](docs/PORTING.md) for the roadmap, live Gitea tracking
|
||||
links, architectural direction, and provenance rules.
|
||||
|
||||
## License
|
||||
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Mystcraft Legacy is also
|
||||
LGPL-3.0; copied or adapted upstream code must retain its copyright and license
|
||||
notices. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
|
||||
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
|
||||
@@ -245,6 +245,11 @@ dependencies {
|
||||
// Keep them on the compile classpath without forcing either mod at runtime.
|
||||
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
|
||||
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}"
|
||||
// Ruins is optional; its pre-spawn event is only registered when Ruins is loaded.
|
||||
compileOnly 'curse.maven:ruins-227873:6391825'
|
||||
if (providers.gradleProperty('withRuins').map { it.toBoolean() }.orElse(false).get()) {
|
||||
localRuntime 'curse.maven:ruins-227873:6391825'
|
||||
}
|
||||
// TerraBlender's region API is only used by the guarded compat adapter.
|
||||
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 162 KiB |
+11
-10
@@ -11,7 +11,7 @@ suffixes such as `_2` and `_3`.
|
||||
|
||||
The selected terrain profile, biome distribution, lighting, weather, celestial
|
||||
period, celestial angle, and phase are deterministic and become part of the
|
||||
saved definition. A five-by-five cobblestone platform with clear headroom is
|
||||
saved definition. A five-by-five non-dropping Crystal Platform with clear headroom is
|
||||
prepared at a safe arrival point. The completed book and every copy carrying
|
||||
the same UUID resolve to that definition.
|
||||
|
||||
@@ -42,10 +42,11 @@ on demand when an administrator previously unloaded it. Unloading retains the
|
||||
definition and world folder, so existing books continue to reference the same
|
||||
Age and later reload the same terrain.
|
||||
|
||||
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
|
||||
complete reference index for books in inventories, containers, Book Stands,
|
||||
and unloaded chunks. Until that exists, retaining the definition prevents an
|
||||
administrator action from silently orphaning books.
|
||||
Rivencraft does not automatically delete Ages. The operator-only creative Link
|
||||
Modifier can deliberately retire an unoccupied Age: it unloads the dimension,
|
||||
moves its data to a sibling `<world>-retired-ages/<uuid>` archive, and records
|
||||
the UUID as dead so existing books cannot silently regenerate it. Back up the
|
||||
whole world first.
|
||||
|
||||
Destination-preview chunk loaders are temporary. Rivencraft releases them when
|
||||
the book closes, the player travels, the player disconnects, the destination
|
||||
@@ -101,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
|
||||
matching dimension data and authoritative world saved data from the same
|
||||
backup generation. Do not combine them from different backups.
|
||||
|
||||
There is intentionally no delete command in Milestone 3. Do not manually
|
||||
delete an Age definition or rename its dimension folder: books may exist in
|
||||
inventories, containers, Book Stands, or unloaded chunks, and would be
|
||||
orphaned. `/rivencraft age unload` is the supported reversible maintenance
|
||||
operation.
|
||||
Do not manually delete an Age definition or rename its dimension folder:
|
||||
books may exist in inventories, containers, Book Stands, or unloaded chunks.
|
||||
`/rivencraft age unload` remains the reversible maintenance operation. The
|
||||
Link Modifier's retirement control is not reversible through a book UI;
|
||||
recover its archived data and registry together from a full-world backup.
|
||||
@@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols.
|
||||
Every family retains a safe cobblestone arrival platform. An omitted terrain
|
||||
page still uses the deterministic seed-selected profile.
|
||||
|
||||
### Cave terrain and Legacy
|
||||
|
||||
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
|
||||
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
|
||||
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
|
||||
alteration that carves additional caves; it is not a terrain generator.
|
||||
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
|
||||
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
|
||||
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
|
||||
exact Legacy noise or its older vertical layout. The Caves feature page
|
||||
remains a separate additive carver for other suitable terrain.
|
||||
|
||||
Cave Ages search for safe spawn footing from Y=64 downward rather than using
|
||||
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
|
||||
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
|
||||
Weather, Season, and Celestial pages (including time modifiers) are retained
|
||||
in the book but have no effect on the Cave Age; each adds 100 base instability.
|
||||
The same policy applies to randomly selected Cave terrain and persists through
|
||||
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
|
||||
surface-biome completion is not used for Cave terrain.
|
||||
|
||||
The separate **Floating Islands** feature page adds rare high-altitude island
|
||||
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
|
||||
not replace the terrain page. The feature is seeded by the Age and saved with the written
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Current implementation (Milestone 7 development)
|
||||
|
||||
This is the maintained technical overview of Rivencraft as of the 1.0.0.2
|
||||
slice. It describes implemented behavior, not every behavior in Mystcraft
|
||||
Legacy. [The README](../README.md) is the player introduction;
|
||||
[milestone summaries](MILESTONES.md) explain how the implementation arrived
|
||||
here. The linked deep dives are retained as historical audits and should be
|
||||
read with their version dates in mind.
|
||||
|
||||
## Books, linking, and previews
|
||||
|
||||
- Linking Books bind a named position and direction. Same-dimension use needs
|
||||
Intra Linking. Descriptive Books refer to a persistent Age UUID and keep
|
||||
their authored page order. Copies keep that identity.
|
||||
- Book screens use Legacy-inspired page spreads and destination panels. `AUTO`
|
||||
selects optional Immersive Portals first, then Create/Ponder, then the built-in
|
||||
animation. Travel is validated on the server regardless of renderer.
|
||||
- Handheld travel drops the active book at its departure point as a visible
|
||||
book entity. Book Stands and crystal portals keep the book in place. Disarm
|
||||
drops carried items; Maintain Momentum rotates and carries velocity.
|
||||
- A vanilla Lectern converts to a Book Stand when given a Rivencraft book or
|
||||
page. A Book Receptacle in a connected Crystal Block frame activates a
|
||||
vertical or horizontal portal. Immersive Portals can display a live view;
|
||||
otherwise the Legacy-style animated surface remains. Portal arrivals use
|
||||
existing safe footing; a Descriptive portal does not generate a platform.
|
||||
- Automatic Return Links default to `DISABLED`, with `ON_CREATION` and `ALWAYS`
|
||||
server options. Linking Books do not generate platforms by default; a
|
||||
creative Link Modifier can explicitly enable that property.
|
||||
|
||||
See [preview modes](PREVIEW_COMPATIBILITY.md), [Legacy M7 audit](LEGACY_M7_AUDIT.md),
|
||||
and [Age administration](AGES.md).
|
||||
|
||||
## Ages, writing, and structures
|
||||
|
||||
- Dynamic Dimensions AR Edition creates Age worlds. Each Age has a saved UUID,
|
||||
safe dimension ID, seed, spawn, traits, versioned generation plan, and
|
||||
optional season mode. Page-free or Link-Panel-only books get seeded random
|
||||
choices. Authored supported pages override the appropriate choices.
|
||||
- Terrain choices are Standard, Amplified, Island, Cave, Flat, and Void. Cave
|
||||
terrain uses cave/Nether biomes and an appropriate spawn search; normal
|
||||
terrain starts from exposed surfaces. If no safe arrival site exists, the
|
||||
Age-creation path can construct a non-dropping Crystal Platform at Y=63.
|
||||
- The server catalog accepts typed data-pack symbols. The Binder preserves
|
||||
written order and diagnoses conflicts. Its status readout is off by default;
|
||||
`/rivencraft binder messages enable` makes it visible only to OPs. Symbol
|
||||
Pages are composed at runtime from Legacy glyph assets. Number pages 0–24
|
||||
set color strength or, immediately before a Length, Angle, Phase, or Size
|
||||
modifier, its numeric value.
|
||||
Celestial pages can create multiple suns/moons with independent color, size,
|
||||
angle, period, phase, and direction; authored eclipses affect light.
|
||||
- The Writing Desk stores tabs and copies known Symbol Pages with paper and
|
||||
ink. Notebooks hold ordered pages; the Binder consumes a Link Panel and cover
|
||||
to make a finished Descriptive Book. The Ink Mixer makes Link Panels and
|
||||
rolls Disarm, Intra Linking, and Maintain Momentum from additives. Blank
|
||||
Pages are internal bases, not a craftable or JEI-listed output.
|
||||
- Features include terrain additions, decorations, configurable block/fluid
|
||||
materials, ores, vanilla structures including newer 1.21 structures,
|
||||
Legacy-style libraries, crystals, obelisks, and star fissures. Optional Ruins
|
||||
templates gain authorable pages. Some biome/terrain combinations or feature
|
||||
placements are deliberately unsupported and are diagnosed during binding.
|
||||
|
||||
See [symbol semantics](SYMBOL_SEMANTICS.md), [writing data](WRITING_DATA.md),
|
||||
[generation profiles](AGE_PROFILES.md), and [library/Ruins rules](RUINS_COMPATIBILITY.md).
|
||||
|
||||
## Instability and optional mods
|
||||
|
||||
Invalid or excessive writing and valuable generated blocks can add
|
||||
instability. Block abundance is profiled in loaded chunks, so its contribution
|
||||
and selected effects can change as the Age is explored; the authored plan is
|
||||
not rewritten. Effects are bounded to loaded Ages and include status effects,
|
||||
colored decay, lightning, explosions, meteors, burning, and erosion. Operator
|
||||
commands expose the score and providers; the book GUI does not. See
|
||||
[scoring](INSTABILITY_SCORE.md) and [runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
JEI, Create/Ponder, Immersive Portals, TerraBlender, Serene Seasons with
|
||||
GlitchCore, and Ruins are optional. TerraBlender can supply biomes from any mod
|
||||
using its eligible regions; Biomes O' Plenty is a test example. Serene Seasons
|
||||
has per-Age normal/no/eternal modes and live Age enrollment. Ecliptic Seasons
|
||||
is not integrated because its 1.21.1 API lacks supported live registration and
|
||||
per-Age calendar control. See the [optional-mod matrix](MILESTONE_7_REGRESSION.md).
|
||||
|
||||
## Persistence and boundaries
|
||||
|
||||
An Age registry definition and its dimension folder belong to the **same
|
||||
whole-world backup**. Known older book/writing, plan, workstation, and Age
|
||||
formats have migration or stable-default handling. Unknown symbol IDs remain
|
||||
in books; missing registry objects or removed dimension mods can still prevent
|
||||
world loading. The OP-only Link Modifier can retire an unoccupied Age. That
|
||||
operation marks its UUID dead and moves its dimension data to a sibling
|
||||
`<world>-retired-ages/<uuid>` folder; it is not ordinary book maintenance.
|
||||
Do not use it without a backup.
|
||||
|
||||
This is not an exact port of every Legacy generator, page, hazard, or client
|
||||
renderer. Modern safety budgets and NeoForge's dimension/biome APIs require
|
||||
adaptations. The [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md)
|
||||
separates tested behavior from unverified multiplayer/restart/optional-mod
|
||||
combinations. The [Milestone 7 acceptance record](MILESTONE_7_ACCEPTANCE.md)
|
||||
records release evidence and remaining validation limits.
|
||||
+7
-2
@@ -1,5 +1,9 @@
|
||||
# GameTest suites
|
||||
|
||||
The current Milestone 7 results and manual handoffs are in the
|
||||
[regression matrix](MILESTONE_7_REGRESSION.md). This file documents the test
|
||||
mechanics and older scenario-specific handoffs.
|
||||
|
||||
The default dedicated-server run is a small smoke suite. Its Rivencraft tests
|
||||
cover core linking, deterministic Age definition, writing persistence, legacy
|
||||
data migration, workstation transactions, and forged/replayed menu actions.
|
||||
@@ -85,8 +89,9 @@ With optional JEI installed, search for the Ink Mixer category. It should show
|
||||
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
|
||||
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
|
||||
with their property chances. Black Dye and Clay Ball should not appear: the
|
||||
former had no working Legacy effect, while Generate Platform is redundant
|
||||
with default Age arrival. Without JEI, Rivencraft should still
|
||||
former had no working Legacy effect, while Clay Ball remains unsupported by
|
||||
the mixer. The creative Link Modifier can enable a single arrival platform for
|
||||
Linking Books; they do not create one by default. Without JEI, Rivencraft should still
|
||||
start and the mixer should work normally.
|
||||
|
||||
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
|
||||
|
||||
@@ -27,4 +27,4 @@ Implementation requirements: keep the mixer server-authoritative; persist mixtur
|
||||
|
||||
The built-in vanilla-item rows above are supported except black dye and clay balls. Legacy ignores black dye's empty effect key, so Rivencraft does not consume or advertise it in JEI. Clay Balls are omitted because Age arrival already builds a safe platform by default and a separate Generate Platform link modifier would be redundant. Right-clicking the Ink Mixer basin with a carried item adds one; left-clicking adds the carried stack. The mixture is persistent and its next panel is deterministic. The visual basin tooltip shows current probabilities. Only taking the virtual Link Panel consumes paper/ink and clears the mixture. The Link Panel's property component is copied to a Linking Book by the leather recipe or a Descriptive Book by the Binder. JEI's optional Ink Mixer category lists the base panel and each supported additive interaction with its per-property chance; Rivencraft does not require JEI to run.
|
||||
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The old Generate Platform bit remains readable on pre-0.4.7.1 test items but is neither rolled nor applied. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The Ink Mixer still does not roll Generate Platform, but since 0.6.7.1 the creative Link Modifier can explicitly enable it for Linking Books; their default remains off. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Milestones at a glance
|
||||
|
||||
This is the condensed milestone history. The [current implementation](CURRENT_IMPLEMENTATION.md)
|
||||
is authoritative for what the latest development build does; older acceptance
|
||||
and audit files remain available as dated evidence, not competing current
|
||||
instructions. [Gitea milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
|
||||
hold the live issue status.
|
||||
|
||||
## 1 — Bootstrap (complete)
|
||||
|
||||
Established the NeoForge 1.21.1/Java 21 project, mod identity, registries,
|
||||
assets, build, and first automated checks. The port deliberately shares
|
||||
Mystcraft's concepts while maintaining its own modern package and data model.
|
||||
See [porting principles](PORTING.md) and [asset provenance](ASSET_PROVENANCE.md).
|
||||
|
||||
## 2 — Books and linking (complete)
|
||||
|
||||
Introduced persistent Linking and Descriptive Books, conversion of vanilla
|
||||
Lecterns into Book Stands, named books and Ages, safe travel, destination
|
||||
previews, and a configurable Return Link policy. Later milestones refined
|
||||
renderer selection and portal travel. See [preview compatibility](PREVIEW_COMPATIBILITY.md).
|
||||
|
||||
## 3 — Ages (complete)
|
||||
|
||||
Added persistent runtime dimensions, deterministic random Age traits,
|
||||
name-derived unique dimension folders, terrain/biome choices, spawn recovery,
|
||||
and Age lifecycle commands. See [Age administration](AGES.md),
|
||||
[runtime profiles](AGE_PROFILES.md), and [random weights](RANDOM_AGE_WEIGHTS.md).
|
||||
|
||||
## 4 — Writing system (complete)
|
||||
|
||||
Introduced Symbol Pages, Link Panels, Notebooks, the Writing Desk, Book Binder,
|
||||
and Ink Mixer. Writing order and unknown symbol references persist instead of
|
||||
being inferred from UI text. See [writing data](WRITING_DATA.md),
|
||||
[descriptive-book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), and
|
||||
[Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md).
|
||||
|
||||
## 5 — Age grammar (complete for the supported subset)
|
||||
|
||||
Compiled supported pages into frozen Age plans: terrain, biome distribution,
|
||||
environment, features, block/fluid materials, structures, crystals, and
|
||||
fissures. Added optional TerraBlender and Serene Seasons adapters. This was
|
||||
not a claim of complete Legacy grammar parity. See [M5 acceptance](MILESTONE_5_ACCEPTANCE.md),
|
||||
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md),
|
||||
[symbol schema](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md),
|
||||
and [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md).
|
||||
|
||||
## 6 — Instability (complete)
|
||||
|
||||
Added authored and live block-abundance scoring, frozen provider plans,
|
||||
operator diagnostics, bounded status effects, decay, lightning, explosions,
|
||||
meteors, burning, and erosion. Safety budgets are deliberate modern
|
||||
adaptations. See [M6 acceptance](MILESTONE_6_ACCEPTANCE.md),
|
||||
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), and
|
||||
[runtime policy](INSTABILITY_RUNTIME.md).
|
||||
|
||||
## 7 — Structures and polish (complete in 1.0.0.2)
|
||||
|
||||
Added Legacy-layout Age libraries, page-bearing stands, crystal portals and
|
||||
receptacles, the handheld dropped-book entity, data-driven recipes,
|
||||
advancements, asset/audio polish, optional Ruins, compatibility updates, and
|
||||
expanded sky/glyph pages. Issue #83 covered regression, migration, and document
|
||||
consolidation; issue #84 records the final milestone acceptance. See the
|
||||
[M7 acceptance](MILESTONE_7_ACCEPTANCE.md), [M7 Legacy audit](LEGACY_M7_AUDIT.md),
|
||||
[M7 regression matrix](MILESTONE_7_REGRESSION.md), and
|
||||
[M7 compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Milestone 7 acceptance — 1.0.0.2
|
||||
|
||||
Milestone 7 is complete for the supported NeoForge 1.21.1 feature set. This is
|
||||
not a claim of total Mystcraft Legacy parity or exhaustive optional-mod
|
||||
certification. The [current implementation](CURRENT_IMPLEMENTATION.md) is the
|
||||
up-to-date behavior summary; the [regression matrix](MILESTONE_7_REGRESSION.md)
|
||||
separates automated evidence from manual scenarios.
|
||||
|
||||
## Scope and issue gate
|
||||
|
||||
The Gitea audit for issue #84 found no other open issues. Required existing
|
||||
#14 (cave terrain), #43 (glyph numbers), #44 (sky), and #60 (old-save policy)
|
||||
were closed. M7 implementation and review issues #75–#83, #85–#90, and
|
||||
later additions #91–#94 were closed before this acceptance. No required
|
||||
issue was silently deferred.
|
||||
|
||||
| Legacy concept | Rivencraft implementation or deliberate difference |
|
||||
| --- | --- |
|
||||
| Cave terrain and writing | Cave terrain uses its cave/Nether biome set and a modern Y=64-start spawn search. Existing non-cave terrain still supports caves as features. See [Age profiles](AGE_PROFILES.md) and [grammar](WRITING_GRAMMAR.md). |
|
||||
| Libraries | The recognizable Legacy small-library layout and sparse candidate spacing are ported as a structure template with a chest and up to five page-bearing Book Stands. It generates only in Ages and only on suitable surfaces; player-selectable Legacy or Age-only loot is supported. See [Legacy M7 audit](LEGACY_M7_AUDIT.md). |
|
||||
| Crystal portal and Receptacle | Connected Crystal Blocks support vertical and horizontal portals. The held book stays in the Receptacle; removing it removes the surface. Rivencraft validates loaded bounds and block protection, saves owner pointers, and uses book-travel rules. Optional Immersive Portals supplies a live destination view, with an animated fallback while unavailable. |
|
||||
| Handheld Linking/Descriptive Books | The used book remains behind as a rendered book entity at the origin. The exact Legacy entity pickup rule is not copied: modern contact pickup is used. Return Links default to disabled but remain configurable. |
|
||||
| Recipes and discovery | JSON crafting recipes and recipe-book unlocks cover the supported writing/crystal progression. Optional JEI covers the Binder, Writing Desk, and Ink Mixer; a Lectern conversion has no JEI guide. Blank Pages are not craftable. Optional Create/Ponder teaches portal construction. See [JEI review](MILESTONE_7_JEI_REVIEW.md). |
|
||||
| Models, sounds, and advancements | Legacy-sourced assets are credited in [asset provenance](ASSET_PROVENANCE.md); 1.21.1-compatible models and renderer adaptations are documented in the [visual audit](MILESTONE_7_VISUAL_AUDIT.md). Server-owned advancements cover book, writing, Age, and portal progress; the unimplemented Quinn equivalent is explicitly omitted in [advancement notes](MILESTONE_7_ADVANCEMENTS.md). |
|
||||
| Numbers, sky, and extra items | Base-25 number glyphs, celestial modifiers and eclipses, Symbol Portfolios, the OP-only Link Modifier, and optional Ruins pages were follow-up additions. Legacy page semantics are adapted to the modern authoring plan rather than a binary copy of 1.12.2 worldgen. |
|
||||
|
||||
## Compatibility and migration
|
||||
|
||||
NeoForge **21.1.264** is the minimum 21.1 build. A player passed the
|
||||
singleplayer client with the optional stack, then a local dedicated server
|
||||
including Immersive Portals, before the minimum was raised. See the
|
||||
[compatibility decision](MILESTONE_7_COMPATIBILITY_REVIEW.md). JEI
|
||||
19.69.0.457 and Serene Seasons 10.1.0.9 received their own player checks;
|
||||
Create/Ponder, Immersive Portals, TerraBlender/biome add-ons, Serene Seasons,
|
||||
JEI, and Ruins remain optional. Ecliptic Seasons is deliberately not
|
||||
integrated: its 1.21.1 public API does not support the required live Age
|
||||
registration and per-Age calendar control. Missing optional mods must not
|
||||
prevent the core mod from loading.
|
||||
|
||||
Known older writing/book, Age-plan, and workstation formats have explicit
|
||||
migration or stable-default handling. An Age registry and its dimension
|
||||
folders must be backed up together. Removed mods, missing custom registry
|
||||
objects, and arbitrary prerelease saves are **not** guaranteed to load.
|
||||
Retiring an Age through the creative Link Modifier archives its data and
|
||||
invalidates its UUID; that intentional destructive operation is not a
|
||||
migration. See the [old-save boundary](MILESTONE_7_REGRESSION.md#old-save-boundary-60).
|
||||
|
||||
## Release validation and limits
|
||||
|
||||
The 0.6.11.0 regression candidate passed `./gradlew --offline build` and
|
||||
fresh-world GameTest runs: core **91/91**, generation **129/129**, and
|
||||
environment **103/103**. The player reported an in-game pass for that
|
||||
candidate. The 1.0.0.1 release candidate additionally allows Number Pages to
|
||||
set Celestial Size. The 1.0.0.2 release makes Binder status diagnostics an
|
||||
OP-only, default-off world setting. Its offline build and core GameTests
|
||||
passed (93/93), and the player reported an in-game pass. The
|
||||
preceding 1.0.0.0 candidate passed core and generation GameTests. Its
|
||||
environment archive stalled in the first 50-test batch, repeatedly printing
|
||||
its field without completing; it was stopped and is **not** counted as a
|
||||
1.0.0.0–1.0.0.2 pass. The previous 0.6.11.0 run of that same suite passed 103/103.
|
||||
The focused sky, season, and number-page in-game checks in the regression
|
||||
matrix remain useful regression checks, not claimed full-matrix passes. The
|
||||
Gitea release changelog records the tested JAR and verified SHA-256.
|
||||
|
||||
These results do not prove every two-player race, full restart, migration
|
||||
from every development world, or optional-mod combination. The specific
|
||||
manual matrix remains in [M7 regression](MILESTONE_7_REGRESSION.md); those
|
||||
broader combinations are known validation limits, not claimed passes. Modern
|
||||
chunk and tick budgets, safer portal validation, 1.21.1 world-height and
|
||||
biome APIs, and the dynamic-dimension backend are deliberate deviations from
|
||||
Legacy. A complete Legacy worldgen or link-property port is outside this
|
||||
milestone.
|
||||
@@ -26,12 +26,12 @@ release V0.6.6.0.
|
||||
| --- | --- | --- | --- |
|
||||
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
|
||||
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
|
||||
| JEI (optional) | 19.57.0.449 | 19.64.0.455 | Separate update issue; verify recipe categories and GUI discovery. |
|
||||
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
|
||||
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
|
||||
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
|
||||
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
|
||||
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
|
||||
| Serene Seasons (optional) | 10.1.0.8 compile target | 10.1.0.9 | Separate update issue; the user's Prism instance already contains 10.1.0.9, but that is not a recorded compatibility pass. |
|
||||
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
|
||||
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
|
||||
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# JEI compatibility review (#86)
|
||||
|
||||
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
|
||||
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
|
||||
remains a `compileOnly` API and an opt-in
|
||||
`localRuntime` dependency for development; the published mod has no required
|
||||
JEI dependency.
|
||||
|
||||
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
|
||||
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
|
||||
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
|
||||
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
|
||||
build changes ingredient search to Lucene and adds a runtime ingredient-search
|
||||
refresh API. This makes page search a required client check, even though the
|
||||
current Rivencraft plugin still compiles against the 457 API. The new Maven
|
||||
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
|
||||
compile alone is not evidence that its runtime plugin works.
|
||||
|
||||
Checks performed before changing the pinned dependency:
|
||||
|
||||
| Configuration | Result |
|
||||
| --- | --- |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
|
||||
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
|
||||
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
|
||||
|
||||
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
|
||||
key and saved a backup of the original `jei-client.ini`; that warning was
|
||||
handled by JEI and did not block startup. No Rivencraft config was changed.
|
||||
|
||||
The in-game acceptance report covers the visible JEI integration. A dedicated
|
||||
GameTest server proves no server-side startup failure but does not exercise the
|
||||
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
|
||||
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
|
||||
bundled into or required by the published Rivencraft jar.
|
||||
|
||||
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
|
||||
configuration stalled during test-world preparation before running any cases;
|
||||
it was stopped. The two successful pre-pin runs above used the same JEI version
|
||||
and code, with the dependency supplied by a Gradle property override.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Milestone 7 regression and migration matrix (issue #83)
|
||||
|
||||
This is the dated regression record for the 0.6.11.0 candidate. The
|
||||
[Milestone 7 acceptance](MILESTONE_7_ACCEPTANCE.md) owns the final sign-off. A passing
|
||||
GameTest is server-side evidence; it is not proof of client rendering,
|
||||
multiplayer timing, or every optional-mod combination.
|
||||
|
||||
## Automated evidence
|
||||
|
||||
| Area | Focused check | Candidate result |
|
||||
| --- | --- | --- |
|
||||
| Dedicated server, core linking, book/Age identity, old writing and workstation formats | `./gradlew --offline runGameTestServer`; includes `CoreGameTests`, `AgePersistenceGameTests`, `CrystalPortalGameTests`, M7 crafting/advancement checks | 91/91 required GameTests passed on NeoForge 21.1.264; clean shutdown and fresh-world removal. |
|
||||
| Deterministic structures and terrain | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=generation`; includes library template/spacing/loot/stand checks, cave profiles, vanilla structures, crystal formations, fissures | 129/129 passed after correcting one village test to assert its own contribution rather than a seed-dependent total; fresh-world removal. |
|
||||
| Sky, glyph numbers, seasons, creative/JEI page feed | `./gradlew --offline runGameTestServer -ParchivedGameTestSuites=environment` | 103/103 passed on the 0.6.11.0 candidate, including issue #94's number-page check; fresh-world removal. |
|
||||
| Packaged models and data | `Milestone7ResourceGameTests.coreModelsResolveBundledTextures` in the core suite; parses critical block models, checks their Rivencraft texture references and the library NBT resource | Passed in the 91-test core run. This checks resource presence, not visual orientation or transparency. |
|
||||
| Recipe behavior | `Milestone7CraftingGameTests` and `LinkingBookRecipeGameTests` through the loaded Recipe Manager | Passed in the core run; confirms crystal and receptacle quantities, ink bucket return, no Blank Page crafting, and Linking Book ingredients. |
|
||||
| Portal state/save-load | `CrystalPortalGameTests` plus `AgePersistenceGameTests` in the core suite | Passed; covers activation/release, fallback orientation, horizontal/underside placement, uninterrupted surface, cooldown, retired Age and seed/plan identity. Full server restart remains manual. |
|
||||
|
||||
The generation archive initially failed
|
||||
`AgeStructureGameTests.mixedEndAndTaigaVillagesChargeForEndPortion` because it
|
||||
compared the total instability score (1200) with the village-only expectation
|
||||
(200). A seed-completed sky legitimately supplied an extra sun (+1000). The
|
||||
production scoring was unchanged; the check now isolates the
|
||||
`forced_biome_villages` contribution. The rerun passed 129/129. This is a
|
||||
repaired assertion, not a hidden feature failure.
|
||||
|
||||
Every default GameTest run creates a new world with Minecraft's fixed seed 0
|
||||
and deletes it after pass or fail. See [GameTest guidance](GAMETESTS.md).
|
||||
Do not retry a storage-stalled GameTest: stop it, remove that automated
|
||||
scenario, and give its exact steps to the player. No run in this issue has
|
||||
stalled so far. The older full-archive storage stall is historical and is not
|
||||
counted as a current pass. On the subsequent **1.0.0.0 release candidate**,
|
||||
the environment archive stalled during its first 50-test batch and was
|
||||
stopped. The 103/103 result above applies to 0.6.11.0 only, not to 1.0.0.0
|
||||
or the later 1.0.0.1–1.0.0.2 candidates. For the 1.0.0.2 manual handoff, create an Age
|
||||
with Number Pages immediately before Celestial Angle, Phase, and Size pages;
|
||||
confirm each affects the intended body rather than becoming a stray page. Also verify a
|
||||
fixed-season Age remains fixed across a restart while the Overworld remains
|
||||
independent. Do not rerun the stalled suite merely to obtain a green count.
|
||||
|
||||
## Old-save boundary (#60)
|
||||
|
||||
Issue #60 established a policy of preserving old saves where practical. Tests
|
||||
cover bare-list `OrderedWriting`, page-free books, unknown symbol IDs, older
|
||||
Age definitions with stable defaults, plan-v3/plan-v4 interpretation, and
|
||||
workstation inventory migration. A bound book's UUID and its Age's registered
|
||||
dimension remain stable through ordinary load. This does **not** imply every
|
||||
pre-public-release world, removed biome mod, missing dimension provider, or
|
||||
changed datapack can be opened. Back up the full world; the Age registry and
|
||||
dimension folders are a unit. The creative Link Modifier can intentionally
|
||||
retire an Age, archiving its data and marking the UUID dead. That is an explicit
|
||||
administrative boundary, not a silent migration.
|
||||
|
||||
## Manual matrix for the player
|
||||
|
||||
Use a backup or disposable save. Record the exact mod list and jar version.
|
||||
These checks are **pending for 0.6.11.0** unless a later note records an actual
|
||||
pass. Historical passes on earlier slices should not be silently promoted to
|
||||
this candidate.
|
||||
|
||||
| Scenario | What to verify |
|
||||
| --- | --- |
|
||||
| Full restart | Create a random and an authored Age, a library with a looted chest/page stand, an active portal, and a bound book. Stop and restart the server; UUID, terrain, loot removal, stand contents, portal activation, and return travel must persist. |
|
||||
| Two players | Cross the same portal at nearly the same time, return independently, and use separate book previews. Cooldowns, destination positions, dropped-book ownership/retrieval, and preview chunk loaders must not interfere. |
|
||||
| Cross-dimension travel | Compare the same Descriptive Book by hand, stand, and portal. Check footing/platform rules, arrival coordinates and facing, Disarm, Maintain Momentum, Intra Linking, and Return Link mode. |
|
||||
| Client visuals/audio | Compare book spreads, glyphs, D'ni numbers, page/desk/binder/mixer models, 16×16 crystal buds and translucent elements, portal frame/surface order, skies/eclipses, page turns, and Legacy link sounds. A server test cannot observe pixels or audio. |
|
||||
| Optional renderers | With neither Create nor Immersive Portals, confirm Legacy fallback. With Create alone, confirm Ponder snapshots and portal guide. With Immersive Portals, confirm live view, Explore exit, sky/sound isolation, portal travel, and fallback while chunks load. With both, `AUTO` should choose Immersive Portals. |
|
||||
| Optional world/recipe mods | Test JEI guides/search, TerraBlender with a biome-producing addon (for example Biomes O' Plenty), Serene's six season modes plus Overworld independence/restart, and Ruins template selection/spawn weighting. Repeat startup without optional mods. |
|
||||
| Migration | Copy a backed-up older development world. Verify books and Notebooks retain their Age/page IDs, a desk and stand keep contents, old Ages load or provide a clear error, and removing a custom symbol retains its unresolved ID. Do not test by modifying the only copy. |
|
||||
| Binder diagnostics | As a non-OP, verify the Binder still creates books but shows no instability/seeded/status messages. As an OP, run `/rivencraft binder messages enable`, reopen the Binder, and verify those messages appear only for the OP. Disable them and restart the world; the setting should remain disabled. |
|
||||
|
||||
Historical evidence: the player approved NeoForge 21.1.264 on singleplayer
|
||||
and a dedicated server with Immersive Portals; JEI 19.69.0.457 guides, Serene
|
||||
Seasons 10.1.0.9's full per-Age matrix, Ruins generation, and issue #94's
|
||||
numbered celestials each received an in-game pass on their respective earlier
|
||||
release slices. See [compatibility review](MILESTONE_7_COMPATIBILITY_REVIEW.md),
|
||||
[JEI review](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md),
|
||||
and [Ruins notes](RUINS_COMPATIBILITY.md). The current candidate's broad
|
||||
multiplayer/restart/optional-mod matrix remains unverified until tested.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Serene Seasons 10.1.0.9 compatibility review (#88)
|
||||
|
||||
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
|
||||
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
|
||||
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
|
||||
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
|
||||
it does not introduce a new dynamic-dimension API.
|
||||
|
||||
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
|
||||
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
|
||||
classes is identical. In particular, the dimension whitelist field, per-level
|
||||
saved season clock, and season update method have unchanged signatures and
|
||||
implementations. The adapter still writes the whitelist through GlitchCore's
|
||||
config API and sends Rivencraft's whitelist update to already connected clients.
|
||||
|
||||
Verification:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
|
||||
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
|
||||
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
|
||||
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
|
||||
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
|
||||
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
|
||||
|
||||
Serene's sync packet is incompatible with GameTest mock players, so its
|
||||
in-game behavior must be checked with a real client and server. The seasonal
|
||||
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
|
||||
remains the regression checklist for future upgrades. Rivencraft does not
|
||||
bundle or require Serene Seasons or GlitchCore.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Rivencraft 1.0.1.2-alpha.1 — Minecraft 26.3
|
||||
|
||||
Experimental **NeoForge 26.3** port preview. This is a separate artifact from
|
||||
the supported Minecraft 1.21.1 release, and it does **not** have feature
|
||||
parity. Use a disposable test world or make a backup first. Do not migrate a
|
||||
1.21.1 Rivencraft world to this alpha.
|
||||
|
||||
## Working in this alpha
|
||||
|
||||
- Rivencraft loads on Minecraft 26.3 / NeoForge 26.3.0.99 with Java 25.
|
||||
- The first-party runtime Age backend creates separately seeded, persistent
|
||||
prototype dimensions. The operator-only `/rivencraft_port age` commands can
|
||||
create and inspect them.
|
||||
- Runtime Age catalogs are synchronized and acknowledged before cross-dimension
|
||||
travel. A missing or unacknowledged destination is refused.
|
||||
- Operator-created Descriptive Books can enter prototype Ages.
|
||||
- Handheld Linking Books can be named and bound to a location, then used from
|
||||
their book GUI to return there. The destination data survives a restart.
|
||||
- The Crystal Block and Crystal Shard are registered with reused art.
|
||||
|
||||
## Not yet ported
|
||||
|
||||
Normal authored Age generation, book crafting and workstations, Page symbols,
|
||||
most blocks and items, portal systems, Link modifiers, the rotating destination
|
||||
preview, instability, and optional mod integrations. The Linking Book GUI shows
|
||||
a placeholder destination panel rather than a live scene. Generated Ages use
|
||||
prototype vanilla terrain, not the player's authored Pages.
|
||||
|
||||
The [26.3 port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
|
||||
track the remaining work. The stable 1.21.1 release remains available
|
||||
separately. Please file alpha bugs with the exact Minecraft, NeoForge and
|
||||
Rivencraft versions, plus the relevant client/server log.
|
||||
|
||||
## Verification
|
||||
|
||||
- Clean 26.3 Gradle build passed.
|
||||
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
|
||||
their terrain persisted across a restart.
|
||||
- The user verified the handheld Linking Book GUI click and travel in-game on
|
||||
2026-10-27.
|
||||
|
||||
These checks are **not** the full port acceptance matrix or a stability claim.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Rivencraft 1.0.1.3-alpha.1 — Minecraft 26.3
|
||||
|
||||
Experimental **NeoForge 26.3** port preview. This is separate from the
|
||||
supported Minecraft 1.21.1 release and does **not** have feature parity. Use
|
||||
a disposable test world or make a backup first. Do not migrate a 1.21.1
|
||||
Rivencraft world to this alpha.
|
||||
|
||||
## Changes since 1.0.1.2-alpha.1
|
||||
|
||||
- An unbound Descriptive Book now opens a naming screen. Binding it creates a
|
||||
distinct prototype Age with a random seed; duplicate names receive a
|
||||
numbered suffix.
|
||||
- A bound Descriptive Book opens a book screen whose destination panel can be
|
||||
clicked to enter its Age. The server validates the held book and waits for
|
||||
the client's Age catalog acknowledgement before travel.
|
||||
- Retired Age IDs are excluded when assigning a new book's Age name, so an
|
||||
archived world cannot be silently reused.
|
||||
|
||||
## Still experimental
|
||||
|
||||
These Ages use prototype vanilla terrain. Page-authored generation, crafting
|
||||
and workstations, most items and blocks, portals, Link modifiers, instability,
|
||||
live destination previews, and optional mod integrations are not ported. The
|
||||
book panel is a placeholder, not a rendered destination view. The
|
||||
[port status](MINECRAFT_26_3_PORT.md) and [open port issues](https://git.paddedcell.org/dnorberg/rivencraft/issues)
|
||||
track the remaining work. The stable 1.21.1 release remains available
|
||||
separately.
|
||||
|
||||
## Verification
|
||||
|
||||
- Clean 26.3 Gradle build passed.
|
||||
- Disposable dedicated-server smoke test passed: two distinct Age seeds and
|
||||
their terrain persisted across restart.
|
||||
- The player verified Descriptive Book binding and travel in game on
|
||||
2026-10-29.
|
||||
|
||||
These checks are **not** the full port acceptance matrix or a stability claim.
|
||||
File diff suppressed because it is too large.
Load diff
+7
-2
@@ -1,5 +1,9 @@
|
||||
# Porting roadmap
|
||||
|
||||
For a concise current status, start with [current implementation](CURRENT_IMPLEMENTATION.md)
|
||||
and [milestone summaries](MILESTONES.md). This file retains the original
|
||||
roadmap and architectural rationale.
|
||||
|
||||
Rivencraft ports Mystcraft Legacy from Minecraft 1.12.2 to NeoForge 1.21.1.
|
||||
The world-generation, dimension, registry, networking, and rendering APIs have
|
||||
changed substantially, so the work is divided into independently testable
|
||||
@@ -62,8 +66,9 @@ acceptance issue and all required implementation issues are complete.
|
||||
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
|
||||
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
|
||||
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
||||
sounds, advancements, compatibility, and migration tests. Start with the
|
||||
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
|
||||
sounds, advancements, compatibility, and migration tests. **In progress.**
|
||||
See the [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md) and
|
||||
[Legacy audit](LEGACY_M7_AUDIT.md).
|
||||
|
||||
## Architectural direction
|
||||
|
||||
|
||||
@@ -66,8 +66,11 @@ unstable in a particular graphics or mod configuration.
|
||||
|
||||
For preview-related releases:
|
||||
|
||||
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
|
||||
core Age creation, persistence, and travel without optional renderers.
|
||||
1. Run `./gradlew runGameTestServer` for the core selection matrix, Age
|
||||
creation, persistence, and travel without optional renderers. Add the
|
||||
relevant archives from [GameTest guidance](GAMETESTS.md); if a scenario
|
||||
stalls, stop it and hand that exact check to a real client rather than
|
||||
treating the interrupted run as a pass.
|
||||
2. Open held Linking and Descriptive Books once with each available backend.
|
||||
3. Open both book types from a Book Stand and confirm Explore is disabled.
|
||||
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Publishing Rivencraft with the Gitea, Modrinth, and CurseForge APIs
|
||||
|
||||
The local helper is [`scripts/publish_mod_sites.py`](../scripts/publish_mod_sites.py).
|
||||
It previews a release unless `--publish` is explicitly supplied. The current
|
||||
configuration is for the Minecraft **26.3** artifact only. The 1.21.1 line needs
|
||||
its required external dependencies mapped on both sites before API publishing
|
||||
is enabled for it.
|
||||
|
||||
## One-time token setup
|
||||
|
||||
For Gitea, an existing personal access token is sufficient if it has
|
||||
`write:repository` scope. That scope covers release creation and attachment
|
||||
upload; do not grant `all` if a narrower token is available. Create or inspect
|
||||
tokens under [Gitea user settings → Applications](https://git.paddedcell.org/user/settings/applications).
|
||||
The helper checks token identity and scope without displaying the secret.
|
||||
|
||||
Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication)
|
||||
in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only
|
||||
credential check) scopes. Create a
|
||||
[CurseForge Upload API token](https://support.curseforge.com/support/solutions/articles/9000197321)
|
||||
from the author's API Tokens page. Treat both as passwords: never put them in
|
||||
git, chat, a release note, or a command-line argument.
|
||||
|
||||
In a terminal at the repository root, run these one at a time and paste the
|
||||
corresponding token into the hidden prompt:
|
||||
|
||||
```sh
|
||||
python3 scripts/publish_mod_sites.py store-token modrinth
|
||||
python3 scripts/publish_mod_sites.py store-token curseforge
|
||||
python3 scripts/publish_mod_sites.py store-token gitea
|
||||
```
|
||||
|
||||
The tokens are stored in the desktop Secret Service keyring under
|
||||
`application=rivencraft-publishing`, separately by site. The script also accepts
|
||||
`RIVENCRAFT_MODRINTH_TOKEN` or `RIVENCRAFT_CURSEFORGE_TOKEN` environment variables,
|
||||
but these take precedence over keyring entries and should not be put in shell
|
||||
history or startup files.
|
||||
|
||||
Verify the credentials without uploading anything:
|
||||
|
||||
```sh
|
||||
python3 scripts/publish_mod_sites.py check-token modrinth
|
||||
python3 scripts/publish_mod_sites.py check-token curseforge
|
||||
python3 scripts/publish_mod_sites.py check-token gitea
|
||||
```
|
||||
|
||||
## Release workflow
|
||||
|
||||
Build the JAR, write a Markdown changelog, and preview the metadata:
|
||||
|
||||
```sh
|
||||
python3 scripts/publish_mod_sites.py release \
|
||||
--jar ports/mc263/build/libs/rivencraft-26.3-1.0.1.32-beta.1.jar \
|
||||
--changelog ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md \
|
||||
--version 1.0.1.32-beta.1 \
|
||||
--name 'Rivencraft 1.0.1.32-beta.1 — NeoForge 26.3' \
|
||||
--minecraft 26.3 --java 25 --channel beta
|
||||
```
|
||||
|
||||
For a *new* version only, append `--publish` to upload to both sites. Use
|
||||
`--site modrinth` or `--site curseforge` to target one site. Modrinth rejects an
|
||||
existing version number; CurseForge may not, so check its file list before any
|
||||
retry after a partial failure. CurseForge approval may take additional time.
|
||||
|
||||
The default destination is now all three sites. The Gitea release step requires
|
||||
the annotated version tag to have been pushed first. Gitea's `Vx.y.z.b-mc26.3-beta.1`
|
||||
tag convention is derived from the version and channel; an existing release
|
||||
with different title or notes is never overwritten. If release creation
|
||||
succeeds but its JAR upload fails, rerun with `--site gitea --publish` to resume
|
||||
only the missing attachment. `--site both` retains the earlier Modrinth and
|
||||
CurseForge-only behavior.
|
||||
|
||||
The helper uses Gitea's documented
|
||||
[`POST /repos/{owner}/{repo}/releases`](https://docs.gitea.com/api/operations/repo-create-release/)
|
||||
and [release attachment endpoint](https://docs.gitea.com/api/operations/repo-create-release-attachment/),
|
||||
plus Modrinth's documented multipart
|
||||
[`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and
|
||||
CurseForge's documented
|
||||
[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321)
|
||||
with the JAR, changelog, version channel, NeoForge, Minecraft version, Java
|
||||
version, and client/server environment. It sets no external dependencies for
|
||||
26.3, which uses Rivencraft's first-party Age backend.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Documentation map
|
||||
|
||||
Start with the player-facing [project README](../README.md). The two maintained
|
||||
technical entry points are [current implementation](CURRENT_IMPLEMENTATION.md)
|
||||
and [milestone summaries](MILESTONES.md). Use [GameTest guidance](GAMETESTS.md)
|
||||
to run checks. The files below retain detailed design, provenance, and
|
||||
version-specific test evidence; an older milestone record may describe a
|
||||
former behavior or loader version.
|
||||
|
||||
| Milestone | Detailed records |
|
||||
| --- | --- |
|
||||
| M1–M3 | [Porting roadmap](PORTING.md), [Age administration](AGES.md), [Age profiles](AGE_PROFILES.md), [preview compatibility](PREVIEW_COMPATIBILITY.md), [random Age weights](RANDOM_AGE_WEIGHTS.md) |
|
||||
| M4 | [Writing data](WRITING_DATA.md), [book presentation](DESCRIPTIVE_BOOK_PRESENTATION.md), [Ink Mixer audit](INK_MIXER_MODIFIER_AUDIT.md) |
|
||||
| M5 | [Acceptance](MILESTONE_5_ACCEPTANCE.md), [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md), [writing grammar](WRITING_GRAMMAR.md), [symbol semantics](SYMBOL_SEMANTICS.md), [TerraBlender](TERRABLENDER_COMPATIBILITY.md), [Serene Seasons](SERENE_SEASONS_COMPATIBILITY.md) |
|
||||
| M6 | [Acceptance](MILESTONE_6_ACCEPTANCE.md), [regression](MILESTONE_6_REGRESSION.md), [Legacy audit](LEGACY_INSTABILITY_AUDIT.md), [score](INSTABILITY_SCORE.md), [live blocks](LIVE_BLOCK_INSTABILITY.md), [runtime](INSTABILITY_RUNTIME.md), [diagnostics](INSTABILITY_DIAGNOSTICS.md), [status effects](INSTABILITY_STATUS_EFFECTS.md), [decay](INSTABILITY_DECAY.md), [environmental hazards](INSTABILITY_ENVIRONMENTAL.md), [cloud platform](AGE_CLOUD_PLATFORM.md) |
|
||||
| M7 | [Acceptance](MILESTONE_7_ACCEPTANCE.md), [regression and manual matrix](MILESTONE_7_REGRESSION.md), [Legacy audit](LEGACY_M7_AUDIT.md), [visual/audio](MILESTONE_7_VISUAL_AUDIT.md), [advancements](MILESTONE_7_ADVANCEMENTS.md), [JEI](MILESTONE_7_JEI_REVIEW.md), [Serene review](MILESTONE_7_SERENE_REVIEW.md), [compatibility](MILESTONE_7_COMPATIBILITY_REVIEW.md), [Ruins](RUINS_COMPATIBILITY.md) |
|
||||
|
||||
[Asset provenance](ASSET_PROVENANCE.md) applies across milestones. Detailed
|
||||
audits are intentionally retained for source attribution and migration
|
||||
history; these three entry points replace the former practice of treating
|
||||
every dated task note as a current installation guide.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Optional Ruins template pages (issue #91)
|
||||
|
||||
Rivencraft supports Ruins 1.21.1.2NF as an optional mod. When it is installed,
|
||||
every `.tml` template in Ruins' active `config/ruins_config/<generic-or-biome>/`
|
||||
folders receives a separate Ruins page in Rivencraft's creative and JEI page
|
||||
feeds. New templates added to Ruins' config appear after a restart; removing a
|
||||
template makes its page unavailable to new books. With Ruins absent, no Ruins
|
||||
pages or structure integration are active.
|
||||
|
||||
An authored Age with Ruins pages is admitted through Ruins' dimension gate.
|
||||
Rivencraft also admits the authored templates through their individual
|
||||
`dimensionsToSpawnIn` gates, which usually list only `overworld` and would
|
||||
otherwise prevent them from loading in an Age. Ruins still owns chunk
|
||||
inspection, spawn chance, surface validation, biome pools, and spacing.
|
||||
Rivencraft filters Ruins' weighted
|
||||
template choice to the exact pages in that Age and vetoes an unauthorized
|
||||
template in Ruins' pre-spawn event. A page therefore selects *which* template
|
||||
may appear; it does not guarantee a structure in every chunk. Manual
|
||||
`/testruin` placements are unaffected.
|
||||
|
||||
Repeating a template page multiplies that template's Ruins selection weight by
|
||||
its page count and adds one spawn attempt per chunk for each duplicate page.
|
||||
The extra attempts are capped at 32 per chunk (or Ruins' configured count if
|
||||
higher). Distinct template pages do not add attempts, and Ruins' spacing and
|
||||
surface checks still apply. As with Rivencraft's standard
|
||||
structure pages, the first three copies of each template are instability-free;
|
||||
each additional copy adds 100 instability. Ruins retains its own biome and
|
||||
surface checks, so the standard structure forced-biome penalty does not apply.
|
||||
|
||||
Ruins' event identifies a template only by its filename. If two active folders
|
||||
contain the same `.tml` basename, Rivencraft cannot distinguish them and
|
||||
disables all Ruins pages for that configuration. Give each template a unique
|
||||
filename to restore support. Ruins must also be able to parse the template and
|
||||
find a suitable surface in the selected biome; a page cannot repair an invalid
|
||||
or biome-incompatible template.
|
||||
|
||||
The adapter and dimension/selection mixin are version-sensitive to Ruins
|
||||
1.21.1.2NF. The published Rivencraft jar does not include Ruins. To include it
|
||||
in a development run, use `-PwithRuins=true`.
|
||||
|
||||
## Manual check
|
||||
|
||||
1. Start a client with Ruins 1.21.1.2NF and Rivencraft 0.6.9.3. Confirm the
|
||||
Ruins Pages tab contains one page per configured `.tml` file, and search a
|
||||
distinctive template name in JEI.
|
||||
2. Bind a new Descriptive Book with a Link Panel, Standard Terrain, a suitable
|
||||
surface biome, and one Ruins page (for example ZombieHut). Enter the Age and
|
||||
explore *unvisited* chunks. Ruins marks inspected chunks and will not retry
|
||||
chunks explored under the earlier broken build. Only the selected Ruins
|
||||
template should appear. Ruins'
|
||||
own chance and distance settings still determine how far exploration takes.
|
||||
3. For duplicate weighting, bind an Age with one copy of one template page and
|
||||
three copies of a second template page valid in the same biome. Across enough
|
||||
new candidate chunks, the second should be selected more often relative to
|
||||
the first, and there should be two additional spawn attempts per inspected
|
||||
chunk. Four copies of one template should add 100 instability, visible
|
||||
through the OP Age instability diagnostic, while three copies add none.
|
||||
4. Make another Age without a Ruins page and confirm Ruins structures do not
|
||||
appear there. Restart the game and confirm the authored Age keeps its page
|
||||
selection. Then remove Ruins and confirm Rivencraft starts normally without
|
||||
a Ruins tab.
|
||||
|
||||
The Ruins-enabled GameTest server passed all 90 required tests and loaded the
|
||||
template-dimension and attempt mixins. Its test world was removed afterward.
|
||||
Use the client check above to verify actual Ruins generation and the increased
|
||||
attempt rate.
|
||||
@@ -20,7 +20,7 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
|
||||
|
||||
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
|
||||
|
||||
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Legacy's `ModWords.constructNumber` defines 0 as component 1 and 1–24 by overlaying a unit mark (1–4) and a five-step mark (5–20) from the bottom row of `symbolcomponents.png` in the *same square*. Legacy's Writing Desk has 25 tabs (0–24) and scrolls its four-tab window **one tab at a time**. Thus the supplied Legacy screenshots labeled 1–4, 5–8, and so on show overlapping windows, not 88 distinct numerals. Rivencraft retains its previously requested four-at-a-time navigation and 88 tabs. Its additional tab numbers use positional base 25: each place is an overlaid Legacy digit, with the places side by side (25 = 1,0). The two glyphs are sized and positioned to fit the tab. Hovering reveals the decimal tab index for accessibility. The generated-word mechanism renders page-free Descriptive Book text as layered glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
|
||||
| Role | Value type and allowed values |
|
||||
| --- | --- |
|
||||
@@ -32,9 +32,10 @@ The Writing Desk's 88 collection tabs use Legacy's number components, chiefly th
|
||||
| `weather` | `normal`, `fast`, `slow`, `off`, `always`, `cloudy`, `rain`, `snow`, or `storm`. |
|
||||
| `season` | `no_seasons`, `seasons`, `eternal_winter`, `eternal_summer`, `eternal_fall`, or `eternal_spring`. Seasonal effects other than No Seasons require optional Serene Seasons. |
|
||||
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
|
||||
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
|
||||
| `angle_modifier` | JSON number in `[0, 360)` degrees. |
|
||||
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler will convert to turns. |
|
||||
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. An immediately preceding D'ni number page overrides it with digit × 0.08. |
|
||||
| `angle_modifier` | JSON number in `[0, 360)` degrees. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
|
||||
| `phase_modifier` | JSON number in `[0, 360)` degrees; the compiler converts it to turns. An immediately preceding D'ni number page overrides it with digit × 15°, wrapping 360° to 0°. |
|
||||
| `size_modifier` | JSON scale in `[0.25, 3]`. An immediately preceding D'ni number page overrides it with a linear 0.25×–3× scale: digit 0 is 0.25× and digit 24 is 3×. Without a number, Small/Normal/Large remain 0.5×/1×/2×. |
|
||||
| `block` | Canonical namespaced material ID for a compatible following feature; registered full-collision blocks and source fluids receive generated Pages. |
|
||||
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
|
||||
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ neo_version=21.1.264
|
||||
ponder_version=1.0.82
|
||||
dynamic_dimensions_file_id=7707789
|
||||
badpackets_version=0.8.2
|
||||
jei_version=19.57.0.449
|
||||
jei_version=19.69.0.457
|
||||
# The loader version range can only use the major version of FML as bounds
|
||||
loader_version_range=[1,)
|
||||
|
||||
@@ -36,10 +36,10 @@ mod_name=Rivencraft
|
||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||
mod_license=LGPL-3.0-or-later
|
||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||
mod_version=0.6.6.6
|
||||
mod_version=1.0.1.1
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
serene_seasons_version_id=pHEgQQUE
|
||||
glitchcore_version_id=S2TfWrZR
|
||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||
# This should match the base package used for the mod sources.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.32-beta.1
|
||||
|
||||
This is the first 26.3 beta, focused on authored Age generation. It is not yet
|
||||
feature-complete with the separate Minecraft 1.21.1 release.
|
||||
|
||||
- Ported the Book Binder's ordered symbol-page strip and Legacy-inspired GUI,
|
||||
plus runtime symbol artwork, creative pages, and registry-derived biome pages.
|
||||
- Ported the Ink Mixer, its additive modifiers, and one-paper/one-ink-unit Link
|
||||
Panel creation.
|
||||
- Added authored Standard, Amplified, and Island terrain; fixed, Tiled, Grid,
|
||||
Native, and Tiny through Huge biome distributions; and persistent Age plans.
|
||||
- Fixed workstation block-face occlusion beside the Book Binder and Ink Mixer.
|
||||
|
||||
Feature/material writing, Cave/Flat/Void terrain, portals, and other 1.21.1
|
||||
systems are still being ported. Use a test world and keep a backup. Minecraft
|
||||
1.21.1 saves are not supported by this beta.
|
||||
|
||||
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and
|
||||
Java 25.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.36-beta.1
|
||||
|
||||
This beta adds Flat and Void terrain to authored Ages on Minecraft 26.3.
|
||||
|
||||
- Bind Descriptive Books with Flat or Void terrain, including multiple Biome pages and supported biome-distribution pages.
|
||||
- Flat ground starts at Y=0; travel lands on its surface rather than on an elevated fallback platform.
|
||||
- Flat Ages generate the selected biomes' surface decorations, including trees and vegetation. Void Ages remain empty while retaining authored biome climate.
|
||||
- Expanded dedicated-server checks for Flat and Void terrain, biome distribution, and persistence after restart.
|
||||
|
||||
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
|
||||
|
||||
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.39-beta.1
|
||||
|
||||
This beta adds Cave terrain to authored Ages on Minecraft 26.3.
|
||||
|
||||
- Bind Descriptive Books with the Cave terrain page and Cave or Nether biome pages, including supported multi-biome distributions.
|
||||
- Cave Ages use the cave noise shape but remain dry by default. Newly generated chunks no longer flood below the vanilla cave sea level.
|
||||
- Cave travel searches from Y=64 downward for dry, properly footed space instead of landing on the cave roof or underwater.
|
||||
- Expanded dedicated-server checks for Cave creation, biome distribution, and persistence after restart.
|
||||
|
||||
The player verified the Cave and safe-landing changes in game. Existing generated chunks are not drained or regenerated. A future Flooded page will provide an authored water level; see [issue #107](https://git.paddedcell.org/dnorberg/rivencraft/issues/107).
|
||||
|
||||
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
|
||||
|
||||
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.41-beta.1
|
||||
|
||||
This beta expands Age generation on Minecraft 26.3.
|
||||
|
||||
- Descriptive Books with no authored symbol pages now choose a seeded random terrain and biome distribution. The chosen plan is saved with the Age and restored after restart.
|
||||
- A Block material page followed by a Rocks feature page now generates small clusters of that block in newly generated surface chunks. This first feature path supports Standard, Amplified, Island, and Flat terrain; Cave and Void combinations remain unsupported.
|
||||
- Expanded dedicated-server checks cover random Age plans and authored Rocks generation across restart.
|
||||
|
||||
The player verified the Rocks feature in game. Random feature selection, duplicate feature pages, unpaired materials, and instability scoring are not yet ported. Existing chunks are not regenerated.
|
||||
|
||||
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
|
||||
|
||||
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.54-beta.1
|
||||
|
||||
This beta collects the player-validated Age feature port slices since
|
||||
1.0.1.41-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
|
||||
|
||||
## Age generation
|
||||
|
||||
- Rocks now use the established 1.21.1 forest-rock placement. Unmodified
|
||||
material-bearing features choose seeded materials independently; a preceding
|
||||
compatible Block or Fluid page selects the material explicitly.
|
||||
- Added Flowers, Trees, Huge Trees, Dungeons, Surface Lakes, Deep Lakes, and
|
||||
Small Lakes using the 1.21.1 placement rules adapted to 26.3.
|
||||
- Added Floating Islands, Tendrils, Skylands, Spheres, Spikes, and Obelisks
|
||||
using the existing Rivencraft generation algorithms.
|
||||
- Added blue Crystal Formations with budding crystal and initially seeded buds.
|
||||
- Added Dense Ores: repeated pages replay eligible biome ore placements,
|
||||
including compatible modded ores and explicitly tagged custom placements.
|
||||
- Added Star Fissures: one Legacy-shaped rift near the first arrival point,
|
||||
a starfield at the dimension's minimum Y, and return travel to the shared
|
||||
Overworld spawn with the original fissure sound. Cave roofs are preserved.
|
||||
- Feature plans, materials, repeated occurrences, and fissure completion
|
||||
persist across restart. Existing generated chunks are not redecorated.
|
||||
|
||||
## Current limits
|
||||
|
||||
- Authored features retain terrain restrictions. Most repeated features allow
|
||||
up to 32 occurrences; exceeding the current authoring cap blocks assembly.
|
||||
Skylands and Star Fissures retain their single-effect behavior when repeated.
|
||||
- Put a material page immediately before a compatible feature. Dangling
|
||||
material assignment, full Legacy grammar recovery, instability, and random
|
||||
feature selection are not yet ported. Pageless books randomize terrain and
|
||||
biome distribution, not the newly authored feature list.
|
||||
- 26.3 combines base terrain, surface, and carving. Added solid volumes run
|
||||
after that combined operation and before decoration, so base carvers do not
|
||||
cut those added volumes.
|
||||
- The full 1.21.1 writing/discovery loop, structures, environmental systems,
|
||||
crystal portals, and optional integration parity remain in progress. The
|
||||
presence of a Creative page is not a promise that its effect is ported.
|
||||
|
||||
Build and static checks passed. The player reported in-game passes for these
|
||||
slices. Earlier server smoke coverage was expanded, but the later generation
|
||||
slices were manually validated; this release does not claim a new full
|
||||
dedicated-server regression pass.
|
||||
|
||||
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
|
||||
and **Java 25**. Dynamic Dimensions and Bad Packets are not required by this
|
||||
26.3 build. Do not use Minecraft 1.21.1 dependencies or saves with it. Back up
|
||||
the entire world before updating; beta-save compatibility is not guaranteed.
|
||||
|
||||
Report issues and help with documentation at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,72 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.65-beta.1
|
||||
|
||||
This beta collects the player-tested port slices since 1.0.1.54-beta.1.
|
||||
It is not yet feature-complete with the Minecraft 1.21.1 release.
|
||||
|
||||
## Age generation
|
||||
|
||||
- Added authored structure pages: Villages, Mineshafts, Strongholds, Trial
|
||||
Chambers, Buried Treasure, Shipwrecks, Ocean Ruins, Pillager Outposts,
|
||||
Ruined Portals, Nether Fossils, Ancient Cities, Trail Ruins, Nether
|
||||
Fortresses, Bastions, and 26.3's Abandoned Camps. Dungeons remain their
|
||||
separate feature.
|
||||
- Fixed the early structure-biome check so authored structures can generate
|
||||
outside their native biome restrictions, within the supported terrain rules.
|
||||
Repeated structure pages increase density up to the current authoring cap.
|
||||
- Added Caves and Ravines feature pages through 26.3's native carving stage.
|
||||
These are distinct from Cave terrain; Flat/Void support remains pending.
|
||||
- Added Poplar Trees, Fallen Poplar Trees, Red Shrubs, and Shelf Mushrooms.
|
||||
Shelf Mushrooms find exposed logs at any height, including log Tendrils in
|
||||
Void terrain. No Trees page is required.
|
||||
- Added Normal, Fast, Slow, Off, Always, and Storm weather pages. Each Age
|
||||
has independent saved weather data and timers; its policy does not change
|
||||
the Overworld or other Ages. Cave terrain keeps weather disabled.
|
||||
|
||||
## Fixes and feedback
|
||||
|
||||
- Non-Cave Descriptive Book arrivals trace actual blocks down from maximum
|
||||
build height instead of using a Flat-layer shortcut or only a heightmap.
|
||||
The search stops at roofs/fluids rather than selecting underground pockets.
|
||||
Leaves count as footing; submerged footing is rejected. Fallback platforms
|
||||
sit above obstructions, with floor Y=63 in an empty Void.
|
||||
- Stored surface-Age Star Fissure arrivals are rechecked from above. Cave
|
||||
terrain still searches down from Y=64, and Linking Books retain exact
|
||||
bound destinations.
|
||||
- Restored Legacy linking sounds and successful-link cloud bursts. Disarm
|
||||
and Intra-Linking use their respective sounds. Star Fissure return audio
|
||||
also plays after arrival so a dimension switch does not discard it.
|
||||
- Fixed weather-only authoring, including Link Panel + Storm books: non-biome
|
||||
pages no longer get interpreted as biome IDs.
|
||||
- Unsupported pages now have a red tint and availability tooltip. Supported
|
||||
pages and their descriptions reflect the implemented 26.3 behavior.
|
||||
- Added operator weather and surface diagnostics for testing.
|
||||
|
||||
## Current limits
|
||||
|
||||
- Only the supported page/terrain combinations can be bound. Most repeated
|
||||
features are capped at 32; vegetation uses a combined 32-page limit. The
|
||||
full 1.21.1 grammar recovery and instability systems remain unported.
|
||||
- Pageless books currently randomize terrain and biome distribution, not the
|
||||
complete feature/environment list. Omitted weather currently means Normal.
|
||||
- Base carvers do not carve authored solid volumes added after 26.3's combined
|
||||
terrain operation. Previously generated chunks are not regenerated.
|
||||
- The full writing/discovery loop, celestial/lighting systems, crystal portals,
|
||||
instability, and optional-integration parity remain in progress. Creative
|
||||
availability alone does not mean a page is supported; check its tooltip.
|
||||
- Vanilla `/weather` targets global server weather, not the independent Age
|
||||
policy. Biomes retain their normal precipitation rules.
|
||||
|
||||
The player reported in-game passes for these slices. Build/static checks passed.
|
||||
Targeted disposable dedicated-server checks covered all six weather policies,
|
||||
Overworld isolation, unload/reload and restart persistence, weather-only page
|
||||
compilation, and dry surface/leaf spawn probes. This is not a claim of full
|
||||
dedicated-server or optional-mod regression coverage. Test servers were stopped
|
||||
and their temporary worlds removed.
|
||||
|
||||
Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build,
|
||||
and **Java 25**. This build does not require Dynamic Dimensions or Bad Packets.
|
||||
Keep it separate from the 1.21.1 build and saves. Back up entire worlds before
|
||||
updating; beta-save compatibility is not guaranteed.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,59 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.71-beta.1
|
||||
|
||||
This beta collects the player-tested lighting and sky port slices since
|
||||
1.0.1.65-beta.1. It is not yet feature-complete with Minecraft 1.21.1.
|
||||
|
||||
## Lighting and seeded environments
|
||||
|
||||
- Added Normal, Dark, and Bright lighting pages with saved per-Age settings.
|
||||
- Bright works in Cave terrain too, without adding skylight or weather.
|
||||
- Bright supplies effective ambient light level 15 to native combined-light
|
||||
checks and the white ambient rendering attribute. Vanilla darkness-based
|
||||
monster checks respond to that lighting; there is no separate mob-spawn
|
||||
veto. Mobs that ignore light retain their normal rules.
|
||||
- Missing weather and lighting controllers now resolve deterministically from
|
||||
the Age seed, using independent choices and Legacy-style Normal weighting.
|
||||
Explicit pages win; Cave terrain still forces weather Off. Existing Ages
|
||||
keep their saved choices rather than rerolling on load.
|
||||
|
||||
## Sky styles
|
||||
|
||||
- Added Overworld, Nether, End, and animated Static Sky pages, independently
|
||||
of terrain, weather, and lighting. Choices persist on reload and restart.
|
||||
- Nether Sky uses a reddish fog backdrop; End Sky uses the native End sky.
|
||||
These are visual styles, not Nether/End terrain or gameplay conversions:
|
||||
dimension geometry, coordinate scale, bed/water rules, and dragon behavior
|
||||
are not changed.
|
||||
- Static Sky adapts the existing Legacy-style renderer: six independent
|
||||
48×48 animated gray noise faces. A cached cube and small dynamic texture
|
||||
avoid rebuilding thousands of per-cell vertices each frame. Client-only
|
||||
rendering resources are released with the sky renderer.
|
||||
- Non-Overworld backgrounds have transparent clouds and a derived day
|
||||
timeline that avoids Overworld sky/fog/cloud color tracks while retaining
|
||||
gameplay and illumination schedules.
|
||||
- Updated supported-page feedback and added operator sky/environment probes.
|
||||
|
||||
## Current limits and testing
|
||||
|
||||
- Missing Sky pages still default to Overworld; random sky selection, color
|
||||
pages and individual celestial bodies remain pending. Number-controlled
|
||||
Static Sky resolution is tracked separately in issue #109 for both versions.
|
||||
- The writing/discovery loop, instability, portals, and optional-integration
|
||||
parity are still incomplete. Creative availability is not a support claim;
|
||||
check page tooltips and the beta guide for supported combinations.
|
||||
- Previously generated chunks are not regenerated. Save compatibility during
|
||||
the beta is not guaranteed; keep backups and keep both version tracks apart.
|
||||
|
||||
The player reported in-game passes for the included slices, including Static
|
||||
Sky. Build checks passed for both version tracks. Targeted disposable-server
|
||||
checks covered environment defaults, explicit choices, registered sky/lighting
|
||||
variants, persistence and restart, including Cave + Bright and Static Sky.
|
||||
This is not a claim of complete dedicated-server or optional-mod coverage.
|
||||
Test servers were stopped and temporary worlds removed.
|
||||
|
||||
Requires Minecraft **26.3**, NeoForge **26.3.0.99** and **Java 25**. Dynamic
|
||||
Dimensions and Bad Packets are not required by this build. The 1.21.1 release
|
||||
and its dependencies are unchanged.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.78-beta.1
|
||||
|
||||
Collects the player-validated port slices since 1.0.1.71-beta.1. This beta is
|
||||
not yet feature-complete with the Minecraft 1.21.1 version.
|
||||
|
||||
## Sky and color writing
|
||||
|
||||
- Missing Sky pages now select a deterministic seeded style. Books with
|
||||
Normal Time and no explicit Sky select celestial-compatible Overworld Sky.
|
||||
Explicit Sky pages always win; weather and lighting choices stay independent.
|
||||
- Ported Red, Green, Blue, Black and White color pages. Preceding Number pages
|
||||
control component strength; unnumbered colors use seeded values. Colors mix
|
||||
before clamping the completed group. Colors before Sky tint that sky; a
|
||||
trailing color group also targets the sky.
|
||||
- Added an Ambient Color controller for an independent ambient-light tint.
|
||||
Color alone does not change effective illumination or monster-spawn rules.
|
||||
- Native, End and animated Static backgrounds use saved Age color settings.
|
||||
|
||||
## Normal Time celestial controls
|
||||
|
||||
- Length pages control the native day cycle; a preceding Number fine-tunes
|
||||
length. Zero Length freezes the cycle at a seeded time or an authored Phase,
|
||||
without interpolation bounce or changing the shared Overworld clock.
|
||||
- Rising, Zenith, Setting and Nadir Phase pages choose the starting phase.
|
||||
Clockwise and Counterclockwise control direction. North, East, South and
|
||||
West Angle pages choose orbit bearing. Preceding Number pages fine-tune
|
||||
Phase and Angle in 15-degree increments.
|
||||
- Small, Normal and Large Celestial size Normal Time's sun to 0.5×, 1× and 2×.
|
||||
A preceding Number overrides size from 0.25× to 3×. As in 1.21.1, the paired
|
||||
moon remains normal-sized. Size does not alter illumination or day length.
|
||||
- Settings persist through reload/restart and remain stable when reseeding
|
||||
future terrain. Older profiles retain native/default values.
|
||||
- Added operator diagnostics for environment, color and celestial profiles.
|
||||
|
||||
## Compatibility and testing
|
||||
|
||||
- Catalog protocol is now 6: use matching client/server builds.
|
||||
- Player in-game checks passed for the included slices. Build checks and
|
||||
targeted disposable-server diagnostics covered numeric modifiers, catalog
|
||||
round trips, unchanged sampled lighting, reload, reseed and restart.
|
||||
- Standalone Sun/Moon controllers, multiple bodies, per-body colors, eclipses,
|
||||
full writing/discovery, instability, portals and optional-integration parity
|
||||
remain incomplete. Creative availability does not imply full page support.
|
||||
- Number-controlled Static Sky resolution remains tracked in issue #109;
|
||||
time-page naming improvements remain tracked in issue #110.
|
||||
- Existing chunks are not regenerated. Save compatibility is not guaranteed
|
||||
during beta; keep backups and keep the two Minecraft version tracks apart.
|
||||
|
||||
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.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.93-beta.1
|
||||
|
||||
Collects the player-validated port slices .79–.93 since 1.0.1.78-beta.1.
|
||||
This beta is not yet feature-complete with Minecraft 1.21.1.
|
||||
|
||||
## Celestial writing and rendering
|
||||
|
||||
- Ported standalone Sun and Moon pages and multiple independently authored
|
||||
bodies, up to ten of each. Each controller consumes its own ordered Color,
|
||||
Length, Phase, Direction, Angle and Size modifiers; Number pages fine-tune
|
||||
supported values. Normal Time retains its paired sun/moon behavior.
|
||||
- Color groups before Normal Time tint both bodies; independent Sun/Moon
|
||||
groups can use different colors. Authored moons use full-moon sprites as in
|
||||
1.21.1; native Normal Time retains calendar moon phases.
|
||||
- Authored bodies render over Overworld, Nether, End and animated Static
|
||||
backgrounds. Opaque centers retain transparent glows. Eclipse alignment
|
||||
affects body color, background shading and gameplay sky light.
|
||||
- Bare Sun/Moon pages receive independent seeded colors, sizes, bearings,
|
||||
phases, directions and lengths, including stopped cycles. Explicit modifier
|
||||
groups retain their authored behavior.
|
||||
- Books without celestial controllers seed-complete 0–3 suns and 0–3 moons.
|
||||
Empty skies do not accidentally draw a vanilla pair. Explicit controllers
|
||||
suppress extra-body completion. Link-Panel-only random Ages also receive
|
||||
seeded sky/fog colors using the 1.21.1 palette.
|
||||
- All resolved settings are saved, not rerolled by viewing a book, restarting,
|
||||
or reseeding future terrain. Existing Age profiles remain unchanged.
|
||||
|
||||
## Biome and feature completion
|
||||
|
||||
- Explicit biome-distribution pages can now seed-complete missing biome
|
||||
pools. Single selects one; other nonnative controllers require two distinct
|
||||
biomes. Fully omitted pools use the seeded continuation rule, bounded by the
|
||||
available biome pool. Authored pools fill only the missing minimum.
|
||||
- Supplied biome order and duplicate weights are retained. Two distinct
|
||||
supplied biomes do not acquire an unwanted third biome. Cave pools use
|
||||
recognized Cave/Nether biomes; Void does not inject The Void alongside
|
||||
supplied biome pages.
|
||||
- Unpaired Block/Fluid pages receive seeded terrain-compatible implied
|
||||
features. Consecutive materials resolve independently; explicit pairs remain
|
||||
authoritative. Unsupported pairings and the current feature caps remain.
|
||||
- Link-Panel-only Ages roll 0–2 distinct material feature types with Legacy
|
||||
rank weighting and compatible registered solids/source fluids. Additional
|
||||
rare rolls cover Skylands, Crystal Formations and Star Fissures.
|
||||
- Random Ages also roll 0–2 distinct structure families, filtered by terrain,
|
||||
registry availability and the chosen biome pool. Newer structures, including
|
||||
26.3 Abandoned Camps, are rare candidates. An intentionally empty selection
|
||||
disables page-controlled structure sets rather than enabling all of them.
|
||||
- Cave terrain now supports authored and randomly selected Dungeons, matching
|
||||
1.21.1. Dungeon placement retains eight monster-room attempts per page and
|
||||
modern build-height anchors. Flat/Void Dungeons and Cave lakes remain
|
||||
unsupported.
|
||||
|
||||
## Nether and End material correction
|
||||
|
||||
- Ported 1.21.1's biome-specific material-rule layer for noise terrain. Nether
|
||||
biomes in Cave Ages now use their native Netherrack, Nylium, Soul Sand/Soil,
|
||||
Basalt and Blackstone rules instead of Overworld grass. End biomes use End
|
||||
Stone. Other biomes retain the original terrain rules.
|
||||
- Geometry, biome selection, dry Cave aquifers and other environment settings
|
||||
are preserved. Flat/Void layers and authored feature materials are unchanged.
|
||||
- Existing Ages pick up the correction after reload, but only new chunks
|
||||
change. Already generated grass is not replaced.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
|
||||
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
|
||||
- Catalog protocol is **10**: use matching client/server builds.
|
||||
- Player in-game checks passed for all included slices. Builds and targeted
|
||||
disposable-server checks covered writing, seeded completion, codecs/catalog
|
||||
round trips, terrain materials, reload, reseed and restart. Temporary servers
|
||||
and test worlds were removed. This is not a full automated GameTest run.
|
||||
- Full writing recovery, discovery/stations, instability, portals and optional
|
||||
integration parity remain unfinished. Random-page eligibility config also
|
||||
remains pending; valuable/hazardous random materials currently lack the
|
||||
finished version's instability scoring. Creative availability does not imply
|
||||
full page support. Number-controlled Static resolution (#109) and time-page
|
||||
naming (#110) remain separate work.
|
||||
- Beta save compatibility is not guaranteed. Back up worlds and keep the two
|
||||
Minecraft version tracks and saves separate.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,63 @@
|
||||
# Rivencraft 26.3 beta 1.0.1.98-beta.1
|
||||
|
||||
Collects the player-validated port slices .94–.98 since 1.0.1.93-beta.1.
|
||||
This beta is not yet feature-complete with Minecraft 1.21.1.
|
||||
|
||||
## Seeded terrain and biome completion
|
||||
|
||||
- Books without a Terrain page now choose terrain using the 1.21.1 weighted
|
||||
selector. Incompatible choices are replaced with a seeded compatible terrain;
|
||||
explicit Terrain pages retain their authored behavior.
|
||||
- Omitted biome controllers now use the seeded weighted distribution selector.
|
||||
Supplied biome pages are retained, with missing minimum slots completed.
|
||||
Void without biome pages still uses The Void. Link-Panel-only random Ages
|
||||
retain their existing selection path.
|
||||
- Missing nonnative biome pools account for authored structures. Single chooses
|
||||
a terrain-compatible biome satisfying all structure requirements where one
|
||||
exists. Multiple-biome distributions add compatible biomes for unmet structure
|
||||
requirements before completing their ordinary minimum. Explicit Single
|
||||
choices are never replaced.
|
||||
- New Native Cave Ages freeze the full recognized Cave/Nether biome pool and
|
||||
distribute it using seeded Tiny regions, matching the 1.21.1 implementation.
|
||||
This includes recognized modded cave biomes. Existing empty Native Cave
|
||||
profiles retain their previous Lush Caves fallback; explicit Single is unchanged.
|
||||
- Removing Nether-style biomes from the default Native Cave pool is separate
|
||||
planned work for both versions in issue #111, not part of this release.
|
||||
|
||||
## Accelerated pages
|
||||
|
||||
- Ported the Accelerated environment page. Repeated pages stack, each adding
|
||||
three extra random positions per eligible block section per tick near players.
|
||||
- Defaults match 1.21.1: at most eight already-loaded candidate chunks and
|
||||
16,384 extra random-tick attempts per Age tick, shared fairly across sections.
|
||||
No extra chunks are force-loaded.
|
||||
- Limits are configurable in `config/rivencraft-local.toml`, under
|
||||
`accelerated_age`. A zero attempt budget disables the extra ticks.
|
||||
- Page counts persist across unload/reload, reseeding and restart. Existing
|
||||
Ages default to zero. Pageless random Ages do not gain Accelerated automatically.
|
||||
- This currently affects random-ticking blocks such as crops and saplings.
|
||||
Machine/block-entity acceleration is deferred for both versions under issue
|
||||
#112; entities and scheduled block ticks are also unaffected. Instability
|
||||
scoring remains part of the unfinished instability port.
|
||||
- Operator command `/rivencraft_port age acceleration <name>` reports the
|
||||
saved page count and current budgets.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic
|
||||
Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged.
|
||||
- Catalog protocol remains **10**. Use matching client/server builds.
|
||||
- Player in-game checks passed. Builds and targeted disposable-server checks
|
||||
covered seeded completion, Native Cave sampling, tick-budget arithmetic,
|
||||
unload/reload, reseeding and restart. Test servers were stopped and their
|
||||
worlds removed. This was not a full automated GameTest run.
|
||||
- Back up worlds before updating. New populated Native Cave profiles cannot
|
||||
be read by earlier beta builds; do not downgrade those saves. Keep Minecraft
|
||||
1.21.1 and 26.3 saves separate.
|
||||
- Full writing recovery, discovery/stations, instability, crystal portals and
|
||||
optional integration parity remain unfinished. Random-page eligibility config
|
||||
and modded biome surface-rule integration also remain pending. Creative page
|
||||
availability does not imply full support.
|
||||
|
||||
Report issues and help improve the wiki at
|
||||
[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft).
|
||||
@@ -0,0 +1,267 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'net.neoforged.moddev' version '2.0.148'
|
||||
}
|
||||
|
||||
group = 'net.dnorberg.rivencraft'
|
||||
version = mod_version
|
||||
|
||||
base {
|
||||
archivesName = "rivencraft-${minecraft_version}"
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
|
||||
neoForge {
|
||||
version = project.neo_version
|
||||
runs {
|
||||
client {
|
||||
client()
|
||||
if (project.hasProperty('connectAddress')) {
|
||||
programArgument '--quickPlayMultiplayer'
|
||||
programArgument project.property('connectAddress').toString()
|
||||
}
|
||||
if (project.hasProperty('clientName')) {
|
||||
programArgument '--username'
|
||||
programArgument project.property('clientName').toString()
|
||||
gameDirectory = project.layout.buildDirectory.dir('run-client-' + project.property('clientName')).get().asFile
|
||||
}
|
||||
}
|
||||
server {
|
||||
server()
|
||||
gameDirectory = project.hasProperty('serverRunDir')
|
||||
? file(project.property('serverRunDir').toString())
|
||||
: project.layout.buildDirectory.dir('run-server').get().asFile
|
||||
programArgument '--nogui'
|
||||
}
|
||||
}
|
||||
mods {
|
||||
rivencraft {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def generateModMetadata = tasks.register('generateModMetadata', ProcessResources) {
|
||||
def replaceProperties = [
|
||||
mod_id: mod_id,
|
||||
mod_name: mod_name,
|
||||
mod_license: mod_license,
|
||||
mod_version: mod_version,
|
||||
neo_version: neo_version,
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
from 'src/main/templates'
|
||||
into 'build/generated/sources/modMetadata'
|
||||
}
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
|
||||
// Keep the bundled page list in step with the 1.21.1 symbol definitions.
|
||||
abstract class GeneratePortSymbolIndex extends DefaultTask {
|
||||
@InputDirectory abstract DirectoryProperty getSymbolsDir()
|
||||
@InputDirectory abstract DirectoryProperty getPortSymbolsDir()
|
||||
@OutputDirectory abstract DirectoryProperty getIndexDir()
|
||||
|
||||
@TaskAction void generate() {
|
||||
File root = symbolsDir.get().asFile
|
||||
def definitions = new LinkedHashMap<String, Object>()
|
||||
java.nio.file.Files.walk(root.toPath()).withCloseable { paths ->
|
||||
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
|
||||
.sorted().forEach { source ->
|
||||
String path = root.toPath().relativize(source).toString()
|
||||
.replace('\\', '/').replaceAll(/\.json$/, '')
|
||||
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
|
||||
}
|
||||
}
|
||||
File portRoot = portSymbolsDir.get().asFile
|
||||
java.nio.file.Files.walk(portRoot.toPath()).withCloseable { paths ->
|
||||
paths.filter { java.nio.file.Files.isRegularFile(it) && it.toString().endsWith('.json') }
|
||||
.sorted().forEach { source ->
|
||||
String path = portRoot.toPath().relativize(source).toString()
|
||||
.replace('\\', '/').replaceAll(/\.json$/, '')
|
||||
definitions["rivencraft:${path}"] = new groovy.json.JsonSlurper().parse(source.toFile())
|
||||
}
|
||||
}
|
||||
File index = new File(indexDir.get().asFile, 'assets/rivencraft/creative_symbol_pages.json')
|
||||
index.parentFile.mkdirs()
|
||||
index.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definitions)) + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
def generatePortSymbolIndex = tasks.register('generatePortSymbolIndex', GeneratePortSymbolIndex) {
|
||||
symbolsDir.set(layout.projectDirectory.dir('../../src/main/resources/data/rivencraft/rivencraft/symbols'))
|
||||
portSymbolsDir.set(layout.projectDirectory.dir('src/main/resources/data/rivencraft/rivencraft/symbols'))
|
||||
indexDir.set(layout.buildDirectory.dir('generated/resources/creativeSymbols'))
|
||||
}
|
||||
sourceSets.main.resources.srcDir generatePortSymbolIndex
|
||||
|
||||
// Use the established symbol names/descriptions without overwriting 26.3-only strings.
|
||||
abstract class GeneratePortLanguage extends DefaultTask {
|
||||
@InputFile abstract RegularFileProperty getBaseLanguage()
|
||||
@InputFile abstract RegularFileProperty getPortLanguage()
|
||||
@OutputDirectory abstract DirectoryProperty getOutputDir()
|
||||
|
||||
@TaskAction void generate() {
|
||||
def base = new groovy.json.JsonSlurper().parse(baseLanguage.get().asFile) as Map
|
||||
def port = new groovy.json.JsonSlurper().parse(portLanguage.get().asFile) as Map
|
||||
base.putAll(port)
|
||||
File result = new File(outputDir.get().asFile, 'assets/rivencraft/lang/en_us.json')
|
||||
result.parentFile.mkdirs()
|
||||
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(base)) + '\n'
|
||||
}
|
||||
}
|
||||
|
||||
def generatePortLanguage = tasks.register('generatePortLanguage', GeneratePortLanguage) {
|
||||
baseLanguage.set(layout.projectDirectory.file('../../src/main/resources/assets/rivencraft/lang/en_us.json'))
|
||||
portLanguage.set(layout.projectDirectory.file('port_en_us.json'))
|
||||
outputDir.set(layout.buildDirectory.dir('generated/resources/portLanguage'))
|
||||
}
|
||||
sourceSets.main.resources.srcDir generatePortLanguage
|
||||
|
||||
// Derive registered presentation variants without duplicating vanilla gameplay settings.
|
||||
abstract class GeneratePortDimensionTypes extends DefaultTask {
|
||||
@InputFile abstract RegularFileProperty getMinecraftJar()
|
||||
@OutputDirectory abstract DirectoryProperty getOutputDir()
|
||||
|
||||
@TaskAction void generate() {
|
||||
new java.util.zip.ZipFile(minecraftJar.get().asFile).withCloseable { archive ->
|
||||
def entry = archive.getEntry('data/minecraft/dimension_type/overworld.json')
|
||||
if (entry == null) throw new GradleException('Missing vanilla Overworld dimension type')
|
||||
String template = archive.getInputStream(entry).withCloseable { it.getText('UTF-8') }
|
||||
def dayEntry = archive.getEntry('data/minecraft/timeline/day.json')
|
||||
if (dayEntry == null) throw new GradleException('Missing vanilla day timeline')
|
||||
def day = archive.getInputStream(dayEntry).withCloseable {
|
||||
new groovy.json.JsonSlurper().parseText(it.getText('UTF-8'))
|
||||
}
|
||||
// Keep gameplay schedules and illumination, but do not tint these
|
||||
// native non-Overworld sky backgrounds blue/black with the day cycle.
|
||||
['fog_color', 'sky_color', 'cloud_color'].each { day.tracks.remove("minecraft:visual/${it}".toString()) }
|
||||
// Markers are global per clock and already owned by minecraft:day.
|
||||
day.remove('time_markers')
|
||||
File dayFile = new File(outputDir.get().asFile, 'data/rivencraft/timeline/age_sky_day.json')
|
||||
dayFile.parentFile.mkdirs()
|
||||
dayFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(day)) + '\n'
|
||||
File timelineTag = new File(outputDir.get().asFile, 'data/rivencraft/tags/timeline/age_sky.json')
|
||||
timelineTag.parentFile.mkdirs()
|
||||
timelineTag.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson([
|
||||
values: ['#minecraft:universal', 'rivencraft:age_sky_day',
|
||||
'minecraft:moon', 'minecraft:early_game']])) + '\n'
|
||||
['overworld', 'nether', 'end', 'static'].each { sky ->
|
||||
['age_normal', 'age_dark', 'age_bright', 'age_cave_bright'].each { lighting ->
|
||||
if (sky == 'overworld' && lighting == 'age_normal') return
|
||||
String name = lighting + (sky == 'overworld' ? '' : "_sky_${sky}")
|
||||
def definition = new groovy.json.JsonSlurper().parseText(template)
|
||||
definition.has_skylight = lighting in ['age_normal', 'age_bright']
|
||||
definition.ambient_light = lighting in ['age_bright', 'age_cave_bright'] ? 1.0 : 0.0
|
||||
// 26.3's lightmap reads this attribute, not ambient_light.
|
||||
if (definition.ambient_light == 1.0)
|
||||
definition.attributes['minecraft:visual/ambient_light_color'] = '#ffffff'
|
||||
if (sky != 'overworld') {
|
||||
definition.skybox = sky == 'end' ? 'end' : 'none'
|
||||
definition.attributes['minecraft:visual/fog_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#181318' : '#6b2725'
|
||||
definition.attributes['minecraft:visual/sky_color'] = sky == 'static' ? '#929292' : sky == 'end' ? '#000000' : '#6b2725'
|
||||
definition.attributes['minecraft:visual/cloud_color'] = '#00000000'
|
||||
if (sky == 'nether') {
|
||||
definition.attributes['minecraft:visual/fog_start_distance'] = 10.0
|
||||
definition.attributes['minecraft:visual/fog_end_distance'] = 96.0
|
||||
}
|
||||
definition.timelines = '#rivencraft:age_sky'
|
||||
}
|
||||
File result = new File(outputDir.get().asFile, "data/rivencraft/dimension_type/${name}.json")
|
||||
result.parentFile.mkdirs()
|
||||
result.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(definition)) + '\n'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def generatePortDimensionTypes = tasks.register('generatePortDimensionTypes', GeneratePortDimensionTypes) {
|
||||
dependsOn 'createMinecraftArtifacts'
|
||||
minecraftJar.set(layout.buildDirectory.file("moddev/artifacts/minecraft-patched-${neo_version}.jar"))
|
||||
outputDir.set(layout.buildDirectory.dir('generated/resources/dimensionTypes'))
|
||||
}
|
||||
sourceSets.main.resources.srcDir generatePortDimensionTypes
|
||||
|
||||
tasks.named('processResources', ProcessResources).configure {
|
||||
// Reuse the existing sprite and base model without copying binary assets.
|
||||
from('../../src/main/resources') {
|
||||
include 'data/rivencraft/worldgen/structure_set/fortresses.json'
|
||||
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/sounds.json'
|
||||
include 'assets/rivencraft/sounds/**/*.ogg'
|
||||
include 'assets/rivencraft/textures/item/crystal_shard.png'
|
||||
include 'assets/rivencraft/textures/item/page.png'
|
||||
include 'assets/rivencraft/textures/item/page_link_panel.png'
|
||||
include 'assets/rivencraft/textures/item/page_symbol.png'
|
||||
include 'assets/rivencraft/models/item/page_symbol.json'
|
||||
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/ink_vial.png.mcmeta'
|
||||
include 'assets/rivencraft/models/item/ink_vial.json'
|
||||
include 'assets/rivencraft/models/item/crystal_shard.json'
|
||||
include 'assets/rivencraft/textures/item/descriptive_book.png'
|
||||
include 'assets/rivencraft/models/item/descriptive_book.json'
|
||||
include 'assets/rivencraft/textures/item/linking_book.png'
|
||||
include 'assets/rivencraft/textures/item/linking_book_unwritten.png'
|
||||
include 'assets/rivencraft/models/item/linking_book_bound.json'
|
||||
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/block/crystal_block.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder.png'
|
||||
include 'assets/rivencraft/textures/block/book_binder_press.png'
|
||||
include 'assets/rivencraft/textures/gui/book_binder_legacy.png'
|
||||
include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png'
|
||||
include 'assets/rivencraft/textures/block/inkwell_base.png'
|
||||
include 'assets/rivencraft/textures/block/inkwell_cup.png'
|
||||
include 'assets/rivencraft/textures/block/ink.png'
|
||||
include 'assets/rivencraft/textures/block/ink_glass.png'
|
||||
include 'assets/rivencraft/models/block/ink_mixer.json'
|
||||
include 'assets/rivencraft/models/item/ink_mixer.json'
|
||||
include 'assets/rivencraft/blockstates/ink_mixer.json'
|
||||
include 'data/rivencraft/loot_table/blocks/ink_mixer.json'
|
||||
include 'assets/rivencraft/models/block/book_binder.json'
|
||||
include 'assets/rivencraft/models/item/book_binder.json'
|
||||
include 'assets/rivencraft/blockstates/book_binder.json'
|
||||
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/blockstates/budding_crystal.json'
|
||||
include 'assets/rivencraft/blockstates/small_crystal_bud.json'
|
||||
include 'assets/rivencraft/blockstates/medium_crystal_bud.json'
|
||||
include 'assets/rivencraft/blockstates/large_crystal_bud.json'
|
||||
include 'assets/rivencraft/blockstates/crystal_cluster.json'
|
||||
include 'assets/rivencraft/models/block/budding_crystal.json'
|
||||
include 'assets/rivencraft/models/block/small_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/block/medium_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/block/large_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/block/crystal_cluster.json'
|
||||
include 'assets/rivencraft/models/item/budding_crystal.json'
|
||||
include 'assets/rivencraft/models/item/small_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/item/medium_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/item/large_crystal_bud.json'
|
||||
include 'assets/rivencraft/models/item/crystal_cluster.json'
|
||||
include 'assets/rivencraft/textures/block/small_crystal_bud.png'
|
||||
include 'assets/rivencraft/textures/block/medium_crystal_bud.png'
|
||||
include 'assets/rivencraft/textures/block/large_crystal_bud.png'
|
||||
include 'assets/rivencraft/textures/block/crystal_cluster.png'
|
||||
include 'data/rivencraft/loot_table/blocks/crystal_block.json'
|
||||
include 'data/minecraft/tags/block/mineable/pickaxe.json'
|
||||
include 'data/minecraft/tags/item/cluster_max_harvestables.json'
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
tasks.named('runServer').configure {
|
||||
standardInput = System.in
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.caching=true
|
||||
|
||||
minecraft_version=26.3
|
||||
minecraft_version_range=[26.3]
|
||||
neo_version=26.3.0.99
|
||||
mod_id=rivencraft
|
||||
mod_name=Rivencraft
|
||||
mod_license=LGPL-3.0-or-later
|
||||
mod_version=1.0.1.98-beta.1
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"item.rivencraft.crystal_shard": "Crystal Shard",
|
||||
"item.rivencraft.descriptive_book": "Descriptive Book",
|
||||
"item.rivencraft.linking_book": "Linking Book",
|
||||
"item.rivencraft.page": "Page",
|
||||
"item.rivencraft.page.blank": "Blank Page",
|
||||
"item.rivencraft.page.link_panel": "Link Panel Page",
|
||||
"item.rivencraft.page.unresolved": "Unknown Symbol Page",
|
||||
"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 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 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.",
|
||||
"symbol.rivencraft.lighting.dark.description": "Remove skylight from this Age; placed light sources still work.",
|
||||
"symbol.rivencraft.lighting.bright.description": "Provide full ambient brightness and effective light level 15. Vanilla light-based spawn rules apply, including in Cave terrain without skylight.",
|
||||
"symbol.rivencraft.weather.fast.description": "Use the faster Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
|
||||
"symbol.rivencraft.weather.slow.description": "Use the slower Legacy weather cycle in this Age only. Cave terrain keeps weather off.",
|
||||
"symbol.rivencraft.weather.off.description": "Keep this Age clear without changing Overworld weather.",
|
||||
"symbol.rivencraft.weather.always.description": "Keep this Age rainy, or snowy in cold biomes, without thunder. Cave terrain keeps weather off.",
|
||||
"symbol.rivencraft.weather.storm.description": "Keep this Age rainy and thundering. Precipitation still depends on the biome; Cave terrain keeps weather off.",
|
||||
"item.rivencraft.page.symbol.unimplemented": "Not yet implemented in 26.3; cannot be bound into an Age.",
|
||||
"item.rivencraft.link_property.disarm": "Disarm",
|
||||
"item.rivencraft.link_property.maintain_momentum": "Maintain Momentum",
|
||||
"item.rivencraft.link_property.intra_linking": "Intra-Linking",
|
||||
"item.rivencraft.ink_vial": "Ink Vial",
|
||||
"item.rivencraft.ink_vial.remaining": "Ink: %s/%s",
|
||||
"item.rivencraft.ink_vial.usage": "Use with paper in the other hand, or place in an Ink Mixer.",
|
||||
"item.rivencraft.page.blank.port_status": "Internal symbol-page base; not an authoring ingredient.",
|
||||
"item.rivencraft.page.link_panel.port_status": "Use with leather in a Book Binder to make a Descriptive Book.",
|
||||
"block.rivencraft.book_binder": "Book Binder",
|
||||
"container.rivencraft.book_binder": "Book Binder",
|
||||
"screen.rivencraft.book_binder.missing_panel": "Link Panel required",
|
||||
"screen.rivencraft.book_binder.authored_pending": "Unsupported page or page combination",
|
||||
"block.rivencraft.ink_mixer": "Ink Mixer",
|
||||
"container.rivencraft.ink_mixer": "Ink Mixer",
|
||||
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
|
||||
"screen.rivencraft.ink_mixer.additive_hint": "Click with an additive to mix it in",
|
||||
"screen.rivencraft.ink_mixer.chance": "%s: %s%%",
|
||||
"block.rivencraft.crystal_block": "Crystal Block",
|
||||
"block.rivencraft.budding_crystal": "Budding Crystal",
|
||||
"block.rivencraft.small_crystal_bud": "Small Crystal Bud",
|
||||
"block.rivencraft.medium_crystal_bud": "Medium Crystal Bud",
|
||||
"block.rivencraft.large_crystal_bud": "Large Crystal Bud",
|
||||
"block.rivencraft.crystal_cluster": "Crystal Cluster",
|
||||
"itemGroup.rivencraft": "Rivencraft",
|
||||
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
|
||||
"symbol.rivencraft.feature.poplar_trees": "Poplar Trees",
|
||||
"symbol.rivencraft.feature.poplar_trees.description": "Add red, orange, and yellow poplar trees to surface terrain with suitable soil.",
|
||||
"symbol.rivencraft.feature.fallen_poplar_trees": "Fallen Poplar Trees",
|
||||
"symbol.rivencraft.feature.fallen_poplar_trees.description": "Add fallen poplar logs and stumps to surface terrain with suitable soil.",
|
||||
"symbol.rivencraft.feature.shelf_mushrooms": "Shelf Mushrooms",
|
||||
"symbol.rivencraft.feature.shelf_mushrooms.description": "Add shelf mushrooms to exposed log faces on any terrain, including Void. No tree page is required.",
|
||||
"symbol.rivencraft.feature.red_shrubs": "Red Shrubs",
|
||||
"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": "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.",
|
||||
"symbol.rivencraft.feature.mineshafts.description": "Allow mineshafts in noise terrain, including Cave, regardless of normal biome eligibility.",
|
||||
"symbol.rivencraft.feature.nether_fortresses.description": "Allow Nether fortresses in Cave terrain; the authored page overrides biome eligibility.",
|
||||
"symbol.rivencraft.feature.strongholds.description": "Allow strongholds in surface noise or Flat terrain, including outside their usual biomes.",
|
||||
"symbol.rivencraft.feature.villages.description": "Allow villages in surface noise or Flat terrain, including outside their usual biomes.",
|
||||
"symbol.rivencraft.feature.trial_chambers.description": "Allow underground Trial Chambers in noise terrain, including Cave, regardless of biome eligibility.",
|
||||
"symbol.rivencraft.feature.bastions.description": "Allow Bastion Remnants in Cave terrain; the authored page overrides biome eligibility.",
|
||||
"symbol.rivencraft.feature.buried_treasure.description": "Allow buried treasure in surface noise terrain; biome eligibility is overridden, but suitable terrain is required.",
|
||||
"symbol.rivencraft.feature.shipwrecks.description": "Allow shipwrecks in surface noise terrain, including outside their usual ocean and beach biomes.",
|
||||
"symbol.rivencraft.feature.ocean_ruins.description": "Allow ocean ruins in surface noise terrain, including outside their usual biomes.",
|
||||
"symbol.rivencraft.feature.pillager_outposts.description": "Allow pillager outposts in surface noise terrain, including outside their usual biomes.",
|
||||
"symbol.rivencraft.feature.ruined_portals.description": "Allow ruined portals in noise terrain, including Cave, regardless of normal biome eligibility.",
|
||||
"symbol.rivencraft.feature.nether_fossils.description": "Allow Nether fossils in Cave terrain; the authored page overrides biome eligibility.",
|
||||
"symbol.rivencraft.feature.ancient_cities.description": "Allow underground Ancient Cities in noise terrain, including Cave, regardless of biome eligibility.",
|
||||
"symbol.rivencraft.feature.trail_ruins.description": "Allow Trail Ruins in surface noise terrain, including outside their usual biomes."
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||
}
|
||||
|
||||
rootProject.name = 'rivencraft-mc263-port'
|
||||
@@ -0,0 +1,23 @@
|
||||
package net.dnorberg.rivencraft;
|
||||
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** Server-side limits shared with the 1.21.1 Accelerated implementation. */
|
||||
public final class PortConfig {
|
||||
public static final ModConfigSpec SPEC;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS;
|
||||
public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS;
|
||||
|
||||
static {
|
||||
var builder = new ModConfigSpec.Builder();
|
||||
builder.push("accelerated_age");
|
||||
ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.")
|
||||
.defineInRange("max_loaded_chunks", 8, 1, 64);
|
||||
ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.")
|
||||
.defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536);
|
||||
builder.pop();
|
||||
SPEC = builder.build();
|
||||
}
|
||||
|
||||
private PortConfig() {}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package net.dnorberg.rivencraft;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.mojang.serialization.Codec;
|
||||
import net.dnorberg.rivencraft.age.RuntimeAgeBackend;
|
||||
import net.dnorberg.rivencraft.age.PortAgeCommands;
|
||||
import net.dnorberg.rivencraft.age.AgeCatalogSync;
|
||||
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
|
||||
import net.dnorberg.rivencraft.block.PortBookBinderBlock;
|
||||
import net.dnorberg.rivencraft.block.InkMixerBlock;
|
||||
import net.dnorberg.rivencraft.block.StarFissureBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkBookTarget;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.registry.PortBiomeSources;
|
||||
import net.dnorberg.rivencraft.registry.PortChunkGenerators;
|
||||
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
|
||||
import net.minecraft.core.component.DataComponentType;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.sounds.SoundEvent;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.level.material.MapColor;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.crafting.RecipeSerializer;
|
||||
import net.minecraft.world.inventory.MenuType;
|
||||
import net.minecraft.world.level.block.entity.BlockEntityType;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.registries.DeferredBlock;
|
||||
import net.neoforged.neoforge.registries.DeferredItem;
|
||||
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||
import net.neoforged.neoforge.common.extensions.IMenuTypeExtension;
|
||||
import java.util.function.Supplier;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/** First 26.3 registry slice; gameplay remains in the 1.21.1 target for now. */
|
||||
@Mod(Rivencraft.MOD_ID)
|
||||
public final class Rivencraft {
|
||||
public static final String MOD_ID = "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<SoundEvent> SOUNDS =
|
||||
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
|
||||
public static final Supplier<SoundEvent> LINK = sound("linking.link");
|
||||
public static final Supplier<SoundEvent> LINK_DISARM = sound("linking.link-disarm");
|
||||
public static final Supplier<SoundEvent> LINK_INTRA = sound("linking.link-intra");
|
||||
public static final Supplier<SoundEvent> LINK_FISSURE = sound("linking.link-fissure");
|
||||
public static final Supplier<SoundEvent> LINK_PORTAL = sound("linking.link-portal");
|
||||
public static final Supplier<SoundEvent> METEOR_ROAR = sound("entity.meteor.roar");
|
||||
|
||||
private static Supplier<SoundEvent> sound(String name) {
|
||||
return SOUNDS.register(name, () -> SoundEvent.createVariableRangeEvent(
|
||||
Identifier.fromNamespaceAndPath(MOD_ID, name)));
|
||||
}
|
||||
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
|
||||
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
|
||||
private static final DeferredRegister<MenuType<?>> MENUS =
|
||||
DeferredRegister.create(Registries.MENU, MOD_ID);
|
||||
private static final DeferredRegister<RecipeSerializer<?>> RECIPE_SERIALIZERS =
|
||||
DeferredRegister.create(Registries.RECIPE_SERIALIZER, MOD_ID);
|
||||
private static final DeferredRegister.DataComponents COMPONENTS =
|
||||
DeferredRegister.createDataComponents(Registries.DATA_COMPONENT_TYPE, MOD_ID);
|
||||
|
||||
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<LinkBookTarget>> LINK_BOOK_TARGET = COMPONENTS.registerComponentType(
|
||||
"link_book_target", builder -> builder.persistent(LinkBookTarget.CODEC)
|
||||
.networkSynchronized(LinkBookTarget.STREAM_CODEC));
|
||||
/** A present component distinguishes Link Panels from unwritten base Pages. */
|
||||
public static final Supplier<DataComponentType<Boolean>> LINK_PANEL_PAGE = COMPONENTS.registerComponentType(
|
||||
"link_panel_page", builder -> builder.persistent(Codec.BOOL)
|
||||
.networkSynchronized(ByteBufCodecs.BOOL));
|
||||
public static final Supplier<DataComponentType<Identifier>> SYMBOL_PAGE_ID = COMPONENTS.registerComponentType(
|
||||
"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<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)
|
||||
.apply(Identifier.STREAM_CODEC)));
|
||||
/** Set only on temporary GUI stacks; NeoForge 26.3 requires a registered codec. */
|
||||
public static final Supplier<DataComponentType<Boolean>> PAGE_GLYPH_ONLY = COMPONENTS.registerComponentType(
|
||||
"page_glyph_only", builder -> builder.persistent(Codec.BOOL)
|
||||
.networkSynchronized(ByteBufCodecs.BOOL));
|
||||
public static final Supplier<DataComponentType<Integer>> INK_REMAINING = COMPONENTS.registerComponentType(
|
||||
"ink_remaining", builder -> builder.persistent(Codec.INT)
|
||||
.networkSynchronized(ByteBufCodecs.VAR_INT));
|
||||
public static final Supplier<DataComponentType<LinkProperties>> LINK_PROPERTIES = COMPONENTS.registerComponentType(
|
||||
"link_properties", builder -> builder.persistent(LinkProperties.CODEC)
|
||||
.networkSynchronized(LinkProperties.STREAM_CODEC));
|
||||
|
||||
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
|
||||
Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK));
|
||||
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<PortBookBinderBlock> BOOK_BINDER = BLOCKS.registerBlock(
|
||||
"book_binder", PortBookBinderBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
public static final DeferredItem<BlockItem> BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER);
|
||||
public static final DeferredBlock<InkMixerBlock> INK_MIXER = BLOCKS.registerBlock(
|
||||
"ink_mixer", InkMixerBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
|
||||
public static final DeferredItem<BlockItem> INK_MIXER_ITEM = ITEMS.registerSimpleBlockItem(INK_MIXER);
|
||||
public static final Supplier<BlockEntityType<PortBookBinderBlockEntity>> BOOK_BINDER_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("book_binder", () ->
|
||||
new BlockEntityType<>(PortBookBinderBlockEntity::new, BOOK_BINDER.get()));
|
||||
public static final Supplier<MenuType<PortBookBinderMenu>> BOOK_BINDER_MENU =
|
||||
MENUS.register("book_binder", () -> IMenuTypeExtension.create(PortBookBinderMenu::new));
|
||||
public static final Supplier<BlockEntityType<InkMixerBlockEntity>> INK_MIXER_BLOCK_ENTITY =
|
||||
BLOCK_ENTITY_TYPES.register("ink_mixer", () ->
|
||||
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 DeferredItem<BlockItem> CRYSTAL_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_BLOCK);
|
||||
public static final DeferredBlock<BuddingCrystalBlock> BUDDING_CRYSTAL = BLOCKS.registerBlock(
|
||||
"budding_crystal", BuddingCrystalBlock::new,
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.BUDDING_AMETHYST).mapColor(MapColor.COLOR_BLUE));
|
||||
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL_ITEM = ITEMS.registerSimpleBlockItem(BUDDING_CRYSTAL);
|
||||
public static final DeferredBlock<AmethystClusterBlock> SMALL_CRYSTAL_BUD = BLOCKS.registerBlock(
|
||||
"small_crystal_bud", properties -> new AmethystClusterBlock(3.0F, 4.0F, properties),
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.SMALL_AMETHYST_BUD));
|
||||
public static final DeferredItem<BlockItem> SMALL_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(SMALL_CRYSTAL_BUD);
|
||||
public static final DeferredBlock<AmethystClusterBlock> MEDIUM_CRYSTAL_BUD = BLOCKS.registerBlock(
|
||||
"medium_crystal_bud", properties -> new AmethystClusterBlock(4.0F, 3.0F, properties),
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.MEDIUM_AMETHYST_BUD));
|
||||
public static final DeferredItem<BlockItem> MEDIUM_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(MEDIUM_CRYSTAL_BUD);
|
||||
public static final DeferredBlock<AmethystClusterBlock> LARGE_CRYSTAL_BUD = BLOCKS.registerBlock(
|
||||
"large_crystal_bud", properties -> new AmethystClusterBlock(5.0F, 3.0F, properties),
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.LARGE_AMETHYST_BUD));
|
||||
public static final DeferredItem<BlockItem> LARGE_CRYSTAL_BUD_ITEM = ITEMS.registerSimpleBlockItem(LARGE_CRYSTAL_BUD);
|
||||
public static final DeferredBlock<AmethystClusterBlock> CRYSTAL_CLUSTER = BLOCKS.registerBlock(
|
||||
"crystal_cluster", properties -> new AmethystClusterBlock(7.0F, 3.0F, properties),
|
||||
() -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_CLUSTER));
|
||||
public static final DeferredItem<BlockItem> CRYSTAL_CLUSTER_ITEM = ITEMS.registerSimpleBlockItem(CRYSTAL_CLUSTER);
|
||||
public static final DeferredItem<Item> CRYSTAL_SHARD = ITEMS.registerSimpleItem("crystal_shard");
|
||||
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem("page", PageItem::new);
|
||||
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
|
||||
"ink_vial", InkVialItem::new, properties -> properties.stacksTo(16));
|
||||
public static final DeferredItem<DescriptiveBookItem> DESCRIPTIVE_BOOK = ITEMS.registerItem(
|
||||
"descriptive_book", DescriptiveBookItem::new, properties -> properties.stacksTo(1));
|
||||
public static final DeferredItem<LinkingBookItem> LINKING_BOOK = ITEMS.registerItem(
|
||||
"linking_book", LinkingBookItem::new, properties -> properties.stacksTo(1));
|
||||
public static final Supplier<RecipeSerializer<LinkingBookRecipe>> LINKING_BOOK_RECIPE =
|
||||
RECIPE_SERIALIZERS.register("linking_book", () -> LinkingBookRecipe.SERIALIZER);
|
||||
|
||||
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);
|
||||
BLOCK_ENTITY_TYPES.register(modEventBus);
|
||||
MENUS.register(modEventBus);
|
||||
COMPONENTS.register(modEventBus);
|
||||
RECIPE_SERIALIZERS.register(modEventBus);
|
||||
ModCreativeTabs.register(modEventBus);
|
||||
PortBiomeSources.register(modEventBus);
|
||||
PortChunkGenerators.register(modEventBus);
|
||||
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
|
||||
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped);
|
||||
modEventBus.addListener(AgeCatalogSync::registerPayloads);
|
||||
modEventBus.addListener(PortBookNetworking::registerPayloads);
|
||||
LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Acknowledges the exact catalog version applied on the client main thread. */
|
||||
public record AgeCatalogAckPayload(int version) implements CustomPacketPayload {
|
||||
public static final Type<AgeCatalogAckPayload> TYPE = new Type<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog_ack"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogAckPayload> STREAM_CODEC = StreamCodec.composite(
|
||||
ByteBufCodecs.VAR_INT, AgeCatalogAckPayload::version, AgeCatalogAckPayload::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<AgeCatalogAckPayload> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Full server level catalog, sent on login and after a runtime Age is added. */
|
||||
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
|
||||
Map<Identifier, PortAgeColors> colors,
|
||||
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
|
||||
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
|
||||
ByteBufCodecs.VAR_INT, AgeCatalogPayload::version,
|
||||
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
|
||||
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
|
||||
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
|
||||
AgeCatalogPayload::new
|
||||
);
|
||||
|
||||
public AgeCatalogPayload {
|
||||
dimensions = List.copyOf(dimensions);
|
||||
colors = Map.copyOf(colors);
|
||||
dayCycles = Map.copyOf(dayCycles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Type<AgeCatalogPayload> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
|
||||
import net.neoforged.neoforge.network.PacketDistributor;
|
||||
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||
|
||||
/** Refuses runtime-Age travel until the connected client confirms its catalog. */
|
||||
public final class AgeCatalogSync {
|
||||
private static final Map<MinecraftServer, State> STATES = new IdentityHashMap<>();
|
||||
|
||||
private AgeCatalogSync() {
|
||||
}
|
||||
|
||||
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
|
||||
var registrar = event.registrar("10");
|
||||
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
|
||||
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
|
||||
(payload, context) -> {
|
||||
if (context.player() instanceof ServerPlayer player) acknowledge(player, payload.version());
|
||||
});
|
||||
}
|
||||
|
||||
public static void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) {
|
||||
try {
|
||||
send(player);
|
||||
} catch (RuntimeException exception) {
|
||||
Rivencraft.LOGGER.error("Could not send initial Age catalog to {}; travel remains blocked",
|
||||
player.getName().getString(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void onLogout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) {
|
||||
State state = STATES.get(player.level().getServer());
|
||||
if (state != null) state.acknowledged.remove(player.getUUID());
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerStopped(ServerStoppedEvent event) {
|
||||
STATES.remove(event.getServer());
|
||||
}
|
||||
|
||||
public static void onCatalogChanged(MinecraftServer server) {
|
||||
State state = state(server);
|
||||
state.version++;
|
||||
state.acknowledged.clear();
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
try {
|
||||
send(player);
|
||||
} catch (RuntimeException exception) {
|
||||
Rivencraft.LOGGER.error("Could not send Age catalog to {}; travel remains blocked",
|
||||
player.getName().getString(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean ready(ServerPlayer player, Identifier target) {
|
||||
MinecraftServer server = player.level().getServer();
|
||||
State state = state(server);
|
||||
return state.version == state.acknowledged.getOrDefault(player.getUUID(), -1)
|
||||
&& server.levelKeys().stream().anyMatch(key -> key.identifier().equals(target));
|
||||
}
|
||||
|
||||
private static void send(ServerPlayer player) {
|
||||
MinecraftServer server = player.level().getServer();
|
||||
State state = state(server);
|
||||
state.acknowledged.remove(player.getUUID());
|
||||
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
|
||||
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
|
||||
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
|
||||
}
|
||||
|
||||
private static void acknowledge(ServerPlayer player, int version) {
|
||||
State state = state(player.level().getServer());
|
||||
if (version != state.version) {
|
||||
Rivencraft.LOGGER.warn("Ignoring stale Age catalog acknowledgement {} from {} (current {})",
|
||||
version, player.getName().getString(), state.version);
|
||||
return;
|
||||
}
|
||||
state.acknowledged.put(player.getUUID(), version);
|
||||
Rivencraft.LOGGER.info("Age catalog {} acknowledged by {}", version, player.getName().getString());
|
||||
}
|
||||
|
||||
private static State state(MinecraftServer server) {
|
||||
return STATES.computeIfAbsent(server, ignored -> new State());
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private int version = 1;
|
||||
private final Map<UUID, Integer> acknowledged = new HashMap<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/** Server-owned seed index for the first-party 26.3 runtime-level prototype. */
|
||||
final class AgeLevelIndex extends SavedData {
|
||||
private static final Codec<AgeLevelIndex> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.unboundedMap(Identifier.CODEC, Codec.LONG).fieldOf("ages").forGetter(index -> index.ages),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("retired", List.of())
|
||||
.forGetter(index -> List.copyOf(index.retired)),
|
||||
Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC).optionalFieldOf("biome_overrides", Map.of())
|
||||
.forGetter(index -> index.biomeOverrides),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortBiomePlan.CODEC).optionalFieldOf("biome_plans", Map.of())
|
||||
.forGetter(index -> index.biomePlans),
|
||||
Codec.unboundedMap(Identifier.CODEC,
|
||||
Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name))
|
||||
.optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortRockPlan.CODEC).optionalFieldOf("rocks", Map.of())
|
||||
.forGetter(index -> index.rocks),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortVegetationPlan.CODEC)
|
||||
.optionalFieldOf("vegetation", Map.of()).forGetter(index -> index.vegetation),
|
||||
Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, 32))
|
||||
.optionalFieldOf("dungeons", Map.of()).forGetter(index -> index.dungeons),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortLakePlan.CODEC)
|
||||
.optionalFieldOf("lakes", Map.of()).forGetter(index -> index.lakes),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortTerrainFeaturePlan.CODEC)
|
||||
.optionalFieldOf("terrain_features", Map.of()).forGetter(index -> index.terrainFeatures),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortLighting.CODEC)
|
||||
.optionalFieldOf("lighting", Map.of()).forGetter(index -> index.lighting),
|
||||
Codec.unboundedMap(Identifier.CODEC, PortSkyStyle.CODEC)
|
||||
.optionalFieldOf("sky", Map.of()).forGetter(index -> index.sky),
|
||||
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)
|
||||
).apply(instance, AgeLevelIndex::new));
|
||||
|
||||
static final SavedDataType<AgeLevelIndex> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "runtime_ages"),
|
||||
AgeLevelIndex::new,
|
||||
CODEC
|
||||
);
|
||||
|
||||
private final Map<Identifier, Long> ages;
|
||||
private final Set<Identifier> retired;
|
||||
private final Map<Identifier, Identifier> biomeOverrides;
|
||||
private final Map<Identifier, PortBiomePlan> biomePlans;
|
||||
private final Map<Identifier, PortAgeTerrain> terrain;
|
||||
private final Map<Identifier, PortRockPlan> rocks;
|
||||
private final Map<Identifier, PortVegetationPlan> vegetation;
|
||||
private final Map<Identifier, Integer> dungeons;
|
||||
private final Map<Identifier, PortLakePlan> lakes;
|
||||
private final Map<Identifier, PortTerrainFeaturePlan> terrainFeatures;
|
||||
private final Map<Identifier, PortLighting> lighting;
|
||||
private final Map<Identifier, PortSkyStyle> sky;
|
||||
private final Map<Identifier, PortAgeColors> colors;
|
||||
private final Map<Identifier, PortDayCycle> dayCycles;
|
||||
|
||||
private AgeLevelIndex() {
|
||||
this.ages = new LinkedHashMap<>();
|
||||
this.retired = new LinkedHashSet<>();
|
||||
this.biomeOverrides = new LinkedHashMap<>();
|
||||
this.biomePlans = new LinkedHashMap<>();
|
||||
this.terrain = new LinkedHashMap<>();
|
||||
this.rocks = new LinkedHashMap<>();
|
||||
this.vegetation = new LinkedHashMap<>();
|
||||
this.dungeons = new LinkedHashMap<>();
|
||||
this.lakes = new LinkedHashMap<>();
|
||||
this.terrainFeatures = new LinkedHashMap<>();
|
||||
this.lighting = new LinkedHashMap<>();
|
||||
this.sky = new LinkedHashMap<>();
|
||||
this.colors = new LinkedHashMap<>();
|
||||
this.dayCycles = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
private AgeLevelIndex(Map<Identifier, Long> ages, List<Identifier> retired,
|
||||
Map<Identifier, Identifier> biomeOverrides,
|
||||
Map<Identifier, PortBiomePlan> biomePlans,
|
||||
Map<Identifier, PortAgeTerrain> terrain,
|
||||
Map<Identifier, PortRockPlan> rocks,
|
||||
Map<Identifier, PortVegetationPlan> vegetation,
|
||||
Map<Identifier, Integer> dungeons,
|
||||
Map<Identifier, PortLakePlan> lakes,
|
||||
Map<Identifier, PortTerrainFeaturePlan> terrainFeatures,
|
||||
Map<Identifier, PortLighting> lighting,
|
||||
Map<Identifier, PortSkyStyle> sky, Map<Identifier, PortAgeColors> colors,
|
||||
Map<Identifier, PortDayCycle> dayCycles) {
|
||||
this.ages = new LinkedHashMap<>(ages);
|
||||
this.retired = new LinkedHashSet<>(retired);
|
||||
this.biomeOverrides = new LinkedHashMap<>(biomeOverrides);
|
||||
this.biomePlans = new LinkedHashMap<>(biomePlans);
|
||||
this.terrain = new LinkedHashMap<>(terrain);
|
||||
this.rocks = new LinkedHashMap<>(rocks);
|
||||
this.vegetation = new LinkedHashMap<>(vegetation);
|
||||
this.dungeons = new LinkedHashMap<>(dungeons);
|
||||
this.lakes = new LinkedHashMap<>(lakes);
|
||||
this.terrainFeatures = new LinkedHashMap<>(terrainFeatures);
|
||||
this.lighting = new LinkedHashMap<>(lighting);
|
||||
this.sky = new LinkedHashMap<>(sky);
|
||||
this.colors = new LinkedHashMap<>(colors);
|
||||
this.dayCycles = new LinkedHashMap<>(dayCycles);
|
||||
}
|
||||
|
||||
Map<Identifier, Long> entries() {
|
||||
return Map.copyOf(ages);
|
||||
}
|
||||
|
||||
boolean contains(Identifier id) {
|
||||
return ages.containsKey(id) || retired.contains(id);
|
||||
}
|
||||
|
||||
Long seed(Identifier id) {
|
||||
return ages.get(id);
|
||||
}
|
||||
|
||||
PortBiomePlan biomePlan(Identifier id) {
|
||||
PortBiomePlan plan = biomePlans.get(id);
|
||||
if (plan != null) return plan;
|
||||
Identifier oldBiome = biomeOverrides.get(id);
|
||||
return oldBiome == null ? null : new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, List.of(oldBiome));
|
||||
}
|
||||
|
||||
PortAgeTerrain terrain(Identifier id) {
|
||||
return terrain.getOrDefault(id, PortAgeTerrain.STANDARD);
|
||||
}
|
||||
|
||||
PortRockPlan rocks(Identifier id) { return rocks.get(id); }
|
||||
PortVegetationPlan vegetation(Identifier id) { return vegetation.get(id); }
|
||||
int dungeons(Identifier id) { return dungeons.getOrDefault(id, 0); }
|
||||
PortLakePlan lakes(Identifier id) { return lakes.get(id); }
|
||||
PortTerrainFeaturePlan terrainFeatures(Identifier id) { return terrainFeatures.get(id); }
|
||||
PortLighting lighting(Identifier id) { return lighting.getOrDefault(id, PortLighting.NORMAL); }
|
||||
PortSkyStyle sky(Identifier id) { return sky.getOrDefault(id, PortSkyStyle.OVERWORLD); }
|
||||
PortAgeColors colors(Identifier id) { return colors.getOrDefault(id, PortAgeColors.NATIVE); }
|
||||
Map<Identifier, PortAgeColors> colors() { return Map.copyOf(colors); }
|
||||
PortDayCycle dayCycle(Identifier id) { return dayCycles.getOrDefault(id, PortDayCycle.NATIVE); }
|
||||
Map<Identifier, PortDayCycle> dayCycles() { return Map.copyOf(dayCycles); }
|
||||
|
||||
void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain,
|
||||
PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeonCount,
|
||||
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light, PortSkyStyle skyStyle,
|
||||
PortAgeColors colorProfile, PortDayCycle dayCycle) {
|
||||
if (contains(id)) {
|
||||
throw new IllegalArgumentException("Age already exists: " + id);
|
||||
}
|
||||
ages.put(id, seed);
|
||||
if (biomePlan != null) biomePlans.put(id, biomePlan);
|
||||
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
|
||||
if (rockPlan != null) rocks.put(id, rockPlan);
|
||||
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
|
||||
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
|
||||
if (lakePlan != null) lakes.put(id, lakePlan);
|
||||
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
|
||||
if (light != PortLighting.NORMAL) lighting.put(id, light);
|
||||
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
|
||||
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
|
||||
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
|
||||
setDirty();
|
||||
}
|
||||
|
||||
void retire(Identifier id) {
|
||||
if (ages.remove(id) == null) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
biomeOverrides.remove(id);
|
||||
biomePlans.remove(id);
|
||||
terrain.remove(id);
|
||||
rocks.remove(id);
|
||||
vegetation.remove(id);
|
||||
dungeons.remove(id);
|
||||
lakes.remove(id);
|
||||
terrainFeatures.remove(id);
|
||||
lighting.remove(id);
|
||||
sky.remove(id);
|
||||
colors.remove(id);
|
||||
dayCycles.remove(id);
|
||||
retired.add(id);
|
||||
setDirty();
|
||||
}
|
||||
|
||||
void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan,
|
||||
PortAgeTerrain ageTerrain, PortRockPlan rockPlan,
|
||||
PortVegetationPlan vegetationPlan, int dungeonCount,
|
||||
PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan, PortLighting light,
|
||||
PortSkyStyle skyStyle, PortAgeColors colorProfile, PortDayCycle dayCycle) {
|
||||
if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id);
|
||||
ages.put(id, seed);
|
||||
if (biomePlan != null) biomePlans.put(id, biomePlan);
|
||||
if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain);
|
||||
if (rockPlan != null) rocks.put(id, rockPlan);
|
||||
if (vegetationPlan != null) vegetation.put(id, vegetationPlan);
|
||||
if (dungeonCount > 0) dungeons.put(id, dungeonCount);
|
||||
if (lakePlan != null) lakes.put(id, lakePlan);
|
||||
if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan);
|
||||
if (light != PortLighting.NORMAL) lighting.put(id, light);
|
||||
if (skyStyle != PortSkyStyle.OVERWORLD) sky.put(id, skyStyle);
|
||||
if (!colorProfile.equals(PortAgeColors.NATIVE)) colors.put(id, colorProfile);
|
||||
if (!dayCycle.equals(PortDayCycle.NATIVE)) dayCycles.put(id, dayCycle);
|
||||
setDirty();
|
||||
}
|
||||
|
||||
void reseed(Identifier id, long seed) {
|
||||
if (!ages.containsKey(id)) throw new IllegalArgumentException("Age not indexed: " + id);
|
||||
ages.put(id, seed);
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.Level;
|
||||
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, 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).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,
|
||||
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
|
||||
|| 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) {
|
||||
if (level instanceof PortAgeLevel age) return age.colors();
|
||||
return level.isClientSide() ? clientColors.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
|
||||
}
|
||||
|
||||
public static boolean isColor(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "color/red", "color/green", "color/blue", "color/white", "color/black" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static int number(Identifier page) {
|
||||
if (!page.getNamespace().equals("rivencraft") || !page.getPath().startsWith("number/")) return -1;
|
||||
try {
|
||||
int value = Integer.parseInt(page.getPath().substring(7));
|
||||
return value >= 0 && value <= 24 ? value : -1;
|
||||
} catch (NumberFormatException exception) { return -1; }
|
||||
}
|
||||
|
||||
public static boolean isModifier(Identifier page) { return isColor(page) || number(page) >= 0 || page.equals(AMBIENT); }
|
||||
|
||||
public static boolean validNumbers(List<Identifier> pages) {
|
||||
for (int i = 0; i < pages.size(); i++)
|
||||
if (number(pages.get(i)) >= 0 && (i + 1 == pages.size()
|
||||
|| !isColor(pages.get(i + 1)) && !PortDayCycle.isPeriod(pages.get(i + 1))
|
||||
&& !PortDayCycle.isPhase(pages.get(i + 1)) && !PortDayCycle.isAngle(pages.get(i + 1))
|
||||
&& !PortDayCycle.isSize(pages.get(i + 1)))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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, celestial = -1;
|
||||
boolean written = false;
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
Identifier page = pages.get(i);
|
||||
if (isColor(page)) {
|
||||
int amount = i > 0 ? number(pages.get(i - 1)) : -1;
|
||||
if (amount < 0) amount = new Random(seed ^ (0x9E3779B97F4A7C15L * (i + 1L))).nextInt(25);
|
||||
amount *= 10;
|
||||
written = true;
|
||||
switch (page.getPath()) {
|
||||
case "color/red" -> red += amount;
|
||||
case "color/green" -> green += amount;
|
||||
case "color/blue" -> blue += amount;
|
||||
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)
|
||||
|| 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 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, 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)); }
|
||||
public static Vector3fc vector(int rgb) {
|
||||
return new Vector3f((rgb >> 16 & 255) / 255f, (rgb >> 8 & 255) / 255f, (rgb & 255) / 255f);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,63 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.saveddata.WeatherData;
|
||||
import net.minecraft.world.level.storage.DerivedLevelData;
|
||||
|
||||
/** Runtime Age with independent native weather, rather than 26.3's shared server cycle. */
|
||||
final class PortAgeLevel extends ServerLevel {
|
||||
private final WeatherData ageWeather;
|
||||
private final PortWeatherSettings weatherSettings;
|
||||
private final boolean cave;
|
||||
private PortAgeColors colors = PortAgeColors.NATIVE;
|
||||
private PortDayCycle dayCycle = PortDayCycle.NATIVE;
|
||||
|
||||
PortDayCycle dayCycle() { return dayCycle == null ? PortDayCycle.NATIVE : dayCycle; }
|
||||
void configureDayCycle(PortDayCycle cycle) {
|
||||
this.dayCycle = cycle;
|
||||
environmentAttributes().invalidateTickCache();
|
||||
updateSkyBrightness();
|
||||
}
|
||||
|
||||
PortAgeColors colors() { return colors == null ? PortAgeColors.NATIVE : colors; }
|
||||
void configureColors(PortAgeColors colors) {
|
||||
this.colors = colors;
|
||||
environmentAttributes().invalidateTickCache();
|
||||
}
|
||||
|
||||
PortAgeLevel(MinecraftServer server, ResourceKey<Level> key, LevelStem stem,
|
||||
long biomeZoomSeed, PortAgeTerrain terrain) {
|
||||
super(server, server.executor, server.storageSource,
|
||||
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
|
||||
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
|
||||
ageWeather = getDataStorage().computeIfAbsent(WeatherData.TYPE);
|
||||
weatherSettings = getDataStorage().computeIfAbsent(PortWeatherSettings.TYPE);
|
||||
cave = terrain == PortAgeTerrain.CAVE;
|
||||
PortAgeWeather.apply(this);
|
||||
// The base constructor initializes visual levels from server weather.
|
||||
// Replace them before publishing this Age or sending its join packets.
|
||||
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
|
||||
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WeatherData getWeatherData() {
|
||||
// The base constructor may query weather before our fields are initialized.
|
||||
return ageWeather == null ? super.getWeatherData() : ageWeather;
|
||||
}
|
||||
|
||||
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
|
||||
|
||||
void configureWeather(PortWeatherMode mode) {
|
||||
weatherSettings.setMode(mode);
|
||||
PortAgeWeather.apply(this);
|
||||
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
|
||||
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
|
||||
getDataStorage().saveAndJoin();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
/** Terrain variants currently backed by 26.3 vanilla noise settings. */
|
||||
public enum PortAgeTerrain {
|
||||
STANDARD,
|
||||
AMPLIFIED,
|
||||
ISLAND,
|
||||
CAVE,
|
||||
FLAT,
|
||||
VOID
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Set;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
|
||||
public final class PortAgeTravel {
|
||||
private PortAgeTravel() {
|
||||
}
|
||||
|
||||
public static String enter(ServerPlayer player, Identifier id) {
|
||||
ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
|
||||
if (target == null) return "Age not loaded: " + id;
|
||||
if (!AgeCatalogSync.ready(player, id)) {
|
||||
return "Age catalog not acknowledged by client; remaining in source dimension";
|
||||
}
|
||||
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
|
||||
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
|
||||
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
|
||||
if (landing != null) landing = isCaveTerrain(target)
|
||||
? findFootedDestination(target, landing).orElse(null)
|
||||
: findSurfaceLanding(target, landing.getX(), landing.getZ());
|
||||
if (landing == null) landing = findLanding(target);
|
||||
if (landing == null) return "No safe test landing available; remaining in source dimension";
|
||||
if (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";
|
||||
}
|
||||
|
||||
private 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
|
||||
// at that height only when no footed opening can be found.
|
||||
for (int radius = 0; radius <= 16; radius++) {
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
|
||||
for (int y = Math.min(64, target.getMaxY() - 2); y > target.getMinY(); y--) {
|
||||
BlockPos foot = new BlockPos(x, y, z);
|
||||
if (hasFooting(target, foot)) return foot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return makePlatform(target, new BlockPos(0, 64, 0));
|
||||
}
|
||||
BlockPos surface = findSurfaceLanding(target, 0, 0);
|
||||
if (surface != null) return surface;
|
||||
// Only build above the actual highest obstruction, never in a pocket
|
||||
// beneath it. Void Ages retain the usual platform floor at Y=63.
|
||||
BlockPos obstruction = highestObstruction(target, 0, 0);
|
||||
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
|
||||
return makePlatform(target, new BlockPos(0, y, 0));
|
||||
}
|
||||
|
||||
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
|
||||
for (int radius = 0; radius <= 16; radius++) {
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
|
||||
BlockPos floor = highestObstruction(target, originX + x, originZ + z);
|
||||
if (floor != null && hasFooting(target, floor.above())) return floor.above();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First obstructing surface seen from the sky; never continue through a roof or liquid. */
|
||||
static BlockPos highestObstruction(ServerLevel level, int x, int z) {
|
||||
var chunk = level.getChunk(x >> 4, z >> 4);
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(x, level.getMaxY() - 1, z);
|
||||
for (int y = level.getMaxY() - 1; y >= level.getMinY(); y--) {
|
||||
pos.setY(y);
|
||||
var state = chunk.getBlockState(pos);
|
||||
if (!state.isAir() && (!state.getFluidState().isEmpty()
|
||||
|| state.is(net.minecraft.tags.BlockTags.LEAVES)
|
||||
|| !state.getCollisionShape(level, pos).isEmpty())) return pos.immutable();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isCaveTerrain(ServerLevel target) {
|
||||
var generator = target.getChunkSource().getGenerator();
|
||||
if (generator instanceof PortFeatureChunkGenerator features) generator = features.base();
|
||||
return generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise
|
||||
&& noise.generatorSettings().value().noiseSettings().equals(target.registryAccess()
|
||||
.lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES)
|
||||
.value().noiseSettings());
|
||||
}
|
||||
|
||||
private static BlockPos makePlatform(ServerLevel target, BlockPos foot) {
|
||||
if (foot.getY() <= target.getMinY() || foot.getY() + 2 >= target.getMaxY()) return null;
|
||||
// A carved pocket inside a flooded cave immediately refills. Refuse
|
||||
// that landing instead of placing the player in submerged spawn space.
|
||||
if (!target.getFluidState(foot).isEmpty() || !target.getFluidState(foot.above()).isEmpty()) return null;
|
||||
for (Direction direction : Direction.Plane.HORIZONTAL) {
|
||||
if (!target.getFluidState(foot.relative(direction)).isEmpty()
|
||||
|| !target.getFluidState(foot.above().relative(direction)).isEmpty()) return null;
|
||||
}
|
||||
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, Blocks.AIR.defaultBlockState(), 3);
|
||||
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
|
||||
return foot;
|
||||
}
|
||||
|
||||
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
|
||||
if (foot.getY() <= target.getMinY() || foot.getY() + 1 >= target.getMaxY()
|
||||
|| !target.getWorldBorder().isWithinBounds(foot)) return false;
|
||||
BlockPos floor = foot.below();
|
||||
var floorState = target.getBlockState(floor);
|
||||
return (floorState.isFaceSturdy(target, floor, Direction.UP)
|
||||
|| floorState.is(net.minecraft.tags.BlockTags.LEAVES))
|
||||
&& floorState.getFluidState().isEmpty()
|
||||
&& target.getFluidState(foot).isEmpty()
|
||||
&& target.getFluidState(foot.above()).isEmpty()
|
||||
&& passable(target, foot) && passable(target, foot.above());
|
||||
}
|
||||
|
||||
private static boolean passable(ServerLevel level, BlockPos pos) {
|
||||
var state = level.getBlockState(pos);
|
||||
return state.getCollisionShape(level, pos).isEmpty() || state.is(net.minecraft.tags.BlockTags.LEAVES);
|
||||
}
|
||||
|
||||
/** Same local-then-surface safe-return search used by 1.21.1 fissures. */
|
||||
public static java.util.Optional<BlockPos> findFootedDestination(ServerLevel level, BlockPos origin) {
|
||||
var local = BlockPos.betweenClosedStream(origin.offset(-4, -2, -4), origin.offset(4, 2, 4))
|
||||
.map(BlockPos::immutable).sorted(java.util.Comparator.comparingDouble(pos -> pos.distSqr(origin)))
|
||||
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
|
||||
.filter(pos -> hasFooting(level, pos)).findFirst();
|
||||
if (local.isPresent()) return local;
|
||||
for (int radius = 1; radius <= 64; radius++) {
|
||||
final int ring = radius;
|
||||
var found = BlockPos.betweenClosedStream(origin.offset(-ring, 0, -ring), origin.offset(ring, 0, ring))
|
||||
.filter(pos -> Math.max(Math.abs(pos.getX() - origin.getX()), Math.abs(pos.getZ() - origin.getZ())) == ring)
|
||||
.map(BlockPos::immutable)
|
||||
.flatMap(pos -> {
|
||||
BlockPos surface = new BlockPos(pos.getX(),
|
||||
level.getHeight(Heightmap.Types.MOTION_BLOCKING, pos.getX(), pos.getZ()), pos.getZ());
|
||||
return java.util.stream.Stream.of(surface, surface.above(), surface.below(),
|
||||
surface.above(2), surface.below(2));
|
||||
})
|
||||
.filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos))
|
||||
.filter(pos -> hasFooting(level, pos)).findFirst();
|
||||
if (found.isPresent()) return found;
|
||||
}
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.world.level.saveddata.WeatherData;
|
||||
import net.neoforged.neoforge.event.tick.LevelTickEvent;
|
||||
|
||||
/** 1.21.1 weather policies, applied before the independent native Age cycle advances. */
|
||||
public final class PortAgeWeather {
|
||||
private static final Map<PortAgeLevel, Integer> STATES = new WeakHashMap<>();
|
||||
|
||||
private PortAgeWeather() {}
|
||||
|
||||
public static void onLevelTickPre(LevelTickEvent.Pre event) {
|
||||
if (event.getLevel() instanceof PortAgeLevel level) apply(level);
|
||||
}
|
||||
|
||||
static void apply(PortAgeLevel level) {
|
||||
PortWeatherMode mode = level.weatherMode();
|
||||
if (mode == PortWeatherMode.NORMAL) {
|
||||
STATES.remove(level);
|
||||
return;
|
||||
}
|
||||
WeatherData weather = level.getWeatherData();
|
||||
if (mode == PortWeatherMode.FAST || mode == PortWeatherMode.SLOW) {
|
||||
int state = (weather.isRaining() ? 1 : 0) | (weather.isThundering() ? 2 : 0);
|
||||
Integer previous = STATES.put(level, state);
|
||||
if (previous != null && previous == state) return;
|
||||
// Keep persisted timers on reload; rescale only when a cycle changes.
|
||||
if (previous == null && weather.getRainTime() > 0) return;
|
||||
int scale = mode == PortWeatherMode.FAST ? 1 : 4;
|
||||
int duration = 6000 * scale + level.getRandom().nextInt(
|
||||
(weather.isRaining() ? 6000 : 84000) * scale);
|
||||
set(weather, duration, weather.isRaining(), weather.isThundering());
|
||||
} else {
|
||||
STATES.remove(level);
|
||||
boolean rain = mode != PortWeatherMode.OFF;
|
||||
boolean thunder = mode == PortWeatherMode.STORM;
|
||||
if (weather.isRaining() != rain || weather.isThundering() != thunder
|
||||
|| weather.getRainTime() < 200 || weather.getThunderTime() < 200
|
||||
|| weather.getClearWeatherTime() != 0) set(weather, 6000, rain, thunder);
|
||||
}
|
||||
}
|
||||
|
||||
private static void set(WeatherData data, int duration, boolean rain, boolean thunder) {
|
||||
data.setClearWeatherTime(0);
|
||||
data.setRainTime(duration);
|
||||
data.setThunderTime(duration);
|
||||
data.setRaining(rain);
|
||||
data.setThundering(thunder);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** 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 }
|
||||
|
||||
public static final Codec<PortBiomePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Codec.STRING.xmap(Distribution::valueOf, Distribution::name).fieldOf("distribution")
|
||||
.forGetter(PortBiomePlan::distribution),
|
||||
Identifier.CODEC.listOf().fieldOf("biomes").forGetter(PortBiomePlan::biomes)
|
||||
).apply(instance, PortBiomePlan::new));
|
||||
|
||||
public PortBiomePlan {
|
||||
biomes = List.copyOf(biomes);
|
||||
if (distribution == null || distribution == Distribution.SINGLE && biomes.size() != 1
|
||||
|| distribution != Distribution.NATIVE && distribution != Distribution.SINGLE && biomes.size() < 2)
|
||||
throw new IllegalArgumentException("Invalid authored biome distribution");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.QuartPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.CarvingMask;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.WorldGenerationContext;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.carver.WorldCarver;
|
||||
|
||||
/** Authored Caves/Ravines, independent of the terrain page, with 1.21.1 source seeds. */
|
||||
public final class PortCarverPages {
|
||||
public static final Identifier CAVES = Identifier.fromNamespaceAndPath("rivencraft", "feature/caves");
|
||||
public static final Identifier RAVINES = Identifier.fromNamespaceAndPath("rivencraft", "feature/ravines");
|
||||
private static final long CAVE_SALT = 0x4341564553464541L;
|
||||
private static final long RAVINE_SALT = 0x524156494E45534CL;
|
||||
|
||||
private PortCarverPages() {}
|
||||
|
||||
public static boolean supports(Identifier page) { return CAVES.equals(page) || RAVINES.equals(page); }
|
||||
|
||||
public static void validateCounts(List<Identifier> pages) {
|
||||
Map<Identifier, Integer> counts = new HashMap<>();
|
||||
for (Identifier page : pages) if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
|
||||
throw new IllegalArgumentException("Carver plan requires Caves/Ravines pages, at most 32 per type");
|
||||
}
|
||||
|
||||
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
|
||||
if (pages.stream().noneMatch(PortCarverPages::supports)) return;
|
||||
var carvers = registries.lookupOrThrow(Registries.CARVER);
|
||||
for (String name : pages.contains(CAVES) ? List.of("cave", "nether_cave", "canyon") : List.of("canyon")) {
|
||||
if (name.equals("canyon") && !pages.contains(RAVINES)) continue;
|
||||
carvers.get(ResourceKey.create(Registries.CARVER, Identifier.withDefaultNamespace(name)))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Missing authored carver minecraft:" + name));
|
||||
}
|
||||
}
|
||||
|
||||
public static void carve(ChunkGenerator generator, ChunkAccess chunk, RandomState randomState,
|
||||
HolderLookup.Provider registries, List<Identifier> pages, long seed, CarvingMask mask) {
|
||||
if (pages.isEmpty()) return;
|
||||
var registry = registries.lookupOrThrow(Registries.CARVER);
|
||||
var biomes = generator.getBiomeSource().createUncachedResolver(randomState);
|
||||
var context = new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration());
|
||||
var random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
ChunkPos target = chunk.getPos();
|
||||
// 1.21.1 repeats identical seeds for duplicate Caves/Ravines pages.
|
||||
// Their union is identical; avoid replaying the same mask up to 32 times.
|
||||
for (Identifier page : pages.stream().distinct().toList()) {
|
||||
boolean ravine = page.equals(RAVINES);
|
||||
WorldCarver overworld = registry.getOrThrow(ResourceKey.create(Registries.CARVER,
|
||||
Identifier.withDefaultNamespace(ravine ? "canyon" : "cave"))).value();
|
||||
WorldCarver nether = ravine ? null : registry.getOrThrow(ResourceKey.create(Registries.CARVER,
|
||||
Identifier.withDefaultNamespace("nether_cave"))).value();
|
||||
for (int dx = -8; dx <= 8; dx++) {
|
||||
for (int dz = -8; dz <= 8; dz++) {
|
||||
ChunkPos source = new ChunkPos(target.x() + dx, target.z() + dz);
|
||||
boolean isNether = biomes.getNoiseBiome(QuartPos.fromBlock(source.getMinBlockX()), 0,
|
||||
QuartPos.fromBlock(source.getMinBlockZ())).is(BiomeTags.IS_NETHER);
|
||||
if (ravine && isNether) continue;
|
||||
WorldCarver carver = isNether ? nether : overworld;
|
||||
random.setLargeFeatureSeed(seed ^ (ravine ? RAVINE_SALT : CAVE_SALT), source.x(), source.z());
|
||||
if (carver.isStartChunk(random)) carver.carve(context, random, target, source, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/** 26.3 port of the 1.21.1 surface crystal mound with budding hearts and seeded buds. */
|
||||
final class PortCrystalFormationsFeature {
|
||||
private static final long SALT = 0x4352595354414C53L;
|
||||
private static final int CHANCE = 15;
|
||||
|
||||
private PortCrystalFormationsFeature() {}
|
||||
|
||||
record Site(int x, int z, long seed) {}
|
||||
|
||||
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
|
||||
long seed = mix(ageSeed ^ SALT ^ (long) chunk.x() * 341873128712L
|
||||
^ (long) chunk.z() * 132897987541L ^ (long) occurrence * 0x9E3779B97F4A7C15L);
|
||||
RandomSource random = RandomSource.create(seed);
|
||||
if (random.nextInt(CHANCE) != 0) return null;
|
||||
return new Site(chunk.getMinBlockX() + 5 + random.nextInt(6),
|
||||
chunk.getMinBlockZ() + 5 + random.nextInt(6), seed);
|
||||
}
|
||||
|
||||
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, int occurrence) {
|
||||
Site site = site(ageSeed, chunk.getPos(), occurrence);
|
||||
if (site == null) return;
|
||||
RandomSource random = RandomSource.create(site.seed() ^ 0x42554444494E474CL);
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, site.x(), site.z());
|
||||
if (y <= level.getMinY() + 1 || y + 4 >= level.getMaxY()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
int x = site.x() + dx;
|
||||
int z = site.z() + dz;
|
||||
if (Math.abs(level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - y) > 1) return;
|
||||
BlockState floor = level.getBlockState(pos.set(x, y - 1, z));
|
||||
if (!floor.isFaceSturdy(level, pos, Direction.UP) || !floor.getFluidState().isEmpty()
|
||||
|| floor.hasBlockEntity()) return;
|
||||
for (int h = 0; h < 4; h++) {
|
||||
BlockState existing = level.getBlockState(pos.set(x, y + h, z));
|
||||
if (!existing.isAir() && !existing.canBeReplaced()) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
boolean center = dx == 0 && dz == 0;
|
||||
int height = center ? 3 : random.nextInt(3) == 0 ? 2 : 1;
|
||||
for (int h = 0; h < height; h++) {
|
||||
boolean budding = h == height - 1 && (center || random.nextBoolean());
|
||||
level.setBlock(pos.set(site.x() + dx, y + h, site.z() + dz),
|
||||
budding ? Rivencraft.BUDDING_CRYSTAL.get().defaultBlockState()
|
||||
: Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 2);
|
||||
}
|
||||
if (level.getBlockState(pos.set(site.x() + dx, y + height - 1, site.z() + dz))
|
||||
.is(Rivencraft.BUDDING_CRYSTAL) && random.nextBoolean()) {
|
||||
level.setBlock(pos.set(site.x() + dx, y + height, site.z() + dz),
|
||||
Rivencraft.SMALL_CRYSTAL_BUD.get().defaultBlockState()
|
||||
.setValue(AmethystClusterBlock.FACING, Direction.UP), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.clock.ClockInstance;
|
||||
import net.minecraft.world.clock.ClockManager;
|
||||
import net.minecraft.world.clock.WorldClock;
|
||||
import net.minecraft.world.clock.WorldClocks;
|
||||
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, 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(
|
||||
Codec.BOOL.fieldOf("authored").forGetter(PortDayCycle::authored),
|
||||
Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortDayCycle::period),
|
||||
Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortDayCycle::stoppedTime),
|
||||
Codec.LONG.optionalFieldOf("origin_tick", 0L).forGetter(PortDayCycle::originTick),
|
||||
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.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,
|
||||
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
|
||||
|| 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) {
|
||||
this(authored, period, stoppedTime, 0, 0, true);
|
||||
}
|
||||
|
||||
public PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
long originTick, int phaseTick, boolean clockwise) {
|
||||
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, -1);
|
||||
}
|
||||
|
||||
public PortDayCycle(boolean authored, double period, int stoppedTime,
|
||||
long originTick, int phaseTick, boolean clockwise, int bearing) {
|
||||
this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, 1F);
|
||||
}
|
||||
|
||||
public static boolean isPeriod(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "period/full", "period/half", "period/double", "period/zero" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isPhase(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "phase/rising", "phase/zenith", "phase/setting", "phase/nadir" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isDirection(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && (page.getPath().equals("direction/clockwise")
|
||||
|| page.getPath().equals("direction/counterclockwise"));
|
||||
}
|
||||
|
||||
public static boolean isAngle(Identifier page) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "angle/north", "angle/east", "angle/south", "angle/west" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean supports(Identifier 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) {
|
||||
return page.getNamespace().equals("rivencraft") && switch (page.getPath()) {
|
||||
case "size/small", "size/normal", "size/large" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean validWriting(List<Identifier> pages) {
|
||||
int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0;
|
||||
for (Identifier page : pages) {
|
||||
if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1
|
||||
|| isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1
|
||||
|| isSize(page) && ++sizes > 1) return false;
|
||||
if (isController(page)) {
|
||||
if (!page.getPath().equals("celestial/moon")) suns++;
|
||||
if (!page.getPath().equals("celestial/sun")) moons++;
|
||||
if (suns > 10 || moons > 10) return false;
|
||||
periods = phases = directions = angles = sizes = 0;
|
||||
}
|
||||
}
|
||||
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 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;
|
||||
int bearing = -1;
|
||||
float size = 1F;
|
||||
for (int i = 0; i < pages.size(); i++) {
|
||||
Identifier page = pages.get(i);
|
||||
int number = i > 0 ? PortAgeColors.number(pages.get(i - 1)) : -1;
|
||||
if (isPhase(page)) {
|
||||
int degrees = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
|
||||
case "phase/rising" -> 90;
|
||||
case "phase/zenith" -> 180;
|
||||
case "phase/setting" -> 270;
|
||||
default -> 0;
|
||||
};
|
||||
// Same conversion as 1.21.1 AgeDimensionService.applyInitialCelestialPhase:
|
||||
// the written angle starts at Nadir, while native time zero is sunrise.
|
||||
phase = (degrees * 24000 / 360 + 18000) % 24000;
|
||||
} else if (isAngle(page)) {
|
||||
bearing = number >= 0 ? number * 15 % 360 : switch (page.getPath()) {
|
||||
case "angle/east" -> 90;
|
||||
case "angle/south" -> 180;
|
||||
case "angle/west" -> 270;
|
||||
default -> 0;
|
||||
};
|
||||
} else if (isSize(page)) {
|
||||
size = number >= 0 ? (float) (0.25D + number * (2.75D / 24D)) : switch (page.getPath()) {
|
||||
case "size/small" -> 0.5F;
|
||||
case "size/large" -> 2F;
|
||||
default -> 1F;
|
||||
};
|
||||
} else if (isDirection(page)) {
|
||||
clockwise = page.getPath().equals("direction/clockwise");
|
||||
} else if (isPeriod(page)) period = number >= 0 ? number * 0.08 : switch (page.getPath()) {
|
||||
case "period/half" -> 0.5;
|
||||
case "period/double" -> 2;
|
||||
case "period/zero" -> 0;
|
||||
default -> 1;
|
||||
};
|
||||
}
|
||||
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, 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) {
|
||||
if (!authored) return nativeTicks;
|
||||
return (long) Math.floor(time(nativeTicks));
|
||||
}
|
||||
|
||||
private double time(long nativeTicks) {
|
||||
return !authored ? nativeTicks : period == 0 ? stoppedTime
|
||||
: (clockwise ? 1 : -1) * ((double) nativeTicks - originTick) / period + phaseTick;
|
||||
}
|
||||
|
||||
public static long nativeTicks(Level level) {
|
||||
return level.clockManager().getInstance(level.registryAccess().lookupOrThrow(
|
||||
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
|
||||
}
|
||||
|
||||
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
|
||||
public static PortDayCycle forLevel(Level level) {
|
||||
if (level instanceof PortAgeLevel age) return age.dayCycle();
|
||||
return level.isClientSide() ? clientCycles.getOrDefault(level.dimension().identifier(), NATIVE) : NATIVE;
|
||||
}
|
||||
|
||||
/** Timelines keep this manager; profiles are read lazily after level construction/catalog sync. */
|
||||
public static ClockManager timelines(Level level, ClockManager nativeManager) {
|
||||
Map<Holder<WorldClock>, ClockInstance> wrappers = new HashMap<>();
|
||||
return definition -> {
|
||||
if (!definition.is(WorldClocks.OVERWORLD)) return nativeManager.getInstance(definition);
|
||||
return wrappers.computeIfAbsent(definition, ignored -> new ClockInstance() {
|
||||
private ClockInstance source() { return nativeManager.getInstance(definition); }
|
||||
public long totalTicks() { return forLevel(level).ticks(source().totalTicks()); }
|
||||
public float partialTick() {
|
||||
PortDayCycle cycle = forLevel(level);
|
||||
if (!cycle.authored) return source().partialTick();
|
||||
if (cycle.period == 0) return 0;
|
||||
double time = cycle.time(source().totalTicks());
|
||||
return (float) (time - Math.floor(time)
|
||||
+ (cycle.clockwise ? 1 : -1) * source().partialTick() / cycle.period);
|
||||
}
|
||||
public float rate() {
|
||||
PortDayCycle cycle = forLevel(level);
|
||||
return !cycle.authored ? source().rate() : cycle.period == 0 ? 0
|
||||
: (float) ((cycle.clockwise ? 1 : -1) * source().rate() / cycle.period);
|
||||
}
|
||||
public boolean isPaused() { return source().isPaused() || forLevel(level).authored && forLevel(level).period == 0; }
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractOreFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.FeaturePlacer;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.neoforged.neoforge.common.Tags;
|
||||
|
||||
/** Replays each eligible biome ore placement once per page, matching Rivencraft 1.21.1. */
|
||||
public final class PortDenseOres {
|
||||
public static final TagKey<PlacedFeature> EXTRA_ORES = TagKey.create(Registries.PLACED_FEATURE,
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dense_ores"));
|
||||
|
||||
private PortDenseOres() {}
|
||||
|
||||
public static boolean hasOres(Holder<Biome> biome) {
|
||||
return oreFeatures(biome).anyMatch(PortDenseOres::isOre);
|
||||
}
|
||||
|
||||
/** Modern ore features carry their targets directly rather than an OreConfiguration. */
|
||||
public static boolean isOre(Holder<PlacedFeature> holder) {
|
||||
if (holder.is(EXTRA_ORES)) return true;
|
||||
if (!(holder.value().feature().value() instanceof AbstractOreFeature ore)) return false;
|
||||
return ore.targetStates().stream().anyMatch(target -> isOreBlock(target.state()));
|
||||
}
|
||||
|
||||
private static boolean isOreBlock(BlockState state) {
|
||||
if (state.is(Tags.BlockItems.ORES.block())) return true;
|
||||
String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
|
||||
return path.endsWith("_ore") || path.contains("_ore_");
|
||||
}
|
||||
|
||||
private static java.util.stream.Stream<Holder<PlacedFeature>> oreFeatures(Holder<Biome> biome) {
|
||||
return biome.value().getGenerationSettings().features().stream().flatMap(HolderSet::stream);
|
||||
}
|
||||
|
||||
public static void place(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator generator,
|
||||
long ageSeed, int count) {
|
||||
if (count == 0) return;
|
||||
Set<Holder<Biome>> biomes = new HashSet<>();
|
||||
ChunkPos.rangeClosed(chunk.getPos(), 1).forEach(pos -> {
|
||||
for (var section : level.getChunk(pos.x(), pos.z()).getSections())
|
||||
section.getBiomes().getAll(biomes::add);
|
||||
});
|
||||
biomes.retainAll(generator.getBiomeSource().possibleBiomes());
|
||||
// Collect once per chunk, not once per repeated page. Stable registry-ID
|
||||
// order and per-occurrence seeds retain the 1.21.1 placement sequence.
|
||||
Map<Identifier, PlacedFeature> ores = new TreeMap<>();
|
||||
for (Holder<Biome> biome : biomes) {
|
||||
oreFeatures(biome).filter(PortDenseOres::isOre).forEach(feature ->
|
||||
feature.unwrapKey().ifPresent(key -> ores.putIfAbsent(key.identifier(), feature.value())));
|
||||
}
|
||||
BlockPos origin = new BlockPos(chunk.getPos().getMinBlockX(), level.getMinY(),
|
||||
chunk.getPos().getMinBlockZ());
|
||||
FeaturePlacer placer = new FeaturePlacer(level, generator);
|
||||
for (int occurrence = 0; occurrence < count; occurrence++) {
|
||||
for (var entry : ores.entrySet()) {
|
||||
long seed = mix(ageSeed ^ (long) origin.getX() * 341873128712L
|
||||
^ (long) origin.getZ() * 132897987541L
|
||||
^ (long) entry.getKey().hashCode() * 0x9E3779B97F4A7C15L
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L);
|
||||
placer.placeWithBiomeCheck(entry.getValue(), RandomSource.create(seed), origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Seed-completes missing environment controllers once, before an Age is saved. */
|
||||
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
|
||||
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
|
||||
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
|
||||
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
|
||||
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
|
||||
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
|
||||
}
|
||||
|
||||
private static <T> T rankedDefault(Random random, T[] choices) {
|
||||
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
|
||||
// Its rank-1 weight is the complete rare group plus one. Derive this
|
||||
// from the supported pool, rather than selecting unported weather.
|
||||
int[] weights = new int[choices.length];
|
||||
Arrays.fill(weights, 1);
|
||||
weights[0] = choices.length;
|
||||
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.PortRegistryPages;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.NoiseColumn;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.blockpredicates.BlockPredicate;
|
||||
import net.minecraft.world.level.levelgen.feature.BlockBlobFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.LakeFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.stateproviders.BlockStateProvider;
|
||||
import net.minecraft.world.level.levelgen.placement.BiomeFilter;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraft.world.level.levelgen.placement.RarityFilter;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
import com.mojang.datafixers.util.Pair;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
|
||||
/** Delegates terrain generation and applies frozen Age-scoped features. */
|
||||
public final class PortFeatureChunkGenerator extends ChunkGenerator {
|
||||
private static final ResourceKey<PlacedFeature> FOREST_ROCK = ResourceKey.create(
|
||||
Registries.PLACED_FEATURE, Identifier.withDefaultNamespace("forest_rock"));
|
||||
private static final ResourceKey<PlacedFeature> DUNGEONS = ResourceKey.create(
|
||||
Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dungeons"));
|
||||
private static final TagKey<Block> ROCK_GROUND = TagKey.create(
|
||||
Registries.BLOCK, Identifier.withDefaultNamespace("forest_rock_can_place_on"));
|
||||
public static final MapCodec<PortFeatureChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base),
|
||||
PortRockPlan.CODEC.optionalFieldOf("rocks").forGetter(generator -> Optional.ofNullable(generator.rocks)),
|
||||
PortVegetationPlan.CODEC.optionalFieldOf("vegetation")
|
||||
.forGetter(generator -> Optional.ofNullable(generator.vegetation)),
|
||||
com.mojang.serialization.Codec.intRange(0, 32).optionalFieldOf("dungeons", 0)
|
||||
.forGetter(generator -> generator.dungeons),
|
||||
PortLakePlan.CODEC.optionalFieldOf("lakes")
|
||||
.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) ->
|
||||
new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null),
|
||||
dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed)));
|
||||
|
||||
private final ChunkGenerator base;
|
||||
private final PortRockPlan rocks;
|
||||
private final PortVegetationPlan vegetation;
|
||||
private final int dungeons;
|
||||
private final PortLakePlan lakes;
|
||||
private final PortTerrainFeaturePlan terrainFeatures;
|
||||
private final long ageSeed;
|
||||
|
||||
public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, long ageSeed) {
|
||||
super(base.getBiomeSource(), base::getBiomeGenerationSettings);
|
||||
this.base = base;
|
||||
this.rocks = rocks;
|
||||
this.vegetation = vegetation;
|
||||
this.dungeons = dungeons;
|
||||
this.lakes = lakes;
|
||||
this.terrainFeatures = terrainFeatures;
|
||||
this.ageSeed = ageSeed;
|
||||
}
|
||||
|
||||
public ChunkGenerator base() { return base; }
|
||||
public PortRockPlan rocks() { return rocks; }
|
||||
public PortVegetationPlan vegetation() { return vegetation; }
|
||||
public int dungeons() { return dungeons; }
|
||||
public PortLakePlan lakes() { return lakes; }
|
||||
public PortTerrainFeaturePlan terrainFeatures() { return terrainFeatures; }
|
||||
|
||||
@Override protected MapCodec<? extends ChunkGenerator> codec() { return CODEC; }
|
||||
@Override public void validate() { base.validate(); }
|
||||
@Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); }
|
||||
@Override public java.util.Optional<Identifier> getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); }
|
||||
@Override public CompletableFuture<ChunkAccess> createBiomes(RandomState random, Blender blender,
|
||||
StructureManager structures, ChunkAccess chunk) {
|
||||
return base.createBiomes(random, blender, structures, chunk);
|
||||
}
|
||||
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
|
||||
RandomState random, long seed) {
|
||||
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)
|
||||
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), planning,
|
||||
lookup.listElements().map(holder -> (Holder<StructureSet>) holder));
|
||||
return ChunkGeneratorStructureState.createForNormal(random, seed, getOrigin(random), planning, lookup);
|
||||
}
|
||||
return base.createState(sets, random, seed);
|
||||
}
|
||||
|
||||
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 (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) {
|
||||
base.createReferences(level, structures, chunk);
|
||||
}
|
||||
@Override public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level,
|
||||
HolderSet<Structure> targets, BlockPos origin, int radius, boolean skipKnown) {
|
||||
return base.findNearestMapStructure(level, targets, origin, radius, skipKnown);
|
||||
}
|
||||
@Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); }
|
||||
@Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); }
|
||||
@Override public int getGenDepth() { return base.getGenDepth(); }
|
||||
@Override public int getSeaLevel() { return base.getSeaLevel(); }
|
||||
@Override public int getMinY() { return base.getMinY(); }
|
||||
@Override public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
|
||||
RandomState random, StructureManager structures, BiomeManager biomes,
|
||||
WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
|
||||
CompletableFuture<ChunkAccess> built = base.buildTerrain(
|
||||
chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes);
|
||||
if (terrainFeatures == null || !terrainFeatures.skylands() && terrainFeatures.choices().isEmpty()) return built;
|
||||
return built.thenApply(result -> {
|
||||
// Match 1.21.1: filter the base terrain once, then add the authored
|
||||
// solid volumes, irrespective of the Skylands page's written position.
|
||||
if (terrainFeatures.skylands()) PortSkylandsFeature.filter(result, ageSeed);
|
||||
java.util.Map<Identifier, Integer> occurrences = new java.util.HashMap<>();
|
||||
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
|
||||
if (PortTerrainFeaturePlan.isSurface(choice.page())) continue;
|
||||
int occurrence = occurrences.merge(choice.page(), 1, Integer::sum) - 1;
|
||||
long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L;
|
||||
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
|
||||
if (choice.page().equals(PortTerrainFeaturePlan.FLOATING_ISLANDS))
|
||||
PortFloatingIslandFeature.generate(result, featureSeed, material);
|
||||
else if (choice.page().equals(PortTerrainFeaturePlan.TENDRILS))
|
||||
PortTendrilFeature.generate(result, featureSeed, material);
|
||||
else if (choice.page().equals(PortTerrainFeaturePlan.SPHERES))
|
||||
PortSpheresFeature.generate(result, featureSeed, material);
|
||||
else throw new IllegalStateException("Unsupported terrain feature: " + choice.page());
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@Override public int getBaseHeight(int x, int z, Heightmap.Types type,
|
||||
LevelHeightAccessor height, RandomState random) {
|
||||
return base.getBaseHeight(x, z, type, height, random);
|
||||
}
|
||||
@Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
|
||||
return base.getBaseColumn(x, z, height, random);
|
||||
}
|
||||
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
|
||||
BlockPos pos, SamplerContext context) {
|
||||
base.addDebugScreenInfo(info, random, pos, context);
|
||||
}
|
||||
@Override public void refreshFeaturesPerStep() {
|
||||
base.refreshFeaturesPerStep();
|
||||
super.refreshFeaturesPerStep();
|
||||
}
|
||||
|
||||
@Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk,
|
||||
StructureManager structures) {
|
||||
base.applyBiomeDecoration(level, chunk, structures);
|
||||
if (terrainFeatures != null) {
|
||||
PortDenseOres.place(level, chunk, this, ageSeed, terrainFeatures.denseOres());
|
||||
int spikeOccurrence = 0;
|
||||
int obeliskOccurrence = 0;
|
||||
int crystalOccurrence = 0;
|
||||
for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) {
|
||||
if (!PortTerrainFeaturePlan.isSurface(choice.page())) continue;
|
||||
BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState();
|
||||
if (choice.page().equals(PortTerrainFeaturePlan.SPIKES))
|
||||
PortSpikesFeature.generate(level, chunk, ageSeed, material, spikeOccurrence++);
|
||||
else if (choice.page().equals(PortTerrainFeaturePlan.OBELISKS))
|
||||
PortObelisksFeature.generate(level, chunk, ageSeed, material, obeliskOccurrence++);
|
||||
else if (choice.page().equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS))
|
||||
PortCrystalFormationsFeature.generate(level, chunk, ageSeed, crystalOccurrence++);
|
||||
else throw new IllegalStateException("Unsupported surface feature: " + choice.page());
|
||||
}
|
||||
}
|
||||
ChunkPos chunkPos = chunk.getPos();
|
||||
long chunkSeed = ageSeed ^ (long) chunkPos.x() * 341873128712L
|
||||
^ (long) chunkPos.z() * 132897987541L;
|
||||
BlockPos origin = new BlockPos(chunkPos.getMinBlockX(), level.getMinY(), chunkPos.getMinBlockZ());
|
||||
if (rocks != null) {
|
||||
PlacedFeature vanilla = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
.getOrThrow(FOREST_ROCK).value();
|
||||
var placement = vanilla.placement().stream()
|
||||
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList();
|
||||
for (int occurrence = 0; occurrence < rocks.materials().size(); occurrence++) {
|
||||
RandomSource random = RandomSource.create(chunkSeed
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L);
|
||||
BlockState material = BuiltInRegistries.BLOCK.getValue(
|
||||
rocks.materials().get(occurrence)).defaultBlockState();
|
||||
PlacedFeature authored = new PlacedFeature(Holder.direct(new BlockBlobFeature(
|
||||
material, BlockPredicate.matchesTag(ROCK_GROUND))), placement);
|
||||
authored.place(level, this, random, origin);
|
||||
}
|
||||
}
|
||||
if (dungeons > 0) {
|
||||
PlacedFeature authored = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
.getOrThrow(DUNGEONS).value();
|
||||
for (int occurrence = 0; occurrence < dungeons; occurrence++) {
|
||||
RandomSource random = RandomSource.create(chunkSeed
|
||||
^ 0x44554E47454F4E53L ^ (long) occurrence * 0xD1B54A32D192ED03L);
|
||||
authored.place(level, this, random, origin);
|
||||
}
|
||||
}
|
||||
if (lakes != null) {
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
|
||||
java.util.Map<PortLakePlan.Choice, Integer> occurrences = new java.util.HashMap<>();
|
||||
for (PortLakePlan.Choice choice : lakes.choices()) {
|
||||
PlacedFeature vanilla = registry.getOrThrow(ResourceKey.create(
|
||||
Registries.PLACED_FEATURE, PortLakePlan.placedFeature(choice.page()))).value();
|
||||
if (!(vanilla.feature().value() instanceof LakeFeature lake))
|
||||
throw new IllegalStateException("Lake placement no longer references a LakeFeature: " + choice.page());
|
||||
BlockState fluid = BuiltInRegistries.BLOCK.getValue(choice.fluid()).defaultBlockState();
|
||||
LakeFeature feature = new LakeFeature(BlockStateProvider.holderOf(fluid), lake.barrier(),
|
||||
lake.canPlaceFeature(), lake.canReplaceWithAirOrFluid(), lake.canReplaceWithBarrier());
|
||||
PlacedFeature authored = new PlacedFeature(Holder.direct(feature), vanilla.placement().stream()
|
||||
.filter(modifier -> !(modifier instanceof BiomeFilter))
|
||||
.map(modifier -> !PortLakePlan.isDeep(choice.page()) && modifier instanceof RarityFilter
|
||||
? RarityFilter.onAverageOnceEvery(12) : modifier)
|
||||
.toList());
|
||||
int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1;
|
||||
RandomSource random = RandomSource.create(chunkSeed
|
||||
^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L);
|
||||
authored.place(level, this, random, origin);
|
||||
}
|
||||
}
|
||||
if (vegetation != null) {
|
||||
int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8);
|
||||
Holder<Biome> biome = level.getBiome(new BlockPos(
|
||||
chunkPos.getMinBlockX() + 8, surface, chunkPos.getMinBlockZ() + 8));
|
||||
boolean surfaceBiome = !biome.is(BiomeTags.IS_END) && !PortRegistryPages.isCaveBiome(biome);
|
||||
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
|
||||
List<BlockPos> mushroomHosts = null;
|
||||
for (int occurrence = 0; occurrence < vegetation.pages().size(); occurrence++) {
|
||||
Identifier page = vegetation.pages().get(occurrence);
|
||||
RandomSource random = RandomSource.create(chunkSeed
|
||||
^ (long) page.hashCode() * 0x9E3779B97F4A7C15L
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L);
|
||||
if (page.equals(PortVegetationPlan.SHELF_MUSHROOMS)) {
|
||||
if (mushroomHosts == null) mushroomHosts = PortShelfMushrooms.findHosts(chunk);
|
||||
PortShelfMushrooms.decorate(level, chunk, mushroomHosts, random);
|
||||
continue;
|
||||
}
|
||||
if (!surfaceBiome) continue;
|
||||
Identifier placedId = PortVegetationPlan.placedFeature(page);
|
||||
PlacedFeature vanilla = registry.getOrThrow(
|
||||
ResourceKey.create(Registries.PLACED_FEATURE, placedId)).value();
|
||||
PlacedFeature authored = new PlacedFeature(vanilla.feature(), vanilla.placement().stream()
|
||||
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList());
|
||||
authored.place(level, this, random, origin);
|
||||
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.NoiseColumn;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeGenerationSettings;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
|
||||
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.neoforged.neoforge.common.util.MemoizedFunction;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/** Flat layers with the authored biome distribution used by 1.21.1 Ages. */
|
||||
public final class PortFlatChunkGenerator extends ChunkGenerator {
|
||||
public static final MapCodec<PortFlatChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.biomeSource),
|
||||
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(PortFlatChunkGenerator::settings)
|
||||
).apply(instance, PortFlatChunkGenerator::new));
|
||||
|
||||
private final FlatLevelGeneratorSettings settings;
|
||||
private final MemoizedFunction<Holder<Biome>, BiomeGenerationSettings> generationSettingsGetter;
|
||||
|
||||
public PortFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
|
||||
super(biomeSource, this.generationSettingsGetter = MemoizedFunction.of(
|
||||
biome -> generationSettings(settings, biome)));
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public FlatLevelGeneratorSettings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static BiomeGenerationSettings generationSettings(FlatLevelGeneratorSettings settings, Holder<Biome> biome) {
|
||||
// Void biomes still control climate, but must not place terrain features.
|
||||
if (settings.getLayersInfo().isEmpty()) return BiomeGenerationSettings.EMPTY;
|
||||
if (biome.equals(settings.getBiome())) return settings.adjustGenerationSettings(biome);
|
||||
BiomeGenerationSettings.PlainBuilder builder = new BiomeGenerationSettings.PlainBuilder();
|
||||
List<HolderSet<PlacedFeature>> features = biome.value().getGenerationSettings().features();
|
||||
for (int step = 0; step < features.size(); step++) {
|
||||
if (step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES.ordinal()
|
||||
|| step == GenerationStep.Decoration.SURFACE_STRUCTURES.ordinal()) continue;
|
||||
for (Holder<PlacedFeature> feature : features.get(step)) builder.addFeature(step, feature);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<? extends ChunkGenerator> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> structures,
|
||||
RandomState random, long seed) {
|
||||
Stream<Holder<StructureSet>> entries = settings.structureOverrides()
|
||||
.map(HolderSet::stream)
|
||||
.orElseGet(() -> structures.listElements().map(holder -> (Holder<StructureSet>) holder));
|
||||
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), biomeSource, entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSpawnHeight(LevelHeightAccessor height) {
|
||||
return height.getMinY() + Math.min(height.getHeight(), settings.getLayers().size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
|
||||
RandomState random, StructureManager structures, BiomeManager biomes,
|
||||
@Nullable WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
|
||||
List<BlockState> layers = settings.getLayers();
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
for (int layer = 0; layer < Math.min(chunk.getHeight(), layers.size()); layer++) {
|
||||
BlockState state = layers.get(layer);
|
||||
if (state == null) continue;
|
||||
int y = chunk.getMinY() + layer;
|
||||
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) {
|
||||
chunk.setBlockState(pos.set(x, y, z), state);
|
||||
floor.update(x, y, z, state);
|
||||
surface.update(x, y, z, state);
|
||||
}
|
||||
}
|
||||
return CompletableFuture.completedFuture(chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBaseHeight(int x, int z, Heightmap.Types type,
|
||||
LevelHeightAccessor height, RandomState random) {
|
||||
List<BlockState> layers = settings.getLayers();
|
||||
for (int layer = Math.min(layers.size() - 1, height.getHeight() - 1); layer >= 0; layer--) {
|
||||
BlockState state = layers.get(layer);
|
||||
if (state != null && type.isOpaque().test(state)) return height.getMinY() + layer + 1;
|
||||
}
|
||||
return height.getMinY();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
|
||||
return new NoiseColumn(height.getMinY(), settings.getLayers().stream().limit(height.getHeight())
|
||||
.map(state -> state == null ? Blocks.AIR.defaultBlockState() : state)
|
||||
.toArray(BlockState[]::new));
|
||||
}
|
||||
|
||||
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
|
||||
BlockPos pos, SamplerContext context) { }
|
||||
@Override public void spawnOriginalMobs(WorldGenRegion level) { }
|
||||
@Override public int getMinY() { return 0; }
|
||||
@Override public int getGenDepth() { return 384; }
|
||||
@Override public int getSeaLevel() { return -63; }
|
||||
|
||||
@Override
|
||||
public void refreshFeaturesPerStep() {
|
||||
super.refreshFeaturesPerStep();
|
||||
generationSettingsGetter.clear();
|
||||
settings.updateLayers();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.QuartPos;
|
||||
import net.minecraft.tags.BiomeTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.dnorberg.rivencraft.writing.PortRegistryPages;
|
||||
|
||||
/** 1.21.1 floating-island shape adapted to the 26.3 chunk terrain stage. */
|
||||
final class PortFloatingIslandFeature {
|
||||
private static final long SALT = 0x464C4F4154494E47L;
|
||||
private static final int SOURCE_RANGE = 3;
|
||||
private static final int CHANCE = 128;
|
||||
|
||||
private PortFloatingIslandFeature() {}
|
||||
|
||||
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
|
||||
var target = chunk.getPos();
|
||||
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
|
||||
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
|
||||
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
|
||||
^ (long) sourceX * 341873128712L ^ (long) sourceZ * 132897987541L));
|
||||
if (random.nextInt(CHANCE) != 0) continue;
|
||||
int centerX = sourceX * 16 + random.nextInt(16);
|
||||
int centerZ = sourceZ * 16 + random.nextInt(16);
|
||||
int centerY = Math.min(chunk.getMaxY() - 30, 168 + random.nextInt(48));
|
||||
if (centerY < chunk.getMinY() + 24) continue;
|
||||
int radiusX = 24 + random.nextInt(18);
|
||||
int radiusZ = 24 + random.nextInt(18);
|
||||
fill(chunk, ageSeed, material, centerX, centerY, centerZ, radiusX, radiusZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void fill(ChunkAccess chunk, long ageSeed, BlockState material,
|
||||
int centerX, int centerY, int centerZ, int radiusX, int radiusZ) {
|
||||
int originX = chunk.getPos().getMinBlockX();
|
||||
int originZ = chunk.getPos().getMinBlockZ();
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
int worldX = originX + localX;
|
||||
double dx = (worldX - centerX) / (double) radiusX;
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int worldZ = originZ + localZ;
|
||||
double dz = (worldZ - centerZ) / (double) radiusZ;
|
||||
double distance = dx * dx + dz * dz;
|
||||
if (distance >= 1.0) continue;
|
||||
long detail = mix(ageSeed ^ (long) centerX * 341873128712L
|
||||
^ (long) centerZ * 132897987541L ^ (long) worldX * 42317861L
|
||||
^ (long) worldZ * 374761393L);
|
||||
int top = centerY + (int) Math.round(3 * (1 - distance)) + Math.floorMod(detail, 3) - 1;
|
||||
int depth = Math.max(1, (int) Math.round(3 + 17 * Math.pow(1 - distance, 1.5)));
|
||||
// Biomes are already populated by createBiomes in 26.3, so
|
||||
// sampling this chunk preserves its authored biome distribution.
|
||||
var biome = chunk.getNoiseBiome(QuartPos.fromBlock(worldX), QuartPos.fromBlock(top),
|
||||
QuartPos.fromBlock(worldZ));
|
||||
BlockState soil = biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome)
|
||||
? material : Blocks.DIRT.defaultBlockState();
|
||||
BlockState cap = soil == material ? material : Blocks.GRASS_BLOCK.defaultBlockState();
|
||||
for (int y = Math.max(top - depth, chunk.getMinY()); y <= top; y++) {
|
||||
pos.set(worldX, y, worldZ);
|
||||
if (!chunk.getBlockState(pos).isAir()) continue;
|
||||
BlockState state = y == top ? cap : y >= top - 3 ? soil : material;
|
||||
chunk.setBlockState(pos, state);
|
||||
surface.update(localX, y, localZ, state);
|
||||
floor.update(localX, y, localZ, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Ordered lake pages with fluid choices frozen when the Age is bound. */
|
||||
public record PortLakePlan(List<Choice> choices) {
|
||||
public static final Codec<PortLakePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortLakePlan::choices)
|
||||
).apply(instance, PortLakePlan::new));
|
||||
|
||||
public record Choice(Identifier page, Identifier fluid) {
|
||||
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
|
||||
Identifier.CODEC.fieldOf("fluid").forGetter(Choice::fluid)
|
||||
).apply(instance, Choice::new));
|
||||
|
||||
public Choice {
|
||||
if (!isLake(page) || fluid == null) throw new IllegalArgumentException("Invalid lake choice");
|
||||
}
|
||||
}
|
||||
|
||||
public PortLakePlan {
|
||||
choices = List.copyOf(choices);
|
||||
if (choices.isEmpty() || choices.size() > 32)
|
||||
throw new IllegalArgumentException("Lake plan must contain 1–32 choices");
|
||||
}
|
||||
|
||||
public static boolean isLake(Identifier page) {
|
||||
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
|
||||
return switch (page.getPath()) {
|
||||
case "feature/surface_lakes", "feature/deep_lakes", "feature/lava_lakes" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isDeep(Identifier page) { return page.getPath().equals("feature/deep_lakes"); }
|
||||
|
||||
public static Identifier placedFeature(Identifier page) {
|
||||
if (!isLake(page)) throw new IllegalArgumentException("Unsupported lake page " + page);
|
||||
return Identifier.withDefaultNamespace(isDeep(page) ? "lake_lava_underground" : "lake_lava_surface");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.Locale;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.Holder;
|
||||
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.DimensionType;
|
||||
import net.minecraft.world.level.Level;
|
||||
|
||||
/** Authored lighting choices using statically registered, client-synchronized types. */
|
||||
public enum PortLighting {
|
||||
NORMAL, DARK, BRIGHT;
|
||||
|
||||
public static final Codec<PortLighting> CODEC = Codec.STRING.xmap(
|
||||
name -> valueOf(name.toUpperCase(Locale.ROOT)), mode -> mode.name().toLowerCase(Locale.ROOT));
|
||||
|
||||
public static PortLighting forPage(Identifier page) {
|
||||
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
|
||||
return switch (page.getPath()) {
|
||||
case "lighting/normal" -> NORMAL;
|
||||
case "lighting/dark" -> DARK;
|
||||
case "lighting/bright" -> BRIGHT;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isBright(Level level) {
|
||||
return level.dimensionTypeRegistration().unwrapKey().map(key -> {
|
||||
Identifier id = key.identifier();
|
||||
return id.getNamespace().equals(Rivencraft.MOD_ID)
|
||||
&& (id.getPath().equals("age_bright") || id.getPath().equals("age_cave_bright")
|
||||
|| id.getPath().startsWith("age_bright_sky_")
|
||||
|| id.getPath().startsWith("age_cave_bright_sky_"));
|
||||
}).orElse(false);
|
||||
}
|
||||
|
||||
Holder<DimensionType> dimensionType(MinecraftServer server, PortAgeTerrain terrain,
|
||||
Holder<DimensionType> overworldType, PortSkyStyle sky) {
|
||||
boolean cave = terrain == PortAgeTerrain.CAVE;
|
||||
if (this == NORMAL && !cave && sky == PortSkyStyle.OVERWORLD) return overworldType;
|
||||
String variant = this == BRIGHT ? (cave ? "age_cave_bright" : "age_bright")
|
||||
: this == DARK || cave ? "age_dark" : "age_normal";
|
||||
if (sky != PortSkyStyle.OVERWORLD) variant += "_sky_" + sky.name().toLowerCase(Locale.ROOT);
|
||||
return server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(
|
||||
ResourceKey.create(Registries.DIMENSION_TYPE,
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, variant)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/** 26.3 port of Rivencraft 1.21.1's Legacy-style 4×4 plinth and 2×12×2 shaft. */
|
||||
final class PortObelisksFeature {
|
||||
private static final long SALT = 0x4F42454C49534B53L;
|
||||
private static final int CHANCE = 128;
|
||||
private static final int BASE = 4;
|
||||
private static final int HEIGHT = 12;
|
||||
|
||||
private PortObelisksFeature() {}
|
||||
|
||||
record Site(int x, int z) {}
|
||||
|
||||
static Site site(long ageSeed, ChunkPos chunk, int occurrence) {
|
||||
RandomSource random = RandomSource.create(mix(ageSeed ^ SALT
|
||||
^ (long) chunk.x() * 341873128712L ^ (long) chunk.z() * 132897987541L
|
||||
^ (long) occurrence * 0x9E3779B97F4A7C15L));
|
||||
if (random.nextInt(CHANCE) != 0) return null;
|
||||
return new Site(chunk.getMinBlockX() + 4 + random.nextInt(8),
|
||||
chunk.getMinBlockZ() + 4 + random.nextInt(8));
|
||||
}
|
||||
|
||||
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
|
||||
Site site = site(ageSeed, chunk.getPos(), occurrence);
|
||||
if (site == null) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
int minSurface = Integer.MAX_VALUE;
|
||||
int maxSurface = Integer.MIN_VALUE;
|
||||
int[][] surface = new int[BASE][BASE];
|
||||
for (int dx = 0; dx < BASE; dx++) {
|
||||
for (int dz = 0; dz < BASE; dz++) {
|
||||
int x = site.x() + dx;
|
||||
int z = site.z() + dz;
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
|
||||
if (y <= level.getMinY() + 1) return;
|
||||
BlockState ground = level.getBlockState(pos.set(x, y - 1, z));
|
||||
if (!ground.isFaceSturdy(level, pos, Direction.UP)
|
||||
|| !ground.getFluidState().isEmpty() || ground.hasBlockEntity()) return;
|
||||
surface[dx][dz] = y;
|
||||
minSurface = Math.min(minSurface, y);
|
||||
maxSurface = Math.max(maxSurface, y);
|
||||
}
|
||||
}
|
||||
if (maxSurface - minSurface > 2 || maxSurface + HEIGHT >= level.getMaxY()) return;
|
||||
for (int dx = 0; dx < BASE; dx++) {
|
||||
for (int dz = 0; dz < BASE; dz++) {
|
||||
for (int y = surface[dx][dz]; y <= maxSurface; y++) {
|
||||
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int dx = 1; dx <= 2; dx++) {
|
||||
for (int dz = 1; dz <= 2; dz++) {
|
||||
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) {
|
||||
if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int dx = 0; dx < BASE; dx++) {
|
||||
for (int dz = 0; dz < BASE; dz++) {
|
||||
for (int y = surface[dx][dz]; y <= maxSurface; y++)
|
||||
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
|
||||
}
|
||||
}
|
||||
for (int dx = 1; dx <= 2; dx++) {
|
||||
for (int dz = 1; dz <= 2; dz++) {
|
||||
for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++)
|
||||
level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean open(BlockState state) {
|
||||
return state.getFluidState().isEmpty() && (state.isAir() || state.canBeReplaced());
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.writing.PortRegistryPages;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
|
||||
/** Seed-frozen terrain and biome choices for a book with no authored symbols. */
|
||||
public final class PortRandomAgeSelection {
|
||||
private PortRandomAgeSelection() {}
|
||||
|
||||
public record Selection(PortAgeTerrain terrain, PortBiomePlan biomes) {}
|
||||
|
||||
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 = terrain(seed);
|
||||
if (terrain == PortAgeTerrain.VOID)
|
||||
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
|
||||
List.of(Identifier.withDefaultNamespace("the_void"))));
|
||||
|
||||
PortBiomePlan.Distribution distribution = distribution(seed);
|
||||
List<Identifier> eligible = eligibleBiomes(server, terrain);
|
||||
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
|
||||
distribution = PortBiomePlan.Distribution.SINGLE;
|
||||
if (distribution == PortBiomePlan.Distribution.NATIVE)
|
||||
return new Selection(terrain, new PortBiomePlan(distribution,
|
||||
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
|
||||
|
||||
int count = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
|
||||
if (count > 1) {
|
||||
Random countRandom = new Random(seed ^ 0x42494F4D45434F55L);
|
||||
while (count < eligible.size() && countRandom.nextInt(3) != 0) count++;
|
||||
}
|
||||
List<Identifier> shuffled = new ArrayList<>(eligible);
|
||||
Collections.shuffle(shuffled, new Random(seed ^ 0x42494F4D45504F4FL));
|
||||
return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count)));
|
||||
}
|
||||
|
||||
public static List<PortAgeTerrain> terrains() {
|
||||
return List.of(PortAgeTerrain.AMPLIFIED, PortAgeTerrain.CAVE, PortAgeTerrain.FLAT,
|
||||
PortAgeTerrain.ISLAND, PortAgeTerrain.STANDARD, PortAgeTerrain.VOID);
|
||||
}
|
||||
|
||||
public static PortAgeTerrain terrain(long seed) {
|
||||
return weighted(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{1, 1, 5, 1, 6, 1});
|
||||
}
|
||||
|
||||
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
|
||||
public static PortBiomePlan.Distribution distribution(long seed) {
|
||||
return weighted(new Random(seed ^ 0x524956454E435241L),
|
||||
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
|
||||
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
|
||||
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
|
||||
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
|
||||
PortBiomePlan.Distribution.HUGE),
|
||||
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
|
||||
}
|
||||
|
||||
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
|
||||
if (terrain == PortAgeTerrain.VOID) return List.of(Identifier.withDefaultNamespace("the_void"));
|
||||
if (terrain == PortAgeTerrain.CAVE) {
|
||||
return caveBiomes(server.registryAccess());
|
||||
}
|
||||
List<Identifier> surface = server.overworld().getChunkSource().getGenerator().getBiomeSource()
|
||||
.possibleBiomes().stream().flatMap(holder -> holder.unwrapKey().stream())
|
||||
.map(key -> key.identifier()).distinct().sorted(Comparator.naturalOrder()).toList();
|
||||
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;
|
||||
int ticket = random.nextInt(total);
|
||||
for (int index = 0; index < choices.size(); index++) {
|
||||
ticket -= weights[index];
|
||||
if (ticket < 0) return choices.get(index);
|
||||
}
|
||||
throw new IllegalStateException("Random Age ticket was not consumed");
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Frozen, ordered Rocks materials. Old single-material beta Ages remain readable. */
|
||||
public record PortRockPlan(List<Identifier> materials) {
|
||||
public static final Codec<PortRockPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.optionalFieldOf("material").forGetter(plan -> Optional.empty()),
|
||||
Identifier.CODEC.listOf().optionalFieldOf("materials", List.of()).forGetter(PortRockPlan::materials)
|
||||
).apply(instance, (old, current) -> new PortRockPlan(current.isEmpty()
|
||||
? old.map(List::of).orElse(List.of()) : current)));
|
||||
|
||||
public PortRockPlan {
|
||||
materials = List.copyOf(materials);
|
||||
if (materials.isEmpty() || materials.size() > 32)
|
||||
throw new IllegalArgumentException("Rocks plan must contain 1–32 materials");
|
||||
}
|
||||
|
||||
public PortRockPlan(Identifier material) { this(List.of(material)); }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
|
||||
/** 26.3 resolver port of the seeded 1.21.1 Mystcraft-style region distribution. */
|
||||
public final class PortScaledBiomeSource extends BiomeSource implements BiomeResolver {
|
||||
public static final MapCodec<PortScaledBiomeSource> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
|
||||
Biome.LIST_CODEC.fieldOf("biomes").forGetter(source -> source.biomes),
|
||||
Codec.LONG.fieldOf("seed").forGetter(source -> source.seed),
|
||||
Codec.intRange(0, 4).fieldOf("scale").forGetter(source -> source.scale)
|
||||
).apply(instance, PortScaledBiomeSource::new));
|
||||
|
||||
private final HolderSet<Biome> biomes;
|
||||
private final long seed;
|
||||
private final int scale;
|
||||
private final int cellWidth;
|
||||
|
||||
public PortScaledBiomeSource(HolderSet<Biome> biomes, long seed, int scale) {
|
||||
if (biomes.size() < 2 || scale < 0 || scale > 4)
|
||||
throw new IllegalArgumentException("Scaled regions need two biomes and scale 0..4");
|
||||
this.biomes = biomes;
|
||||
this.seed = seed;
|
||||
this.scale = scale;
|
||||
// Quart coordinates; Tiny starts at 16 blocks and each scale doubles.
|
||||
this.cellWidth = 4 << scale;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MapCodec<? extends BiomeSource> codec() {
|
||||
return CODEC;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return biomes.stream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int x, int y, int z) {
|
||||
int baseX = Math.floorDiv(x, cellWidth);
|
||||
int baseZ = Math.floorDiv(z, cellWidth);
|
||||
int closestX = baseX;
|
||||
int closestZ = baseZ;
|
||||
double closestDistance = Double.POSITIVE_INFINITY;
|
||||
for (int cellX = baseX - 1; cellX <= baseX + 1; cellX++) {
|
||||
for (int cellZ = baseZ - 1; cellZ <= baseZ + 1; cellZ++) {
|
||||
long cellSeed = mix(seed, cellX, cellZ);
|
||||
double centerX = (cellX + 0.5D + jitter(cellSeed) * 0.8D) * cellWidth;
|
||||
double centerZ = (cellZ + 0.5D + jitter(mix64(cellSeed)) * 0.8D) * cellWidth;
|
||||
double deltaX = x - centerX;
|
||||
double deltaZ = z - centerZ;
|
||||
double distance = deltaX * deltaX + deltaZ * deltaZ;
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestX = cellX;
|
||||
closestZ = cellZ;
|
||||
}
|
||||
}
|
||||
}
|
||||
return biomes.get(Math.floorMod((int) mix(seed ^ 0x42494F4D45534CL,
|
||||
closestX, closestZ), biomes.size()));
|
||||
}
|
||||
|
||||
private static double jitter(long value) {
|
||||
return ((value >>> 11) * 0x1.0p-53) - 0.5D;
|
||||
}
|
||||
|
||||
private static long mix(long seed, int x, int z) {
|
||||
return mix64(seed ^ (long) x * 0x9E3779B97F4A7C15L ^ (long) z * 0xC2B2AE3D27D4EB4FL);
|
||||
}
|
||||
|
||||
private static long mix64(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ShelfMushroomBlock;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.feature.treedecorators.ShelfMushroomDecorator;
|
||||
import net.minecraft.world.level.levelgen.feature.treedecorators.TreeDecorator;
|
||||
|
||||
/** Shelf mushrooms on exposed log faces, regardless of how the logs were generated. */
|
||||
final class PortShelfMushrooms {
|
||||
private PortShelfMushrooms() {}
|
||||
|
||||
static List<BlockPos> findHosts(ChunkAccess chunk) {
|
||||
List<BlockPos> logs = new ArrayList<>();
|
||||
var sections = chunk.getSections();
|
||||
for (int index = 0; index < sections.length; index++) {
|
||||
var section = sections[index];
|
||||
if (!section.maybeHas(state -> state.is(BlockTags.LOGS))) continue;
|
||||
int baseY = chunk.getSectionYFromSectionIndex(index) << 4;
|
||||
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
|
||||
if (section.getBlockState(x, y, z).is(BlockTags.LOGS))
|
||||
logs.add(new BlockPos(chunk.getPos().getMinBlockX() + x, baseY + y,
|
||||
chunk.getPos().getMinBlockZ() + z));
|
||||
}
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
static void decorate(WorldGenLevel level, ChunkAccess chunk, List<BlockPos> logs, RandomSource random) {
|
||||
// Reuse vanilla replaceability/water checks, but not its tree-base height
|
||||
// band: a log tendril or sphere has no meaningful tree base.
|
||||
TreeDecorator.Context context = new TreeDecorator.Context(level,
|
||||
(pos, state) -> level.setBlock(pos, state, 2), random, Set.of(), Set.of(), Set.of());
|
||||
for (BlockPos log : logs) {
|
||||
if (!level.getBlockState(log).is(BlockTags.LOGS)) continue;
|
||||
Direction first = Direction.Plane.HORIZONTAL.getRandomDirection(random);
|
||||
for (Direction facing : List.of(first, first.getClockWise())) {
|
||||
if (random.nextFloat() > 0.25F) continue;
|
||||
BlockPos pos = log.relative(facing);
|
||||
if ((pos.getX() >> 4) != chunk.getPos().x() || (pos.getZ() >> 4) != chunk.getPos().z()) continue;
|
||||
if (pos.getY() < level.getMinY() || pos.getY() >= level.getMaxY()) continue;
|
||||
if (!level.getFluidState(pos).isEmpty()
|
||||
|| !ShelfMushroomDecorator.isBlockReplaceableWithShelfMushroom(context, pos)
|
||||
|| level.getBlockState(pos.below()).is(Blocks.SHELF_MUSHROOM)) continue;
|
||||
boolean adjacent = false;
|
||||
for (Direction side : Direction.Plane.HORIZONTAL) {
|
||||
if (level.getBlockState(pos.relative(side)).is(Blocks.SHELF_MUSHROOM)) {
|
||||
adjacent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (adjacent) continue;
|
||||
var mushroom = Blocks.SHELF_MUSHROOM.defaultBlockState()
|
||||
.setValue(ShelfMushroomBlock.FACING, facing)
|
||||
.setValue(ShelfMushroomBlock.AGE, random.nextInt(2));
|
||||
if (!mushroom.canSurvive(level, pos)) continue;
|
||||
context.setBlock(pos, mushroom);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Persisted sky styles, including the client-rendered Legacy static background. */
|
||||
public enum PortSkyStyle {
|
||||
OVERWORLD, NETHER, END, STATIC;
|
||||
|
||||
public static final Codec<PortSkyStyle> CODEC = Codec.STRING.xmap(
|
||||
name -> valueOf(name.toUpperCase(Locale.ROOT)), sky -> sky.name().toLowerCase(Locale.ROOT));
|
||||
|
||||
public static PortSkyStyle forPage(Identifier page) {
|
||||
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
|
||||
return switch (page.getPath()) {
|
||||
case "sky/overworld" -> OVERWORLD;
|
||||
case "sky/nether" -> NETHER;
|
||||
case "sky/end" -> END;
|
||||
case "sky/static" -> STATIC;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
|
||||
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
|
||||
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
|
||||
? 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()) {
|
||||
// Preserve the established compatible-default rule for written controllers.
|
||||
case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static PortSkyStyle random(long seed) {
|
||||
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
|
||||
// likely INHERITED and OVERWORLD slots currently share the native
|
||||
// Overworld presentation here. Do not add unported colors/bodies.
|
||||
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
|
||||
return switch (choice) {
|
||||
case 0, 1 -> OVERWORLD;
|
||||
case 2 -> NETHER;
|
||||
case 3 -> END;
|
||||
case 4 -> STATIC;
|
||||
default -> throw new IllegalStateException("Invalid random sky choice");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/**
|
||||
* 26.3 port of Rivencraft's subtractive Skylands filter, adapted from
|
||||
* Mystcraft Legacy's SymbolSkylands (LGPL-3.0-or-later). Keeps upper solid
|
||||
* terrain above a seven-octave cutoff near Y=76 and removes liquids.
|
||||
*/
|
||||
final class PortSkylandsFeature {
|
||||
private static final long SALT = 0x534B594C414E4453L;
|
||||
private static final int BASE_CUTOFF = 76;
|
||||
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
|
||||
|
||||
private PortSkylandsFeature() {}
|
||||
|
||||
static void filter(ChunkAccess chunk, long ageSeed) {
|
||||
int originX = chunk.getPos().getMinBlockX();
|
||||
int originZ = chunk.getPos().getMinBlockZ();
|
||||
int minY = chunk.getMinY();
|
||||
int maxY = chunk.getMaxY();
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ);
|
||||
for (int y = minY; y < maxY; y++) {
|
||||
pos.set(originX + localX, y, originZ + localZ);
|
||||
BlockState state = chunk.getBlockState(pos);
|
||||
if (!removes(state, y, cutoff)) continue;
|
||||
chunk.setBlockState(pos, AIR);
|
||||
surface.update(localX, y, localZ, AIR);
|
||||
floor.update(localX, y, localZ, AIR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean removes(BlockState state, int y, int cutoff) {
|
||||
return !state.isAir() && (y <= cutoff || state.liquid());
|
||||
}
|
||||
|
||||
static int cutoff(long ageSeed, int x, int z) {
|
||||
double variation = 0.0D;
|
||||
int cellSize = 128;
|
||||
double amplitude = 12.0D;
|
||||
for (int octave = 0; octave < 7; octave++) {
|
||||
variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L,
|
||||
x, z, cellSize) * amplitude;
|
||||
cellSize /= 2;
|
||||
amplitude /= 2.0D;
|
||||
}
|
||||
return BASE_CUTOFF + (int) Math.round(variation);
|
||||
}
|
||||
|
||||
private static double valueNoise(long seed, int x, int z, int cellSize) {
|
||||
int cellX = Math.floorDiv(x, cellSize);
|
||||
int cellZ = Math.floorDiv(z, cellSize);
|
||||
double u = smooth((x - cellX * cellSize) / (double) cellSize);
|
||||
double v = smooth((z - cellZ * cellSize) / (double) cellSize);
|
||||
double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u);
|
||||
double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u);
|
||||
return lerp(north, south, v);
|
||||
}
|
||||
|
||||
private static double value(long seed, int x, int z) {
|
||||
long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L ^ (long) z * 0xABC98388FB8FAC03L);
|
||||
return (hash >>> 11) * 0x1.0p-52 - 1.0D;
|
||||
}
|
||||
|
||||
private static double smooth(double value) { return value * value * (3.0D - 2.0D * value); }
|
||||
|
||||
private static double lerp(double first, double second, double amount) {
|
||||
return first + (second - first) * amount;
|
||||
}
|
||||
|
||||
private static long mix(long value) {
|
||||
value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L;
|
||||
value = (value ^ value >>> 27) * 0x94D049BB133111EBL;
|
||||
return value ^ value >>> 31;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Random;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/**
|
||||
* 26.3 adaptation of Rivencraft 1.21.1's solid sphere nodes, derived from
|
||||
* Mystcraft Legacy's MapGenSpheresMyst (Mystcraft contributors,
|
||||
* LGPL-3.0-or-later, commit 9bc8ddc). Writes stay within the target chunk.
|
||||
*/
|
||||
final class PortSpheresFeature {
|
||||
private static final int SOURCE_RANGE = 8;
|
||||
private static final long SALT = 0x53504845524553L;
|
||||
|
||||
private PortSpheresFeature() {}
|
||||
|
||||
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
|
||||
var target = chunk.getPos();
|
||||
Random master = new Random(ageSeed ^ SALT);
|
||||
long xSeed = master.nextLong();
|
||||
long zSeed = master.nextLong();
|
||||
for (int sx = target.x() - SOURCE_RANGE; sx <= target.x() + SOURCE_RANGE; sx++) {
|
||||
for (int sz = target.z() - SOURCE_RANGE; sz <= target.z() + SOURCE_RANGE; sz++) {
|
||||
Random source = new Random((long) sx * xSeed ^ (long) sz * zSeed ^ ageSeed ^ SALT);
|
||||
if (source.nextFloat() > 0.05F) continue;
|
||||
double x = sx * 16 + source.nextInt(16);
|
||||
double y = source.nextInt(source.nextInt(192) + 1) + 32;
|
||||
double z = sz * 16 + source.nextInt(16);
|
||||
trace(chunk, material, source.nextLong(), x, y, z,
|
||||
1.0F + source.nextFloat() * 4.0F, 0, 0, -1, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void trace(ChunkAccess chunk, BlockState material, long seed,
|
||||
double x, double y, double z, float radius, float yaw, float pitch,
|
||||
int step, int length) {
|
||||
Random random = new Random(seed);
|
||||
if (length <= 0) {
|
||||
int maximum = SOURCE_RANGE * 16 - 16;
|
||||
length = maximum - random.nextInt(maximum / 4);
|
||||
}
|
||||
boolean singleNode = step == -1;
|
||||
if (singleNode) step = length / 2;
|
||||
int splitAt = random.nextInt(length / 2) + length / 4;
|
||||
boolean slowPitch = random.nextInt(6) == 0;
|
||||
float yawDrift = 0;
|
||||
float pitchDrift = 0;
|
||||
double middleX = chunk.getPos().getMinBlockX() + 8;
|
||||
double middleZ = chunk.getPos().getMinBlockZ() + 8;
|
||||
for (; step < length; step++) {
|
||||
double nodeRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
|
||||
float horizontal = Mth.cos(pitch);
|
||||
x += Mth.cos(yaw) * horizontal;
|
||||
y += Mth.sin(pitch);
|
||||
z += Mth.sin(yaw) * horizontal;
|
||||
pitch *= slowPitch ? 0.92F : 0.7F;
|
||||
pitch += pitchDrift * 0.1F;
|
||||
yaw += yawDrift * 0.1F;
|
||||
pitchDrift = pitchDrift * 0.9F
|
||||
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
|
||||
yawDrift = yawDrift * 0.75F
|
||||
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
|
||||
if (!singleNode && step == splitAt && radius > 1.0F) {
|
||||
trace(chunk, material, random.nextLong(), x, y, z,
|
||||
random.nextFloat() * 0.5F + 0.5F, yaw - Mth.HALF_PI, pitch / 3, step, length);
|
||||
trace(chunk, material, random.nextLong(), x, y, z,
|
||||
random.nextFloat() * 0.5F + 0.5F, yaw + Mth.HALF_PI, pitch / 3, step, length);
|
||||
return;
|
||||
}
|
||||
if (!singleNode && random.nextInt(4) == 0) continue;
|
||||
double dx = x - middleX;
|
||||
double dz = z - middleZ;
|
||||
double remaining = length - step;
|
||||
double reach = radius + 18;
|
||||
if (dx * dx + dz * dz - remaining * remaining > reach * reach) return;
|
||||
if (Math.abs(dx) <= 16 + nodeRadius * 2 && Math.abs(dz) <= 16 + nodeRadius * 2)
|
||||
fillNode(chunk, material, x, y, z, nodeRadius);
|
||||
if (singleNode) break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillNode(ChunkAccess chunk, BlockState material,
|
||||
double centerX, double centerY, double centerZ, double radius) {
|
||||
int originX = chunk.getPos().getMinBlockX();
|
||||
int originZ = chunk.getPos().getMinBlockZ();
|
||||
int minX = Math.max(0, Mth.floor(centerX - radius) - originX - 1);
|
||||
int maxX = Math.min(16, Mth.floor(centerX + radius) - originX + 2);
|
||||
int minZ = Math.max(0, Mth.floor(centerZ - radius) - originZ - 1);
|
||||
int maxZ = Math.min(16, Mth.floor(centerZ + radius) - originZ + 2);
|
||||
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - radius) - 1);
|
||||
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + radius) + 2);
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
for (int worldY = minY; worldY < maxY; worldY++) {
|
||||
double dy = (worldY + 0.5D - centerY) / radius;
|
||||
for (int localZ = minZ; localZ < maxZ; localZ++) {
|
||||
double dz = (originZ + localZ + 0.5D - centerZ) / radius;
|
||||
for (int localX = minX; localX < maxX; localX++) {
|
||||
double dx = (originX + localX + 0.5D - centerX) / radius;
|
||||
if (!insideSphere(dx, dy, dz)) continue;
|
||||
pos.set(originX + localX, worldY, originZ + localZ);
|
||||
BlockState previous = chunk.getBlockState(pos);
|
||||
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
|
||||
chunk.setBlockState(pos, material);
|
||||
surface.update(localX, worldY, localZ, material);
|
||||
floor.update(localX, worldY, localZ, material);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean insideSphere(double dx, double dy, double dz) {
|
||||
return dx * dx + dy * dy + dz * dz < 1.0D;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/**
|
||||
* 26.3 port of Rivencraft 1.21.1's rare surface spikes, adapted from
|
||||
* Mystcraft Legacy's WorldGenSpikesAdv (Mystcraft contributors,
|
||||
* LGPL-3.0-or-later, commit 9bc8ddc). The footprint stays within one chunk.
|
||||
*/
|
||||
final class PortSpikesFeature {
|
||||
private static final long SALT = 0x5350494B4553L;
|
||||
|
||||
private PortSpikesFeature() {}
|
||||
|
||||
static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) {
|
||||
int originX = chunk.getPos().getMinBlockX();
|
||||
int originZ = chunk.getPos().getMinBlockZ();
|
||||
RandomSource random = RandomSource.create(ageSeed ^ SALT
|
||||
^ (long) originX * 341873128712L ^ (long) originZ * 132897987541L
|
||||
^ (long) occurrence * 0x9E3779B97F4A7C15L);
|
||||
if (random.nextInt(18) != 0) return;
|
||||
int x = originX + 4 + random.nextInt(8);
|
||||
int z = originZ + 4 + random.nextInt(8);
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z);
|
||||
int height = 6 + random.nextInt(32);
|
||||
int width = 1 + random.nextInt(4);
|
||||
if (y < level.getMinY() + 1 || y + height >= level.getMaxY()) return;
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
if (!level.isEmptyBlock(pos.set(x, y, z))) return;
|
||||
for (int px = x - width; px <= x + width; px++) {
|
||||
for (int pz = z - width; pz <= z + width; pz++) {
|
||||
if (!insideFootprint(px - x, pz - z, width)) continue;
|
||||
if (!level.getBlockState(pos.set(px, y - 1, pz)).isSolid()) return;
|
||||
}
|
||||
}
|
||||
for (int px = x - width; px <= x + width; px++) {
|
||||
for (int pz = z - width; pz <= z + width; pz++) {
|
||||
if (!insideFootprint(px - x, pz - z, width)) continue;
|
||||
int top = y + 1 + random.nextInt(random.nextInt(height) + 1);
|
||||
for (int py = y; py < top; py++) {
|
||||
pos.set(px, py, pz);
|
||||
if (level.isEmptyBlock(pos)) level.setBlock(pos, material, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean insideFootprint(int dx, int dz, int width) {
|
||||
return dx * dx + dz * dz <= width * width + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.saveddata.SavedData;
|
||||
import net.minecraft.world.level.saveddata.SavedDataType;
|
||||
|
||||
/**
|
||||
* Ports Rivencraft 1.21.1's Legacy fissure footprint/carving.
|
||||
* Derived from Mystcraft's WorldGenMystStarFissure, LGPL-3.0,
|
||||
* copyright XCompWiz and Mystcraft contributors.
|
||||
*/
|
||||
public final class PortStarFissureFeature {
|
||||
private static final long SALT = 0x5354415246495353L;
|
||||
|
||||
private PortStarFissureFeature() {}
|
||||
|
||||
/** Per-Age data prevents revisits/restarts from carving the same fissure again. */
|
||||
private static final class Arrival extends SavedData {
|
||||
private static final Codec<Arrival> CODEC = BlockPos.CODEC.optionalFieldOf("spawn").codec()
|
||||
.xmap(Arrival::new, data -> Optional.ofNullable(data.spawn));
|
||||
private static final SavedDataType<Arrival> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "star_fissure_arrival"),
|
||||
() -> new Arrival(Optional.empty()), CODEC);
|
||||
private BlockPos spawn;
|
||||
|
||||
private Arrival(Optional<BlockPos> spawn) { this.spawn = spawn.orElse(null); }
|
||||
}
|
||||
|
||||
public static BlockPos savedSpawn(ServerLevel level) {
|
||||
return level.getDataStorage().computeIfAbsent(Arrival.TYPE).spawn;
|
||||
}
|
||||
|
||||
public static void generateOnce(ServerLevel level, BlockPos spawn, boolean caveTerrain) {
|
||||
Arrival data = level.getDataStorage().computeIfAbsent(Arrival.TYPE);
|
||||
if (data.spawn != null) return;
|
||||
generate(level, level.getSeed(), spawn, caveTerrain);
|
||||
data.spawn = spawn.immutable();
|
||||
data.setDirty();
|
||||
}
|
||||
|
||||
/** Legacy chooses 8–23 blocks after the spawn chunk, then grows along +Z. */
|
||||
public static List<BlockPos> footprint(long ageSeed, BlockPos spawn) {
|
||||
long chunkX = spawn.getX() >> 4;
|
||||
long chunkZ = spawn.getZ() >> 4;
|
||||
Random random = new Random(ageSeed ^ SALT ^ chunkX ^ (chunkZ << 32));
|
||||
int originX = ((int) chunkX << 4) + random.nextInt(16) + 8;
|
||||
int originZ = ((int) chunkZ << 4) + random.nextInt(16) + 8;
|
||||
int length = random.nextInt(8) + 10;
|
||||
int[][] intervals = new int[length][2];
|
||||
List<BlockPos> columns = new ArrayList<>();
|
||||
addRow(columns, originX, originZ, 0, 0, 0);
|
||||
for (int row = 1; row < length; row++) {
|
||||
int range = length + Math.min(row, length - row) + 1;
|
||||
int right = (random.nextInt(range) - (range >> 1) + intervals[row - 1][1]) / 2;
|
||||
int left = (random.nextInt(range) - (range >> 1) + intervals[row - 1][0]) / 2;
|
||||
if (left > right) {
|
||||
int swap = left;
|
||||
left = right;
|
||||
right = swap;
|
||||
}
|
||||
left = Math.min(left, intervals[row - 1][1]);
|
||||
right = Math.max(right, intervals[row - 1][0]);
|
||||
intervals[row][0] = left;
|
||||
intervals[row][1] = right;
|
||||
addRow(columns, originX, originZ, row, left, right);
|
||||
}
|
||||
return List.copyOf(columns);
|
||||
}
|
||||
|
||||
private static void addRow(List<BlockPos> columns, int originX, int originZ, int row, int left, int right) {
|
||||
for (int x = left; x <= right; x++) {
|
||||
// Only X/Z are used; the real floor is the dimension's minimum Y.
|
||||
columns.add(new BlockPos(originX + x, 0, originZ + row * 2));
|
||||
columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static void generate(ServerLevel level, long ageSeed, BlockPos spawn, boolean caveTerrain) {
|
||||
int floorY = level.getMinY();
|
||||
BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos();
|
||||
for (BlockPos column : footprint(ageSeed, spawn)) {
|
||||
int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ());
|
||||
if (caveTerrain) {
|
||||
for (int y = Math.max(spawn.getY(), floorY + 1); y < top; y++) {
|
||||
if (level.getBlockState(mutable.set(column.getX(), y, column.getZ())).is(Blocks.BEDROCK)) {
|
||||
top = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int y = floorY + 1; y < top; y++)
|
||||
level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(mutable.set(column.getX(), floorY, column.getZ()),
|
||||
Rivencraft.STAR_FISSURE.get().defaultBlockState(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.HolderOwner;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
|
||||
/** 1.21.1 structure planning/density rules adapted to modern placement classes. */
|
||||
public final class PortStructurePages {
|
||||
private static final Map<Identifier, Identifier> SETS = Map.ofEntries(
|
||||
vanillaSet("villages", "villages"),
|
||||
vanillaSet("mineshafts", "mineshafts"),
|
||||
vanillaSet("strongholds", "strongholds"),
|
||||
vanillaSet("trial_chambers", "trial_chambers"),
|
||||
vanillaSet("buried_treasure", "buried_treasures"),
|
||||
vanillaSet("shipwrecks", "shipwrecks"),
|
||||
vanillaSet("ocean_ruins", "ocean_ruins"),
|
||||
vanillaSet("pillager_outposts", "pillager_outposts"),
|
||||
vanillaSet("ruined_portals", "ruined_portals"),
|
||||
vanillaSet("nether_fossils", "nether_fossils"),
|
||||
vanillaSet("ancient_cities", "ancient_cities"),
|
||||
vanillaSet("trail_ruins", "trail_ruins"),
|
||||
vanillaSet("abandoned_camps", "abandoned_camp"),
|
||||
Map.entry(page("nether_fortresses"), Identifier.fromNamespaceAndPath("rivencraft", "fortresses")),
|
||||
Map.entry(page("bastions"), Identifier.fromNamespaceAndPath("rivencraft", "bastions")));
|
||||
// Suppress vanilla's combined Nether set so an authored Fortress page does
|
||||
// not also introduce Bastions (or vice versa).
|
||||
private static final Set<Identifier> CONTROLLED_SETS = Stream.concat(SETS.values().stream(),
|
||||
Stream.of(Identifier.withDefaultNamespace("nether_complexes")))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
private static final Set<Identifier> CAVE_ONLY = Set.of(
|
||||
page("nether_fortresses"), page("bastions"), page("nether_fossils"));
|
||||
private static final Set<Identifier> SURFACE_ONLY = Set.of(
|
||||
page("villages"), page("strongholds"), page("buried_treasure"), page("shipwrecks"),
|
||||
page("ocean_ruins"), page("pillager_outposts"), page("trail_ruins"), page("abandoned_camps"));
|
||||
private static final Set<Identifier> FLAT_ALLOWED = Set.of(page("villages"), page("strongholds"));
|
||||
|
||||
private PortStructurePages() {}
|
||||
|
||||
private static Identifier page(String name) {
|
||||
return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name);
|
||||
}
|
||||
|
||||
private static Map.Entry<Identifier, Identifier> vanillaSet(String page, String set) {
|
||||
return Map.entry(page(page), Identifier.withDefaultNamespace(set));
|
||||
}
|
||||
|
||||
public static boolean supports(Identifier page) { return SETS.containsKey(page); }
|
||||
|
||||
public static void validateCounts(List<Identifier> pages) {
|
||||
Map<Identifier, Integer> counts = new HashMap<>();
|
||||
for (Identifier page : pages) {
|
||||
if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
|
||||
throw new IllegalArgumentException("Structure plan requires supported pages, at most 32 per feature");
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean supportsTerrain(Identifier page, PortAgeTerrain terrain) {
|
||||
if (!supports(page) || terrain == PortAgeTerrain.VOID) return false;
|
||||
if (terrain == PortAgeTerrain.FLAT && !FLAT_ALLOWED.contains(page)) return false;
|
||||
if (CAVE_ONLY.contains(page)) return terrain == PortAgeTerrain.CAVE;
|
||||
return terrain != PortAgeTerrain.CAVE || !SURFACE_ONLY.contains(page);
|
||||
}
|
||||
|
||||
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
|
||||
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
pages.stream().filter(PortStructurePages::supports).distinct().forEach(page ->
|
||||
sets.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
|
||||
.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);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static boolean authored(Structure structure, HolderLookup.Provider registries, List<Identifier> pages) {
|
||||
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
return enabled(pages).keySet().stream()
|
||||
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
|
||||
.flatMap(set -> set.structures().stream()).anyMatch(entry -> entry.structure().value() == structure);
|
||||
}
|
||||
|
||||
/** Extra possible biomes affect planning only; terrain still uses its original source. */
|
||||
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets, List<Identifier> pages) {
|
||||
List<Holder<Biome>> extra = enabled(pages).keySet().stream()
|
||||
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
|
||||
.flatMap(set -> set.structures().stream()).flatMap(entry -> entry.structure().value().biomes().stream())
|
||||
.distinct().toList();
|
||||
return new BiomeSource() {
|
||||
@Override protected MapCodec<? extends BiomeSource> codec() {
|
||||
throw new UnsupportedOperationException("Planning biome sources are never serialized");
|
||||
}
|
||||
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return Stream.concat(actual.possibleBiomes().stream(), extra.stream());
|
||||
}
|
||||
@Override public net.minecraft.world.level.biome.BiomeResolver createResolver(Climate.Sampler sampler) {
|
||||
return actual.createResolver(sampler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static HolderLookup<StructureSet> filtered(HolderLookup<StructureSet> source, List<Identifier> pages,
|
||||
BiomeSource actual) {
|
||||
Map<Identifier, Integer> counts = enabled(pages);
|
||||
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> copies = new HashMap<>();
|
||||
return new HolderLookup<>() {
|
||||
@Override public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
|
||||
if (CONTROLLED_SETS.contains(key.identifier()) && !counts.containsKey(key.identifier())) return Optional.empty();
|
||||
return source.get(key).filter(holder -> counts.containsKey(key.identifier()) || hasBiome(holder.value(), actual))
|
||||
.map(holder -> copies.computeIfAbsent(key, ignored -> {
|
||||
int count = counts.getOrDefault(key.identifier(), 1);
|
||||
return count == 1 ? holder : new LocalReference(key, scaled(holder.value(), count));
|
||||
}));
|
||||
}
|
||||
@Override public Optional<HolderSet.Named<StructureSet>> get(TagKey<StructureSet> key) { return source.get(key); }
|
||||
@Override public Stream<Holder.Reference<StructureSet>> listElements() {
|
||||
return source.listElements().flatMap(holder -> get(holder.key()).stream());
|
||||
}
|
||||
@Override public Stream<HolderSet.Named<StructureSet>> listTags() { return source.listTags(); }
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean hasBiome(StructureSet set, BiomeSource source) {
|
||||
Set<Holder<Biome>> possible = source.possibleBiomes();
|
||||
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream()).anyMatch(possible::contains);
|
||||
}
|
||||
|
||||
private static StructureSet scaled(StructureSet set, int count) {
|
||||
var placement = set.placement();
|
||||
if (!(placement instanceof RandomSpreadStructurePlacement)
|
||||
&& !(placement instanceof ConcentricRingsStructurePlacement)) return set;
|
||||
StructurePlacementAccessor settings = (StructurePlacementAccessor) placement;
|
||||
if (placement instanceof RandomSpreadStructurePlacement random) {
|
||||
float original = settings.rivencraft$frequency();
|
||||
float frequency = Math.min(1.0F, original * count);
|
||||
double frequencyFactor = original > 0 ? frequency / original : 1;
|
||||
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(count / frequencyFactor)));
|
||||
int separation = spacing == 1 ? 0 : Math.min(spacing - 1,
|
||||
(int) Math.round((double) random.separation() * spacing / random.spacing()));
|
||||
return new StructureSet(set.structures(), new RandomSpreadStructurePlacement(placement.locateOffset(),
|
||||
settings.rivencraft$frequencyReductionMethod(), frequency, settings.rivencraft$salt(),
|
||||
settings.rivencraft$exclusionZone(), spacing, separation, random.spreadType()));
|
||||
}
|
||||
if (placement instanceof ConcentricRingsStructurePlacement rings) {
|
||||
int total = (int) Math.min(4095, (long) rings.count() * count);
|
||||
int spread = Math.min(total, (int) Math.min(1023, (long) rings.spread() * count));
|
||||
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(placement.locateOffset(),
|
||||
settings.rivencraft$frequencyReductionMethod(), settings.rivencraft$frequency(),
|
||||
settings.rivencraft$salt(), settings.rivencraft$exclusionZone(), rings.distance(), spread,
|
||||
total, rings.preferredBiomes()));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static final class LocalReference extends Holder.Reference<StructureSet> {
|
||||
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
|
||||
private LocalReference(ResourceKey<StructureSet> key, StructureSet value) { super(Type.STAND_ALONE, OWNER, key, value); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.util.Random;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.util.Mth;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
/**
|
||||
* 26.3 adaptation of Rivencraft 1.21.1's winding solid volumes, originally
|
||||
* adapted from Mystcraft Legacy's SymbolTendrils and MapGenCavesMyst (LGPL-3.0-or-later).
|
||||
* Each chunk reconstructs the neighboring source paths to preserve continuous borders.
|
||||
*/
|
||||
final class PortTendrilFeature {
|
||||
private static final long SALT = 0x54454E4452494C53L;
|
||||
private static final int SOURCE_RANGE = 8;
|
||||
private static final int SOURCE_RATE = 15;
|
||||
private static final int SOURCE_SIZE = 18;
|
||||
|
||||
private PortTendrilFeature() {}
|
||||
|
||||
static void generate(ChunkAccess chunk, long ageSeed, BlockState material) {
|
||||
var target = chunk.getPos();
|
||||
Random master = new Random(ageSeed ^ SALT);
|
||||
long xSeed = master.nextLong();
|
||||
long zSeed = master.nextLong();
|
||||
for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) {
|
||||
for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) {
|
||||
Random source = new Random((long) sourceX * xSeed ^ (long) sourceZ * zSeed ^ ageSeed ^ SALT);
|
||||
int nodes = source.nextInt(source.nextInt(source.nextInt(SOURCE_SIZE) + 1) + 1);
|
||||
if (source.nextInt(SOURCE_RATE) != 0) continue;
|
||||
for (int node = 0; node < nodes; node++) {
|
||||
double x = sourceX * 16 + source.nextInt(16);
|
||||
double y = source.nextInt(source.nextInt(120) + 8);
|
||||
double z = sourceZ * 16 + source.nextInt(16);
|
||||
int branches = 1;
|
||||
if (source.nextInt(4) == 0) {
|
||||
trace(chunk, material, source.nextLong(), x, y, z,
|
||||
1.0F + source.nextFloat() * 6.0F, 0.0F, 0.0F, -1, -1, 0.5D);
|
||||
branches += source.nextInt(4);
|
||||
}
|
||||
for (int branch = 0; branch < branches; branch++) {
|
||||
float yaw = source.nextFloat() * Mth.TWO_PI;
|
||||
float pitch = (source.nextFloat() - 0.5F) / 4.0F;
|
||||
float radius = source.nextFloat() * 2.0F + source.nextFloat();
|
||||
if (source.nextInt(10) == 0)
|
||||
radius *= source.nextFloat() * source.nextFloat() * 3.0F + 1.0F;
|
||||
trace(chunk, material, source.nextLong(), x, y, z,
|
||||
radius, yaw, pitch, 0, 0, 1.0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void trace(ChunkAccess chunk, BlockState material, long seed,
|
||||
double x, double y, double z, float radius, float yaw, float pitch,
|
||||
int step, int length, double verticalScale) {
|
||||
Random random = new Random(seed);
|
||||
if (length <= 0) {
|
||||
int maximum = SOURCE_RANGE * 16 - 16;
|
||||
length = maximum - random.nextInt(maximum / 4);
|
||||
}
|
||||
boolean singleNode = step == -1;
|
||||
if (singleNode) step = length / 2;
|
||||
int splitAt = random.nextInt(length / 2) + length / 4;
|
||||
boolean slowPitch = random.nextInt(6) == 0;
|
||||
float yawDrift = 0.0F;
|
||||
float pitchDrift = 0.0F;
|
||||
double middleX = chunk.getPos().getMinBlockX() + 8;
|
||||
double middleZ = chunk.getPos().getMinBlockZ() + 8;
|
||||
for (; step < length; step++) {
|
||||
double horizontalRadius = 1.5D + Math.sin(step * Math.PI / length) * radius;
|
||||
double verticalRadius = horizontalRadius * verticalScale;
|
||||
float horizontalMotion = Mth.cos(pitch);
|
||||
x += Mth.cos(yaw) * horizontalMotion;
|
||||
y += Mth.sin(pitch);
|
||||
z += Mth.sin(yaw) * horizontalMotion;
|
||||
pitch *= slowPitch ? 0.92F : 0.7F;
|
||||
pitch += pitchDrift * 0.1F;
|
||||
yaw += yawDrift * 0.1F;
|
||||
pitchDrift = pitchDrift * 0.9F
|
||||
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F;
|
||||
yawDrift = yawDrift * 0.75F
|
||||
+ (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F;
|
||||
if (!singleNode && step == splitAt && radius > 1.0F) {
|
||||
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
|
||||
yaw - Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
|
||||
trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F,
|
||||
yaw + Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D);
|
||||
return;
|
||||
}
|
||||
if (!singleNode && random.nextInt(4) == 0) continue;
|
||||
double distanceX = x - middleX;
|
||||
double distanceZ = z - middleZ;
|
||||
double remaining = length - step;
|
||||
double reach = radius + 18.0D;
|
||||
if (distanceX * distanceX + distanceZ * distanceZ - remaining * remaining > reach * reach) return;
|
||||
if (x >= middleX - 16.0D - horizontalRadius * 2.0D
|
||||
&& x <= middleX + 16.0D + horizontalRadius * 2.0D
|
||||
&& z >= middleZ - 16.0D - horizontalRadius * 2.0D
|
||||
&& z <= middleZ + 16.0D + horizontalRadius * 2.0D) {
|
||||
fillNode(chunk, material, x, y, z, horizontalRadius, verticalRadius);
|
||||
}
|
||||
if (singleNode) break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillNode(ChunkAccess chunk, BlockState material, double centerX, double centerY,
|
||||
double centerZ, double horizontalRadius, double verticalRadius) {
|
||||
int originX = chunk.getPos().getMinBlockX();
|
||||
int originZ = chunk.getPos().getMinBlockZ();
|
||||
int minX = Math.max(0, Mth.floor(centerX - horizontalRadius) - originX - 1);
|
||||
int maxX = Math.min(16, Mth.floor(centerX + horizontalRadius) - originX + 2);
|
||||
int minZ = Math.max(0, Mth.floor(centerZ - horizontalRadius) - originZ - 1);
|
||||
int maxZ = Math.min(16, Mth.floor(centerZ + horizontalRadius) - originZ + 2);
|
||||
int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - verticalRadius) - 1);
|
||||
int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + verticalRadius) + 2);
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
|
||||
for (int worldY = minY; worldY < maxY; worldY++) {
|
||||
double dy = (worldY + 0.5D - centerY) / verticalRadius;
|
||||
if (dy <= -0.7D || dy * dy >= 1.0D) continue;
|
||||
for (int localZ = minZ; localZ < maxZ; localZ++) {
|
||||
double dz = (originZ + localZ + 0.5D - centerZ) / horizontalRadius;
|
||||
for (int localX = minX; localX < maxX; localX++) {
|
||||
double dx = (originX + localX + 0.5D - centerX) / horizontalRadius;
|
||||
if (dx * dx + dy * dy + dz * dz >= 1.0D) continue;
|
||||
pos.set(originX + localX, worldY, originZ + localZ);
|
||||
BlockState previous = chunk.getBlockState(pos);
|
||||
if (previous == material || previous.is(Blocks.BEDROCK)) continue;
|
||||
chunk.setBlockState(pos, material);
|
||||
surface.update(localX, worldY, localZ, material);
|
||||
floor.update(localX, worldY, localZ, material);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
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 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");
|
||||
public static final Identifier SKYLANDS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/skylands");
|
||||
public static final Identifier SPHERES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spheres");
|
||||
public static final Identifier SPIKES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spikes");
|
||||
public static final Identifier OBELISKS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/obelisks");
|
||||
public static final Identifier CRYSTAL_FORMATIONS = Identifier.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "feature/crystal_formations");
|
||||
public static final Identifier CRYSTAL_MATERIAL = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "crystal_block");
|
||||
public static final Identifier DENSE_ORES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dense_ores");
|
||||
public static final Identifier STAR_FISSURES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/star_fissures");
|
||||
public static final int MAX_PER_FEATURE = 32;
|
||||
public static final Codec<PortTerrainFeaturePlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Choice.CODEC.listOf().fieldOf("choices").forGetter(PortTerrainFeaturePlan::choices),
|
||||
Codec.BOOL.optionalFieldOf("skylands", false).forGetter(PortTerrainFeaturePlan::skylands),
|
||||
Codec.intRange(0, MAX_PER_FEATURE).optionalFieldOf("dense_ores", 0)
|
||||
.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),
|
||||
Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl)
|
||||
).apply(instance, PortTerrainFeaturePlan::new));
|
||||
|
||||
public record Choice(Identifier page, Identifier material) {
|
||||
public static final Codec<Choice> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.fieldOf("page").forGetter(Choice::page),
|
||||
Identifier.CODEC.fieldOf("material").forGetter(Choice::material)
|
||||
).apply(instance, Choice::new));
|
||||
|
||||
public Choice {
|
||||
if (!isSupported(page) || material == null)
|
||||
throw new IllegalArgumentException("Invalid terrain feature choice");
|
||||
if (CRYSTAL_FORMATIONS.equals(page) && !CRYSTAL_MATERIAL.equals(material))
|
||||
throw new IllegalArgumentException("Crystal formations use Rivencraft crystal, not a Block modifier");
|
||||
}
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan {
|
||||
choices = List.copyOf(choices);
|
||||
structures = List.copyOf(structures);
|
||||
carvers = List.copyOf(carvers);
|
||||
PortStructurePages.validateCounts(structures);
|
||||
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() && !structureControl)
|
||||
throw new IllegalArgumentException("Terrain feature plan must not be empty");
|
||||
var counts = new HashMap<Identifier, Integer>();
|
||||
for (Choice choice : choices) {
|
||||
if (counts.merge(choice.page(), 1, Integer::sum) > MAX_PER_FEATURE)
|
||||
throw new IllegalArgumentException("Terrain feature plan permits at most 32 occurrences per feature");
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, boolean starFissures) {
|
||||
this(choices, skylands, denseOres, starFissures, List.of());
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres) {
|
||||
this(choices, skylands, denseOres, false);
|
||||
}
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands) { this(choices, skylands, 0, false); }
|
||||
|
||||
public PortTerrainFeaturePlan(List<Choice> choices) { this(choices, false, 0, false); }
|
||||
|
||||
public static boolean isSupported(Identifier page) {
|
||||
return FLOATING_ISLANDS.equals(page) || TENDRILS.equals(page) || SPHERES.equals(page) || isSurface(page);
|
||||
}
|
||||
|
||||
public static boolean acceptsMaterial(Identifier page) {
|
||||
return isSupported(page) && !CRYSTAL_FORMATIONS.equals(page);
|
||||
}
|
||||
|
||||
public static boolean isSurface(Identifier page) {
|
||||
return SPIKES.equals(page) || OBELISKS.equals(page) || CRYSTAL_FORMATIONS.equals(page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** Ordered, frozen plants and log decorations; not all require surface terrain. */
|
||||
public record PortVegetationPlan(List<Identifier> pages) {
|
||||
public static final Identifier FLOWERS = page("flowers");
|
||||
public static final Identifier TREES = page("trees");
|
||||
public static final Identifier HUGE_TREES = page("huge_trees");
|
||||
public static final Identifier POPLAR_TREES = page("poplar_trees");
|
||||
public static final Identifier FALLEN_POPLAR_TREES = page("fallen_poplar_trees");
|
||||
public static final Identifier RED_SHRUBS = page("red_shrubs");
|
||||
public static final Identifier SHELF_MUSHROOMS = page("shelf_mushrooms");
|
||||
public static final Codec<PortVegetationPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
|
||||
Identifier.CODEC.listOf().fieldOf("pages").forGetter(PortVegetationPlan::pages)
|
||||
).apply(instance, PortVegetationPlan::new));
|
||||
|
||||
public PortVegetationPlan {
|
||||
pages = List.copyOf(pages);
|
||||
if (pages.isEmpty() || pages.size() > 32 || pages.stream().anyMatch(page -> !isVegetation(page)))
|
||||
throw new IllegalArgumentException("Vegetation plan requires 1–32 supported pages");
|
||||
}
|
||||
|
||||
public static boolean isVegetation(Identifier page) {
|
||||
return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES)
|
||||
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES)
|
||||
|| page.equals(RED_SHRUBS) || page.equals(SHELF_MUSHROOMS);
|
||||
}
|
||||
|
||||
public static Identifier placedFeature(Identifier page) {
|
||||
if (page.equals(FLOWERS)) return Identifier.withDefaultNamespace("flower_default");
|
||||
if (page.equals(TREES)) return Identifier.withDefaultNamespace("trees_plains");
|
||||
if (page.equals(HUGE_TREES)) return Identifier.withDefaultNamespace("trees_old_growth_spruce_taiga");
|
||||
if (page.equals(POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "poplar_trees");
|
||||
if (page.equals(FALLEN_POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "fallen_poplar_trees");
|
||||
if (page.equals(RED_SHRUBS)) return Identifier.withDefaultNamespace("patch_red_shrub");
|
||||
throw new IllegalArgumentException("Unsupported vegetation page " + page);
|
||||
}
|
||||
|
||||
public static boolean addsLogs(Identifier page) {
|
||||
return page.equals(TREES) || page.equals(HUGE_TREES)
|
||||
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES);
|
||||
}
|
||||
|
||||
public static boolean requiresSurface(Identifier page) {
|
||||
return isVegetation(page) && !page.equals(SHELF_MUSHROOMS);
|
||||
}
|
||||
|
||||
private static Identifier page(String name) {
|
||||
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/** The six active weather pages in the 1.21.1 writing system. */
|
||||
public enum PortWeatherMode {
|
||||
NORMAL, FAST, SLOW, OFF, ALWAYS, STORM;
|
||||
|
||||
public static final Codec<PortWeatherMode> CODEC = Codec.STRING.xmap(
|
||||
name -> valueOf(name.toUpperCase(java.util.Locale.ROOT)),
|
||||
mode -> mode.name().toLowerCase(java.util.Locale.ROOT));
|
||||
|
||||
public static PortWeatherMode forPage(Identifier page) {
|
||||
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
|
||||
return switch (page.getPath()) {
|
||||
case "weather/normal" -> NORMAL;
|
||||
case "weather/fast" -> FAST;
|
||||
case "weather/slow" -> SLOW;
|
||||
case "weather/off" -> OFF;
|
||||
case "weather/always" -> ALWAYS;
|
||||
case "weather/storm" -> STORM;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 policy; native WeatherData separately persists its live cycle. */
|
||||
final class PortWeatherSettings extends SavedData {
|
||||
private static final Codec<PortWeatherSettings> CODEC = PortWeatherMode.CODEC
|
||||
.fieldOf("mode").codec().xmap(PortWeatherSettings::new, settings -> settings.mode);
|
||||
static final SavedDataType<PortWeatherSettings> TYPE = new SavedDataType<>(
|
||||
Identifier.fromNamespaceAndPath("rivencraft", "weather_policy"),
|
||||
() -> new PortWeatherSettings(PortWeatherMode.NORMAL), CODEC);
|
||||
|
||||
private PortWeatherMode mode;
|
||||
|
||||
private PortWeatherSettings(PortWeatherMode mode) { this.mode = mode; }
|
||||
PortWeatherMode mode() { return mode; }
|
||||
void setMode(PortWeatherMode mode) {
|
||||
this.mode = mode;
|
||||
setDirty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package net.dnorberg.rivencraft.age;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.PortRegistryPages;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.TicketStorage;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.FixedBiomeSource;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
|
||||
import net.minecraft.world.level.dimension.LevelStem;
|
||||
import net.minecraft.world.level.levelgen.FlatLevelSource;
|
||||
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
|
||||
import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
|
||||
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||
|
||||
/**
|
||||
* Server-side runtime-level foundation for 26.3. Authored generators and
|
||||
* dynamic client registry synchronization remain separate migration steps.
|
||||
*/
|
||||
public final class RuntimeAgeBackend {
|
||||
private RuntimeAgeBackend() {
|
||||
}
|
||||
|
||||
public static void onLevelLoad(LevelEvent.Load event) {
|
||||
if (!(event.getLevel() instanceof ServerLevel overworld) || overworld.dimension() != Level.OVERWORLD) {
|
||||
return;
|
||||
}
|
||||
MinecraftServer server = overworld.getServer();
|
||||
AgeLevelIndex index = index(server);
|
||||
for (Map.Entry<Identifier, Long> age : index.entries().entrySet()) {
|
||||
try {
|
||||
load(server, age.getKey(), age.getValue(), index.biomePlan(age.getKey()),
|
||||
index.terrain(age.getKey()), index.rocks(age.getKey()), index.vegetation(age.getKey()),
|
||||
index.dungeons(age.getKey()), index.lakes(age.getKey()), index.terrainFeatures(age.getKey()));
|
||||
} catch (RuntimeException exception) {
|
||||
Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a server level, then commits its identity only after successful construction. */
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed) {
|
||||
return create(server, id, seed, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed, PortBiomePlan biomePlan) {
|
||||
return create(server, id, seed, biomePlan, PortAgeTerrain.STANDARD);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain) {
|
||||
return create(server, id, seed, biomePlan, terrain, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, 0);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, null);
|
||||
}
|
||||
|
||||
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, PortWeatherMode.NORMAL);
|
||||
}
|
||||
|
||||
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) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, PortLighting.NORMAL);
|
||||
}
|
||||
|
||||
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) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, lighting, PortSkyStyle.OVERWORLD);
|
||||
}
|
||||
|
||||
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) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, lighting, sky, PortAgeColors.NATIVE);
|
||||
}
|
||||
|
||||
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) {
|
||||
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, weather, lighting, sky, colors, PortDayCycle.NATIVE);
|
||||
}
|
||||
|
||||
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) {
|
||||
checkServerThread(server);
|
||||
ageName(id);
|
||||
AgeLevelIndex index = index(server);
|
||||
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
|
||||
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);
|
||||
// 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.
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
return level;
|
||||
}
|
||||
|
||||
/** Unloads an Age only when no player remains; save(true) flushes other entities. */
|
||||
public static void unload(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
requireSeed(server, id);
|
||||
unloadLoaded(server, id);
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
}
|
||||
|
||||
public static ServerLevel reload(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
long seed = requireSeed(server, id);
|
||||
ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id),
|
||||
index(server).rocks(id), index(server).vegetation(id), index(server).dungeons(id), index(server).lakes(id),
|
||||
index(server).terrainFeatures(id));
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
return level;
|
||||
}
|
||||
|
||||
/** Existing saved chunks stay in place; the replacement level uses the new seed. */
|
||||
public static void reseed(MinecraftServer server, Identifier id, long seed) {
|
||||
checkServerThread(server);
|
||||
AgeLevelIndex index = index(server);
|
||||
long previousSeed = requireSeed(server, id);
|
||||
PortBiomePlan biomePlan = index.biomePlan(id);
|
||||
PortAgeTerrain terrain = index.terrain(id);
|
||||
PortRockPlan rocks = index.rocks(id);
|
||||
PortVegetationPlan vegetation = index.vegetation(id);
|
||||
int dungeons = index.dungeons(id);
|
||||
PortLakePlan lakes = index.lakes(id);
|
||||
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
|
||||
if (previousSeed == seed) return;
|
||||
boolean loaded = server.getLevel(dimensionKey(id)) != null;
|
||||
if (loaded) unloadLoaded(server, id);
|
||||
try {
|
||||
if (loaded) load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
|
||||
index.reseed(id, seed);
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
if (loaded) AgeCatalogSync.onCatalogChanged(server);
|
||||
} catch (RuntimeException exception) {
|
||||
if (loaded) {
|
||||
try {
|
||||
load(server, id, previousSeed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retires one unloaded Age and moves only its own storage to a recoverable archive. */
|
||||
public static void retire(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
String name = ageName(id);
|
||||
AgeLevelIndex index = index(server);
|
||||
long seed = requireSeed(server, id);
|
||||
PortBiomePlan biomePlan = index.biomePlan(id);
|
||||
PortAgeTerrain terrain = index.terrain(id);
|
||||
PortRockPlan rocks = index.rocks(id);
|
||||
PortVegetationPlan vegetation = index.vegetation(id);
|
||||
int dungeons = index.dungeons(id);
|
||||
PortLakePlan lakes = index.lakes(id);
|
||||
PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id);
|
||||
boolean loaded = server.getLevel(dimensionKey(id)) != null;
|
||||
ArchivePaths paths = archivePaths(server, name);
|
||||
PortLighting lighting = index.lighting(id);
|
||||
PortSkyStyle sky = index.sky(id);
|
||||
PortAgeColors colors = index.colors(id);
|
||||
PortDayCycle dayCycle = index.dayCycle(id);
|
||||
if (loaded) unloadLoaded(server, id);
|
||||
// Persist the tombstone before moving files: a crash mid-archive must
|
||||
// not let an old book silently regenerate this identity.
|
||||
index.retire(id);
|
||||
try {
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
if (Files.exists(paths.live(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(paths.archive().getParent());
|
||||
try {
|
||||
Files.move(paths.live(), paths.archive(), StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(paths.live(), paths.archive());
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
try {
|
||||
index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, lighting, sky, colors, dayCycle);
|
||||
server.overworld().getDataStorage().saveAndJoin();
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
if (loaded) {
|
||||
try {
|
||||
load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Could not archive Age " + id, exception);
|
||||
}
|
||||
AgeCatalogSync.onCatalogChanged(server);
|
||||
Rivencraft.LOGGER.info("Retired 26.3 prototype Age {} (archived at {})", id, paths.archive());
|
||||
}
|
||||
|
||||
public static Map<Identifier, Long> savedAges(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
return index(server).entries();
|
||||
}
|
||||
|
||||
public static Map<Identifier, PortAgeColors> savedColors(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
return index(server).colors();
|
||||
}
|
||||
|
||||
public static Map<Identifier, PortDayCycle> savedDayCycles(MinecraftServer server) {
|
||||
checkServerThread(server);
|
||||
return index(server).dayCycles();
|
||||
}
|
||||
|
||||
/** Includes retired IDs, which must never be silently reused by a new book. */
|
||||
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
|
||||
checkServerThread(server);
|
||||
ageName(id);
|
||||
return index(server).contains(id);
|
||||
}
|
||||
|
||||
private static AgeLevelIndex index(MinecraftServer server) {
|
||||
return server.overworld().getDataStorage().computeIfAbsent(AgeLevelIndex.TYPE);
|
||||
}
|
||||
|
||||
private static void checkServerThread(MinecraftServer server) {
|
||||
if (!server.isSameThread()) throw new IllegalStateException("Runtime Age operation outside server thread");
|
||||
}
|
||||
|
||||
private static long requireSeed(MinecraftServer server, Identifier id) {
|
||||
ageName(id);
|
||||
Long seed = index(server).seed(id);
|
||||
if (seed == null) throw new IllegalArgumentException("Age not active: " + id);
|
||||
return seed;
|
||||
}
|
||||
|
||||
private static String ageName(Identifier id) {
|
||||
if (!id.getNamespace().equals(Rivencraft.MOD_ID) || !id.getPath().startsWith("age/")) {
|
||||
throw new IllegalArgumentException("Expected a rivencraft:age/* dimension ID: " + id);
|
||||
}
|
||||
String name = id.getPath().substring("age/".length());
|
||||
if (name.isBlank() || name.contains("/") || name.equals(".") || name.equals("..")) {
|
||||
throw new IllegalArgumentException("Expected a single safe Age path segment: " + id);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static ResourceKey<Level> dimensionKey(Identifier id) {
|
||||
return ResourceKey.create(Registries.DIMENSION, id);
|
||||
}
|
||||
|
||||
private static void unloadLoaded(MinecraftServer server, Identifier id) {
|
||||
ResourceKey<Level> key = dimensionKey(id);
|
||||
ServerLevel level = server.getLevel(key);
|
||||
if (level == null) throw new IllegalStateException("Age not loaded: " + id);
|
||||
if (!level.players().isEmpty()) {
|
||||
throw new IllegalStateException("Age still contains players; move them before unloading: " + id);
|
||||
}
|
||||
// Match the server shutdown sequence so forced tickets are persisted
|
||||
// as inactive, rather than holding stale references to this level.
|
||||
level.getChunkSource().deactivateTicketsOnClosing();
|
||||
level.getChunkSource().tick(() -> true, false);
|
||||
level.save(null, true, false);
|
||||
try {
|
||||
level.close();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Could not close Age: " + id, exception);
|
||||
}
|
||||
server.levels.remove(key);
|
||||
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
|
||||
Rivencraft.LOGGER.info("Unloaded 26.3 prototype Age {}", id);
|
||||
}
|
||||
|
||||
private static ArchivePaths archivePaths(MinecraftServer server, String name) {
|
||||
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||
Path dimensions = world.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(Rivencraft.MOD_ID);
|
||||
Path ageRoot = namespace.resolve("age");
|
||||
Path live = ageRoot.resolve(name).normalize();
|
||||
Path archiveRoot = world.getParent().resolve(world.getFileName() + "-retired-ages").normalize();
|
||||
Path archive = archiveRoot.resolve(name).normalize();
|
||||
if (!live.getParent().equals(ageRoot) || !archive.getParent().equals(archiveRoot)
|
||||
|| Files.isSymbolicLink(world) || Files.isSymbolicLink(dimensions)
|
||||
|| Files.isSymbolicLink(namespace) || Files.isSymbolicLink(ageRoot)
|
||||
|| Files.isSymbolicLink(live) || Files.isSymbolicLink(archiveRoot)
|
||||
|| Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalStateException("Unsafe or existing Age archive path for " + name);
|
||||
}
|
||||
return new ArchivePaths(live, archive);
|
||||
}
|
||||
|
||||
private record ArchivePaths(Path live, Path archive) {
|
||||
}
|
||||
|
||||
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
|
||||
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 -> 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);
|
||||
case TINY, SMALL, MEDIUM, LARGE, HUGE -> new PortScaledBiomeSource(
|
||||
HolderSet.direct(biomes), seed, plan.distribution().ordinal() -
|
||||
PortBiomePlan.Distribution.TINY.ordinal());
|
||||
};
|
||||
}
|
||||
|
||||
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures) {
|
||||
return load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
|
||||
terrainFeatures, index(server).lighting(id), index(server).sky(id), index(server).colors(id), index(server).dayCycle(id));
|
||||
}
|
||||
|
||||
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
|
||||
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
|
||||
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
|
||||
PortTerrainFeaturePlan terrainFeatures, PortLighting lighting, PortSkyStyle sky,
|
||||
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 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);
|
||||
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
|
||||
var generator = overworldStem.generator();
|
||||
if (terrain == PortAgeTerrain.FLAT || terrain == PortAgeTerrain.VOID) {
|
||||
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
Holder<Biome> biome = biomes.getOrThrow(terrain == PortAgeTerrain.VOID
|
||||
? Biomes.THE_VOID : Biomes.PLAINS);
|
||||
if (biomePlan != null && !biomePlan.biomes().isEmpty()) {
|
||||
Identifier biomeId = biomePlan.biomes().getFirst();
|
||||
biome = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
|
||||
}
|
||||
FlatLevelGeneratorSettings settings;
|
||||
if (terrain == PortAgeTerrain.FLAT) {
|
||||
settings = FlatLevelGeneratorSettings.getDefault(biomes,
|
||||
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
||||
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE));
|
||||
// Vanilla superflat begins at the modern minimum build height.
|
||||
// Pad the column with air so the first solid layer begins at Y=0.
|
||||
int airBelowZero = Math.max(0, -server.overworld().getMinY());
|
||||
if (airBelowZero > 0)
|
||||
settings.getLayersInfo().addFirst(new FlatLayerInfo(airBelowZero, Blocks.AIR));
|
||||
settings = settings.withBiomeAndLayers(settings.getLayersInfo(),
|
||||
settings.structureOverrides(), biome);
|
||||
settings.setDecoration();
|
||||
} else {
|
||||
settings = new FlatLevelGeneratorSettings(Optional.empty(), biome, List.of());
|
||||
settings.updateLayers();
|
||||
}
|
||||
generator = biomePlan != null && biomePlan.biomes().size() > 1
|
||||
? new PortFlatChunkGenerator(authoredBiomeSource(server, biomePlan, seed), settings)
|
||||
: new FlatLevelSource(settings);
|
||||
} else if ((biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE)
|
||||
|| terrain != PortAgeTerrain.STANDARD) {
|
||||
if (!(generator instanceof NoiseBasedChunkGenerator noise))
|
||||
throw new IllegalStateException("Overworld generator cannot accept authored noise settings");
|
||||
var source = terrain == PortAgeTerrain.CAVE
|
||||
? new FixedBiomeSource(server.registryAccess().lookupOrThrow(Registries.BIOME)
|
||||
.getOrThrow(Biomes.LUSH_CAVES))
|
||||
: generator.getBiomeSource();
|
||||
if (terrain == PortAgeTerrain.CAVE && biomePlan != null) {
|
||||
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
for (Identifier biomeId : biomePlan.biomes()) {
|
||||
var holder = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
|
||||
if (!PortRegistryPages.isCaveBiome(holder))
|
||||
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
|
||||
}
|
||||
}
|
||||
if (biomePlan != null && (biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE
|
||||
|| terrain == PortAgeTerrain.CAVE && !biomePlan.biomes().isEmpty())) {
|
||||
source = authoredBiomeSource(server, biomePlan, seed);
|
||||
}
|
||||
var settings = switch (terrain) {
|
||||
case STANDARD -> noise.generatorSettings();
|
||||
case AMPLIFIED -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(NoiseGeneratorSettings.AMPLIFIED);
|
||||
case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS);
|
||||
case CAVE -> dryCaveSettings(server);
|
||||
case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource");
|
||||
};
|
||||
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);
|
||||
LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky),
|
||||
generator, OptionalLong.of(seed));
|
||||
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
|
||||
ServerLevel level = new PortAgeLevel(server, key, stem, biomeZoomSeed, terrain);
|
||||
((PortAgeLevel) level).configureColors(colors);
|
||||
((PortAgeLevel) level).configureDayCycle(dayCycle);
|
||||
|
||||
// Publish only after the constructor succeeds. If the load event fails,
|
||||
// roll back the map and close the newly opened chunk/entity stores.
|
||||
try {
|
||||
server.levels.put(key, level);
|
||||
level.getWorldBorder().setAbsoluteMaxSize(server.getAbsoluteMaxWorldSize());
|
||||
server.getPlayerList().addWorldborderListener(level);
|
||||
NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));
|
||||
TicketStorage tickets = level.getDataStorage().get(TicketStorage.TYPE);
|
||||
if (tickets != null) {
|
||||
tickets.activateAllDeactivatedTickets();
|
||||
ForcedChunkManager.activateAllDeactivatedTickets(level, tickets);
|
||||
}
|
||||
Rivencraft.LOGGER.info("Loaded 26.3 prototype Age {} with seed {}", id, seed);
|
||||
return level;
|
||||
} catch (RuntimeException exception) {
|
||||
server.levels.remove(key);
|
||||
try {
|
||||
level.close();
|
||||
} catch (IOException closeException) {
|
||||
exception.addSuppressed(closeException);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static Holder<NoiseGeneratorSettings> dryCaveSettings(MinecraftServer server) {
|
||||
NoiseGeneratorSettings caves = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
|
||||
.getOrThrow(NoiseGeneratorSettings.CAVES).value();
|
||||
return Holder.direct(new NoiseGeneratorSettings(
|
||||
caves.noiseSettings(), caves.defaultBlock(), Blocks.AIR.defaultBlockState(),
|
||||
caves.noiseRouter(), caves.materialRule(), caves.spawnTarget(),
|
||||
caves.noiseSettings().minY() - 1, caves.disableMobGeneration(), Optional.empty(),
|
||||
caves.useLegacyRandomSource(), caves.debugFunctions()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
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.level.block.AmethystBlock;
|
||||
import net.minecraft.world.level.block.AmethystClusterBlock;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.BuddingAmethystBlock;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
|
||||
/** Vanilla amethyst growth, using Rivencraft's blue crystal stages. */
|
||||
public final class BuddingCrystalBlock extends AmethystBlock {
|
||||
public BuddingCrystalBlock(BlockBehaviour.Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void randomTick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||
if (random.nextInt(5) != 0) return;
|
||||
Direction direction = Direction.values()[random.nextInt(Direction.values().length)];
|
||||
BlockPos target = pos.relative(direction);
|
||||
BlockState existing = level.getBlockState(target);
|
||||
Block next = nextStage(existing, direction);
|
||||
if (next == null) return;
|
||||
level.setBlockAndUpdate(target, next.defaultBlockState()
|
||||
.setValue(AmethystClusterBlock.FACING, direction)
|
||||
.setValue(AmethystClusterBlock.WATERLOGGED,
|
||||
existing.getFluidState().getType() == Fluids.WATER));
|
||||
}
|
||||
|
||||
public static Block nextStage(BlockState existing, Direction direction) {
|
||||
if (BuddingAmethystBlock.canClusterGrowAtState(existing)) return Rivencraft.SMALL_CRYSTAL_BUD.get();
|
||||
if (!existing.hasProperty(AmethystClusterBlock.FACING)
|
||||
|| existing.getValue(AmethystClusterBlock.FACING) != direction) return null;
|
||||
if (existing.is(Rivencraft.SMALL_CRYSTAL_BUD)) return Rivencraft.MEDIUM_CRYSTAL_BUD.get();
|
||||
if (existing.is(Rivencraft.MEDIUM_CRYSTAL_BUD)) return Rivencraft.LARGE_CRYSTAL_BUD.get();
|
||||
if (existing.is(Rivencraft.LARGE_CRYSTAL_BUD)) return Rivencraft.CRYSTAL_CLUSTER.get();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
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.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
/** 26.3 API adaptation of the 1.21.1 Ink Mixer block. */
|
||||
public final class InkMixerBlock extends BaseEntityBlock {
|
||||
public InkMixerBlock(Properties properties) { super(properties); }
|
||||
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new InkMixerBlockEntity(pos, state);
|
||||
}
|
||||
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
|
||||
&& level.getBlockEntity(pos) instanceof InkMixerBlockEntity mixer) {
|
||||
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||
(id, inventory, ignored) -> new InkMixerMenu(id, inventory, mixer),
|
||||
Component.translatable("container.rivencraft.ink_mixer")),
|
||||
buffer -> buffer.writeBlockPos(pos));
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
|
||||
BlockState state, BlockEntity entity, ItemStack tool) {
|
||||
if (entity instanceof InkMixerBlockEntity mixer) Containers.dropContents(level, pos, mixer);
|
||||
super.playerDestroy(level, player, pos, state, entity, tool);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.menu.PortBookBinderMenu;
|
||||
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.server.level.ServerLevel;
|
||||
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.ItemStack;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||
import net.minecraft.world.level.block.RenderShape;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.StateDefinition;
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
|
||||
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||
import net.minecraft.world.phys.BlockHitResult;
|
||||
|
||||
public final class PortBookBinderBlock extends BaseEntityBlock {
|
||||
public static final EnumProperty<Direction> FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||
|
||||
public PortBookBinderBlock(Properties properties) {
|
||||
super(properties);
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||
}
|
||||
|
||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block, BlockState> builder) {
|
||||
builder.add(FACING);
|
||||
}
|
||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new PortBookBinderBlockEntity(pos, state);
|
||||
}
|
||||
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||
Player player, BlockHitResult hit) {
|
||||
if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer
|
||||
&& level.getBlockEntity(pos) instanceof PortBookBinderBlockEntity binder) {
|
||||
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||
(id, inventory, ignored) -> new PortBookBinderMenu(id, inventory, binder),
|
||||
Component.translatable("container.rivencraft.book_binder")),
|
||||
buffer -> {
|
||||
buffer.writeBlockPos(pos);
|
||||
buffer.writeUtf(binder.title(), 32);
|
||||
});
|
||||
}
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
@Override public void playerDestroy(ServerLevel level, ServerPlayer player, BlockPos pos,
|
||||
BlockState state, BlockEntity entity, ItemStack tool) {
|
||||
if (entity instanceof PortBookBinderBlockEntity binder) Containers.dropContents(level, pos, binder);
|
||||
super.playerDestroy(level, player, pos, state, entity, tool);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.dnorberg.rivencraft.block;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.InsideBlockEffectApplier;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.EndPortalBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockBehaviour;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.portal.TeleportTransition;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||
|
||||
/** Non-obtainable starfield portal returning entities to the shared Overworld spawn. */
|
||||
public final class StarFissureBlock extends EndPortalBlock {
|
||||
private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16);
|
||||
|
||||
public StarFissureBlock(BlockBehaviour.Properties properties) { super(properties); }
|
||||
|
||||
@Override
|
||||
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
return new StarFissureBlockEntity(pos, state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void animateTick(BlockState state, Level level, BlockPos pos, net.minecraft.util.RandomSource random) {
|
||||
// Legacy fissures have a starfield, not vanilla End portal smoke.
|
||||
}
|
||||
|
||||
@Override
|
||||
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) {
|
||||
return SHAPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity,
|
||||
InsideBlockEffectApplier effects, boolean precise) {
|
||||
// Do not inherit End credits or End-dimension travel from the vanilla block.
|
||||
if (entity.canUsePortal(false)) entity.setAsInsidePortal(this, pos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TeleportTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) {
|
||||
if (level.dimension() == Level.OVERWORLD) return null;
|
||||
ServerLevel overworld = level.getServer().overworld();
|
||||
if (overworld == null) return null;
|
||||
BlockPos spawn = overworld.getRespawnData().pos();
|
||||
BlockPos landing = net.dnorberg.rivencraft.age.PortAgeTravel.findFootedDestination(overworld, spawn).orElse(spawn);
|
||||
level.playSound(null, pos, Rivencraft.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F);
|
||||
return new TeleportTransition(overworld, Vec3.atBottomCenterOf(landing), entity.getDeltaMovement(),
|
||||
entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET.then(arrived -> {
|
||||
// Departure audio can be discarded when the client switches levels.
|
||||
// Play after arrival too so the traveler hears the successful return.
|
||||
arrived.level().playSound(null, arrived.blockPosition(), Rivencraft.LINK_FISSURE.get(),
|
||||
SoundSource.BLOCKS, 1.0F, 1.0F);
|
||||
}));
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import java.util.Random;
|
||||
import net.dnorberg.rivencraft.item.LinkProperties;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.ContainerHelper;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.Level;
|
||||
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;
|
||||
|
||||
/** Server-owned Link Panel mixer with a persistent, Legacy-style additive mixture. */
|
||||
public final class InkMixerBlockEntity extends BlockEntity implements Container {
|
||||
public static final int INK = 0;
|
||||
public static final int PAPER = 1;
|
||||
public static final int BOTTLES = 2;
|
||||
public static final int SIZE = 3;
|
||||
|
||||
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
private int inkRemaining;
|
||||
private long nextModifierSeed = new Random().nextLong();
|
||||
private final double[] modifierChances = new double[3];
|
||||
private static final int[] FLAGS = {LinkProperties.DISARM,
|
||||
LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.INTRA_LINKING};
|
||||
private static final String[] CHANCE_KEYS = {"DisarmChance", "MomentumChance", "IntraLinkingChance"};
|
||||
private static final int[] OLD_CHANCE_INDEX = {0, 2, 3};
|
||||
|
||||
public InkMixerBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.INK_MIXER_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
|
||||
public int inkRemaining() { return inkRemaining; }
|
||||
|
||||
public int modifierChancePermille(int index) {
|
||||
return Math.clamp((int) Math.round(modifierChances[index] * 1000), 0, 1000);
|
||||
}
|
||||
|
||||
/** Infuses a carried ingredient into the current ink pool; only the server calls this. */
|
||||
public boolean addModifier(ItemStack carried, int amount) {
|
||||
InkModifierIngredients.Ingredient ingredient = InkModifierIngredients.find(carried);
|
||||
if (ingredient == null || inkRemaining == 0 || amount <= 0 || carried.getCount() < amount)
|
||||
return false;
|
||||
for (InkModifierIngredients.Effect effect : ingredient.effects())
|
||||
for (int index = 0; index < FLAGS.length; index++)
|
||||
if (FLAGS[index] == effect.flag())
|
||||
modifierChances[index] = 1.0 - (1.0 - modifierChances[index])
|
||||
* Math.pow(1.0 - effect.chance(), amount);
|
||||
carried.shrink(amount);
|
||||
setChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public ItemStack previewLinkPanel() {
|
||||
if (!canCreateLinkPanel()) return ItemStack.EMPTY;
|
||||
ItemStack panel = PageItem.createLinkPanelPage();
|
||||
LinkProperties properties = rollProperties(nextModifierSeed).properties();
|
||||
if (properties.flags() != 0) panel.set(Rivencraft.LINK_PROPERTIES.get(), properties);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private record Roll(LinkProperties properties, long nextSeed) {}
|
||||
|
||||
private Roll rollProperties(long seed) {
|
||||
Random random = new Random(seed);
|
||||
int selected = 0;
|
||||
for (int index = 0; index < FLAGS.length; index++)
|
||||
if (modifierChances[index] > 0 && random.nextDouble() < modifierChances[index])
|
||||
selected |= FLAGS[index];
|
||||
return new Roll(new LinkProperties(selected), random.nextLong());
|
||||
}
|
||||
|
||||
@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()) {
|
||||
setChanged();
|
||||
if (slot == BOTTLES) refillEmptyBasin();
|
||||
}
|
||||
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());
|
||||
setChanged();
|
||||
if (slot == INK || slot == BOTTLES) refillEmptyBasin();
|
||||
}
|
||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
return switch (slot) {
|
||||
case INK -> stack.is(Rivencraft.INK_VIAL.get());
|
||||
case PAPER -> stack.is(Items.PAPER);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
/** No server tick: refill only when ink, bottle space, or consumption changes. */
|
||||
private void refillEmptyBasin() {
|
||||
if (level != null && !level.isClientSide() && inkRemaining == 0) {
|
||||
int addedInk = InkVialExchange.exchange(items, INK, BOTTLES);
|
||||
if (addedInk > 0) {
|
||||
inkRemaining = addedInk;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean canCreateLinkPanel() {
|
||||
if (!items.get(PAPER).is(Items.PAPER)) return false;
|
||||
return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, 1);
|
||||
}
|
||||
|
||||
/** Commit inputs only when a virtual output is actually taken. */
|
||||
public boolean consumeLinkPanelMaterials() {
|
||||
if (!canCreateLinkPanel()) return false;
|
||||
int cost = 1;
|
||||
if (inkRemaining < cost) {
|
||||
inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES);
|
||||
}
|
||||
inkRemaining -= cost;
|
||||
items.get(PAPER).shrink(1);
|
||||
if (items.get(PAPER).isEmpty()) items.set(PAPER, ItemStack.EMPTY);
|
||||
nextModifierSeed = rollProperties(nextModifierSeed).nextSeed();
|
||||
java.util.Arrays.fill(modifierChances, 0);
|
||||
setChanged();
|
||||
if (inkRemaining == 0) refillEmptyBasin();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override public void setLevel(Level level) {
|
||||
super.setLevel(level);
|
||||
refillEmptyBasin();
|
||||
}
|
||||
@Override public void clearContent() {
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
inkRemaining = 0;
|
||||
java.util.Arrays.fill(modifierChances, 0);
|
||||
setChanged();
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
ContainerHelper.loadAllItems(input, items);
|
||||
inkRemaining = Math.clamp(input.getIntOr("InkRemaining", 0), 0, InkVialItem.CAPACITY);
|
||||
nextModifierSeed = input.getLongOr("NextModifierSeed", nextModifierSeed);
|
||||
for (int index = 0; index < modifierChances.length; index++) {
|
||||
modifierChances[index] = Math.clamp(input.getDoubleOr(CHANCE_KEYS[index],
|
||||
input.getDoubleOr("ModifierChance" + OLD_CHANCE_INDEX[index], 0.0)), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
ContainerHelper.saveAllItems(output, items);
|
||||
output.putInt("InkRemaining", inkRemaining);
|
||||
output.putLong("NextModifierSeed", nextModifierSeed);
|
||||
for (int index = 0; index < modifierChances.length; index++)
|
||||
output.putDouble(CHANCE_KEYS[index], modifierChances[index]);
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.ContainerHelper;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
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;
|
||||
|
||||
/** Persistent, server-owned Binder inputs. */
|
||||
public final class PortBookBinderBlockEntity extends BlockEntity implements Container {
|
||||
public static final int COVER = 0;
|
||||
public static final int PANEL = 1;
|
||||
public static final int OUTPUT = 2;
|
||||
public static final int FIRST_PAGE = 3;
|
||||
public static final int PAGE_COUNT = 252;
|
||||
public static final int SIZE = FIRST_PAGE + PAGE_COUNT;
|
||||
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
private String title = "";
|
||||
|
||||
public PortBookBinderBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.BOOK_BINDER_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
|
||||
public String title() { return title; }
|
||||
|
||||
public void setTitle(String value) {
|
||||
String normalized = value == null ? "" : value.strip();
|
||||
if (normalized.length() > 32) return;
|
||||
if (!title.equals(normalized)) {
|
||||
title = normalized;
|
||||
setChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public int getContainerSize() { return SIZE; }
|
||||
@Override public boolean isEmpty() { return items.stream().allMatch(ItemStack::isEmpty); }
|
||||
@Override public void clearContent() {
|
||||
items.clear();
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
setChanged();
|
||||
}
|
||||
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||
@Override public ItemStack removeItem(int slot, int amount) {
|
||||
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||
if (!removed.isEmpty()) setChanged();
|
||||
return removed;
|
||||
}
|
||||
@Override public ItemStack removeItemNoUpdate(int slot) {
|
||||
ItemStack removed = ContainerHelper.takeItem(items, slot);
|
||||
if (!removed.isEmpty()) setChanged();
|
||||
return removed;
|
||||
}
|
||||
@Override public void setItem(int slot, ItemStack stack) {
|
||||
items.set(slot, stack);
|
||||
setChanged();
|
||||
}
|
||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||
if (slot == COVER) return stack.is(Items.LEATHER);
|
||||
if (slot == PANEL) return PageItem.isLinkPanel(stack);
|
||||
return slot >= FIRST_PAGE && slot < SIZE && validSymbolPage(stack);
|
||||
}
|
||||
private static boolean validSymbolPage(ItemStack stack) {
|
||||
return stack.is(Rivencraft.PAGE.get()) && !PageItem.isLinkPanel(stack)
|
||||
&& stack.has(Rivencraft.SYMBOL_PAGE_ID.get());
|
||||
}
|
||||
|
||||
public boolean hasSymbolPages() {
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++)
|
||||
if (!items.get(slot).isEmpty()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<Identifier> symbolPages() {
|
||||
List<Identifier> pages = new ArrayList<>();
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
Identifier symbol = items.get(slot).get(Rivencraft.SYMBOL_PAGE_ID.get());
|
||||
if (symbol != null) pages.add(symbol);
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
public void clearSymbolPages() {
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) items.set(slot, ItemStack.EMPTY);
|
||||
setChanged();
|
||||
}
|
||||
|
||||
public int nextPageIndex() {
|
||||
for (int index = PAGE_COUNT - 1; index >= 0; index--)
|
||||
if (!items.get(FIRST_PAGE + index).isEmpty()) return index + 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public boolean insertPage(int index, ItemStack page) {
|
||||
int end = nextPageIndex();
|
||||
if (index < 0 || index > end || end >= PAGE_COUNT || page.getCount() != 1
|
||||
|| !validSymbolPage(page)) return false;
|
||||
for (int current = end; current > index; current--)
|
||||
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current - 1));
|
||||
items.set(FIRST_PAGE + index, page.copy());
|
||||
setChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public ItemStack takePage(int index) {
|
||||
int end = nextPageIndex();
|
||||
if (index < 0 || index >= end) return ItemStack.EMPTY;
|
||||
ItemStack removed = items.get(FIRST_PAGE + index);
|
||||
for (int current = index; current < end - 1; current++)
|
||||
items.set(FIRST_PAGE + current, items.get(FIRST_PAGE + current + 1));
|
||||
items.set(FIRST_PAGE + end - 1, ItemStack.EMPTY);
|
||||
setChanged();
|
||||
return removed;
|
||||
}
|
||||
@Override public boolean stillValid(Player player) {
|
||||
return level != null && level.getBlockEntity(worldPosition) == this
|
||||
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||
worldPosition.getZ() + 0.5) <= 64.0;
|
||||
}
|
||||
@Override protected void saveAdditional(ValueOutput output) {
|
||||
super.saveAdditional(output);
|
||||
ContainerHelper.saveAllItems(output, items);
|
||||
output.putString("Title", title);
|
||||
}
|
||||
@Override protected void loadAdditional(ValueInput input) {
|
||||
super.loadAdditional(input);
|
||||
items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
|
||||
ContainerHelper.loadAllItems(input, items);
|
||||
title = input.getStringOr("Title", "");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package net.dnorberg.rivencraft.block.entity;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
|
||||
public final class StarFissureBlockEntity extends TheEndPortalBlockEntity {
|
||||
public StarFissureBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), pos, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.age.AgeCatalogAckPayload;
|
||||
import net.dnorberg.rivencraft.age.AgeCatalogPayload;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||
public final class AgeCatalogClient {
|
||||
private AgeCatalogClient() {
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void register(RegisterClientPayloadHandlersEvent event) {
|
||||
event.register(AgeCatalogPayload.TYPE, (payload, context) -> {
|
||||
if (!(context.listener() instanceof ClientPacketListener listener)) return;
|
||||
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
|
||||
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
|
||||
if (listener.getLevel() != null) {
|
||||
listener.getLevel().environmentAttributes().invalidateTickCache();
|
||||
listener.getLevel().updateSkyBrightness();
|
||||
}
|
||||
listener.levels = payload.dimensions().stream()
|
||||
.map(id -> ResourceKey.<Level>create(Registries.DIMENSION, id))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
context.reply(new AgeCatalogAckPayload(payload.version()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
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;
|
||||
|
||||
/** 1.21.1 Legacy atlas and mixer animation adapted to the 26.3 GUI API. */
|
||||
public final class InkMixerScreen extends AbstractContainerScreen<InkMixerMenu> {
|
||||
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "textures/gui/ink_mixer_legacy.png");
|
||||
private static final int BASIN_X = 88;
|
||||
private static final int BASIN_Y = 49;
|
||||
private static final int BASIN_RADIUS = 30;
|
||||
private static final int[] PROPERTY_COLORS = {0xFFE84848, 0xFF5555FF, 0xFF55EF55};
|
||||
private final int[] lastChances = new int[3];
|
||||
private boolean basinClickPendingRelease;
|
||||
private long lastAdditionTime;
|
||||
|
||||
public InkMixerScreen(InkMixerMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title, 176, 181);
|
||||
inventoryLabelY = 87;
|
||||
}
|
||||
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
|
||||
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.extractBackground(graphics, mouseX, mouseY, partialTick);
|
||||
int left = (width - imageWidth) / 2;
|
||||
int top = (height - imageHeight) / 2;
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left + 54, top + 16,
|
||||
179, 16, 66, 65, 256, 256);
|
||||
if (menu.inkRemaining() > 0) {
|
||||
graphics.fill(left + 54, top + 16, left + 120, top + 81, 0xFF101014);
|
||||
renderPropertyGlyph(graphics);
|
||||
}
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top,
|
||||
0, 0, imageWidth, imageHeight, 256, 256);
|
||||
}
|
||||
|
||||
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
|
||||
}
|
||||
|
||||
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
super.extractTooltip(graphics, mouseX, mouseY);
|
||||
if (overBasin(mouseX, mouseY)) {
|
||||
var lines = new java.util.ArrayList<Component>();
|
||||
lines.add(Component.translatable("screen.rivencraft.ink_mixer.ink",
|
||||
menu.inkRemaining(), InkVialItem.CAPACITY));
|
||||
lines.add(Component.translatable("screen.rivencraft.ink_mixer.additive_hint"));
|
||||
for (int index = 0; index < 3; index++) {
|
||||
int chance = menu.modifierChancePermille(index);
|
||||
if (chance > 0) lines.add(Component.translatable("screen.rivencraft.ink_mixer.chance",
|
||||
switch (index) {
|
||||
case 0 -> Component.translatable("item.rivencraft.link_property.disarm");
|
||||
case 1 -> Component.translatable("item.rivencraft.link_property.maintain_momentum");
|
||||
default -> Component.translatable("item.rivencraft.link_property.intra_linking");
|
||||
}, chance / 10.0));
|
||||
}
|
||||
graphics.setComponentTooltipForNextFrame(font, lines, mouseX, mouseY, ItemStack.EMPTY, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|
||||
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
|
||||
&& overBasin(event.x(), event.y()) && !menu.getCarried().isEmpty()) {
|
||||
minecraft.gameMode.handleContainerInput(menu.containerId, InkMixerMenu.MODIFIER,
|
||||
event.button() == InputConstants.MOUSE_BUTTON_RIGHT ? 1 : 0,
|
||||
ContainerInput.PICKUP, minecraft.player);
|
||||
basinClickPendingRelease = true;
|
||||
return true;
|
||||
}
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
@Override public boolean mouseReleased(MouseButtonEvent event) {
|
||||
if (basinClickPendingRelease) {
|
||||
basinClickPendingRelease = false;
|
||||
return true;
|
||||
}
|
||||
return super.mouseReleased(event);
|
||||
}
|
||||
|
||||
private boolean overBasin(double mouseX, double mouseY) {
|
||||
double x = mouseX - leftPos - BASIN_X;
|
||||
double y = mouseY - topPos - BASIN_Y;
|
||||
return x * x + y * y < BASIN_RADIUS * BASIN_RADIUS;
|
||||
}
|
||||
|
||||
/** A GUI-scale projection of Legacy's colored, rotating D'ni ink effect. */
|
||||
private void renderPropertyGlyph(GuiGraphicsExtractor graphics) {
|
||||
long now = System.currentTimeMillis();
|
||||
int[] active = new int[3];
|
||||
int count = 0;
|
||||
int strongest = 0;
|
||||
for (int index = 0; index < lastChances.length; index++) {
|
||||
int chance = menu.modifierChancePermille(index);
|
||||
if (chance > lastChances[index]) lastAdditionTime = now;
|
||||
lastChances[index] = chance;
|
||||
if (chance > 0) {
|
||||
active[count++] = index;
|
||||
strongest = Math.max(strongest, chance);
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
lastAdditionTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
double cycle = now / 1800.0;
|
||||
int first = active[(int) cycle % count];
|
||||
int second = active[((int) cycle + 1) % count];
|
||||
double blend = (1.0 - Math.cos((cycle % 1.0) * Math.PI)) / 2.0;
|
||||
int rgb = blendRgb(PROPERTY_COLORS[first], PROPERTY_COLORS[second], blend);
|
||||
double pulse = Math.max(0.0, 1.0 - (now - lastAdditionTime) / 700.0);
|
||||
int alpha = Math.clamp((int) (70 + strongest * 0.14 + pulse * 45), 0, 255);
|
||||
int color = alpha << 24 | rgb;
|
||||
int centerX = leftPos + BASIN_X;
|
||||
int centerY = topPos + BASIN_Y;
|
||||
double turn = now / 1200.0;
|
||||
|
||||
for (int spoke = 0; spoke < 4; spoke++) {
|
||||
for (int step = 0; step <= 30; step++) {
|
||||
double progress = step / 30.0;
|
||||
double angle = turn + spoke * Math.PI / 2.0 + progress * 0.9;
|
||||
double radius = 3.0 + progress * (15.0 + pulse * 3.0);
|
||||
plot(graphics, centerX + Math.cos(angle) * radius,
|
||||
centerY + Math.sin(angle) * radius, color);
|
||||
}
|
||||
}
|
||||
for (int step = 0; step < 96; step++) {
|
||||
double angle = step * Math.PI / 48.0 + turn * 0.3;
|
||||
plot(graphics, centerX + Math.cos(angle) * 19.0,
|
||||
centerY + Math.sin(angle) * 19.0, color);
|
||||
if (strongest >= 990 && step % 2 == 0) {
|
||||
plot(graphics, centerX + Math.cos(angle) * 23.0,
|
||||
centerY + Math.sin(angle) * 23.0, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int blendRgb(int first, int second, double amount) {
|
||||
int red = (int) (((first >> 16) & 0xFF) * (1.0 - amount)
|
||||
+ ((second >> 16) & 0xFF) * amount);
|
||||
int green = (int) (((first >> 8) & 0xFF) * (1.0 - amount)
|
||||
+ ((second >> 8) & 0xFF) * amount);
|
||||
int blue = (int) ((first & 0xFF) * (1.0 - amount) + (second & 0xFF) * amount);
|
||||
return red << 16 | green << 8 | blue;
|
||||
}
|
||||
|
||||
private static void plot(GuiGraphicsExtractor graphics, double x, double y, int color) {
|
||||
int pixelX = (int) Math.round(x);
|
||||
int pixelY = (int) Math.round(y);
|
||||
graphics.fill(pixelX, pixelY, pixelX + 1, pixelY + 1, color);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
||||
import net.neoforged.neoforge.client.event.RegisterSpecialModelRendererEvent;
|
||||
import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.packs.resources.ResourceManagerReloadListener;
|
||||
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||
public final class PortBookBinderClientEvents {
|
||||
private PortBookBinderClientEvents() {}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerBlockEntityRenderers(
|
||||
net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) {
|
||||
event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer());
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerPageRenderer(RegisterSpecialModelRendererEvent event) {
|
||||
event.register(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page"),
|
||||
PortSymbolPageRenderer.Unbaked.MAP_CODEC);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerPageTextureReload(AddClientReloadListenersEvent event) {
|
||||
event.addListener(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "symbol_page_textures"),
|
||||
(ResourceManagerReloadListener) manager -> PortSymbolPageTextures.clear());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.InputConstants;
|
||||
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.network.AssembleBinderPayload;
|
||||
import net.dnorberg.rivencraft.network.PortBookNetworking;
|
||||
import net.dnorberg.rivencraft.network.SetBinderTitlePayload;
|
||||
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
import net.minecraft.client.input.KeyEvent;
|
||||
import net.minecraft.client.input.MouseButtonEvent;
|
||||
import net.minecraft.client.renderer.RenderPipelines;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
|
||||
|
||||
/** Legacy-sized Binder with a page strip and single-click book assembly. */
|
||||
public final class PortBookBinderScreen extends AbstractContainerScreen<PortBookBinderMenu> {
|
||||
private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/gui/book_binder_legacy.png");
|
||||
private static final Identifier PAGE_ATLAS = Identifier.fromNamespaceAndPath(
|
||||
"rivencraft", "textures/gui/notebook_pages.png");
|
||||
private static final int PAGE_Y = 48;
|
||||
private static final int PAGE_WIDTH = 26;
|
||||
private static final int PAGE_HEIGHT = 34;
|
||||
private static final int PAGE_STEP = 28;
|
||||
private static final int VISIBLE_PAGES = 5;
|
||||
private static final int STRIP_X = 7;
|
||||
private static final int STRIP_Y = 45;
|
||||
private static final int STRIP_WIDTH = 162;
|
||||
private static final int STRIP_HEIGHT = 40;
|
||||
private static final int MISSING_PANEL_X = 27;
|
||||
private static final int MISSING_PANEL_Y = 26;
|
||||
private static final int MISSING_PANEL_SIZE = 16;
|
||||
private static final ItemStack MISSING_PANEL_ICON = PageItem.createLinkPanelPage();
|
||||
private EditBox titleInput;
|
||||
private boolean stripClickPendingRelease;
|
||||
private int firstVisible;
|
||||
|
||||
public PortBookBinderScreen(PortBookBinderMenu menu, Inventory inventory, Component title) {
|
||||
super(menu, inventory, title, 176, 181);
|
||||
inventoryLabelY = 87;
|
||||
}
|
||||
|
||||
@Override public boolean isPauseScreen() { return false; }
|
||||
|
||||
@Override protected void init() {
|
||||
super.init();
|
||||
int left = (width - imageWidth) / 2;
|
||||
int top = (height - imageHeight) / 2;
|
||||
titleInput = new EditBox(font, left + 9, top + 10, 112, 12, Component.literal("Age name"));
|
||||
titleInput.setBordered(false);
|
||||
titleInput.setTextColor(0xFFFFFFFF);
|
||||
titleInput.setMaxLength(PortBookNetworking.MAX_BOOK_TITLE_LENGTH);
|
||||
titleInput.setValue(menu.title());
|
||||
titleInput.setHint(Component.literal("Name this Age"));
|
||||
titleInput.setResponder(value -> ClientPacketDistributor.sendToServer(
|
||||
new SetBinderTitlePayload(menu.containerId, value)));
|
||||
addRenderableWidget(titleInput);
|
||||
setInitialFocus(titleInput);
|
||||
}
|
||||
|
||||
@Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.extractBackground(graphics, mouseX, mouseY, partialTick);
|
||||
int left = (width - imageWidth) / 2;
|
||||
int top = (height - imageHeight) / 2;
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, left, top, 0, 0,
|
||||
imageWidth, imageHeight, 256, 256);
|
||||
graphics.fill(left + 7, top + 9, left + 123, top + 23, 0xFF000000);
|
||||
firstVisible = Math.min(firstVisible, maxFirstVisible());
|
||||
int visibleCount = Math.min(VISIBLE_PAGES, visiblePageCount() - firstVisible);
|
||||
for (int visible = 0; visible < visibleCount; visible++) {
|
||||
int x = left + 16 + visible * PAGE_STEP;
|
||||
graphics.blit(RenderPipelines.GUI_TEXTURED, PAGE_ATLAS,
|
||||
x, top + PAGE_Y, 156, 0, PAGE_WIDTH, PAGE_HEIGHT, 256, 256);
|
||||
}
|
||||
// GUI extraction batches textures within a stratum. The glyphs need a
|
||||
// later stratum so another atlas page cannot cover their transparent quad.
|
||||
graphics.nextStratum();
|
||||
for (int visible = 0; visible < visibleCount; visible++) {
|
||||
int index = firstVisible + visible;
|
||||
int x = left + 16 + visible * PAGE_STEP;
|
||||
int y = top + PAGE_Y;
|
||||
ItemStack page = pageAt(index);
|
||||
if (page.isEmpty()) {
|
||||
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0xAA000000);
|
||||
} else if (PageItem.isLinkPanel(page)) {
|
||||
graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
|
||||
} else {
|
||||
if (!PortAuthoredWriting.isPageImplemented(page.get(Rivencraft.SYMBOL_PAGE_ID.get())))
|
||||
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x88FF3333);
|
||||
// Render the full-size transparent glyph through the working
|
||||
// item pipeline, without its small parchment-backed icon.
|
||||
ItemStack glyphOnly = page.copy();
|
||||
glyphOnly.set(Rivencraft.PAGE_GLYPH_ONLY.get(), true);
|
||||
graphics.pose().pushMatrix();
|
||||
graphics.pose().translate(x + 1, y + 5);
|
||||
graphics.pose().scale(1.5F, 1.5F);
|
||||
graphics.fakeItem(glyphOnly, 0, 0);
|
||||
graphics.pose().popMatrix();
|
||||
}
|
||||
}
|
||||
graphics.fill(left + STRIP_X, top + STRIP_Y, left + STRIP_X + 8,
|
||||
top + STRIP_Y + STRIP_HEIGHT, firstVisible == 0 ? 0x33000000 : 0xAA000000);
|
||||
graphics.fill(left + STRIP_X + STRIP_WIDTH - 8, top + STRIP_Y,
|
||||
left + STRIP_X + STRIP_WIDTH, top + STRIP_Y + STRIP_HEIGHT,
|
||||
firstVisible >= maxFirstVisible() ? 0x33000000 : 0xAA000000);
|
||||
boolean panelPresent = !menu.getSlot(PortBookBinderBlockEntity.PANEL).getItem().isEmpty();
|
||||
if (titleInput == null || titleInput.getValue().isBlank()) {
|
||||
graphics.fill(left + 7, top + 9, left + 123, top + 10, 0xFFFF3030);
|
||||
graphics.fill(left + 7, top + 22, left + 123, top + 23, 0xFFFF3030);
|
||||
}
|
||||
if (!panelPresent) {
|
||||
graphics.fakeItem(MISSING_PANEL_ICON, left + MISSING_PANEL_X, top + MISSING_PANEL_Y);
|
||||
float phase = (System.currentTimeMillis() % 4000L) / 2000.0F;
|
||||
float fade = phase > 1.0F ? 2.0F - phase : phase;
|
||||
int alpha = 40 + (int) (125 * fade);
|
||||
graphics.fill(left + MISSING_PANEL_X, top + MISSING_PANEL_Y,
|
||||
left + MISSING_PANEL_X + MISSING_PANEL_SIZE,
|
||||
top + MISSING_PANEL_Y + MISSING_PANEL_SIZE,
|
||||
(alpha << 24) | 0x00B02020);
|
||||
}
|
||||
if (hasUnsupportedPages()) graphics.text(font,
|
||||
Component.translatable("screen.rivencraft.book_binder.authored_pending"),
|
||||
left + 75, top + 31, 0xFFB84B42, false);
|
||||
if (!hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty() && !menu.getSlot(0).getItem().isEmpty()
|
||||
&& !menu.getSlot(1).getItem().isEmpty() && titleInput != null
|
||||
&& !titleInput.getValue().isBlank()) {
|
||||
graphics.fakeItem(new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()), left + 152, top + 27);
|
||||
}
|
||||
}
|
||||
|
||||
@Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false);
|
||||
}
|
||||
|
||||
@Override protected void extractTooltip(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
|
||||
super.extractTooltip(graphics, mouseX, mouseY);
|
||||
int x = mouseX - leftPos;
|
||||
int y = mouseY - topPos;
|
||||
if (x >= MISSING_PANEL_X && x < MISSING_PANEL_X + MISSING_PANEL_SIZE
|
||||
&& y >= MISSING_PANEL_Y && y < MISSING_PANEL_Y + MISSING_PANEL_SIZE
|
||||
&& menu.getSlot(1).getItem().isEmpty()) {
|
||||
graphics.setTooltipForNextFrame(font,
|
||||
Component.translatable("screen.rivencraft.book_binder.missing_panel"), mouseX, mouseY);
|
||||
} else {
|
||||
int index = pageIndexAt(mouseX, mouseY);
|
||||
if (index >= 0 && !pageAt(index).isEmpty())
|
||||
graphics.setTooltipForNextFrame(font, pageAt(index), mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
private ItemStack pageAt(int index) {
|
||||
return menu.getSlot(index == 0 ? PortBookBinderBlockEntity.PANEL
|
||||
: PortBookBinderBlockEntity.FIRST_PAGE + index - 1).getItem();
|
||||
}
|
||||
|
||||
private int displayedPages() {
|
||||
for (int index = PortBookBinderBlockEntity.PAGE_COUNT; index >= 1; index--)
|
||||
if (!pageAt(index).isEmpty()) return index + 1;
|
||||
return pageAt(0).isEmpty() ? 0 : 1;
|
||||
}
|
||||
|
||||
private boolean hasUnsupportedPages() {
|
||||
java.util.List<Identifier> pages = new java.util.ArrayList<>();
|
||||
for (int index = 1; index <= PortBookBinderBlockEntity.PAGE_COUNT; index++) {
|
||||
Identifier id = pageAt(index).get(Rivencraft.SYMBOL_PAGE_ID.get());
|
||||
if (id != null) pages.add(id);
|
||||
}
|
||||
return !PortAuthoredWriting.supports(pages);
|
||||
}
|
||||
|
||||
private int visiblePageCount() {
|
||||
return Math.min(PortBookBinderBlockEntity.PAGE_COUNT + 1,
|
||||
displayedPages() + (menu.getCarried().isEmpty() ? 0 : 1));
|
||||
}
|
||||
|
||||
private int maxFirstVisible() { return Math.max(0, visiblePageCount() - VISIBLE_PAGES); }
|
||||
|
||||
private int pageIndexAt(double mouseX, double mouseY) {
|
||||
int x = (int) mouseX - leftPos;
|
||||
int y = (int) mouseY - topPos;
|
||||
if (x < 16 || x >= STRIP_X + STRIP_WIDTH - 8 || y < STRIP_Y || y >= STRIP_Y + STRIP_HEIGHT)
|
||||
return -1;
|
||||
int visible = (x - 16) / PAGE_STEP;
|
||||
return visible < VISIBLE_PAGES ? firstVisible + visible : -1;
|
||||
}
|
||||
|
||||
@Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
||||
int left = (width - imageWidth) / 2;
|
||||
int top = (height - imageHeight) / 2;
|
||||
if ((event.button() == InputConstants.MOUSE_BUTTON_LEFT
|
||||
|| event.button() == InputConstants.MOUSE_BUTTON_RIGHT)
|
||||
&& event.x() >= left + STRIP_X && event.x() < left + STRIP_X + STRIP_WIDTH
|
||||
&& event.y() >= top + STRIP_Y && event.y() < top + STRIP_Y + STRIP_HEIGHT) {
|
||||
stripClickPendingRelease = true;
|
||||
if (event.x() < left + STRIP_X + 8) firstVisible = Math.max(0, firstVisible - 1);
|
||||
else if (event.x() >= left + STRIP_X + STRIP_WIDTH - 8)
|
||||
firstVisible = Math.min(maxFirstVisible(), firstVisible + 1);
|
||||
else {
|
||||
int index = pageIndexAt(event.x(), event.y());
|
||||
if (index >= 0 && index <= displayedPages()
|
||||
&& (index < displayedPages() || !menu.getCarried().isEmpty()))
|
||||
minecraft.gameMode.handleInventoryButtonClick(menu.containerId,
|
||||
PortBookBinderMenu.STRIP_CLICK_BASE + index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT
|
||||
&& event.x() >= left + 151 && event.x() < left + 170
|
||||
&& event.y() >= top + 26 && event.y() < top + 45
|
||||
&& !hasUnsupportedPages() && menu.getSlot(2).getItem().isEmpty()
|
||||
&& menu.getCarried().isEmpty()) {
|
||||
String name = titleInput.getValue().strip();
|
||||
if (!name.isEmpty()) ClientPacketDistributor.sendToServer(new AssembleBinderPayload(name));
|
||||
return true;
|
||||
}
|
||||
return super.mouseClicked(event, doubleClick);
|
||||
}
|
||||
|
||||
@Override public boolean mouseReleased(MouseButtonEvent event) {
|
||||
if (stripClickPendingRelease) {
|
||||
stripClickPendingRelease = false;
|
||||
return true;
|
||||
}
|
||||
return super.mouseReleased(event);
|
||||
}
|
||||
|
||||
@Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||
if (mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + STRIP_WIDTH
|
||||
&& mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||
firstVisible = Math.clamp(firstVisible - (int) Math.signum(scrollY), 0, maxFirstVisible());
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
|
||||
}
|
||||
|
||||
@Override public boolean keyPressed(KeyEvent event) {
|
||||
if (titleInput != null && titleInput.isFocused()) {
|
||||
if (event.isEscape()) {
|
||||
setFocused(null);
|
||||
titleInput.setFocused(false);
|
||||
} else {
|
||||
titleInput.keyPressed(event);
|
||||
}
|
||||
// The character event inserts text. Never forward a focused key
|
||||
// to the container's inventory/hotbar shortcuts.
|
||||
return true;
|
||||
}
|
||||
return super.keyPressed(event);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package net.dnorberg.rivencraft.client;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
|
||||
|
||||
/** Opens screens only on the physical client, including integrated singleplayer. */
|
||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||
public final class PortBookInteractionHandler {
|
||||
private PortBookInteractionHandler() {
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRightClickItem(PlayerInteractEvent.RightClickItem event) {
|
||||
if (!event.getEntity().level().isClientSide()) return;
|
||||
boolean linking = event.getItemStack().is(Rivencraft.LINKING_BOOK.get());
|
||||
boolean descriptive = event.getItemStack().is(Rivencraft.DESCRIPTIVE_BOOK.get());
|
||||
if (!linking && !descriptive) return;
|
||||
event.setCanceled(true);
|
||||
event.setCancellationResult(InteractionResult.SUCCESS);
|
||||
Minecraft.getInstance().setScreenAndShow(linking
|
||||
? new PortLinkingBookScreen(event.getHand(), event.getItemStack().copy())
|
||||
: new PortDescriptiveBookScreen(event.getHand(), event.getItemStack().copy()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user