Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98595c83ea | ||
|
|
e7e2cf7e5a | ||
|
|
77f15f8599 | ||
|
|
2b2605b277 | ||
|
|
2f1e6bd6f2 | ||
|
|
9d5ff2a2cb | ||
|
|
3ac4c499ab | ||
|
|
9044835ec5 | ||
|
|
7ae41da23e | ||
|
|
9cb63e6994 | ||
|
|
6337b5aa10 | ||
|
|
b9faa28dd5 | ||
|
|
09d2b0800c | ||
|
|
ee905cab68 | ||
|
|
5c08f391a2 | ||
|
|
b8d26a9036 | ||
|
|
d0af75239a | ||
|
|
2e5d6e0427 | ||
|
|
54c10cca6c | ||
|
|
c39ebd78ba | ||
|
|
1ff62e9aaa | ||
|
|
b154455563 |
No files matched your search
@@ -1,253 +1,114 @@
|
|||||||
# Rivencraft
|
# Rivencraft
|
||||||
|
|
||||||
Rivencraft is a clean, incremental port of the ideas and gameplay of
|
Write a book. Name an Age. Step into a world that did not exist before.
|
||||||
[Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft
|
|
||||||
1.21.1 on NeoForge.
|
|
||||||
|
|
||||||
The bootstrap, books/linking, Ages, writing-system, and Age-grammar milestones are complete.
|
Rivencraft brings the books, writing stations, symbols, and unpredictable Ages
|
||||||
The writing system uses a data-pack symbol catalog and persistent
|
of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to
|
||||||
ordered-writing model. Linking Books and Descriptive Books create persistent
|
Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing
|
||||||
Ages from either authored supported symbols or seeded random completion.
|
flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft
|
||||||
Milestone 6 instability work is in progress: Ages persist frozen instability
|
also lets you author modern terrain, biomes, structures, seasons, and celestial
|
||||||
scores and provider plans, and the bounded runtime implements status effects,
|
bodies. It is an active pre-public-release project, not a byte-for-byte copy of
|
||||||
colored decay, lightning, explosions, meteors, burning, and erosion. The
|
the 1.12.2 mod.
|
||||||
[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.
|
|
||||||
|
|
||||||
Milestone 4 now includes stackable blank Pages plus server-created symbol,
|
## Getting started
|
||||||
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.
|
|
||||||
|
|
||||||
## Book 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.
|
||||||
|
|
||||||
For a new survival Age: craft an Ink Vial, an Ink Mixer, a Writing Desk, and a Book Binder.
|
Find new Symbol Pages in Age libraries, or copy pages you already own with
|
||||||
Put vanilla paper and an Ink Vial in the Ink Mixer to make a Link Panel
|
paper and ink at the Writing Desk. The Notebook stores ordered pages, including
|
||||||
Page. Place leather (or an empty Notebook) and the Link Panel in the Book Binder,
|
intentional gaps. Written pages can control terrain, biome distribution,
|
||||||
enter an Age name, and take the finished Descriptive Book. Written symbol Pages
|
weather, lighting, seasons, sky color, suns and moons, decorations, structures,
|
||||||
are optional; without them, the book creates a random Age. To duplicate a Page
|
and more. Incorrect or excessive writing can make an Age unstable; instability
|
||||||
you already own, store it in a Writing Desk tab, select it, supply paper and ink,
|
may bring decay, lightning, explosions, meteors, burning, erosion, and other
|
||||||
and take the copied Page from the output. A Notebook can hold and transfer a
|
hazards. Some combinations are still unsupported and the Binder reports them
|
||||||
larger ordered collection of Pages between desks and the binder.
|
before consuming ingredients.
|
||||||
|
|
||||||
With JEI installed, Rivencraft displays the Ink Mixer operation in its own
|
Place a Rivencraft book or page on a vanilla Lectern to convert it to a Book
|
||||||
category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category.
|
Stand. Crystal formations yield shards for Crystal Blocks. A connected crystal
|
||||||
The Link Panel and individual symbol Pages, including registered vanilla biome
|
frame with a Book Receptacle and a bound book forms a portal; frames can stand
|
||||||
pages, appear as separately searchable JEI ingredients.
|
upright or lie flat. Libraries, Book Stands, and portals use Legacy-inspired
|
||||||
Creative inventory separates Link Pages and each symbol category into their own
|
assets and sounds. Handheld travel drops the used book at the departure point,
|
||||||
tabs, including distinct Biome and Biome Generation tabs. Blank Pages are hidden
|
so plan how you will retrieve it.
|
||||||
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`.
|
|
||||||
|
|
||||||
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
## Requirements and optional integrations
|
||||||
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.
|
|
||||||
|
|
||||||
The completed book receives its Age identity when assembled. With only a Link
|
Install the same gameplay mods on the client and server:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Rivencraft works without any of these optional integrations:
|
||||||
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.
|
|
||||||
|
|
||||||
Successful book travel plays Mystcraft Legacy's original linking sound at both
|
| Mod | What it adds |
|
||||||
the departure and arrival locations.
|
| --- | --- |
|
||||||
|
| 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
|
The optional builds used in the latest development checks are JEI
|
||||||
a Book Stand and place the book on it. Use a Linking Book on the stand by
|
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
|
||||||
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
|
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
|
||||||
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
|
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
|
||||||
the stored book and a vanilla lectern.
|
evidence and limits of those combinations.
|
||||||
|
|
||||||
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
|
Without Create or Immersive Portals, book previews use Rivencraft's built-in
|
||||||
Without Create, they use a built-in animated linking panel tinted by the
|
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
|
||||||
destination sky. The server independently validates every link.
|
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
|
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
|
||||||
assigns the book a permanent Age UUID, selects a deterministic base terrain,
|
before changing mod versions, data packs, or optional biome mods: the Age
|
||||||
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
|
registry and dimension data must be restored together. Rivencraft preserves
|
||||||
arrival platform at the
|
known older save formats where possible, but pre-public-release worlds are not
|
||||||
highest suitable nearby terrain. Solid blocks and tree leaves are valid
|
promised universal compatibility. Missing custom symbols retain their IDs in
|
||||||
footing. Candidate columns are scanned from the top down so cave floors are not
|
books; missing registry entries or dimension providers can still prevent an
|
||||||
selected, and fluid-covered columns are excluded. If no suitable surface is
|
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
|
||||||
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
|
for supported behavior and deliberate limits.
|
||||||
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.
|
|
||||||
|
|
||||||
New Age dimension folders use a Minecraft-safe lowercase form of the assigned
|
Server settings include `descriptive_books.return_link_mode` (default
|
||||||
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
|
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
|
||||||
Age folders are never renamed.
|
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
|
## Project and development
|
||||||
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.
|
|
||||||
|
|
||||||
Linking creates a bound Return Link to the exact source position and orientation
|
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
|
||||||
according to the configured Return Link policy.
|
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
|
||||||
|
and [issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues).
|
||||||
|
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
|
```sh
|
||||||
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
|
|
||||||
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.251 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
|
|
||||||
./gradlew build
|
./gradlew build
|
||||||
./gradlew runClient
|
./gradlew runGameTestServer
|
||||||
```
|
```
|
||||||
|
|
||||||
The built mod is written to `build/libs/`.
|
The JAR is written to `build/libs/`. Development runs need the required mods;
|
||||||
The default dedicated-server GameTest run is deliberately small. Use
|
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
|
||||||
`./gradlew runGameTestServer -PwithArchivedGameTests=true` for the complete
|
Versions use `x.y.z.b`: major release, completed milestone, release slice,
|
||||||
regression suite before a release or when investigating a related issue. See
|
then fixes/additional work within that slice.
|
||||||
[docs/GAMETESTS.md](docs/GAMETESTS.md).
|
|
||||||
|
|
||||||
## Versioning
|
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
|
||||||
|
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
|
||||||
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).
|
|
||||||
@@ -132,6 +132,8 @@ neoForge {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
server()
|
server()
|
||||||
|
// Keep dedicated compatibility runs away from the normal dev world.
|
||||||
|
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
|
||||||
programArgument '--nogui'
|
programArgument '--nogui'
|
||||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
}
|
}
|
||||||
@@ -243,6 +245,11 @@ dependencies {
|
|||||||
// Keep them on the compile classpath without forcing either mod at runtime.
|
// Keep them on the compile classpath without forcing either mod at runtime.
|
||||||
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
|
compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}"
|
||||||
compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_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.
|
// TerraBlender's region API is only used by the guarded compat adapter.
|
||||||
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}"
|
||||||
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
|
if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 43 KiB |
+13
-13
@@ -11,7 +11,7 @@ suffixes such as `_2` and `_3`.
|
|||||||
|
|
||||||
The selected terrain profile, biome distribution, lighting, weather, celestial
|
The selected terrain profile, biome distribution, lighting, weather, celestial
|
||||||
period, celestial angle, and phase are deterministic and become part of the
|
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
|
prepared at a safe arrival point. The completed book and every copy carrying
|
||||||
the same UUID resolve to that definition.
|
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
|
definition and world folder, so existing books continue to reference the same
|
||||||
Age and later reload the same terrain.
|
Age and later reload the same terrain.
|
||||||
|
|
||||||
Rivencraft does not automatically delete Ages. Safe permanent deletion needs a
|
Rivencraft does not automatically delete Ages. The operator-only creative Link
|
||||||
complete reference index for books in inventories, containers, Book Stands,
|
Modifier can deliberately retire an unoccupied Age: it unloads the dimension,
|
||||||
and unloaded chunks. Until that exists, retaining the definition prevents an
|
moves its data to a sibling `<world>-retired-ages/<uuid>` archive, and records
|
||||||
administrator action from silently orphaning books.
|
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
|
Destination-preview chunk loaders are temporary. Rivencraft releases them when
|
||||||
the book closes, the player travels, the player disconnects, the destination
|
the book closes, the player travels, the player disconnects, the destination
|
||||||
@@ -69,10 +70,9 @@ definition.
|
|||||||
Rivencraft writes gameplay settings to the world/server configuration managed
|
Rivencraft writes gameplay settings to the world/server configuration managed
|
||||||
by NeoForge. `descriptive_books.return_link_mode` accepts:
|
by NeoForge. `descriptive_books.return_link_mode` accepts:
|
||||||
|
|
||||||
- `ON_CREATION` (default): create one Return Link the first time a player
|
- `DISABLED` (default): never create Return Links automatically.
|
||||||
enters each Age.
|
- `ON_CREATION`: create one Return Link the first time a player enters each Age.
|
||||||
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
|
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
|
||||||
- `DISABLED`: never create Return Links automatically.
|
|
||||||
|
|
||||||
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
|
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
|
||||||
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
|
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
|
||||||
@@ -102,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
|
matching dimension data and authoritative world saved data from the same
|
||||||
backup generation. Do not combine them from different backups.
|
backup generation. Do not combine them from different backups.
|
||||||
|
|
||||||
There is intentionally no delete command in Milestone 3. Do not manually
|
Do not manually delete an Age definition or rename its dimension folder:
|
||||||
delete an Age definition or rename its dimension folder: books may exist in
|
books may exist in inventories, containers, Book Stands, or unloaded chunks.
|
||||||
inventories, containers, Book Stands, or unloaded chunks, and would be
|
`/rivencraft age unload` remains the reversible maintenance operation. The
|
||||||
orphaned. `/rivencraft age unload` is the supported reversible maintenance
|
Link Modifier's retirement control is not reversible through a book UI;
|
||||||
operation.
|
recover its archived data and registry together from a full-world backup.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Age arrival cloud platform
|
# Age arrival platform
|
||||||
|
|
||||||
Newly created Ages now place a 5×5 solid cloud platform instead of cobblestone at their saved spawn. The block has full collision, passes skylight, renders translucently, has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform; this change does not overwrite a player's saved world.
|
Newly created Ages place a 5×5 solid platform instead of cobblestone at their saved spawn. The block has full collision, renders as an opaque full block, breaks as easily as glass (0.3 hardness), has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform blocks, which adopt the new appearance and hardness without altering saved terrain.
|
||||||
|
|
||||||
The block texture is `assets/rivencraft/textures/block/cloud_platform.png`, verified at exactly 16×16 RGBA. Legacy's supplied assets contain no cloud-block texture, so this one was generated with the built-in image tool, then reduced by nearest-neighbor sampling. The final prompt requested a seamless Minecraft block-face pixel-art tile with 2–3 chunky white, icy-blue, and powder-blue cloud swirls, genuine transparent holes between them, strong 16×16-scale contrast, and no background or text.
|
The model reuses `assets/rivencraft/textures/block/crystal_block.png`, the 16×16 Legacy crystal tile. The former generated cloud texture is removed. The internal block ID remains `rivencraft:cloud_platform` to preserve saved worlds.
|
||||||
|
|
||||||
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
|
The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material.
|
||||||
@@ -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
|
Every family retains a safe cobblestone arrival platform. An omitted terrain
|
||||||
page still uses the deterministic seed-selected profile.
|
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
|
The separate **Floating Islands** feature page adds rare high-altitude island
|
||||||
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
|
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
|
not replace the terrain page. The feature is seeded by the Age and saved with the written
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
|||||||
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
|
| `assets/rivencraft/textures/block/book_binder_press.png` | `assets/mystcraft/textures/entity/bookbinder/press_part.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder model uses this press texture |
|
||||||
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
|
| `assets/rivencraft/textures/gui/book_binder_legacy.png` | `assets/mystcraft/gui/pagebinder.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Book Binder GUI uses the original 176×181 panel |
|
||||||
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
|
| `assets/rivencraft/textures/gui/ink_mixer_legacy.png` | `assets/mystcraft/gui/inkmixer.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Ink Mixer GUI uses the original 176×181 panel |
|
||||||
| `assets/rivencraft/textures/block/ink_mixer_top.png`, `ink_mixer_side.png`, `ink_mixer_bottom.png` | `assets/mystcraft/textures/blocks/inkmixer_top.png`, `inkmixer_side.png`, `inkmixer_bottom.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only; Rivencraft supplies its own block model |
|
| `assets/rivencraft/textures/block/inkwell_base.png`, `inkwell_cup.png`, `ink.png`, `ink_glass.png` | `assets/mystcraft/textures/entity/inkwell/` counterparts | Legacy 0.13.7.06 local extraction | Byte-identical copies used by the ported Ink Mixer geometry; the paper overlay uses vanilla white concrete instead of Legacy's black surface |
|
||||||
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
| `assets/rivencraft/textures/item/linking_book_unwritten.png` | `assets/mystcraft/textures/items/unlinked.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Namespace/path rename only |
|
||||||
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
|
| `assets/rivencraft/textures/item/page_link_panel.png` | `page_background.png` and `PageBuilder.BasicPageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Reproduces the legacy 160 px scale, black panel at `(25,30)`–`(135,75)`, and 128 px resample |
|
||||||
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
|
| `assets/rivencraft/textures/gui/symbolcomponents.png` | `assets/mystcraft/textures/symbolcomponents.png` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Byte-identical shared 512×512 component sheet for client-side page composition |
|
||||||
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
|
| Runtime-composed symbol pages (`SymbolPageTextures`) | `page_background.png`, `textures/symbolcomponents.png`, `WordData`, `ModSymbols`, and `PageBuilder.PageSprite` | `9bc8ddc061845df0cd5ea47f4fdef773cff786ae` | Replaces the six pre-generated symbol PNGs; layers four poem words on the shared page background and caches bounded client textures |
|
||||||
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block and Budding Crystal models after removing a byte-identical duplicate |
|
| `assets/rivencraft/textures/block/crystal_block.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile shared by Crystal Block, Budding Crystal, and the non-dropping Age arrival platform; its former cloud sprite is removed |
|
||||||
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
|
| `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) |
|
||||||
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
|
| `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 |
|
||||||
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
|
| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home |
|
||||||
@@ -32,6 +32,8 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license.
|
|||||||
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
|
| `assets/rivencraft/textures/block/book_receptacle.png` | `assets/mystcraft/textures/blocks/book_receptacle.png` | Legacy 0.13.7.06 local extraction | Byte-identical 16×16 face tile; modern block model recreates the Legacy thin slab |
|
||||||
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
|
| `assets/rivencraft/textures/block/crystal_portal.png` and `.mcmeta` | `assets/mystcraft/textures/blocks/portal.png` and `.mcmeta` | Legacy 0.13.7.06 local extraction | Byte-identical texture and line-ending-normalized animation metadata, used when Immersive Portals is absent |
|
||||||
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
|
| `assets/rivencraft/sounds/linking/link-portal.ogg` | `assets/mystcraft/sounds/linking/link-portal.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical portal-link sound |
|
||||||
|
| `assets/rivencraft/sounds/linking/link-disarm.ogg` | `assets/mystcraft/sounds/linking/link-disarm.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical disarm-link sound; takes precedence over the portal sound as in Legacy |
|
||||||
|
| `assets/rivencraft/sounds/linking/link-intra.ogg` | `assets/mystcraft/sounds/linking/link-intra.ogg` | Legacy 0.13.7.06 local extraction | Byte-identical intra-link sound for Linking Books |
|
||||||
|
|
||||||
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
|
Mystcraft Legacy identifies XCompWiz as its project developer and includes the
|
||||||
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
|
full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE`
|
||||||
|
|||||||
@@ -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
|
# 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
|
The default dedicated-server run is a small smoke suite. Its Rivencraft tests
|
||||||
cover core linking, deterministic Age definition, writing persistence, legacy
|
cover core linking, deterministic Age definition, writing persistence, legacy
|
||||||
data migration, workstation transactions, and forged/replayed menu actions.
|
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
|
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
|
||||||
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
|
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
|
||||||
with their property chances. Black Dye and Clay Ball should not appear: the
|
with their property chances. Black Dye and Clay Ball should not appear: the
|
||||||
former had no working Legacy effect, while Generate Platform is redundant
|
former had no working Legacy effect, while Clay Ball remains unsupported by
|
||||||
with default Age arrival. Without JEI, Rivencraft should still
|
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.
|
start and the mixer should work normally.
|
||||||
|
|
||||||
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
|
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.
|
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.
|
||||||
@@ -46,13 +46,13 @@ For a manual check, create a fresh surface Age and run `/rivencraft library near
|
|||||||
|
|
||||||
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
|
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
|
||||||
|
|
||||||
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
|
Legacy's travel path creates no automatic Return Link. Rivencraft now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
|
||||||
|
|
||||||
## Crafting, sounds, and progression
|
## Crafting, sounds, and progression
|
||||||
|
|
||||||
| Area | Legacy evidence and modern boundary |
|
| Area | Legacy evidence and modern boundary |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. |
|
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. |
|
||||||
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
|
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
|
||||||
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
|
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Milestone 7 advancements and discovery (#81)
|
||||||
|
|
||||||
|
Rivencraft's advancement tab follows the visible progression in Mystcraft
|
||||||
|
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
|
||||||
|
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
|
||||||
|
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
|
||||||
|
without requiring any assets from that game. The Legacy hidden Quinn trigger
|
||||||
|
has no established Rivencraft action and is deliberately omitted.
|
||||||
|
|
||||||
|
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
|
||||||
|
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
|
||||||
|
use an impossible criterion awarded only after the server commits the
|
||||||
|
corresponding action. The Age-travel award requires a successful book or portal
|
||||||
|
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
|
||||||
|
destination preview, viewing a recipe, or merely loading an Age cannot award
|
||||||
|
it. Progress is stored by Minecraft per player and survives world restarts.
|
||||||
|
|
||||||
|
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
|
||||||
|
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
|
||||||
|
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
|
||||||
|
crafting recipe remains a special recipe: its Link Panel requirement and the
|
||||||
|
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
|
||||||
|
optional JEI integration. No recipe or advancement requires JEI, Create, or
|
||||||
|
Immersive Portals to be installed.
|
||||||
|
|
||||||
|
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
|
||||||
|
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
|
||||||
|
enter its Age; activate a Crystal Portal. Confirm each advancement appears
|
||||||
|
only after the completed action, not from an output preview or GUI opening.
|
||||||
|
Repeat the actions with two players to confirm only the actor receives each
|
||||||
|
award. Restart an existing save and confirm completed progress remains, then
|
||||||
|
complete one previously unearned milestone. With JEI absent, the vanilla
|
||||||
|
recipe book should still reveal standard crafting recipes as their ingredients
|
||||||
|
are discovered; with JEI installed, the special writing guides should appear.
|
||||||
|
|
||||||
|
The automated Book Binder output-award scenario stalls in the GameTest storage
|
||||||
|
runner, so it is intentionally omitted under the project test policy. For its
|
||||||
|
manual check, open a ready Binder, observe that merely previewing the finished
|
||||||
|
book does not award *A Book of Ages*, then take the book and confirm the award.
|
||||||
|
Repeat by shift-clicking the result in a fresh Binder.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Milestone 7 compatibility review (#82)
|
||||||
|
|
||||||
|
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
|
||||||
|
NeoForge 21.1.264 and advertises that as its minimum. The official
|
||||||
|
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
|
||||||
|
lists 21.1.264 as the newest 21.1 release. Its
|
||||||
|
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
|
||||||
|
includes received split-packet size checks (21.1.260), network setup payload
|
||||||
|
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
|
||||||
|
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
|
||||||
|
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
|
||||||
|
its optional integrations work on that loader.
|
||||||
|
|
||||||
|
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
|
||||||
|
succeeded, but its GameTest server repeated its storage-progress display
|
||||||
|
without producing test results. That run was stopped; it is neither a pass nor
|
||||||
|
evidence of a NeoForge defect. The player subsequently passed an in-game
|
||||||
|
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
|
||||||
|
server test including Immersive Portals travel to a Rivencraft Age. The
|
||||||
|
dedicated server also reached the ready state with only the required mods
|
||||||
|
before the full optional stack was installed. Both modes used Rivencraft
|
||||||
|
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
|
||||||
|
release V0.6.6.0.
|
||||||
|
|
||||||
|
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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 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 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. |
|
||||||
|
|
||||||
|
The Modrinth version comparisons above were filtered to **NeoForge and
|
||||||
|
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
|
||||||
|
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
|
||||||
|
separate library-artifact comparison came from Create's Maven. Dynamic
|
||||||
|
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
|
||||||
|
similarly named mods were excluded.
|
||||||
|
|
||||||
|
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
|
||||||
|
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
|
||||||
|
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
|
||||||
|
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
|
||||||
|
approved 21.1.264 after that test. This does not independently certify every
|
||||||
|
optional combination, the LEGACY preview fallback on a client without Create
|
||||||
|
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
|
||||||
|
regression checklist. Serene's GameTest mock-player network limitation still
|
||||||
|
requires real-client checks for seasonal behavior.
|
||||||
@@ -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,63 @@
|
|||||||
|
# Milestone 7 visual and audio polish (#80)
|
||||||
|
|
||||||
|
The source is the local Mystcraft 0.13.7.06 extraction under
|
||||||
|
`/home/dnorberg/Documents/mystcraft-src/mystcraft-1.12.2-0.13.7.06/`.
|
||||||
|
Reused textures and OGG files are recorded in [asset provenance](ASSET_PROVENANCE.md).
|
||||||
|
The `dev images/book binder.png` and `dev images/ink mixer.png` screenshots
|
||||||
|
are the Legacy model references used for this slice.
|
||||||
|
|
||||||
|
## Legacy comparison and modern boundary
|
||||||
|
|
||||||
|
- The Crystal Block, Book Receptacle, and 16×512 portal animation already use
|
||||||
|
Legacy sprites. The Receptacle's 16×16×6 model already matches the Legacy
|
||||||
|
slab. The fallback portal now uses Legacy's four-pixel-deep, double-faced
|
||||||
|
`linkportal_ns.json` geometry, with the original animation; it declares a
|
||||||
|
translucent render layer and portal particle texture. The block remains
|
||||||
|
non-occluding. When Immersive Portals supplies a live view, the fallback
|
||||||
|
model is hidden, while the crystal frame is drawn over the view.
|
||||||
|
- The Age arrival platform now reuses the same Legacy crystal tile as Crystal
|
||||||
|
Block. It is an opaque, occluding full block, while retaining its empty loot
|
||||||
|
table and unobtainable pick-block behavior. It has glass-like 0.3 hardness.
|
||||||
|
The old cloud sprite is unused and removed.
|
||||||
|
- The Book Binder now includes the two inner braces and central press/book
|
||||||
|
piece omitted by the earlier model, using the existing byte-identical
|
||||||
|
Legacy textures. The Ink Mixer uses Legacy's `inkwell_model.json` geometry
|
||||||
|
and four original inkwell textures. A thin white paper surface replaces the
|
||||||
|
black paper appearance from the reference; the old simplified mixer tiles
|
||||||
|
are no longer used. The half-alpha glass atlas is intentionally rendered on
|
||||||
|
a cutout layer; its more opaque look passed the player's in-game check.
|
||||||
|
- Legacy's link listener selects Disarm before a custom portal sound, then
|
||||||
|
Following, Intra Linking, and ordinary Link. Rivencraft has no Following
|
||||||
|
modifier yet, so it selects Disarm, portal, Intra Linking, or ordinary Link
|
||||||
|
in that order. The Disarm and Intra OGGs are byte-identical Legacy copies.
|
||||||
|
- Legacy sends 50 small neutral link particles in roughly a one-by-two-block
|
||||||
|
region at both departure and arrival. Its fixed-function particle class is
|
||||||
|
not portable to 1.21.1. Rivencraft uses a one-shot burst of 50 vanilla Cloud
|
||||||
|
particles at both ends, emitted only after successful server-authoritative
|
||||||
|
travel. There is no continuous portal-particle loop in the inspected Legacy
|
||||||
|
`BlockLinkPortal` class, so the portal surface itself adds none.
|
||||||
|
- The Legacy `textures/entity/linkbook.png` and `linking/pop.ogg` are inspected
|
||||||
|
but not copied in this slice. The custom dropped Linkbook entity is part of
|
||||||
|
#85; adding its assets before the entity exists would leave unused resources.
|
||||||
|
Library walls, shelves, chest, and webs use vanilla blocks in the template,
|
||||||
|
so there are no separate library art files to port.
|
||||||
|
|
||||||
|
## Manual visual/audio check
|
||||||
|
|
||||||
|
Use the same bound book in a held use and a Crystal Portal. Confirm the link
|
||||||
|
particles and audio play at both ends, and the portal landing matches the book.
|
||||||
|
Repeat with Disarm and Intra Linking panels to check the distinct sounds.
|
||||||
|
Compare the fallback portal, crystal frame, and Receptacle with Immersive
|
||||||
|
Portals absent; verify the animated surface is visible from both sides and
|
||||||
|
does not expose caves or other geometry through solid blocks. With Immersive
|
||||||
|
Portals installed, confirm the live view replaces only the fallback surface,
|
||||||
|
the frame draws in front, and no fallback texture covers the live view.
|
||||||
|
Create/Ponder is optional and must not affect either rendering path.
|
||||||
|
Check a freshly generated arrival platform from above and below: the crystal
|
||||||
|
tile should be opaque, break about as quickly as glass, and drop no block item.
|
||||||
|
Compare the Book Binder press and Ink Mixer cup/glass against the `dev images`
|
||||||
|
references; the mixer paper should appear white, and neither block should
|
||||||
|
show missing-texture checkerboards or see-through-world artifacts.
|
||||||
|
|
||||||
|
The player reported that the in-game visual and audio checks pass on 2026-10-19,
|
||||||
|
including acceptance of the cutout glass appearance and current block sounds.
|
||||||
+7
-2
@@ -1,5 +1,9 @@
|
|||||||
# Porting roadmap
|
# 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.
|
Rivencraft ports Mystcraft Legacy from Minecraft 1.12.2 to NeoForge 1.21.1.
|
||||||
The world-generation, dimension, registry, networking, and rendering APIs have
|
The world-generation, dimension, registry, networking, and rendering APIs have
|
||||||
changed substantially, so the work is divided into independently testable
|
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)
|
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
|
||||||
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
|
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
|
||||||
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
||||||
sounds, advancements, compatibility, and migration tests. Start with the
|
sounds, advancements, compatibility, and migration tests. **In progress.**
|
||||||
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
|
See the [Milestone 7 regression matrix](MILESTONE_7_REGRESSION.md) and
|
||||||
|
[Legacy audit](LEGACY_M7_AUDIT.md).
|
||||||
|
|
||||||
## Architectural direction
|
## Architectural direction
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ being captured in the preview.
|
|||||||
|
|
||||||
## Supported combinations and upstream limits
|
## Supported combinations and upstream limits
|
||||||
|
|
||||||
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
|
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
|
||||||
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
|
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
|
||||||
backend run without Create or Immersive Portals; the headless GameTest server
|
backend run without Create or Immersive Portals; the headless GameTest server
|
||||||
uses that dependency-free path.
|
uses that dependency-free path.
|
||||||
@@ -66,8 +66,11 @@ unstable in a particular graphics or mod configuration.
|
|||||||
|
|
||||||
For preview-related releases:
|
For preview-related releases:
|
||||||
|
|
||||||
1. Run `./gradlew runGameTestServer -PwithArchivedGameTests=true` to verify the complete selection matrix,
|
1. Run `./gradlew runGameTestServer` for the core selection matrix, Age
|
||||||
core Age creation, persistence, and travel without optional renderers.
|
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.
|
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.
|
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,
|
4. With Immersive Portals, enter and leave Explore, travel between dimensions,
|
||||||
|
|||||||
@@ -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.
|
`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 |
|
| 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`. |
|
| `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. |
|
| `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). |
|
| `celestial` | `normal_time` (the first representable single-cycle behavior). |
|
||||||
| `period_modifier` | JSON number in `[0, 2]`; zero is stopped time. |
|
| `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. |
|
| `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 will convert to turns. |
|
| `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. |
|
| `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. |
|
| `feature` | Canonical namespaced feature-effect ID; the compiler must recognize and validate it. This includes supported decorations and structures. |
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
|
|||||||
# as they do not follow standard versioning conventions.
|
# as they do not follow standard versioning conventions.
|
||||||
minecraft_version_range=[1.21.1]
|
minecraft_version_range=[1.21.1]
|
||||||
# The Neo version must agree with the Minecraft version to get a valid artifact
|
# The Neo version must agree with the Minecraft version to get a valid artifact
|
||||||
neo_version=21.1.251
|
neo_version=21.1.264
|
||||||
ponder_version=1.0.82
|
ponder_version=1.0.82
|
||||||
dynamic_dimensions_file_id=7707789
|
dynamic_dimensions_file_id=7707789
|
||||||
badpackets_version=0.8.2
|
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
|
# The loader version range can only use the major version of FML as bounds
|
||||||
loader_version_range=[1,)
|
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.
|
# 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
|
mod_license=LGPL-3.0-or-later
|
||||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||||
mod_version=0.6.3.4
|
mod_version=1.0.0.2
|
||||||
immersive_portals_version=6.0.7
|
immersive_portals_version=6.0.7
|
||||||
terrablender_version=4.1.0.8
|
terrablender_version=4.1.0.8
|
||||||
serene_seasons_version_id=B6nTjh4b
|
serene_seasons_version_id=pHEgQQUE
|
||||||
glitchcore_version_id=S2TfWrZR
|
glitchcore_version_id=S2TfWrZR
|
||||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
# 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.
|
# This should match the base package used for the mod sources.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.network.ModNetworking;
|
|||||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||||
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
|
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
|
||||||
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||||
import net.dnorberg.rivencraft.book.AgeWeatherService;
|
import net.dnorberg.rivencraft.book.AgeWeatherService;
|
||||||
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
||||||
@@ -43,6 +44,7 @@ public final class Rivencraft {
|
|||||||
|
|
||||||
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
|
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
|
||||||
AgeDimensionService.register();
|
AgeDimensionService.register();
|
||||||
|
RuinsCompatibility.register();
|
||||||
AgeStatusEffectProviders.register();
|
AgeStatusEffectProviders.register();
|
||||||
AgeDecayProviders.register();
|
AgeDecayProviders.register();
|
||||||
AgeHazardProviders.register();
|
AgeHazardProviders.register();
|
||||||
@@ -54,6 +56,7 @@ public final class Rivencraft {
|
|||||||
ModBiomeSources.register(modEventBus);
|
ModBiomeSources.register(modEventBus);
|
||||||
ModChunkGenerators.register(modEventBus);
|
ModChunkGenerators.register(modEventBus);
|
||||||
ModBlocks.register(modEventBus);
|
ModBlocks.register(modEventBus);
|
||||||
|
net.dnorberg.rivencraft.registry.ModEntities.register(modEventBus);
|
||||||
ModBlockEntities.register(modEventBus);
|
ModBlockEntities.register(modEventBus);
|
||||||
ModItems.register(modEventBus);
|
ModItems.register(modEventBus);
|
||||||
ModMenus.register(modEventBus);
|
ModMenus.register(modEventBus);
|
||||||
@@ -81,6 +84,8 @@ public final class Rivencraft {
|
|||||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
||||||
|
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) ->
|
||||||
|
RuinsCompatibility.onServerStopping());
|
||||||
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
||||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
|
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
|
||||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package net.dnorberg.rivencraft.advancement;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/** Awards action-only progression after the server has committed the action. */
|
||||||
|
public final class ProgressionAdvancements {
|
||||||
|
public static final String LINK_PANEL = "link_panel";
|
||||||
|
public static final String COPY_PAGE = "copy_page";
|
||||||
|
public static final String BIND_AGE = "bind_age";
|
||||||
|
public static final String OPEN_PORTAL = "open_portal";
|
||||||
|
public static final String ENTER_AGE = "enter_age";
|
||||||
|
|
||||||
|
private ProgressionAdvancements() {}
|
||||||
|
|
||||||
|
public static void award(ServerPlayer player, String path) {
|
||||||
|
var advancement = player.getServer().getAdvancements().get(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
|
||||||
|
if (advancement != null) player.getAdvancements().award(advancement, "completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void awardAgeTravel(ServerPlayer player) {
|
||||||
|
if (AgeRegistrySavedData.get(player.serverLevel()).findByDimension(
|
||||||
|
player.serverLevel().dimension().location()).isPresent()) {
|
||||||
|
award(player, ENTER_AGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package net.dnorberg.rivencraft.block;
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
import com.mojang.serialization.MapCodec;
|
import com.mojang.serialization.MapCodec;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||||
@@ -35,6 +36,7 @@ import net.minecraft.world.level.block.state.BlockState;
|
|||||||
import net.minecraft.world.level.block.state.StateDefinition;
|
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.BlockStateProperties;
|
||||||
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
import net.minecraft.world.level.block.state.properties.DirectionProperty;
|
||||||
|
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||||
import net.minecraft.world.phys.BlockHitResult;
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
import net.minecraft.world.phys.shapes.CollisionContext;
|
import net.minecraft.world.phys.shapes.CollisionContext;
|
||||||
import net.minecraft.world.phys.shapes.VoxelShape;
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
@@ -43,10 +45,11 @@ import net.minecraft.world.phys.shapes.VoxelShape;
|
|||||||
public final class BookReceptacleBlock extends BaseEntityBlock {
|
public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||||
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
|
public static final MapCodec<BookReceptacleBlock> CODEC = simpleCodec(BookReceptacleBlock::new);
|
||||||
public static final DirectionProperty FACING = BlockStateProperties.FACING;
|
public static final DirectionProperty FACING = BlockStateProperties.FACING;
|
||||||
|
public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK;
|
||||||
|
|
||||||
public BookReceptacleBlock(Properties properties) {
|
public BookReceptacleBlock(Properties properties) {
|
||||||
super(properties);
|
super(properties);
|
||||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
@@ -63,7 +66,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||||
builder.add(FACING);
|
builder.add(FACING, HAS_BOOK);
|
||||||
}
|
}
|
||||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
return new BookReceptacleBlockEntity(pos, state);
|
return new BookReceptacleBlockEntity(pos, state);
|
||||||
@@ -76,7 +79,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
}
|
}
|
||||||
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||||
Direction face = context.getClickedFace();
|
Direction face = context.getClickedFace();
|
||||||
if (face == Direction.DOWN || !context.getLevel().getBlockState(
|
if (!context.getLevel().getBlockState(
|
||||||
context.getClickedPos().relative(face.getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) return null;
|
context.getClickedPos().relative(face.getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get())) return null;
|
||||||
return defaultBlockState().setValue(FACING, face);
|
return defaultBlockState().setValue(FACING, face);
|
||||||
}
|
}
|
||||||
@@ -96,7 +99,11 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get())
|
LinkTarget destination = stack.is(ModItems.LINKING_BOOK.get())
|
||||||
? stack.get(ModDataComponents.LINK_TARGET.get())
|
? stack.get(ModDataComponents.LINK_TARGET.get())
|
||||||
: player instanceof ServerPlayer serverPlayer
|
: player instanceof ServerPlayer serverPlayer
|
||||||
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get())).orElse(null)
|
? AgeDimensionService.resolve(serverPlayer, stack.get(ModDataComponents.AGE_REFERENCE.get()),
|
||||||
|
net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
|
||||||
|
!stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
|
||||||
|
net.dnorberg.rivencraft.book.LinkProperties.NONE)
|
||||||
|
.has(net.dnorberg.rivencraft.book.LinkProperties.DISABLE_PLATFORM)).orElse(null)
|
||||||
: null;
|
: null;
|
||||||
if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) {
|
if (destination == null || server.getServer().getLevel(destination.location().dimension()) == null) {
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
||||||
@@ -117,6 +124,8 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
server, pos, plan, destination));
|
server, pos, plan, destination));
|
||||||
}
|
}
|
||||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||||
|
if (player instanceof ServerPlayer serverPlayer)
|
||||||
|
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
|
||||||
return ItemInteractionResult.SUCCESS;
|
return ItemInteractionResult.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +155,7 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
|
if (!state.is(replacement.getBlock()) && level instanceof ServerLevel server) {
|
||||||
CrystalPortalNetwork.shutdown(server, pos);
|
CrystalPortalNetwork.shutdown(server, pos);
|
||||||
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
|
if (level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder) {
|
||||||
ItemStack book = holder.takeBook();
|
ItemStack book = holder.takeBookOnBreak();
|
||||||
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
|
if (!book.isEmpty()) level.addFreshEntity(new ItemEntity(level, pos.getX()+0.5, pos.getY()+0.5,
|
||||||
pos.getZ()+0.5, book));
|
pos.getZ()+0.5, book));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package net.dnorberg.rivencraft.block;
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.Direction;
|
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.level.BlockGetter;
|
|
||||||
import net.minecraft.world.level.LevelReader;
|
import net.minecraft.world.level.LevelReader;
|
||||||
import net.minecraft.world.level.block.Block;
|
import net.minecraft.world.level.block.Block;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
/** Solid footing for Age arrivals, rendered as a translucent cloud. */
|
/** Solid crystal-textured footing for Age arrivals that cannot be collected. */
|
||||||
public final class CloudPlatformBlock extends Block {
|
public final class CloudPlatformBlock extends Block {
|
||||||
public CloudPlatformBlock(Properties properties) {
|
public CloudPlatformBlock(Properties properties) {
|
||||||
super(properties);
|
super(properties);
|
||||||
@@ -18,19 +16,4 @@ public final class CloudPlatformBlock extends Block {
|
|||||||
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
|
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
|
||||||
return ItemStack.EMPTY;
|
return ItemStack.EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean skipRendering(BlockState state, BlockState adjacent, Direction side) {
|
|
||||||
return adjacent.is(this) || super.skipRendering(state, adjacent, side);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean propagatesSkylightDown(BlockState state, BlockGetter level, BlockPos pos) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected int getLightBlock(BlockState state, BlockGetter level, BlockPos pos) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,16 +3,14 @@ package net.dnorberg.rivencraft.block;
|
|||||||
import com.mojang.serialization.MapCodec;
|
import com.mojang.serialization.MapCodec;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
|
||||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
|
||||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||||
|
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.Direction;
|
import net.minecraft.core.Direction;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
|
||||||
import net.minecraft.util.RandomSource;
|
import net.minecraft.util.RandomSource;
|
||||||
import net.minecraft.world.entity.Entity;
|
import net.minecraft.world.entity.Entity;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
@@ -55,17 +53,16 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
|||||||
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
||||||
|
|
||||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
||||||
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
if (!(level instanceof ServerLevel server)) return;
|
||||||
long now = server.getServer().getTickCount();
|
long now = server.getServer().getTickCount();
|
||||||
if (PortalTravelCooldown.blocked(player, now)) return;
|
if (PortalTravelCooldown.blocked(entity, now)) return;
|
||||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||||
ItemStack book = holder.getBook();
|
ItemStack book = holder.getBook();
|
||||||
if (book.isEmpty()) return;
|
if (book.isEmpty()) return;
|
||||||
PortalTravelCooldown.mark(player, now);
|
PortalTravelCooldown.mark(entity, now);
|
||||||
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book);
|
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
|
||||||
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.dnorberg.rivencraft.block;
|
||||||
|
|
||||||
|
import com.mojang.serialization.MapCodec;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.Containers;
|
||||||
|
import net.minecraft.world.InteractionResult;
|
||||||
|
import net.minecraft.world.SimpleMenuProvider;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.BaseEntityBlock;
|
||||||
|
import net.minecraft.world.level.block.RenderShape;
|
||||||
|
import net.minecraft.world.level.block.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.DirectionProperty;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
|
||||||
|
public final class LinkModifierBlock extends BaseEntityBlock {
|
||||||
|
public static final MapCodec<LinkModifierBlock> CODEC = simpleCodec(LinkModifierBlock::new);
|
||||||
|
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
|
||||||
|
|
||||||
|
public LinkModifierBlock(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
|
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
|
||||||
|
BlockState> builder) { builder.add(FACING); }
|
||||||
|
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
|
||||||
|
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
|
||||||
|
}
|
||||||
|
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
|
||||||
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
return new LinkModifierBlockEntity(pos, state);
|
||||||
|
}
|
||||||
|
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
|
||||||
|
Player player, BlockHitResult hit) {
|
||||||
|
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
|
||||||
|
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
|
||||||
|
serverPlayer.openMenu(new SimpleMenuProvider(
|
||||||
|
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
|
||||||
|
Component.translatable("container.rivencraft.link_modifier")),
|
||||||
|
buffer -> buffer.writeBlockPos(pos));
|
||||||
|
}
|
||||||
|
return InteractionResult.sidedSuccess(level.isClientSide);
|
||||||
|
}
|
||||||
|
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
|
||||||
|
BlockState replacement, boolean moving) {
|
||||||
|
if (!state.is(replacement.getBlock()) && !level.isClientSide
|
||||||
|
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
|
||||||
|
Containers.dropContents(level, pos, modifier);
|
||||||
|
}
|
||||||
|
super.onRemove(state, level, pos, replacement, moving);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
|||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||||
@@ -96,7 +97,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
|||||||
case COVER -> validCover(stack);
|
case COVER -> validCover(stack);
|
||||||
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
|
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
|
||||||
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
|
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
|
||||||
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
|
case NOTEBOOK -> CollationFolderItem.isCollection(stack);
|
||||||
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
|
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -296,7 +297,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
|||||||
|
|
||||||
/** Import a Notebook from either its stored slot or the player's cursor. */
|
/** Import a Notebook from either its stored slot or the player's cursor. */
|
||||||
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
|
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
|
||||||
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
|
if (!CollationFolderItem.isCollection(notebook) || notebook.getCount() != 1) return ItemStack.EMPTY;
|
||||||
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
if (pages.isEmpty()) return ItemStack.EMPTY;
|
if (pages.isEmpty()) return ItemStack.EMPTY;
|
||||||
int end = nextPageIndex();
|
int end = nextPageIndex();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package net.dnorberg.rivencraft.block.entity;
|
package net.dnorberg.rivencraft.block.entity;
|
||||||
|
|
||||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeEnvironmentCompiler;
|
||||||
|
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||||
import net.minecraft.nbt.NbtOps;
|
import net.minecraft.nbt.NbtOps;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
@@ -20,8 +23,10 @@ import net.neoforged.fml.ModList;
|
|||||||
public final class BookReceptacleBlockEntity extends BlockEntity {
|
public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||||
private static final String BOOK_TAG = "Book";
|
private static final String BOOK_TAG = "Book";
|
||||||
private static final String DESTINATION_TAG = "Destination";
|
private static final String DESTINATION_TAG = "Destination";
|
||||||
|
private static final String SKY_COLOR_TAG = "SkyColor";
|
||||||
private ItemStack book = ItemStack.EMPTY;
|
private ItemStack book = ItemStack.EMPTY;
|
||||||
private LinkTarget destination;
|
private LinkTarget destination;
|
||||||
|
private int skyColor = 0xFFFFFF;
|
||||||
private boolean restoreView = true;
|
private boolean restoreView = true;
|
||||||
|
|
||||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||||
@@ -30,6 +35,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
|
|
||||||
public ItemStack getBook() { return book; }
|
public ItemStack getBook() { return book; }
|
||||||
public LinkTarget destination() { return destination; }
|
public LinkTarget destination() { return destination; }
|
||||||
|
public int skyColor() { return skyColor; }
|
||||||
|
|
||||||
public void setBook(ItemStack stack) {
|
public void setBook(ItemStack stack) {
|
||||||
book = stack.copyWithCount(1);
|
book = stack.copyWithCount(1);
|
||||||
@@ -38,21 +44,46 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
|
|
||||||
public void setDestination(LinkTarget target) {
|
public void setDestination(LinkTarget target) {
|
||||||
destination = target;
|
destination = target;
|
||||||
|
skyColor = 0xFFFFFF;
|
||||||
|
if (target != null && level instanceof ServerLevel server) {
|
||||||
|
ServerLevel targetLevel = server.getServer().getLevel(target.location().dimension());
|
||||||
|
if (targetLevel != null) skyColor = AgeEnvironmentCompiler.previewSkyColor(
|
||||||
|
targetLevel, target.location().pos()) & 0xFFFFFF;
|
||||||
|
}
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ItemStack takeBook() {
|
public ItemStack takeBook() {
|
||||||
|
return removeBook(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The block is already being replaced, so do not write its state back into the world. */
|
||||||
|
public ItemStack takeBookOnBreak() {
|
||||||
|
return removeBook(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack removeBook(boolean notify) {
|
||||||
ItemStack result = book;
|
ItemStack result = book;
|
||||||
book = ItemStack.EMPTY;
|
book = ItemStack.EMPTY;
|
||||||
destination = null;
|
destination = null;
|
||||||
sync();
|
skyColor = 0xFFFFFF;
|
||||||
|
if (notify) sync();
|
||||||
|
else setChanged();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sync() {
|
private void sync() {
|
||||||
setChanged();
|
setChanged();
|
||||||
if (level != null && !level.isClientSide)
|
if (level instanceof ServerLevel server) {
|
||||||
level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2);
|
BlockState state = server.getBlockState(worldPosition);
|
||||||
|
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return;
|
||||||
|
boolean hasBook = !book.isEmpty();
|
||||||
|
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) {
|
||||||
|
state = state.setValue(BookReceptacleBlock.HAS_BOOK, hasBook);
|
||||||
|
server.setBlock(worldPosition, state, 2);
|
||||||
|
}
|
||||||
|
server.sendBlockUpdated(worldPosition, state, state, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -63,6 +94,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
: ItemStack.EMPTY;
|
: ItemStack.EMPTY;
|
||||||
destination = tag.contains(DESTINATION_TAG)
|
destination = tag.contains(DESTINATION_TAG)
|
||||||
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
||||||
|
skyColor = tag.contains(SKY_COLOR_TAG) ? tag.getInt(SKY_COLOR_TAG) & 0xFFFFFF : 0xFFFFFF;
|
||||||
restoreView = true;
|
restoreView = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +104,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
if (!book.isEmpty()) tag.put(BOOK_TAG, book.save(registries));
|
if (!book.isEmpty()) tag.put(BOOK_TAG, book.save(registries));
|
||||||
if (destination != null) LinkTarget.CODEC.encodeStart(NbtOps.INSTANCE, destination)
|
if (destination != null) LinkTarget.CODEC.encodeStart(NbtOps.INSTANCE, destination)
|
||||||
.result().ifPresent(encoded -> tag.put(DESTINATION_TAG, encoded));
|
.result().ifPresent(encoded -> tag.put(DESTINATION_TAG, encoded));
|
||||||
|
if (destination != null) tag.putInt(SKY_COLOR_TAG, skyColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
@Override public Packet<ClientGamePacketListener> getUpdatePacket() {
|
||||||
@@ -85,6 +118,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
|
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
|
||||||
BookReceptacleBlockEntity holder) {
|
BookReceptacleBlockEntity holder) {
|
||||||
if (!(world instanceof ServerLevel level)) return;
|
if (!(world instanceof ServerLevel level)) return;
|
||||||
|
// Reconcile receptacles saved before HAS_BOOK existed, including an
|
||||||
|
// occupied receptacle restored from an older pre-release world.
|
||||||
|
boolean hasBook = !holder.book.isEmpty();
|
||||||
|
if (state.getValue(BookReceptacleBlock.HAS_BOOK) != hasBook) holder.sync();
|
||||||
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
|
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
|
||||||
// Startup maintenance must never turn a saved portal off while its
|
// Startup maintenance must never turn a saved portal off while its
|
||||||
// chunks and block entities are still arriving. Structural edits are
|
// chunks and block entities are still arriving. Structural edits are
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.dnorberg.rivencraft.block.entity;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.core.NonNullList;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.ContainerHelper;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
|
||||||
|
/** One persisted book slot; edits are performed by the server menu. */
|
||||||
|
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
|
||||||
|
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||||
|
|
||||||
|
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
|
||||||
|
super(ModBlockEntities.LINK_MODIFIER.get(), pos, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public int getContainerSize() { return 1; }
|
||||||
|
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
|
||||||
|
@Override public ItemStack getItem(int slot) { return items.get(slot); }
|
||||||
|
@Override public ItemStack removeItem(int slot, int amount) {
|
||||||
|
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
|
||||||
|
if (!removed.isEmpty()) setChanged();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
@Override public ItemStack removeItemNoUpdate(int slot) {
|
||||||
|
ItemStack removed = ContainerHelper.takeItem(items, slot);
|
||||||
|
if (!removed.isEmpty()) setChanged();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
@Override public void setItem(int slot, ItemStack stack) {
|
||||||
|
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||||
|
return slot == 0 && (stack.is(ModItems.LINKING_BOOK.get())
|
||||||
|
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()));
|
||||||
|
}
|
||||||
|
@Override public boolean stillValid(Player player) {
|
||||||
|
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
|
||||||
|
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
|
||||||
|
worldPosition.getZ() + 0.5) <= 64.0;
|
||||||
|
}
|
||||||
|
@Override public void clearContent() {
|
||||||
|
items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||||
|
setChanged();
|
||||||
|
}
|
||||||
|
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.loadAdditional(tag, registries);
|
||||||
|
items = NonNullList.withSize(1, ItemStack.EMPTY);
|
||||||
|
ContainerHelper.loadAllItems(tag, items, registries);
|
||||||
|
}
|
||||||
|
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
super.saveAdditional(tag, registries);
|
||||||
|
ContainerHelper.saveAllItems(tag, items, registries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.block.entity;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
import net.dnorberg.rivencraft.writing.InkVialExchange;
|
||||||
import net.dnorberg.rivencraft.writing.WritingInkService;
|
import net.dnorberg.rivencraft.writing.WritingInkService;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
@@ -87,7 +88,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
|
|||||||
worldPosition.getZ() + 0.5) <= 64.0;
|
worldPosition.getZ() + 0.5) <= 64.0;
|
||||||
}
|
}
|
||||||
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
|
||||||
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get())
|
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
|
||||||
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
|
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
|
||||||
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|
||||||
|| stack.is(ModItems.PAGE.get())
|
|| stack.is(ModItems.PAGE.get())
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ public record AgeDefinition(
|
|||||||
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
|
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
|
||||||
Rivencraft.MOD_ID, "random_vanilla"
|
Rivencraft.MOD_ID, "random_vanilla"
|
||||||
);
|
);
|
||||||
public static final int CURRENT_GENERATION_VERSION = 16;
|
public static final int LEGACY_GENERATOR_CUTOFF = 16;
|
||||||
|
public static final int CURRENT_GENERATION_VERSION = AgeSkyProfile.RANDOM_SKY_GENERATION_VERSION;
|
||||||
|
|
||||||
public AgeDefinition {
|
public AgeDefinition {
|
||||||
Objects.requireNonNull(id, "id");
|
Objects.requireNonNull(id, "id");
|
||||||
@@ -118,4 +119,10 @@ public record AgeDefinition(
|
|||||||
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
|
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
|
||||||
randomFeatures, instability, instabilityPlan);
|
randomFeatures, instability, instabilityPlan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A frozen authored profile takes precedence over the profile ID on old books. */
|
||||||
|
public boolean caveTerrain() {
|
||||||
|
return AgeBiomeSelection.isCave(plan == null
|
||||||
|
? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
|
||||||
|
/** Retires an exact Age and moves its unloaded region data out of the live world. */
|
||||||
|
public final class AgeDeletionService {
|
||||||
|
public enum Result { RETIRED, ALREADY_DEAD, OCCUPIED, UNLOAD_FAILED, STORAGE_FAILED }
|
||||||
|
|
||||||
|
private AgeDeletionService() { }
|
||||||
|
|
||||||
|
public static Result retire(MinecraftServer server, AgeDefinition age) {
|
||||||
|
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
|
||||||
|
if (registry.isDead(age.id())) return Result.ALREADY_DEAD;
|
||||||
|
var level = server.getLevel(AgeDimensionService.dimensionKey(age));
|
||||||
|
if (level != null && !level.players().isEmpty()) return Result.OCCUPIED;
|
||||||
|
if (AgeDimensionService.unload(server, age) == AgeDimensionService.UnloadResult.FAILED)
|
||||||
|
return Result.UNLOAD_FAILED;
|
||||||
|
|
||||||
|
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||||
|
Path ageRoot = world.resolve("dimensions/rivencraft/age").normalize();
|
||||||
|
Path data = world.resolve("dimensions")
|
||||||
|
.resolve(age.dimensionId().getNamespace())
|
||||||
|
.resolve(age.dimensionId().getPath()).normalize();
|
||||||
|
// Exact Rivencraft Age only; never move the shared age/ directory.
|
||||||
|
if (!data.startsWith(ageRoot) || data.equals(ageRoot)
|
||||||
|
|| !"rivencraft".equals(age.dimensionId().getNamespace())
|
||||||
|
|| !age.dimensionId().getPath().startsWith("age/")) return Result.STORAGE_FAILED;
|
||||||
|
try {
|
||||||
|
if (Files.isSymbolicLink(world.resolve("dimensions"))
|
||||||
|
|| Files.isSymbolicLink(world.resolve("dimensions/rivencraft"))
|
||||||
|
|| Files.isSymbolicLink(ageRoot) || Files.isSymbolicLink(data)) return Result.STORAGE_FAILED;
|
||||||
|
if (Files.exists(data, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
Path archive = world.getParent().resolve(world.getFileName() + "-retired-ages")
|
||||||
|
.resolve(age.id().toString());
|
||||||
|
if (Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) return Result.STORAGE_FAILED;
|
||||||
|
Files.createDirectories(archive.getParent());
|
||||||
|
try { Files.move(data, archive, StandardCopyOption.ATOMIC_MOVE); }
|
||||||
|
catch (AtomicMoveNotSupportedException exception) { Files.move(data, archive); }
|
||||||
|
Rivencraft.LOGGER.warn("Retired Age {} data moved from {} to {}", age.id(), data, archive);
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
Rivencraft.LOGGER.error("Cannot archive data for Age {}", age.id(), exception);
|
||||||
|
return Result.STORAGE_FAILED;
|
||||||
|
}
|
||||||
|
registry.markDead(age.id());
|
||||||
|
return Result.RETIRED;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.Rivencraft;
|
|||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||||
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||||
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
||||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||||
@@ -74,6 +75,7 @@ public final class AgeDimensionService {
|
|||||||
DynamicDimensionLoadCallback.register((server, loader) -> {
|
DynamicDimensionLoadCallback.register((server, loader) -> {
|
||||||
ServerLevel overworld = server.overworld();
|
ServerLevel overworld = server.overworld();
|
||||||
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
|
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
|
||||||
|
if (AgeRegistrySavedData.get(overworld).isDead(age.id())) continue;
|
||||||
if (!canLoadProfile(server, age)) continue;
|
if (!canLoadProfile(server, age)) continue;
|
||||||
ServerLevel level = loader.loadDynamicDimension(
|
ServerLevel level = loader.loadDynamicDimension(
|
||||||
age.dimensionId(),
|
age.dimensionId(),
|
||||||
@@ -83,6 +85,7 @@ public final class AgeDimensionService {
|
|||||||
if (level != null) {
|
if (level != null) {
|
||||||
applyCelestialClock(level, age);
|
applyCelestialClock(level, age);
|
||||||
SereneSeasonsCompatibility.registerAge(server, age);
|
SereneSeasonsCompatibility.registerAge(server, age);
|
||||||
|
RuinsCompatibility.registerAge(age);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
synchronizeImmersivePortals(server);
|
synchronizeImmersivePortals(server);
|
||||||
@@ -95,8 +98,19 @@ public final class AgeDimensionService {
|
|||||||
|
|
||||||
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
||||||
OrderedWriting writing) {
|
OrderedWriting writing) {
|
||||||
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing);
|
return resolve(player, reference, writing, true);
|
||||||
ServerLevel level = ensureLoaded(player.getServer(), definition);
|
}
|
||||||
|
|
||||||
|
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
||||||
|
OrderedWriting writing, boolean generatePlatform) {
|
||||||
|
return resolve(player.serverLevel(), reference, writing, generatePlatform);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<LinkTarget> resolve(ServerLevel source, AgeReference reference,
|
||||||
|
OrderedWriting writing, boolean generatePlatform) {
|
||||||
|
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(source, reference, writing);
|
||||||
|
if (AgeRegistrySavedData.get(source).isDead(definition.id())) return Optional.empty();
|
||||||
|
ServerLevel level = ensureLoaded(source.getServer(), definition);
|
||||||
if (level == null) {
|
if (level == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -104,7 +118,8 @@ public final class AgeDimensionService {
|
|||||||
BlockPos spawn = definition.spawn();
|
BlockPos spawn = definition.spawn();
|
||||||
if (spawn == null) {
|
if (spawn == null) {
|
||||||
applyInitialCelestialPhase(level, definition);
|
applyInitialCelestialPhase(level, definition);
|
||||||
spawn = createSafeSpawn(level, definition);
|
spawn = createSafeSpawn(level, definition, generatePlatform);
|
||||||
|
if (spawn == null) return Optional.empty();
|
||||||
if (AgeFeatureSelection.resolved(definition.plan()).stream()
|
if (AgeFeatureSelection.resolved(definition.plan()).stream()
|
||||||
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|
.anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))
|
||||||
|| definition.randomFeatures().stream()
|
|| definition.randomFeatures().stream()
|
||||||
@@ -122,11 +137,13 @@ public final class AgeDimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
|
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
|
||||||
|
if (AgeRegistrySavedData.get(server.overworld()).isDead(definition.id())) return null;
|
||||||
ResourceKey<Level> key = dimensionKey(definition);
|
ResourceKey<Level> key = dimensionKey(definition);
|
||||||
ServerLevel existing = server.getLevel(key);
|
ServerLevel existing = server.getLevel(key);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
applyCelestialClock(existing, definition);
|
applyCelestialClock(existing, definition);
|
||||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||||
|
RuinsCompatibility.registerAge(definition);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +160,7 @@ public final class AgeDimensionService {
|
|||||||
} else {
|
} else {
|
||||||
applyCelestialClock(loaded, definition);
|
applyCelestialClock(loaded, definition);
|
||||||
SereneSeasonsCompatibility.registerAge(server, definition);
|
SereneSeasonsCompatibility.registerAge(server, definition);
|
||||||
|
RuinsCompatibility.registerAge(definition);
|
||||||
synchronizeImmersivePortals(server);
|
synchronizeImmersivePortals(server);
|
||||||
}
|
}
|
||||||
return loaded;
|
return loaded;
|
||||||
@@ -170,6 +188,10 @@ public final class AgeDimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
|
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
|
||||||
|
if (age.caveTerrain()) {
|
||||||
|
level.setDayTimePerTick(1.0F);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (age.traits().hasStoppedCelestialCycle()) {
|
if (age.traits().hasStoppedCelestialCycle()) {
|
||||||
// NeoForge rejects an exact zero rate. The smallest positive float
|
// NeoForge rejects an exact zero rate. The smallest positive float
|
||||||
// never advances the integer clock during a practical world
|
// never advances the integer clock during a practical world
|
||||||
@@ -198,6 +220,7 @@ public final class AgeDimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
|
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
|
||||||
|
if (age.caveTerrain()) return;
|
||||||
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
|
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
|
||||||
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
|
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
|
||||||
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
|
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
|
||||||
@@ -216,21 +239,24 @@ public final class AgeDimensionService {
|
|||||||
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
|
return ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, definition.dimensionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age) {
|
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) {
|
||||||
BlockPos floorCenter;
|
BlockPos floorCenter;
|
||||||
if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) {
|
if (age.caveTerrain()) {
|
||||||
// Cave Ages are enclosed. Searching from the world ceiling would
|
// Cave Ages are enclosed. Searching from the world ceiling would
|
||||||
// select or expose their roof, so begin with the floor beneath a
|
// select or expose their roof, so begin with the floor beneath a
|
||||||
// player standing at Y=64 and search downward from there.
|
// player standing at Y=64 and search downward from there.
|
||||||
floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1)
|
floorCenter = highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1)
|
||||||
.orElseGet(() -> fallbackSpawnFloor(level));
|
.orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
|
||||||
} else if (isNetherStyle(level)) {
|
} else if (isNetherStyle(level)) {
|
||||||
floorCenter = netherSpawnFloor(level);
|
floorCenter = generatePlatform ? netherSpawnFloor(level)
|
||||||
|
: highestFootedSurface(level, CAVE_SPAWN_SEARCH_Y - 1).orElse(null);
|
||||||
} else {
|
} else {
|
||||||
floorCenter = highestFootedSurface(level).orElseGet(() -> fallbackSpawnFloor(level));
|
floorCenter = highestFootedSurface(level).orElseGet(() -> generatePlatform ? fallbackSpawnFloor(level) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
|
if (floorCenter == null) return null;
|
||||||
|
|
||||||
|
if (generatePlatform) for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
|
||||||
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
|
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
|
||||||
BlockPos floor = floorCenter.offset(x, 0, z);
|
BlockPos floor = floorCenter.offset(x, 0, z);
|
||||||
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
|
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
|
||||||
@@ -241,6 +267,11 @@ public final class AgeDimensionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BlockPos spawn = floorCenter.above();
|
BlockPos spawn = floorCenter.above();
|
||||||
|
if (!generatePlatform) {
|
||||||
|
var safe = LinkingService.resolveDestination(level, spawn, true);
|
||||||
|
if (safe.isEmpty()) return null;
|
||||||
|
spawn = BlockPos.containing(safe.get());
|
||||||
|
}
|
||||||
level.setDefaultSpawnPos(spawn, 0.0F);
|
level.setDefaultSpawnPos(spawn, 0.0F);
|
||||||
return spawn;
|
return spawn;
|
||||||
}
|
}
|
||||||
@@ -325,7 +356,7 @@ public final class AgeDimensionService {
|
|||||||
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
||||||
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||||
);
|
);
|
||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||||
}
|
}
|
||||||
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||||
@@ -335,7 +366,7 @@ public final class AgeDimensionService {
|
|||||||
List.of()
|
List.of()
|
||||||
);
|
);
|
||||||
settings.updateLayers();
|
settings.updateLayers();
|
||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +376,7 @@ public final class AgeDimensionService {
|
|||||||
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
|
NoiseGeneratorSettings baseSettings = noiseSettings.getOrThrow(settingsKey).value();
|
||||||
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
|
Holder<NoiseGeneratorSettings> settings = Holder.direct(withBiomeMaterialRules(baseSettings));
|
||||||
boolean caveTerrain = AgeBiomeSelection.isCave(profile);
|
boolean caveTerrain = AgeBiomeSelection.isCave(profile);
|
||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
return age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF
|
||||||
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
||||||
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
||||||
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
|
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
|
||||||
@@ -428,7 +459,7 @@ public final class AgeDimensionService {
|
|||||||
AgeDefinition age,
|
AgeDefinition age,
|
||||||
AgeRuntimeProfile profile
|
AgeRuntimeProfile profile
|
||||||
) {
|
) {
|
||||||
if (age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION) {
|
if (age.generationVersion() < AgeDefinition.LEGACY_GENERATOR_CUTOFF) {
|
||||||
return legacyBiomeSource(server, age, profile);
|
return legacyBiomeSource(server, age, profile);
|
||||||
}
|
}
|
||||||
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
|
AgeBiomeSelection selection = age.plan() != null && age.plan().biomeSelection() != null
|
||||||
@@ -531,7 +562,9 @@ public final class AgeDimensionService {
|
|||||||
DimensionType base = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE)
|
DimensionType base = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE)
|
||||||
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType())).value();
|
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType())).value();
|
||||||
AgeTraits.Lighting lighting = age.traits().lighting();
|
AgeTraits.Lighting lighting = age.traits().lighting();
|
||||||
boolean hasSkyLight = base.hasSkyLight() && lighting != AgeTraits.Lighting.DARK;
|
AgeSkyProfile sky = AgeSkyProfile.forAge(age);
|
||||||
|
boolean hasSkyLight = (base.hasSkyLight() || !sky.bodies().isEmpty())
|
||||||
|
&& lighting != AgeTraits.Lighting.DARK && !age.caveTerrain();
|
||||||
float ambientLight = lighting == AgeTraits.Lighting.BRIGHT ? 0.25F : 0.0F;
|
float ambientLight = lighting == AgeTraits.Lighting.BRIGHT ? 0.25F : 0.0F;
|
||||||
return new DimensionType(
|
return new DimensionType(
|
||||||
base.fixedTime(),
|
base.fixedTime(),
|
||||||
@@ -546,7 +579,9 @@ public final class AgeDimensionService {
|
|||||||
base.height(),
|
base.height(),
|
||||||
base.logicalHeight(),
|
base.logicalHeight(),
|
||||||
base.infiniburn(),
|
base.infiniburn(),
|
||||||
AgeEnvironmentCompiler.skyEffects(age.plan(), base.effectsLocation()),
|
age.plan() == null && sky.custom()
|
||||||
|
? AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS
|
||||||
|
: AgeEnvironmentCompiler.skyEffects(age.plan(), base.effectsLocation()),
|
||||||
ambientLight,
|
ambientLight,
|
||||||
base.monsterSettings()
|
base.monsterSettings()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,21 +2,38 @@ package net.dnorberg.rivencraft.book;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
|
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
|
||||||
|
|
||||||
/** Resolves written environment pages without changing the seeded page-free Age path. */
|
/** Resolves written environment pages without changing the seeded page-free Age path. */
|
||||||
public final class AgeEnvironmentCompiler {
|
public final class AgeEnvironmentCompiler {
|
||||||
|
public static final ResourceLocation CUSTOM_SKY_EFFECTS = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"rivencraft", "custom_sky");
|
||||||
private AgeEnvironmentCompiler() {}
|
private AgeEnvironmentCompiler() {}
|
||||||
|
|
||||||
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
|
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
|
||||||
if (plan == null) return seeded;
|
if (plan == null) return seeded;
|
||||||
|
AgeTraits applied;
|
||||||
if (plan.version() >= 4) {
|
if (plan.version() >= 4) {
|
||||||
AgeTraits base = applyLegacy(plan, seeded);
|
AgeTraits base = applyLegacy(plan, seeded);
|
||||||
CelestialWriting ordered = orderedCelestial(plan, seeded);
|
CelestialWriting ordered = orderedCelestial(plan, seeded);
|
||||||
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||||
ordered.period(), base.celestialAngle(), ordered.phase());
|
ordered.period(), base.celestialAngle(), ordered.phase());
|
||||||
|
} else {
|
||||||
|
applied = applyLegacy(plan, seeded);
|
||||||
}
|
}
|
||||||
return applyLegacy(plan, seeded);
|
AgeSkyProfile.compile(plan, applied);
|
||||||
|
return forTerrain(applied, plan.runtimeProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */
|
||||||
|
public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) {
|
||||||
|
if (!AgeBiomeSelection.isCave(profile)) return traits;
|
||||||
|
return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF,
|
||||||
|
1.0D, 0.0F, 0.0F);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
|
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
|
||||||
@@ -57,8 +74,7 @@ public final class AgeEnvironmentCompiler {
|
|||||||
phaseDegrees = writtenPhase ? circularMidpoint(phaseDegrees, next) : next;
|
phaseDegrees = writtenPhase ? circularMidpoint(phaseDegrees, next) : next;
|
||||||
writtenPhase = true;
|
writtenPhase = true;
|
||||||
}
|
}
|
||||||
case ANGLE_MODIFIER -> throw new IllegalArgumentException(
|
case ANGLE_MODIFIER -> { } // Consumed by the next independently rendered celestial.
|
||||||
"Sun angle pages require a custom sky renderer and are not supported yet");
|
|
||||||
default -> { }
|
default -> { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +87,8 @@ public final class AgeEnvironmentCompiler {
|
|||||||
/** Vanilla effects are the sky styles the current renderer can honor exactly. */
|
/** Vanilla effects are the sky styles the current renderer can honor exactly. */
|
||||||
public static ResourceLocation skyEffects(AgeGenerationPlan plan, ResourceLocation fallback) {
|
public static ResourceLocation skyEffects(AgeGenerationPlan plan, ResourceLocation fallback) {
|
||||||
if (plan == null) return fallback;
|
if (plan == null) return fallback;
|
||||||
|
if (skyColor(plan) != -1 || staticSky(plan) || AgeSkyProfile.hasCustomCelestials(plan))
|
||||||
|
return CUSTOM_SKY_EFFECTS;
|
||||||
return plan.activeSymbols().stream()
|
return plan.activeSymbols().stream()
|
||||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY)
|
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SKY)
|
||||||
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
|
.map(symbol -> ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id())
|
||||||
@@ -82,6 +100,48 @@ public final class AgeEnvironmentCompiler {
|
|||||||
}).findFirst().orElse(fallback);
|
}).findFirst().orElse(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean staticSky(AgeGenerationPlan plan) {
|
||||||
|
return plan != null && plan.activeSymbols().stream().anyMatch(symbol ->
|
||||||
|
symbol.semantics().role() == SymbolSemantics.Role.SKY
|
||||||
|
&& ((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("static"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An authored color is fixed; -1 means the biome or selected vanilla sky supplies it. */
|
||||||
|
public static int skyColor(AgeGenerationPlan plan) {
|
||||||
|
return AgeSkyProfile.authoredSkyColor(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int selectedSkyColor(AgeGenerationPlan plan, ResourceLocation fallback, int biomeSkyColor) {
|
||||||
|
int authored = skyColor(plan);
|
||||||
|
if (authored != -1) return authored;
|
||||||
|
if (staticSky(plan)) return 0xFF929292;
|
||||||
|
ResourceLocation effects = skyEffects(plan, fallback);
|
||||||
|
return previewSkyColor(effects, biomeSkyColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared server answer for book previews and the fallback crystal portal surface. */
|
||||||
|
public static int previewSkyColor(ServerLevel level, BlockPos origin) {
|
||||||
|
int biomeColor = level.getBiome(origin).value().getSkyColor();
|
||||||
|
var age = AgeRegistrySavedData.get(level).definitions().stream()
|
||||||
|
.filter(candidate -> AgeDimensionService.dimensionKey(candidate).equals(level.dimension()))
|
||||||
|
.findFirst();
|
||||||
|
if (age.isEmpty()) return previewSkyColor(level.dimensionType().effectsLocation(), biomeColor);
|
||||||
|
var definition = age.orElseThrow();
|
||||||
|
AgeSkyProfile sky = AgeSkyProfile.forAge(definition);
|
||||||
|
if (definition.plan() == null && definition.generationVersion() >= AgeSkyProfile.RANDOM_SKY_GENERATION_VERSION) {
|
||||||
|
if (sky.color() != -1) return sky.color();
|
||||||
|
if (sky.style() == AgeSkyProfile.Style.STATIC) return 0xFF929292;
|
||||||
|
}
|
||||||
|
var profile = definition.plan() == null
|
||||||
|
? AgeRuntimeProfiles.resolve(definition.generationProfile())
|
||||||
|
: definition.plan().runtimeProfile();
|
||||||
|
ResourceLocation baseEffects = level.getServer().registryAccess()
|
||||||
|
.lookupOrThrow(Registries.DIMENSION_TYPE)
|
||||||
|
.getOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, profile.dimensionType()))
|
||||||
|
.value().effectsLocation();
|
||||||
|
return selectedSkyColor(definition.plan(), baseEffects, biomeColor);
|
||||||
|
}
|
||||||
|
|
||||||
public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
|
public static boolean hasWrittenPhase(AgeGenerationPlan plan) {
|
||||||
if (plan == null) return false;
|
if (plan == null) return false;
|
||||||
if (plan.version() >= 4) return orderedCelestial(plan, null).writtenPhase();
|
if (plan.version() >= 4) return orderedCelestial(plan, null).writtenPhase();
|
||||||
@@ -95,15 +155,19 @@ public final class AgeEnvironmentCompiler {
|
|||||||
Double pendingPeriod = null;
|
Double pendingPeriod = null;
|
||||||
Float pendingPhase = null;
|
Float pendingPhase = null;
|
||||||
boolean writtenPhase = false;
|
boolean writtenPhase = false;
|
||||||
|
Double pendingNumber = null;
|
||||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||||
SymbolSemantics semantics = symbol.semantics();
|
SymbolSemantics semantics = symbol.semantics();
|
||||||
|
Double number = pendingNumber;
|
||||||
|
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
|
||||||
switch (semantics.role()) {
|
switch (semantics.role()) {
|
||||||
|
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||||
case PERIOD_MODIFIER -> {
|
case PERIOD_MODIFIER -> {
|
||||||
double next = ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
double next = CelestialPageValues.value(semantics, number);
|
||||||
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
|
pendingPeriod = pendingPeriod == null ? next : (pendingPeriod + next) / 2.0D;
|
||||||
}
|
}
|
||||||
case PHASE_MODIFIER -> {
|
case PHASE_MODIFIER -> {
|
||||||
float next = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
float next = (float) CelestialPageValues.value(semantics, number);
|
||||||
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
|
pendingPhase = pendingPhase == null ? next : circularMidpoint(pendingPhase, next);
|
||||||
}
|
}
|
||||||
case CELESTIAL -> {
|
case CELESTIAL -> {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||||
@@ -235,6 +236,9 @@ public final class AgeFeatureSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
|
public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) {
|
||||||
|
if (RuinsTemplatePages.isSymbol(choice))
|
||||||
|
return RuinsTemplatePages.contains(choice) ? null : "Ruins template " + choice
|
||||||
|
+ " is unavailable; restore Ruins and its template before loading this Age";
|
||||||
if (AgeStructureSelection.feature(choice))
|
if (AgeStructureSelection.feature(choice))
|
||||||
return AgeStructureSelection.error(choice, profile, null, registries);
|
return AgeStructureSelection.error(choice, profile, null, registries);
|
||||||
if (choice.equals(AgeDenseOres.ID))
|
if (choice.equals(AgeDenseOres.ID))
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ public record AgeGenerationPlan(int version, long ageSeed, OrderedWriting writin
|
|||||||
String raw = item.getString("Value");
|
String raw = item.getString("Value");
|
||||||
SymbolSemantics.Value value = switch (role) {
|
SymbolSemantics.Value value = switch (role) {
|
||||||
case TERRAIN, BIOME, BLOCK, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
|
case TERRAIN, BIOME, BLOCK, FEATURE -> new SymbolSemantics.ResourceValue(ResourceLocation.parse(raw));
|
||||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER ->
|
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER, COLOR_NUMBER ->
|
||||||
new SymbolSemantics.NumberValue(Double.parseDouble(raw));
|
new SymbolSemantics.NumberValue(Double.parseDouble(raw));
|
||||||
default -> new SymbolSemantics.ChoiceValue(raw);
|
default -> new SymbolSemantics.ChoiceValue(raw);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
import net.minecraft.core.RegistryAccess;
|
import net.minecraft.core.RegistryAccess;
|
||||||
import net.minecraft.nbt.CompoundTag;
|
import net.minecraft.nbt.CompoundTag;
|
||||||
import net.minecraft.nbt.ListTag;
|
import net.minecraft.nbt.ListTag;
|
||||||
@@ -15,6 +16,7 @@ import net.minecraft.resources.ResourceLocation;
|
|||||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||||
public static final int CURRENT_VERSION = 2;
|
public static final int CURRENT_VERSION = 2;
|
||||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||||
|
private static final int INEFFECTIVE_CAVE_PAGE_COST = 100;
|
||||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||||
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
|
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
|
||||||
@@ -50,6 +52,10 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
}
|
}
|
||||||
|
|
||||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries) {
|
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries) {
|
||||||
|
return compute(plan, traits, registries, Long.MIN_VALUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries, long ageSeed) {
|
||||||
Objects.requireNonNull(traits, "traits");
|
Objects.requireNonNull(traits, "traits");
|
||||||
List<Contribution> contributions = new ArrayList<>();
|
List<Contribution> contributions = new ArrayList<>();
|
||||||
if (plan == null) {
|
if (plan == null) {
|
||||||
@@ -81,6 +87,17 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
|
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
|
||||||
"Seeded bright lighting"));
|
"Seeded bright lighting"));
|
||||||
}
|
}
|
||||||
|
AgeSkyProfile sky = plan == null && ageSeed != Long.MIN_VALUE ? AgeSkyProfile.random(ageSeed, traits)
|
||||||
|
: plan == null ? new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, List.of())
|
||||||
|
: AgeSkyProfile.compile(plan, traits);
|
||||||
|
int seenSuns = 0;
|
||||||
|
for (AgeSkyProfile.Body body : sky.bodies()) {
|
||||||
|
if (body.kind() != AgeSkyProfile.Kind.SUN) continue;
|
||||||
|
if (++seenSuns <= 2) continue;
|
||||||
|
int points = 1000 * (seenSuns - 2);
|
||||||
|
contributions.add(new Contribution("extra_sun", -1, points,
|
||||||
|
"Sun " + seenSuns + " adds growing instability"));
|
||||||
|
}
|
||||||
return new AgeInstability(CURRENT_VERSION,
|
return new AgeInstability(CURRENT_VERSION,
|
||||||
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
|
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
|
||||||
}
|
}
|
||||||
@@ -88,7 +105,20 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
|
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
|
||||||
List<Contribution> contributions) {
|
List<Contribution> contributions) {
|
||||||
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
||||||
|
boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile());
|
||||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||||
|
if (cave) {
|
||||||
|
var role = symbol.semantics().role();
|
||||||
|
if (switch (role) {
|
||||||
|
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER,
|
||||||
|
ANGLE_MODIFIER, DIRECTION_MODIFIER -> true;
|
||||||
|
default -> false;
|
||||||
|
}) {
|
||||||
|
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
|
||||||
|
INEFFECTIVE_CAVE_PAGE_COST,
|
||||||
|
"Sky or weather page has no effect in Cave terrain"));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (symbol.semantics().role()
|
if (symbol.semantics().role()
|
||||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
|
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
|
||||||
&& symbol.semantics().value()
|
&& symbol.semantics().value()
|
||||||
@@ -121,9 +151,12 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
|| !(symbol.semantics().value()
|
|| !(symbol.semantics().value()
|
||||||
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
|
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
|
||||||
String name = COUNTED_STRUCTURES.get(resource.id());
|
String name = COUNTED_STRUCTURES.get(resource.id());
|
||||||
|
if (name == null && RuinsTemplatePages.isSymbol(resource.id()))
|
||||||
|
name = RuinsTemplatePages.fallbackName(resource.id());
|
||||||
if (name == null) continue;
|
if (name == null) continue;
|
||||||
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
|
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
|
||||||
if (AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
|
if (COUNTED_STRUCTURES.containsKey(resource.id())
|
||||||
|
&& AgeStructureSelection.requiresBiomeOverride(resource.id(), plan.biomeSelection(), registries)) {
|
||||||
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
|
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
|
||||||
FORCED_STRUCTURE_BIOME_COST,
|
FORCED_STRUCTURE_BIOME_COST,
|
||||||
name + " occurrence " + count + " is forced into an unsupported biome"));
|
name + " occurrence " + count + " is forced into an unsupported biome"));
|
||||||
@@ -139,7 +172,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
|
|
||||||
static int featureCost(ResourceLocation feature, int occurrence) {
|
static int featureCost(ResourceLocation feature, int occurrence) {
|
||||||
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
|
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
|
||||||
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
|
return (COUNTED_STRUCTURES.containsKey(feature) || RuinsTemplatePages.isSymbol(feature)) && occurrence > 3
|
||||||
? EXCESS_LEGACY_STRUCTURE_COST : 0;
|
? EXCESS_LEGACY_STRUCTURE_COST : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,49 @@ public final class AgePersistenceGameTests {
|
|||||||
private AgePersistenceGameTests() {
|
private AgePersistenceGameTests() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void modifierSeedChangePersistsWithoutChangingAgeIdentity(GameTestHelper helper) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AgeDefinition original = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
|
new AgeReference(id, "Reseed " + id));
|
||||||
|
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||||
|
long newSeed = original.seed() == 987654321L ? 987654322L : 987654321L;
|
||||||
|
helper.assertTrue(registry.reseed(helper.getLevel().getServer(), id, newSeed),
|
||||||
|
"An unoccupied Age must accept a new seed");
|
||||||
|
AgeDefinition changed = registry.find(id).orElseThrow();
|
||||||
|
helper.assertValueEqual(changed.id(), original.id(), "Reseeding must preserve the Age identity");
|
||||||
|
helper.assertValueEqual(changed.dimensionId(), original.dimensionId(),
|
||||||
|
"Reseeding must preserve its dimension and existing chunk location");
|
||||||
|
helper.assertValueEqual(changed.seed(), newSeed, "The new generator seed must be saved");
|
||||||
|
AgeDefinition restored = AgeRegistrySavedData.load(
|
||||||
|
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||||
|
helper.getLevel().registryAccess()).find(id).orElseThrow();
|
||||||
|
helper.assertValueEqual(restored.seed(), newSeed, "The new seed must survive reload");
|
||||||
|
var ageLevel = AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), changed);
|
||||||
|
helper.assertTrue(ageLevel != null, "The reseeded Age must reload");
|
||||||
|
helper.assertValueEqual(ageLevel.getSeed(), newSeed,
|
||||||
|
"The runtime level must use the saved seed for new chunks");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void retiredAgeIdentitySurvivesSaveAndCannotLoad(GameTestHelper helper) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AgeReference reference = new AgeReference(id, "Retired " + id);
|
||||||
|
AgeDefinition age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
|
||||||
|
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||||
|
helper.assertTrue(registry.markDead(id), "The first retirement must set the tombstone");
|
||||||
|
helper.assertTrue(!registry.markDead(id), "Retirement must be idempotent");
|
||||||
|
helper.assertTrue(AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), age) == null,
|
||||||
|
"A retired Age must never load from an old book");
|
||||||
|
AgeRegistrySavedData restored = AgeRegistrySavedData.load(
|
||||||
|
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||||
|
helper.getLevel().registryAccess());
|
||||||
|
helper.assertTrue(restored.isDead(id) && restored.find(id).isPresent(),
|
||||||
|
"Both the retired flag and reserved Age identity must survive reload");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
|
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
|
||||||
OrderedWriting writing = new OrderedWriting(List.of(
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
@@ -82,9 +125,9 @@ public final class AgePersistenceGameTests {
|
|||||||
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
|
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
|
||||||
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
|
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
|
||||||
helper.assertValueEqual(stableAge.instability().score(), 0,
|
helper.assertValueEqual(nonCelestialInstability(stableAge), 0,
|
||||||
"Missing seeded branches do not add instability");
|
"Missing seeded branches do not add instability");
|
||||||
helper.assertValueEqual(defectiveAge.instability().score(), 600,
|
helper.assertValueEqual(nonCelestialInstability(defectiveAge), 600,
|
||||||
"Out-of-order and repeated terrain pages each add their saved penalty");
|
"Out-of-order and repeated terrain pages each add their saved penalty");
|
||||||
helper.assertTrue(defectiveAge.instability().breakdown().stream()
|
helper.assertTrue(defectiveAge.instability().breakdown().stream()
|
||||||
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
|
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
|
||||||
@@ -141,9 +184,9 @@ public final class AgePersistenceGameTests {
|
|||||||
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
|
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
|
||||||
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
|
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
|
||||||
helper.assertValueEqual(three.instability().score(), 0,
|
helper.assertValueEqual(nonCelestialInstability(three), 0,
|
||||||
"The first three " + structure + " pages retain Legacy's zero cost");
|
"The first three " + structure + " pages retain Legacy's zero cost");
|
||||||
helper.assertValueEqual(four.instability().score(), 100,
|
helper.assertValueEqual(nonCelestialInstability(four), 100,
|
||||||
"The fourth " + structure + " page must add Legacy's 100-point cost");
|
"The fourth " + structure + " page must add Legacy's 100-point cost");
|
||||||
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
|
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
|
||||||
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
|
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
|
||||||
@@ -164,6 +207,13 @@ public final class AgePersistenceGameTests {
|
|||||||
"Modern repeated structures must use the same deterministic scoring path");
|
"Modern repeated structures must use the same deterministic scoring path");
|
||||||
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
|
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
|
||||||
"A Legacy-neutral feature must remain neutral when repeated");
|
"A Legacy-neutral feature must remain neutral when repeated");
|
||||||
|
ResourceLocation ruin = net.dnorberg.rivencraft.compat.RuinsTemplatePages.symbolId("ZombieHut.tml");
|
||||||
|
helper.assertValueEqual(AgeInstability.featureCost(ruin, 1), 0,
|
||||||
|
"The first Ruins template page is free like a standard structure");
|
||||||
|
helper.assertValueEqual(AgeInstability.featureCost(ruin, 3), 0,
|
||||||
|
"The first three copies of a Ruins page are free");
|
||||||
|
helper.assertValueEqual(AgeInstability.featureCost(ruin, 4), 100,
|
||||||
|
"Each Ruins page beyond the third adds 100 instability");
|
||||||
|
|
||||||
OrderedWriting duplicateBright = new OrderedWriting(List.of(
|
OrderedWriting duplicateBright = new OrderedWriting(List.of(
|
||||||
WritingEntry.linkPanel(),
|
WritingEntry.linkPanel(),
|
||||||
@@ -173,7 +223,7 @@ public final class AgePersistenceGameTests {
|
|||||||
));
|
));
|
||||||
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
|
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
|
||||||
helper.assertValueEqual(bright.instability().score(), 1500,
|
helper.assertValueEqual(nonCelestialInstability(bright), 1500,
|
||||||
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
|
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
|
||||||
helper.assertValueEqual(bright.instability().breakdown().stream()
|
helper.assertValueEqual(bright.instability().breakdown().stream()
|
||||||
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
|
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
|
||||||
@@ -377,6 +427,13 @@ public final class AgePersistenceGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Isolate the writing-cost assertion from seed-completed extra-sun instability. */
|
||||||
|
private static int nonCelestialInstability(AgeDefinition age) {
|
||||||
|
return age.instability().breakdown().stream()
|
||||||
|
.filter(part -> !part.reason().equals("extra_sun"))
|
||||||
|
.mapToInt(AgeInstability.Contribution::points).sum();
|
||||||
|
}
|
||||||
|
|
||||||
private static CompoundTag entry(
|
private static CompoundTag entry(
|
||||||
UUID id,
|
UUID id,
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.minecraft.nbt.StringTag;
|
||||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.HolderLookup;
|
import net.minecraft.core.HolderLookup;
|
||||||
@@ -13,6 +16,7 @@ import net.minecraft.nbt.CompoundTag;
|
|||||||
import net.minecraft.nbt.ListTag;
|
import net.minecraft.nbt.ListTag;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
import net.minecraft.util.datafix.DataFixTypes;
|
import net.minecraft.util.datafix.DataFixTypes;
|
||||||
import net.minecraft.world.level.saveddata.SavedData;
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
@@ -21,6 +25,7 @@ import net.neoforged.fml.ModList;
|
|||||||
public final class AgeRegistrySavedData extends SavedData {
|
public final class AgeRegistrySavedData extends SavedData {
|
||||||
private static final String DATA_NAME = "rivencraft_ages";
|
private static final String DATA_NAME = "rivencraft_ages";
|
||||||
private static final String AGES_TAG = "Ages";
|
private static final String AGES_TAG = "Ages";
|
||||||
|
private static final String DEAD_AGES_TAG = "DeadAges";
|
||||||
private static final String ID_TAG = "Id";
|
private static final String ID_TAG = "Id";
|
||||||
private static final String NAME_TAG = "Name";
|
private static final String NAME_TAG = "Name";
|
||||||
private static final String SEED_TAG = "Seed";
|
private static final String SEED_TAG = "Seed";
|
||||||
@@ -50,6 +55,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private final Map<UUID, AgeDefinition> ages = new HashMap<>();
|
private final Map<UUID, AgeDefinition> ages = new HashMap<>();
|
||||||
|
private final Set<UUID> deadAges = new HashSet<>();
|
||||||
|
|
||||||
public static AgeRegistrySavedData get(ServerLevel level) {
|
public static AgeRegistrySavedData get(ServerLevel level) {
|
||||||
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
||||||
@@ -110,6 +116,16 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
return List.copyOf(ages.values());
|
return List.copyOf(ages.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isDead(UUID ageId) { return deadAges.contains(ageId); }
|
||||||
|
|
||||||
|
/** Keep the ID and definition reserved so an old book cannot regenerate a retired Age. */
|
||||||
|
public boolean markDead(UUID ageId) {
|
||||||
|
if (!ages.containsKey(ageId)) return false;
|
||||||
|
boolean changed = deadAges.add(ageId);
|
||||||
|
if (changed) setDirty();
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
|
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
|
||||||
AgeDefinition existing = ages.get(ageId);
|
AgeDefinition existing = ages.get(ageId);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
@@ -121,6 +137,32 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Changes the generator for future chunks; existing chunk data is deliberately preserved. */
|
||||||
|
public boolean reseed(MinecraftServer server, UUID ageId, long seed) {
|
||||||
|
AgeDefinition previous = ages.get(ageId);
|
||||||
|
if (previous == null || isDead(ageId)) return false;
|
||||||
|
if (previous.seed() == seed) return true;
|
||||||
|
ServerLevel loaded = server.getLevel(AgeDimensionService.dimensionKey(previous));
|
||||||
|
if (loaded != null && !loaded.players().isEmpty()) return false;
|
||||||
|
|
||||||
|
// Legacy changes the Age's seed, not its already-written pages or selected traits.
|
||||||
|
// Keeping the frozen plan and feature list prevents a visited Age from changing
|
||||||
|
// its identity while newly generated chunks use the revised terrain noise.
|
||||||
|
AgeGenerationPlan oldPlan = previous.plan();
|
||||||
|
AgeGenerationPlan plan = oldPlan == null ? null : new AgeGenerationPlan(oldPlan.version(), seed,
|
||||||
|
oldPlan.writing(), oldPlan.symbols(), oldPlan.runtimeProfile(), oldPlan.biomeSelection(),
|
||||||
|
oldPlan.activeSymbolIndices(), oldPlan.writingInstability());
|
||||||
|
AgeDefinition changed = new AgeDefinition(previous.id(), previous.name(), seed,
|
||||||
|
previous.generationProfile(), previous.generationVersion(), previous.createdGameTime(),
|
||||||
|
previous.dimensionId(), previous.spawn(), previous.traits(), plan, previous.seasonMode(),
|
||||||
|
previous.randomFeatures(), previous.instability(), previous.instabilityPlan());
|
||||||
|
if (AgeDimensionService.unload(server, previous) == AgeDimensionService.UnloadResult.FAILED)
|
||||||
|
return false;
|
||||||
|
ages.put(ageId, changed);
|
||||||
|
setDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing,
|
private AgeDefinition getOrCreate(AgeReference reference, OrderedWriting writing,
|
||||||
long worldSeed, long createdGameTime, RegistryAccess registries) {
|
long worldSeed, long createdGameTime, RegistryAccess registries) {
|
||||||
AgeDefinition existing = ages.get(reference.id());
|
AgeDefinition existing = ages.get(reference.id());
|
||||||
@@ -134,8 +176,10 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
||||||
AgeTraits traits = AgeTraits.select(seed);
|
AgeTraits traits = AgeTraits.select(seed);
|
||||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
||||||
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
|
AgeTraits effectiveTraits = plan == null
|
||||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
|
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
|
||||||
|
: AgeEnvironmentCompiler.apply(plan, traits);
|
||||||
|
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries, seed);
|
||||||
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
||||||
AgeDefinition created = new AgeDefinition(
|
AgeDefinition created = new AgeDefinition(
|
||||||
reference.id(),
|
reference.id(),
|
||||||
@@ -148,7 +192,8 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
null,
|
null,
|
||||||
effectiveTraits,
|
effectiveTraits,
|
||||||
plan,
|
plan,
|
||||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
|
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
|
||||||
|
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
|
||||||
plan == null ? AgeFeatureSelection.randomChoices(seed,
|
plan == null ? AgeFeatureSelection.randomChoices(seed,
|
||||||
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
|
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
|
||||||
instability,
|
instability,
|
||||||
@@ -161,6 +206,11 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
|
|
||||||
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
AgeRegistrySavedData data = new AgeRegistrySavedData();
|
AgeRegistrySavedData data = new AgeRegistrySavedData();
|
||||||
|
ListTag deadTags = tag.getList(DEAD_AGES_TAG, StringTag.TAG_STRING);
|
||||||
|
for (int index = 0; index < deadTags.size(); index++) {
|
||||||
|
try { data.deadAges.add(UUID.fromString(deadTags.getString(index))); }
|
||||||
|
catch (IllegalArgumentException ignored) { }
|
||||||
|
}
|
||||||
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
|
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
|
||||||
for (int index = 0; index < serializedAges.size(); index++) {
|
for (int index = 0; index < serializedAges.size(); index++) {
|
||||||
CompoundTag entry = serializedAges.getCompound(index);
|
CompoundTag entry = serializedAges.getCompound(index);
|
||||||
@@ -264,6 +314,9 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
serializedAges.add(entry);
|
serializedAges.add(entry);
|
||||||
});
|
});
|
||||||
tag.put(AGES_TAG, serializedAges);
|
tag.put(AGES_TAG, serializedAges);
|
||||||
|
ListTag deadTags = new ListTag();
|
||||||
|
deadAges.stream().sorted().forEach(id -> deadTags.add(StringTag.valueOf(id.toString())));
|
||||||
|
tag.put(DEAD_AGES_TAG, deadTags);
|
||||||
return tag;
|
return tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public enum AgeSeasonMode {
|
|||||||
|
|
||||||
/** An omitted page is selected once when an Age is created with Serene installed. */
|
/** An omitted page is selected once when an Age is created with Serene installed. */
|
||||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
|
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
|
||||||
|
return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
|
||||||
|
AgeRuntimeProfile profile) {
|
||||||
|
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
|
||||||
if (hasAuthoredPage(plan)) return from(plan);
|
if (hasAuthoredPage(plan)) return from(plan);
|
||||||
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
|
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
|
||||||
new LegacyRankChoice.Option<>(SEASONS, 1),
|
new LegacyRankChoice.Option<>(SEASONS, 1),
|
||||||
@@ -31,6 +37,7 @@ public enum AgeSeasonMode {
|
|||||||
|
|
||||||
/** Existing Ages without a stored choice retain their old cycling behavior. */
|
/** Existing Ages without a stored choice retain their old cycling behavior. */
|
||||||
public static AgeSeasonMode from(AgeDefinition age) {
|
public static AgeSeasonMode from(AgeDefinition age) {
|
||||||
|
if (age.caveTerrain()) return NO_SEASONS;
|
||||||
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
|
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +56,7 @@ public enum AgeSeasonMode {
|
|||||||
|
|
||||||
public static AgeSeasonMode from(AgeGenerationPlan plan) {
|
public static AgeSeasonMode from(AgeGenerationPlan plan) {
|
||||||
if (plan == null) return SEASONS;
|
if (plan == null) return SEASONS;
|
||||||
|
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS;
|
||||||
return plan.activeSymbols().stream()
|
return plan.activeSymbols().stream()
|
||||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
|
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
|
||||||
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
|
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
|
||||||
@@ -58,6 +66,7 @@ public enum AgeSeasonMode {
|
|||||||
|
|
||||||
public static void validate(AgeGenerationPlan plan) {
|
public static void validate(AgeGenerationPlan plan) {
|
||||||
if (plan == null) return;
|
if (plan == null) return;
|
||||||
|
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return;
|
||||||
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
|
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
|
||||||
&& !ModList.get().isLoaded("sereneseasons")) {
|
&& !ModList.get().isLoaded("sereneseasons")) {
|
||||||
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
|
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.Set;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
|
|
||||||
|
/** The ordered, deterministic sky specification for an Age. No client data is trusted here. */
|
||||||
|
public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
|
||||||
|
public static final int RANDOM_SKY_GENERATION_VERSION = 17;
|
||||||
|
private static final int[] COLORS = {0xB74A50, 0xD9834B, 0xDBC574, 0x5AAB80,
|
||||||
|
0x6FA8DF, 0x9870B9, 0xD5DBE1, 0x252531};
|
||||||
|
|
||||||
|
public AgeSkyProfile {
|
||||||
|
bodies = List.copyOf(bodies);
|
||||||
|
long suns = bodies.stream().filter(body -> body.kind() == Kind.SUN).count();
|
||||||
|
long moons = bodies.size() - suns;
|
||||||
|
if (suns > 10 || moons > 10) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AgeSkyProfile forAge(AgeDefinition age) {
|
||||||
|
if (age.plan() != null) return compile(age.plan(), age.traits());
|
||||||
|
if (age.generationVersion() >= RANDOM_SKY_GENERATION_VERSION)
|
||||||
|
return random(age.seed(), age.traits());
|
||||||
|
return new AgeSkyProfile(Style.INHERITED, -1, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AgeSkyProfile random(long seed, AgeTraits traits) {
|
||||||
|
Random random = new Random(seed ^ 0x534B5950524F464CL);
|
||||||
|
Style style = Style.values()[random.nextInt(Style.values().length)];
|
||||||
|
int color = randomColor(random);
|
||||||
|
int suns = random.nextInt(4);
|
||||||
|
int moons = random.nextInt(4);
|
||||||
|
List<Body> bodies = new ArrayList<>();
|
||||||
|
for (int i = 0; i < suns; i++)
|
||||||
|
bodies.add(new Body(Kind.SUN, randomColor(random),
|
||||||
|
0.75F + random.nextFloat(), random.nextFloat() * 360.0F,
|
||||||
|
traits.celestialPeriod(), random.nextFloat(), random.nextBoolean()));
|
||||||
|
for (int i = 0; i < moons; i++)
|
||||||
|
bodies.add(new Body(Kind.MOON, randomColor(random),
|
||||||
|
0.75F + random.nextFloat(), random.nextFloat() * 360.0F,
|
||||||
|
traits.celestialPeriod(), random.nextFloat(), random.nextBoolean()));
|
||||||
|
return new AgeSkyProfile(style, color, bodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AgeSkyProfile compile(AgeGenerationPlan plan, AgeTraits traits) {
|
||||||
|
if (plan == null) return new AgeSkyProfile(Style.INHERITED, -1, List.of());
|
||||||
|
Style style = Style.INHERITED;
|
||||||
|
int color = authoredSkyColor(plan);
|
||||||
|
boolean customBodies = hasCustomCelestials(plan);
|
||||||
|
List<Body> bodies = new ArrayList<>();
|
||||||
|
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||||
|
AgeTraits seeded = AgeTraits.select(plan.ageSeed());
|
||||||
|
double period = 1.0D;
|
||||||
|
float angle = seeded.celestialAngle();
|
||||||
|
float phase = seeded.celestialPhase();
|
||||||
|
float size = 1.0F;
|
||||||
|
List<Integer> bodyColors = new ArrayList<>();
|
||||||
|
ColorAccumulator pendingColors = new ColorAccumulator();
|
||||||
|
Double pendingNumber = null;
|
||||||
|
boolean clockwise = true;
|
||||||
|
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||||
|
if (!active.contains(symbol.index())) continue;
|
||||||
|
SymbolSemantics semantics = symbol.semantics();
|
||||||
|
Double number = pendingNumber;
|
||||||
|
if (semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) pendingNumber = null;
|
||||||
|
switch (semantics.role()) {
|
||||||
|
case SKY -> {
|
||||||
|
pendingColors.clear();
|
||||||
|
style = switch (((SymbolSemantics.ChoiceValue) semantics.value()).id()) {
|
||||||
|
case "overworld" -> Style.OVERWORLD;
|
||||||
|
case "nether" -> Style.NETHER;
|
||||||
|
case "end" -> Style.END;
|
||||||
|
case "static" -> Style.STATIC;
|
||||||
|
default -> throw new IllegalArgumentException("Unknown sky style");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case COLOR_NUMBER -> pendingNumber = ((SymbolSemantics.NumberValue)
|
||||||
|
semantics.value()).amount();
|
||||||
|
case COLOR -> {
|
||||||
|
pendingColors.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
|
||||||
|
number == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : number.intValue());
|
||||||
|
}
|
||||||
|
case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
|
||||||
|
case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
|
||||||
|
case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
|
||||||
|
case SIZE_MODIFIER -> size = (float) CelestialPageValues.value(semantics, number);
|
||||||
|
case CELESTIAL_COLOR -> bodyColors.add(color(
|
||||||
|
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
|
||||||
|
case DIRECTION_MODIFIER -> clockwise = ((SymbolSemantics.ChoiceValue) semantics.value())
|
||||||
|
.id().equals("clockwise");
|
||||||
|
case CELESTIAL -> {
|
||||||
|
String choice = ((SymbolSemantics.ChoiceValue) semantics.value()).id();
|
||||||
|
int bodyColor = pendingColors.written() ? pendingColors.rgb()
|
||||||
|
: bodyColors.isEmpty() ? 0xFFFFFF : mixColors(bodyColors);
|
||||||
|
if (choice.equals("normal_time")) {
|
||||||
|
if (customBodies) {
|
||||||
|
bodies.add(new Body(Kind.SUN, bodyColor, size, angle, period, phase, clockwise));
|
||||||
|
bodies.add(new Body(Kind.MOON, bodyColor, 1.0F, angle, period,
|
||||||
|
(phase + 0.5F) % 1.0F, clockwise));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bodies.add(new Body(choice.equals("sun") ? Kind.SUN : Kind.MOON,
|
||||||
|
bodyColor, size, angle, period, phase, clockwise));
|
||||||
|
}
|
||||||
|
period = 1.0D;
|
||||||
|
angle = seeded.celestialAngle();
|
||||||
|
phase = seeded.celestialPhase();
|
||||||
|
size = 1.0F;
|
||||||
|
bodyColors.clear();
|
||||||
|
pendingColors.clear();
|
||||||
|
clockwise = true;
|
||||||
|
}
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Missing branches are seed-completed, including the celestial branch.
|
||||||
|
// An explicit sky or sky-color page does not suppress that completion.
|
||||||
|
if (plan.activeSymbols().stream().noneMatch(symbol ->
|
||||||
|
symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL)) {
|
||||||
|
bodies.addAll(random(plan.ageSeed(), seeded).bodies());
|
||||||
|
}
|
||||||
|
return new AgeSkyProfile(style, color, bodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int mixColors(List<Integer> colors) {
|
||||||
|
if (colors.isEmpty()) return -1;
|
||||||
|
long red = 0, green = 0, blue = 0;
|
||||||
|
for (int color : colors) {
|
||||||
|
red += color >> 16 & 255;
|
||||||
|
green += color >> 8 & 255;
|
||||||
|
blue += color & 255;
|
||||||
|
}
|
||||||
|
int count = colors.size();
|
||||||
|
return (int) ((red + count / 2) / count) << 16
|
||||||
|
| (int) ((green + count / 2) / count) << 8
|
||||||
|
| (int) ((blue + count / 2) / count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared color pages affect the next Sky or celestial page; trailing colors tint the sky. */
|
||||||
|
public static int authoredSkyColor(AgeGenerationPlan plan) {
|
||||||
|
if (plan == null) return -1;
|
||||||
|
ColorAccumulator pending = new ColorAccumulator();
|
||||||
|
Integer multiplier = null;
|
||||||
|
int skyColor = -1;
|
||||||
|
List<Integer> legacyColors = new ArrayList<>();
|
||||||
|
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||||
|
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||||
|
if (!active.contains(symbol.index())) continue;
|
||||||
|
SymbolSemantics semantics = symbol.semantics();
|
||||||
|
if (semantics.role() != SymbolSemantics.Role.COLOR
|
||||||
|
&& semantics.role() != SymbolSemantics.Role.COLOR_NUMBER) multiplier = null;
|
||||||
|
switch (semantics.role()) {
|
||||||
|
case COLOR_NUMBER -> multiplier = (int) ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||||
|
case COLOR -> {
|
||||||
|
pending.add(((SymbolSemantics.ChoiceValue) semantics.value()).id(),
|
||||||
|
multiplier == null ? randomColorNumber(plan.ageSeed(), symbol.index()) : multiplier);
|
||||||
|
multiplier = null;
|
||||||
|
}
|
||||||
|
case SKY -> {
|
||||||
|
if (pending.written()) skyColor = pending.rgb();
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
case CELESTIAL -> pending.clear();
|
||||||
|
case SKY_COLOR -> legacyColors.add(color(
|
||||||
|
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pending.written()) skyColor = pending.rgb();
|
||||||
|
return skyColor != -1 ? skyColor : mixColors(legacyColors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int randomColorNumber(long ageSeed, int pageIndex) {
|
||||||
|
return new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (pageIndex + 1L))).nextInt(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ColorAccumulator {
|
||||||
|
private int red;
|
||||||
|
private int green;
|
||||||
|
private int blue;
|
||||||
|
private boolean written;
|
||||||
|
|
||||||
|
void add(String color, int multiplier) {
|
||||||
|
int amount = multiplier * 10;
|
||||||
|
written = true;
|
||||||
|
switch (color) {
|
||||||
|
case "red" -> red += amount;
|
||||||
|
case "green" -> green += amount;
|
||||||
|
case "blue" -> blue += amount;
|
||||||
|
case "white" -> { red += amount; green += amount; blue += amount; }
|
||||||
|
case "black" -> { red -= amount; green -= amount; blue -= amount; }
|
||||||
|
default -> throw new IllegalArgumentException("Unknown shared color " + color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean written() { return written; }
|
||||||
|
|
||||||
|
int rgb() {
|
||||||
|
return clamp(red) << 16 | clamp(green) << 8 | clamp(blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() { red = green = blue = 0; written = false; }
|
||||||
|
|
||||||
|
private static int clamp(int channel) { return Math.max(0, Math.min(255, channel)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int randomColor(Random random) {
|
||||||
|
int count = 1 + random.nextInt(3);
|
||||||
|
List<Integer> colors = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) colors.add(COLORS[random.nextInt(COLORS.length)]);
|
||||||
|
return mixColors(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasCustomCelestials(AgeGenerationPlan plan) {
|
||||||
|
return plan != null && (hasColorForCelestial(plan) || plan.activeSymbols().stream().anyMatch(symbol ->
|
||||||
|
symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL
|
||||||
|
&& !((SymbolSemantics.ChoiceValue) symbol.semantics().value()).id().equals("normal_time")
|
||||||
|
|| symbol.semantics().role() == SymbolSemantics.Role.CELESTIAL_COLOR
|
||||||
|
|| symbol.semantics().role() == SymbolSemantics.Role.SIZE_MODIFIER
|
||||||
|
|| symbol.semantics().role() == SymbolSemantics.Role.ANGLE_MODIFIER
|
||||||
|
|| symbol.semantics().role() == SymbolSemantics.Role.DIRECTION_MODIFIER));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasColorForCelestial(AgeGenerationPlan plan) {
|
||||||
|
Set<Integer> active = new HashSet<>(plan.activeSymbolIndices());
|
||||||
|
boolean pending = false;
|
||||||
|
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||||
|
if (!active.contains(symbol.index())) continue;
|
||||||
|
switch (symbol.semantics().role()) {
|
||||||
|
case COLOR -> pending = true;
|
||||||
|
case SKY -> pending = false;
|
||||||
|
case CELESTIAL -> {
|
||||||
|
if (pending) return true;
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean custom() {
|
||||||
|
return style == Style.STATIC || color != -1 || !bodies.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vanilla has one skylight channel; combine visible bodies into that channel. */
|
||||||
|
public float lightAt(double dayTime) {
|
||||||
|
float sunlight = 0.0F;
|
||||||
|
float moonlight = 0.0F;
|
||||||
|
for (Body body : bodies) {
|
||||||
|
double orbit = body.period() == 0.0D ? body.phase()
|
||||||
|
: (body.clockwise() ? 1.0D : -1.0D) * dayTime
|
||||||
|
/ (24000.0D * body.period()) + body.phase();
|
||||||
|
float altitude = (float) Math.cos(orbit * Math.PI * 2.0D);
|
||||||
|
if (altitude <= 0.0F) continue;
|
||||||
|
if (body.kind() == Kind.SUN) sunlight += altitude * (1.0F - 0.85F * eclipseFor(body, dayTime));
|
||||||
|
else moonlight += altitude * 0.18F;
|
||||||
|
}
|
||||||
|
return Math.min(1.0F, sunlight > 0.0F ? sunlight : moonlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disc overlap in the rendered sky, from first contact (0) to centered alignment (1). */
|
||||||
|
public float eclipseFor(Body body, double dayTime) {
|
||||||
|
float strongest = 0.0F;
|
||||||
|
net.minecraft.world.phys.Vec3 bodyDirection = direction(body, dayTime);
|
||||||
|
for (Body other : bodies) {
|
||||||
|
if (other.kind() == body.kind()) continue;
|
||||||
|
double dot = Math.max(-1.0D, Math.min(1.0D,
|
||||||
|
bodyDirection.dot(direction(other, dayTime))));
|
||||||
|
double separation = Math.acos(dot);
|
||||||
|
// The outer 32x32 quad is only glow. The opaque body occupies its
|
||||||
|
// central 8x8 pixels, so contact must use one quarter of its size.
|
||||||
|
double radius = Math.atan((body.kind() == Kind.SUN ? 30.0D : 20.0D)
|
||||||
|
* body.size() * 0.25D / 100.0D)
|
||||||
|
+ Math.atan((other.kind() == Kind.SUN ? 30.0D : 20.0D)
|
||||||
|
* other.size() * 0.25D / 100.0D);
|
||||||
|
if (radius > 0.0D) strongest = Math.max(strongest,
|
||||||
|
(float) Math.max(0.0D, 1.0D - separation / radius));
|
||||||
|
}
|
||||||
|
return strongest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public float eclipseAt(double dayTime) {
|
||||||
|
float strongest = 0.0F;
|
||||||
|
for (Body body : bodies) {
|
||||||
|
if (body.kind() == Kind.SUN && direction(body, dayTime).y > 0.0D)
|
||||||
|
strongest = Math.max(strongest, eclipseFor(body, dayTime));
|
||||||
|
}
|
||||||
|
return strongest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static net.minecraft.world.phys.Vec3 direction(Body body, double dayTime) {
|
||||||
|
double orbit = body.period() == 0.0D ? body.phase()
|
||||||
|
: (body.clockwise() ? 1.0D : -1.0D) * dayTime
|
||||||
|
/ (24000.0D * body.period()) + body.phase();
|
||||||
|
double tilt = orbit * Math.PI * 2.0D;
|
||||||
|
double bearing = Math.toRadians(body.angle());
|
||||||
|
double lateral = Math.sin(tilt);
|
||||||
|
return new net.minecraft.world.phys.Vec3(
|
||||||
|
Math.sin(bearing) * lateral, Math.cos(tilt), Math.cos(bearing) * lateral);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int color(String name) {
|
||||||
|
return switch (name) {
|
||||||
|
case "red" -> COLORS[0];
|
||||||
|
case "orange" -> COLORS[1];
|
||||||
|
case "yellow" -> COLORS[2];
|
||||||
|
case "green" -> COLORS[3];
|
||||||
|
case "blue" -> COLORS[4];
|
||||||
|
case "purple" -> COLORS[5];
|
||||||
|
case "white" -> COLORS[6];
|
||||||
|
case "black" -> COLORS[7];
|
||||||
|
default -> throw new IllegalArgumentException("Unknown sky color " + name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Style { INHERITED, OVERWORLD, NETHER, END, STATIC }
|
||||||
|
public enum Kind { SUN, MOON }
|
||||||
|
public record Body(Kind kind, int color, float size, float angle,
|
||||||
|
double period, float phase, boolean clockwise) { }
|
||||||
|
}
|
||||||
@@ -178,8 +178,11 @@ public final class AgeStructureGameTests {
|
|||||||
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
new AgeReference(java.util.UUID.randomUUID(), "Mixed " + firstBiome), writing);
|
new AgeReference(java.util.UUID.randomUUID(), "Mixed " + firstBiome), writing);
|
||||||
int expected = firstBiome.equals("the_end") ? 200 : 0;
|
int expected = firstBiome.equals("the_end") ? 200 : 0;
|
||||||
helper.assertValueEqual(age.instability().score(), expected,
|
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||||
"Each Village page must charge 100 if any selected biome needs the override");
|
.filter(part -> part.reason().equals("forced_biome_villages"))
|
||||||
|
.mapToInt(AgeInstability.Contribution::points).sum(), expected,
|
||||||
|
"Each Village page must charge 100 if any selected biome needs the override; "
|
||||||
|
+ "seed-completed celestial instability is independent");
|
||||||
helper.assertValueEqual(age.instability().breakdown().stream()
|
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||||
.filter(part -> part.reason().equals("forced_biome_villages")).count(),
|
.filter(part -> part.reason().equals("forced_biome_villages")).count(),
|
||||||
firstBiome.equals("the_end") ? 2L : 0L,
|
firstBiome.equals("the_end") ? 2L : 0L,
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ public record AgeTraits(
|
|||||||
if (biomeDistribution == null || lighting == null || weather == null) {
|
if (biomeDistribution == null || lighting == null || weather == null) {
|
||||||
throw new IllegalArgumentException("Age trait choices cannot be null");
|
throw new IllegalArgumentException("Age trait choices cannot be null");
|
||||||
}
|
}
|
||||||
if (celestialPeriod != STOPPED_CELESTIAL_PERIOD
|
if (!Double.isFinite(celestialPeriod)
|
||||||
&& (celestialPeriod < MIN_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD)) {
|
|| celestialPeriod < STOPPED_CELESTIAL_PERIOD || celestialPeriod > MAX_CELESTIAL_PERIOD) {
|
||||||
throw new IllegalArgumentException("Celestial period is outside the legacy range");
|
throw new IllegalArgumentException("Celestial period is outside the legacy range");
|
||||||
}
|
}
|
||||||
if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) {
|
if (celestialAngle < MIN_CELESTIAL_ANGLE || celestialAngle >= MAX_CELESTIAL_ANGLE) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public final class AgeWeatherService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AgeTraits.Weather weather = age.traits().weather();
|
AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather();
|
||||||
if (weather == AgeTraits.Weather.NORMAL) {
|
if (weather == AgeTraits.Weather.NORMAL) {
|
||||||
STATES.remove(level);
|
STATES.remove(level);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.level.biome.Biomes;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class CaveTerrainGameTests {
|
||||||
|
private CaveTerrainGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) {
|
||||||
|
OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes",
|
||||||
|
"biome/tiled", "weather/storm", "season/eternal_winter", "period/zero",
|
||||||
|
"celestial/normal_time");
|
||||||
|
AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL,
|
||||||
|
AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F);
|
||||||
|
var registries = helper.getLevel().registryAccess();
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE,
|
||||||
|
seeded, registries);
|
||||||
|
AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded);
|
||||||
|
helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off");
|
||||||
|
helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock");
|
||||||
|
helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase");
|
||||||
|
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
|
||||||
|
"Cave seasons must be disabled even when Serene Seasons is present");
|
||||||
|
helper.assertValueEqual(plan.biomeSelection().biomes(),
|
||||||
|
List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()),
|
||||||
|
"Cave writing must retain exactly its cave and Nether biomes");
|
||||||
|
AgeInstability instability = AgeInstability.compute(plan, applied, registries);
|
||||||
|
helper.assertValueEqual(instability.breakdown().stream()
|
||||||
|
.filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L,
|
||||||
|
"Each ineffective weather, season, period, and celestial page must add instability");
|
||||||
|
helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries),
|
||||||
|
instability, "Cave page penalties must survive plan reload");
|
||||||
|
|
||||||
|
AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"),
|
||||||
|
42L, AgeGenerationProfiles.STANDARD, seeded, registries);
|
||||||
|
helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile())
|
||||||
|
&& AgeFeatureSelection.resolved(surfaceCaves).stream()
|
||||||
|
.anyMatch(choice -> AgeFeatureSelection.caves(choice.id())),
|
||||||
|
"The separate Caves feature page must still add caves to surface terrain");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty", timeoutTicks = 600)
|
||||||
|
public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID());
|
||||||
|
AgeDimensionService.resolve(player, reference,
|
||||||
|
writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow();
|
||||||
|
var registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||||
|
AgeDefinition age = registry.find(reference.id()).orElseThrow();
|
||||||
|
helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain");
|
||||||
|
helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64,
|
||||||
|
"Cave spawn search must start at Y=64, not atop the roof");
|
||||||
|
helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF,
|
||||||
|
"A new Cave Age must persist disabled weather");
|
||||||
|
helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS,
|
||||||
|
"A new Cave Age must persist disabled seasons");
|
||||||
|
AgeDefinition reloaded = AgeRegistrySavedData.load(
|
||||||
|
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||||
|
helper.getLevel().registryAccess()).find(reference.id()).orElseThrow();
|
||||||
|
helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload");
|
||||||
|
helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(),
|
||||||
|
List.of(Biomes.NETHER_WASTES.location()),
|
||||||
|
"The generated Cave Age must preserve its authored-only Nether pool");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderedWriting writing(String... pages) {
|
||||||
|
var entries = new java.util.ArrayList<WritingEntry>();
|
||||||
|
entries.add(WritingEntry.linkPanel());
|
||||||
|
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
|
||||||
|
ResourceLocation.parse("rivencraft:" + page))));
|
||||||
|
return new OrderedWriting(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
|
|
||||||
|
/** A preceding D'ni number overrides the next celestial modifier's fixed page value. */
|
||||||
|
final class CelestialPageValues {
|
||||||
|
private CelestialPageValues() {}
|
||||||
|
|
||||||
|
static double value(SymbolSemantics semantics, Double number) {
|
||||||
|
if (number == null) return ((SymbolSemantics.NumberValue) semantics.value()).amount();
|
||||||
|
return switch (semantics.role()) {
|
||||||
|
case PERIOD_MODIFIER -> number * 0.08D;
|
||||||
|
case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D;
|
||||||
|
case SIZE_MODIFIER -> 0.25D + number * (2.75D / 24.0D);
|
||||||
|
default -> throw new IllegalArgumentException("Not a celestial numeric modifier");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Focused core coverage without launching the storage-heavy environment archive. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class CelestialSizeGameTests {
|
||||||
|
private CelestialSizeGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void adjacentNumberSetsNextCelestialSize(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||||
|
page("number/0"), page("size/small"), page("celestial/sun"),
|
||||||
|
page("number/24"), page("size/large"), page("celestial/moon"),
|
||||||
|
page("number/12"), page("size/normal"), page("celestial/sun")));
|
||||||
|
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
|
||||||
|
"Adjacent number and size pages must be valid writing");
|
||||||
|
AgeTraits seeded = AgeTraits.select(94L);
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
|
||||||
|
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
|
||||||
|
AgeSkyProfile sky = AgeSkyProfile.compile(plan, AgeEnvironmentCompiler.apply(plan, seeded));
|
||||||
|
helper.assertValueEqual(sky.bodies().size(), 3, "Three authored bodies must remain distinct");
|
||||||
|
helper.assertValueEqual(sky.bodies().get(0).size(), 0.25F, "Digit zero selects minimum size");
|
||||||
|
helper.assertValueEqual(sky.bodies().get(1).size(), 3.0F, "Digit 24 selects maximum size");
|
||||||
|
helper.assertValueEqual(sky.bodies().get(2).size(), 1.625F, "Digit 12 selects midpoint size");
|
||||||
|
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()),
|
||||||
|
AgeEnvironmentCompiler.apply(plan, seeded)), sky,
|
||||||
|
"Numbered sizes must survive an Age-plan save/load round trip");
|
||||||
|
|
||||||
|
OrderedWriting plain = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||||
|
page("size/small"), page("celestial/sun"),
|
||||||
|
page("size/normal"), page("celestial/moon"),
|
||||||
|
page("size/large"), page("celestial/sun")));
|
||||||
|
AgeGenerationPlan plainPlan = AgeGenerationPlan.compile(plain, 95L,
|
||||||
|
AgeGenerationProfiles.STANDARD, AgeTraits.select(95L), helper.getLevel().registryAccess());
|
||||||
|
AgeSkyProfile plainSky = AgeSkyProfile.compile(plainPlan,
|
||||||
|
AgeEnvironmentCompiler.apply(plainPlan, AgeTraits.select(95L)));
|
||||||
|
helper.assertValueEqual(plainSky.bodies().get(0).size(), 0.5F, "Small retains its fixed value");
|
||||||
|
helper.assertValueEqual(plainSky.bodies().get(1).size(), 1.0F, "Normal retains its fixed value");
|
||||||
|
helper.assertValueEqual(plainSky.bodies().get(2).size(), 2.0F, "Large retains its fixed value");
|
||||||
|
|
||||||
|
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||||
|
page("number/24"), page("direction/clockwise"),
|
||||||
|
page("size/large"), page("celestial/sun")));
|
||||||
|
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
|
||||||
|
"An intervening page must not allow a number to modify size");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WritingEntry page(String path) {
|
||||||
|
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ public final class CloudPlatformGameTests {
|
|||||||
helper.getLevel().setBlock(pos, state, 2);
|
helper.getLevel().setBlock(pos, state, 2);
|
||||||
helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(),
|
helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(),
|
||||||
"Cloud platform must support a standing player");
|
"Cloud platform must support a standing player");
|
||||||
|
helper.assertTrue(state.canOcclude(), "Arrival platform must be opaque");
|
||||||
|
helper.assertValueEqual(state.getDestroySpeed(helper.getLevel(), pos), 0.3F,
|
||||||
|
"Arrival platform should break as easily as glass");
|
||||||
helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(),
|
helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(),
|
||||||
"Cloud platform must not provide a pick-block item");
|
"Cloud platform must not provide a pick-block item");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ public final class EnvironmentWritingGameTests {
|
|||||||
public static void environmentPagesEnterCreativeAndJeiFeed(GameTestHelper helper) {
|
public static void environmentPagesEnterCreativeAndJeiFeed(GameTestHelper helper) {
|
||||||
Set<String> expected = Set.of(
|
Set<String> expected = Set.of(
|
||||||
"lighting/normal", "lighting/dark", "lighting/bright",
|
"lighting/normal", "lighting/dark", "lighting/bright",
|
||||||
"sky/overworld", "sky/nether", "sky/end",
|
"sky/overworld", "sky/nether", "sky/end", "sky/static", "color/red", "color/green",
|
||||||
|
"color/blue", "color/black", "color/white", "number/0", "number/24",
|
||||||
"weather/normal", "weather/fast", "weather/slow", "weather/off",
|
"weather/normal", "weather/fast", "weather/slow", "weather/off",
|
||||||
"weather/always", "weather/storm", "celestial/normal_time",
|
"weather/always", "weather/storm", "celestial/normal_time", "celestial/sun",
|
||||||
|
"celestial/moon", "size/large", "angle/east",
|
||||||
|
"direction/clockwise", "direction/counterclockwise",
|
||||||
"season/no_seasons", "season/seasons", "season/eternal_winter",
|
"season/no_seasons", "season/seasons", "season/eternal_winter",
|
||||||
"season/eternal_summer", "season/eternal_fall", "season/eternal_spring",
|
"season/eternal_summer", "season/eternal_fall", "season/eternal_spring",
|
||||||
"period/zero", "period/half", "period/full", "period/double",
|
"period/zero", "period/half", "period/full", "period/double",
|
||||||
@@ -67,6 +70,9 @@ public final class EnvironmentWritingGameTests {
|
|||||||
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(expected)
|
helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(expected)
|
||||||
&& creative.containsAll(expected),
|
&& creative.containsAll(expected),
|
||||||
"Every supported environment page must enter the creative index JEI also consumes");
|
"Every supported environment page must enter the creative index JEI also consumes");
|
||||||
|
helper.assertValueEqual(PageItem.bundledCreativeSymbols().stream()
|
||||||
|
.filter(path -> path.startsWith("number/")).count(), 25L,
|
||||||
|
"Creative and JEI must expose all 25 D'ni number pages");
|
||||||
for (String path : expected) {
|
for (String path : expected) {
|
||||||
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", path);
|
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", path);
|
||||||
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id))
|
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id))
|
||||||
@@ -135,6 +141,207 @@ public final class EnvironmentWritingGameTests {
|
|||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void authoredStaticSkyColorSurvivesWritingAndFeedsPreview(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("terrain/standard"), page("number/20"),
|
||||||
|
page("color/blue"), page("sky/static"), page("celestial/normal_time")));
|
||||||
|
AgeTraits seeded = AgeTraits.select(44L);
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 44L, AgeGenerationProfiles.STANDARD,
|
||||||
|
seeded, helper.getLevel().registryAccess());
|
||||||
|
helper.assertTrue(AgeEnvironmentCompiler.staticSky(plan),
|
||||||
|
"The authored Static Sky page must survive Age compilation");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(plan, BuiltinDimensionTypes.OVERWORLD_EFFECTS),
|
||||||
|
AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS,
|
||||||
|
"The static sky must select Rivencraft's custom renderer");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.selectedSkyColor(plan,
|
||||||
|
BuiltinDimensionTypes.OVERWORLD_EFFECTS, 0xFF77AAFF),
|
||||||
|
0x0000C8, "The authored color must tint the sky and preview consistently");
|
||||||
|
AgeGenerationPlan restored = AgeGenerationPlan.load(plan.save());
|
||||||
|
helper.assertTrue(AgeEnvironmentCompiler.staticSky(restored),
|
||||||
|
"A saved Age must retain its static sky choice");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(restored), 0x0000C8,
|
||||||
|
"A saved Age must retain its sky color choice");
|
||||||
|
AgeGenerationPlan coloredEnd = AgeGenerationPlan.compile(new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("terrain/standard"), page("number/15"),
|
||||||
|
page("color/red"), page("sky/end"), page("celestial/normal_time"))),
|
||||||
|
45L, AgeGenerationProfiles.STANDARD, AgeTraits.select(45L),
|
||||||
|
helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(coloredEnd,
|
||||||
|
BuiltinDimensionTypes.OVERWORLD_EFFECTS),
|
||||||
|
AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS,
|
||||||
|
"A color page must make non-Overworld skies renderable by the custom sky effect");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.selectedSkyColor(coloredEnd,
|
||||||
|
BuiltinDimensionTypes.OVERWORLD_EFFECTS, 0xFF77AAFF),
|
||||||
|
0x960000, "A color page must override the selected sky type's default tint");
|
||||||
|
helper.assertTrue(PageItem.bundledCreativeSymbols().contains("sky/static")
|
||||||
|
&& PageItem.bundledCreativeSymbols().contains("color/blue")
|
||||||
|
&& PageItem.bundledCreativeSymbols().contains("number/24"),
|
||||||
|
"New sky pages must be available through the Creative and JEI page catalog");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void skyAndCelestialColorsBlendWithoutRequiringBodies(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("terrain/standard"),
|
||||||
|
page("number/10"), page("color/red"), page("number/5"), page("color/blue"),
|
||||||
|
page("sky/overworld"), page("number/12"), page("color/green"),
|
||||||
|
page("number/7"), page("color/blue"), page("celestial/sun")));
|
||||||
|
helper.assertTrue(WritingGrammar.parse(writing).canCreate(),
|
||||||
|
"Two sky colors and two colors for one sun must be valid writing");
|
||||||
|
AgeTraits traits = AgeTraits.select(46L);
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 46L,
|
||||||
|
AgeGenerationProfiles.STANDARD, traits, helper.getLevel().registryAccess());
|
||||||
|
int skyColor = 0x640032;
|
||||||
|
int sunColor = 0x007846;
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(plan), skyColor,
|
||||||
|
"Sky color pages must blend without discarding either page");
|
||||||
|
AgeSkyProfile profile = AgeSkyProfile.compile(plan, traits);
|
||||||
|
helper.assertValueEqual(profile.bodies().size(), 1, "The written Age has exactly one sun");
|
||||||
|
helper.assertValueEqual(profile.bodies().getFirst().color(), sunColor,
|
||||||
|
"Celestial colors must blend for the following body");
|
||||||
|
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), traits), profile,
|
||||||
|
"Blended colors must survive the saved writing plan");
|
||||||
|
|
||||||
|
AgeSkyProfile bodyless = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, skyColor, List.of());
|
||||||
|
helper.assertTrue(bodyless.custom() && bodyless.bodies().isEmpty(),
|
||||||
|
"An Age can have an authored sky without any celestial bodies");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void colorNumbersUseDniDigitsAndMissingChannelsRemainZero(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("number/12"), page("color/red"),
|
||||||
|
page("number/5"), page("color/white"), page("number/3"), page("color/black"),
|
||||||
|
page("sky/overworld")));
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 49L,
|
||||||
|
AgeGenerationProfiles.STANDARD, AgeTraits.select(49L), helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(plan), 0x8C1414,
|
||||||
|
"White adds and Black subtracts from every channel; missing green and blue start at zero");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(AgeGenerationPlan.load(plan.save())), 0x8C1414,
|
||||||
|
"D'ni color multipliers must remain frozen in a saved Age plan");
|
||||||
|
|
||||||
|
OrderedWriting unnumbered = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("color/green"), page("sky/overworld")));
|
||||||
|
AgeGenerationPlan randomPlan = AgeGenerationPlan.compile(unnumbered, 50L,
|
||||||
|
AgeGenerationProfiles.STANDARD, AgeTraits.select(50L), helper.getLevel().registryAccess());
|
||||||
|
int color = AgeEnvironmentCompiler.skyColor(randomPlan);
|
||||||
|
helper.assertTrue((color & 0xFF00FF) == 0 && ((color >> 8) & 255) <= 240
|
||||||
|
&& ((color >> 8) & 255) % 10 == 0,
|
||||||
|
"An unnumbered color gets one seeded base-25 digit and leaves omitted channels at zero");
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyColor(AgeGenerationPlan.load(randomPlan.save())), color,
|
||||||
|
"A random color digit must not change when an Age is reloaded");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void numberPagesSetAdjacentCelestialModifiers(GameTestHelper helper) {
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/overworld"),
|
||||||
|
page("number/1"), page("period/double"),
|
||||||
|
page("number/7"), page("angle/east"),
|
||||||
|
page("number/12"), page("phase/rising"), page("celestial/sun"),
|
||||||
|
page("number/24"), page("angle/west"),
|
||||||
|
page("number/0"), page("phase/setting"),
|
||||||
|
page("number/0"), page("period/full"), page("celestial/moon")));
|
||||||
|
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
|
||||||
|
"Adjacent number and celestial modifier pages must not count as misplaced writing");
|
||||||
|
AgeTraits seeded = AgeTraits.select(94L);
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
|
||||||
|
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
|
||||||
|
AgeTraits effective = AgeEnvironmentCompiler.apply(plan, seeded);
|
||||||
|
AgeSkyProfile sky = AgeSkyProfile.compile(plan, effective);
|
||||||
|
helper.assertValueEqual(sky.bodies().getFirst().period(), 0.08D,
|
||||||
|
"A number overrides a fixed Length page at 0.08 per digit, even below the random minimum");
|
||||||
|
helper.assertValueEqual(sky.bodies().getFirst().angle(), 105.0F,
|
||||||
|
"A number overrides a fixed Angle page at 15 degrees per digit");
|
||||||
|
helper.assertValueEqual(sky.bodies().getFirst().phase(), 0.5F,
|
||||||
|
"A number overrides a fixed Phase page at 15 degrees per digit");
|
||||||
|
helper.assertValueEqual(sky.bodies().getLast().angle(), 0.0F,
|
||||||
|
"Digit 24 wraps a full 360-degree rotation to zero");
|
||||||
|
helper.assertValueEqual(sky.bodies().getLast().phase(), 0.0F,
|
||||||
|
"Digit zero is a valid phase override");
|
||||||
|
helper.assertValueEqual(effective.celestialPeriod(), 0.0D,
|
||||||
|
"The final authored celestial controls the world clock");
|
||||||
|
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), effective), sky,
|
||||||
|
"Numbered celestial values must survive a plan save/load round trip");
|
||||||
|
AgeGenerationPlan normalTime = AgeGenerationPlan.compile(new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("number/6"), page("angle/north"),
|
||||||
|
page("celestial/normal_time"))), 95L, AgeGenerationProfiles.STANDARD,
|
||||||
|
AgeTraits.select(95L), helper.getLevel().registryAccess());
|
||||||
|
helper.assertValueEqual(AgeEnvironmentCompiler.skyEffects(normalTime,
|
||||||
|
BuiltinDimensionTypes.OVERWORLD_EFFECTS), AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS,
|
||||||
|
"An authored angle on Normal Time must select the custom celestial renderer");
|
||||||
|
AgeSkyProfile normalSky = AgeSkyProfile.compile(normalTime,
|
||||||
|
AgeEnvironmentCompiler.apply(normalTime, AgeTraits.select(95L)));
|
||||||
|
helper.assertValueEqual(normalSky.bodies().getFirst().angle(), 90.0F,
|
||||||
|
"The custom Normal Time sun must honor its numbered angle");
|
||||||
|
helper.assertValueEqual(normalSky.bodies().getFirst().color(), 0xFFFFFF,
|
||||||
|
"Uncolored custom celestial bodies must remain visible");
|
||||||
|
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||||
|
page("number/7"), page("direction/clockwise"), page("angle/east"), page("celestial/sun")));
|
||||||
|
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
|
||||||
|
"A number does not reach past an intervening page");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void alignedMoonEclipsesSunAndDimsLight(GameTestHelper helper) {
|
||||||
|
AgeSkyProfile.Body sun = new AgeSkyProfile.Body(AgeSkyProfile.Kind.SUN,
|
||||||
|
0xCC8844, 1.0F, 0.0F, 1.0D, 0.0F, true);
|
||||||
|
AgeSkyProfile.Body moon = new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
|
||||||
|
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.0F, true);
|
||||||
|
AgeSkyProfile clear = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA, List.of(sun));
|
||||||
|
AgeSkyProfile eclipse = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
|
||||||
|
List.of(sun, moon));
|
||||||
|
helper.assertValueEqual(eclipse.eclipseAt(0.0D), 1.0F,
|
||||||
|
"Centered sun and moon must produce a full eclipse");
|
||||||
|
helper.assertTrue(eclipse.lightAt(0.0D) < clear.lightAt(0.0D),
|
||||||
|
"An eclipse must dim world skylight, not just the rendered sprites");
|
||||||
|
AgeSkyProfile separated = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
|
||||||
|
List.of(sun, new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
|
||||||
|
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.5F, true)));
|
||||||
|
helper.assertValueEqual(separated.eclipseAt(0.0D), 0.0F,
|
||||||
|
"A moon on the far side of the sky must not eclipse the sun");
|
||||||
|
AgeSkyProfile glowOnlyOverlap = new AgeSkyProfile(AgeSkyProfile.Style.OVERWORLD, 0x6688AA,
|
||||||
|
List.of(sun, new AgeSkyProfile.Body(AgeSkyProfile.Kind.MOON,
|
||||||
|
0xFFFFFF, 1.0F, 0.0F, 1.0D, 0.04F, true)));
|
||||||
|
helper.assertValueEqual(glowOnlyOverlap.eclipseAt(0.0D), 0.0F,
|
||||||
|
"Overlapping glows must not count as an eclipse before the opaque discs touch");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void randomAndAuthoredCelestialsKeepIndependentOrbits(GameTestHelper helper) {
|
||||||
|
AgeTraits traits = AgeTraits.select(12345L);
|
||||||
|
AgeSkyProfile first = AgeSkyProfile.random(12345L, traits);
|
||||||
|
helper.assertValueEqual(first, AgeSkyProfile.random(12345L, traits),
|
||||||
|
"A random Age must recreate the same sky from its saved seed");
|
||||||
|
helper.assertTrue(first.bodies().stream().filter(body -> body.kind() == AgeSkyProfile.Kind.SUN).count() <= 10
|
||||||
|
&& first.bodies().stream().filter(body -> body.kind() == AgeSkyProfile.Kind.MOON).count() <= 10,
|
||||||
|
"Random Ages must obey the same celestial limits as authored Ages");
|
||||||
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(), page("terrain/standard"), page("sky/end"),
|
||||||
|
page("angle/east"), page("direction/counterclockwise"), page("number/24"), page("color/red"),
|
||||||
|
page("size/large"), page("period/half"), page("celestial/sun"),
|
||||||
|
page("direction/clockwise"), page("celestial/moon")));
|
||||||
|
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 12345L, AgeGenerationProfiles.STANDARD,
|
||||||
|
traits, helper.getLevel().registryAccess());
|
||||||
|
AgeSkyProfile sky = AgeSkyProfile.compile(plan, AgeEnvironmentCompiler.apply(plan, traits));
|
||||||
|
helper.assertValueEqual(sky.bodies().size(), 2, "Both written celestial bodies must be retained");
|
||||||
|
helper.assertValueEqual(sky.bodies().getFirst().angle(), 90.0F,
|
||||||
|
"The first body's compass angle must be independent");
|
||||||
|
helper.assertFalse(sky.bodies().getFirst().clockwise(),
|
||||||
|
"The first body should move counterclockwise");
|
||||||
|
helper.assertTrue(sky.bodies().getLast().clockwise(),
|
||||||
|
"The second body should move clockwise");
|
||||||
|
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()), traits), sky,
|
||||||
|
"Body angle and direction must survive Age-plan serialization");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void writtenEnvironmentPersistsAndPageFreeAgesStaySeeded(GameTestHelper helper) {
|
public static void writtenEnvironmentPersistsAndPageFreeAgesStaySeeded(GameTestHelper helper) {
|
||||||
OrderedWriting writing = new OrderedWriting(List.of(
|
OrderedWriting writing = new OrderedWriting(List.of(
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModEntities;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.GlobalPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class HandheldBookTravelGameTests {
|
||||||
|
private HandheldBookTravelGameTests() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void successfulHandheldTravelLeavesOnlyActiveBookAtOrigin(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, book);
|
||||||
|
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
|
||||||
|
var source = player.position();
|
||||||
|
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
|
||||||
|
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
|
||||||
|
|
||||||
|
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
|
||||||
|
new LinkProperties(LinkProperties.INTRA_LINKING), false), LinkingService.LinkResult.SUCCESS,
|
||||||
|
"A valid intra-dimensional handheld link must succeed");
|
||||||
|
helper.assertTrue(player.getMainHandItem().isEmpty(), "Only the active book must leave the hand");
|
||||||
|
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
|
||||||
|
"An unrelated inventory item must remain with the player");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
|
||||||
|
entity -> entity.getItem() == book);
|
||||||
|
helper.assertValueEqual(drops.size(), 1,
|
||||||
|
"Exactly one entity for this active book must remain at the departure point");
|
||||||
|
helper.assertTrue(drops.getFirst().getItem() == book,
|
||||||
|
"The model-rendered entity must retain the exact active book stack");
|
||||||
|
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
|
||||||
|
"The pickup-animation copy must keep the custom renderer's entity type");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void rejectedHandheldTravelKeepsBook(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||||
|
player.setItemInHand(InteractionHand.OFF_HAND, book);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), player.blockPosition()), 0, 0);
|
||||||
|
|
||||||
|
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.OFF_HAND, book, target,
|
||||||
|
LinkProperties.NONE, false), LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
|
||||||
|
"A same-dimension link without Intra Linking must be rejected");
|
||||||
|
helper.assertTrue(player.getOffhandItem() == book, "The exact rejected book must be restored to its hand");
|
||||||
|
helper.assertTrue(helper.getLevel().getEntitiesOfClass(ItemEntity.class,
|
||||||
|
new AABB(player.position(), player.position()).inflate(2.0),
|
||||||
|
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get())).isEmpty(),
|
||||||
|
"A rejected link must not leave a second copy at the origin");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void disarmDropsOtherInventoryWithoutDuplicatingActiveBook(GameTestHelper helper) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||||
|
player.setItemInHand(InteractionHand.MAIN_HAND, book);
|
||||||
|
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
|
||||||
|
var source = player.position();
|
||||||
|
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
|
||||||
|
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
|
||||||
|
|
||||||
|
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
|
||||||
|
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.DISARM), false),
|
||||||
|
LinkingService.LinkResult.SUCCESS, "Disarm must not block handheld travel");
|
||||||
|
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(2.0));
|
||||||
|
helper.assertValueEqual(drops.stream().filter(entity -> entity instanceof DroppedBookEntity).count(),
|
||||||
|
1L, "Disarm must not create a second copy of the active book");
|
||||||
|
helper.assertValueEqual(drops.stream().filter(entity -> entity.getItem().is(Items.DIAMOND)).count(),
|
||||||
|
1L, "Disarm must still drop unrelated inventory items");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void originBookUsesSlowDecayWithoutVanillaDespawn(GameTestHelper helper) {
|
||||||
|
DroppedBookEntity book = new DroppedBookEntity(helper.getLevel(),
|
||||||
|
net.minecraft.world.phys.Vec3.ZERO, new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
CompoundTag seeded = new CompoundTag();
|
||||||
|
book.saveWithoutId(seeded);
|
||||||
|
seeded.putInt("BookDecayTicks", 9_999);
|
||||||
|
book.readAdditionalSaveData(seeded);
|
||||||
|
book.tick();
|
||||||
|
CompoundTag saved = new CompoundTag();
|
||||||
|
book.saveWithoutId(saved);
|
||||||
|
helper.assertValueEqual(saved.getShort("Age"), (short)-32_768,
|
||||||
|
"The dropped book must not expire after vanilla's five minutes");
|
||||||
|
helper.assertValueEqual(saved.getShort("Health"), (short)4,
|
||||||
|
"A dropped book must lose one item-health point after 10,000 loaded ticks");
|
||||||
|
helper.assertValueEqual(saved.getInt("BookDecayTicks"), 0,
|
||||||
|
"Slow decay must reset its interval and persist across a restart");
|
||||||
|
DroppedBookEntity restored = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), helper.getLevel());
|
||||||
|
restored.readAdditionalSaveData(saved);
|
||||||
|
CompoundTag resaved = new CompoundTag();
|
||||||
|
restored.saveWithoutId(resaved);
|
||||||
|
helper.assertTrue(restored.getItem().is(ModItems.LINKING_BOOK.get()),
|
||||||
|
"The custom book entity must retain its book on reload");
|
||||||
|
helper.assertValueEqual(resaved.getShort("Age"), (short)-32_768,
|
||||||
|
"The custom book must remain non-despawning after reload");
|
||||||
|
helper.assertValueEqual(resaved.getShort("Health"), (short)4,
|
||||||
|
"Decay damage must persist after reload");
|
||||||
|
helper.assertTrue(restored.copy().getItem().is(ModItems.LINKING_BOOK.get()),
|
||||||
|
"A pickup-animation copy must retain the book data after reload");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.mixin.MobDropChanceAccessor;
|
||||||
|
import net.minecraft.world.Container;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.entity.EquipmentSlot;
|
||||||
|
import net.minecraft.world.entity.Mob;
|
||||||
|
import net.minecraft.world.entity.animal.horse.AbstractHorse;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
|
||||||
|
public final class LinkDisarmService {
|
||||||
|
private LinkDisarmService() {}
|
||||||
|
|
||||||
|
public static boolean mayTravel(Entity entity) {
|
||||||
|
// Legacy refuses loose items and dropped Link Books on Disarm links.
|
||||||
|
return !(entity instanceof ItemEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void disarm(Entity entity) {
|
||||||
|
if (entity instanceof ServerPlayer player) {
|
||||||
|
eject(player, player.getInventory());
|
||||||
|
player.containerMenu.broadcastChanges();
|
||||||
|
}
|
||||||
|
if (entity instanceof Container container) eject(entity, container);
|
||||||
|
if (entity instanceof AbstractHorse horse) eject(entity, horse.getInventory());
|
||||||
|
if (entity instanceof Mob mob) dropEquipment(mob);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void eject(Entity carrier, Container inventory) {
|
||||||
|
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
||||||
|
ItemStack stack = inventory.getItem(slot);
|
||||||
|
if (stack.isEmpty()) continue;
|
||||||
|
inventory.setItem(slot, ItemStack.EMPTY);
|
||||||
|
carrier.spawnAtLocation(stack);
|
||||||
|
}
|
||||||
|
inventory.setChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void dropEquipment(Mob mob) {
|
||||||
|
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||||
|
ItemStack stack = mob.getItemBySlot(slot);
|
||||||
|
if (stack.isEmpty()) continue;
|
||||||
|
float chance = ((MobDropChanceAccessor) mob).rivencraft$getEquipmentDropChance(slot);
|
||||||
|
mob.setItemSlot(slot, ItemStack.EMPTY);
|
||||||
|
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
|
||||||
|
if (chance <= 1 && stack.isDamageableItem()) {
|
||||||
|
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
|
||||||
|
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
|
||||||
|
1 + mob.getRandom().nextInt(minimum));
|
||||||
|
stack.setDamageValue(Math.clamp(damage, 1, minimum));
|
||||||
|
}
|
||||||
|
mob.spawnAtLocation(stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.GlobalPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.world.entity.EquipmentSlot;
|
||||||
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
import net.minecraft.world.entity.animal.horse.Donkey;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.entity.monster.Zombie;
|
||||||
|
import net.minecraft.world.entity.vehicle.MinecartChest;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class LinkModifierBehaviorGameTests {
|
||||||
|
private LinkModifierBehaviorGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void maintainMomentumRotatesToDestinationYaw(GameTestHelper helper) {
|
||||||
|
Vec3 result = LinkingService.rotatedMomentum(new Vec3(3, -0.5, 4), 90);
|
||||||
|
helper.assertTrue(Math.abs(result.x) < 0.0001 && Math.abs(result.y + 0.3) < 0.0001
|
||||||
|
&& Math.abs(result.z - 5) < 0.0001,
|
||||||
|
"Legacy momentum preserves horizontal speed, rotates to destination yaw, and adds a small lift");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void disarmRefusesLooseItems(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||||
|
level.addFreshEntity(item);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), helper.absolutePos(new BlockPos(2, 1, 2))),
|
||||||
|
0, 0);
|
||||||
|
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||||
|
new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING), false),
|
||||||
|
LinkingService.LinkResult.DISARM_REJECTED,
|
||||||
|
"Legacy Disarm must refuse a loose item before teleportation");
|
||||||
|
helper.assertTrue(item.isAlive() && item.getItem().is(Items.DIAMOND),
|
||||||
|
"Rejected item must remain at the source");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void ordinaryLinkMovesLooseItemWithoutMakingPlatform(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
|
||||||
|
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||||
|
level.addFreshEntity(item);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
|
||||||
|
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||||
|
new LinkProperties(LinkProperties.INTRA_LINKING), false),
|
||||||
|
LinkingService.LinkResult.SUCCESS, "Ordinary portal links may carry loose items");
|
||||||
|
helper.assertTrue(item.blockPosition().distManhattan(landing) <= 1,
|
||||||
|
"Item must arrive at the linked location");
|
||||||
|
helper.assertTrue(level.getBlockState(landing.below()).isAir(),
|
||||||
|
"A Linking Book must not generate a landing platform by default");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void explicitGeneratePlatformAddsOnlyArrivalFloor(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
|
||||||
|
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
level.setBlock(landing.above(2), Blocks.AIR.defaultBlockState(), 2);
|
||||||
|
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||||
|
level.addFreshEntity(item);
|
||||||
|
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
|
||||||
|
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||||
|
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.GENERATE_PLATFORM), false),
|
||||||
|
LinkingService.LinkResult.SUCCESS, "An explicit platform link must travel");
|
||||||
|
helper.assertTrue(level.getBlockState(landing.below()).is(
|
||||||
|
net.dnorberg.rivencraft.registry.ModBlocks.CLOUD_PLATFORM.get()),
|
||||||
|
"An enabled Linking Book platform must add one cloud block below the arrival");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void disarmEmptiesCarrierAndMobEquipment(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
Vec3 source = Vec3.atCenterOf(helper.absolutePos(new BlockPos(1, 1, 1)));
|
||||||
|
MinecartChest cart = EntityType.CHEST_MINECART.create(level);
|
||||||
|
Donkey donkey = EntityType.DONKEY.create(level);
|
||||||
|
Zombie zombie = EntityType.ZOMBIE.create(level);
|
||||||
|
helper.assertTrue(cart != null && donkey != null && zombie != null, "Carriers must instantiate");
|
||||||
|
cart.setPos(source);
|
||||||
|
donkey.setPos(source);
|
||||||
|
zombie.setPos(source);
|
||||||
|
level.addFreshEntity(cart);
|
||||||
|
level.addFreshEntity(donkey);
|
||||||
|
level.addFreshEntity(zombie);
|
||||||
|
cart.setItem(0, new ItemStack(Items.EMERALD));
|
||||||
|
donkey.getInventory().setItem(0, new ItemStack(Items.SADDLE));
|
||||||
|
zombie.setItemSlot(EquipmentSlot.MAINHAND, new ItemStack(Items.IRON_SWORD));
|
||||||
|
zombie.setDropChance(EquipmentSlot.MAINHAND, 2.0F);
|
||||||
|
|
||||||
|
LinkDisarmService.disarm(cart);
|
||||||
|
LinkDisarmService.disarm(donkey);
|
||||||
|
LinkDisarmService.disarm(zombie);
|
||||||
|
|
||||||
|
helper.assertTrue(cart.getItem(0).isEmpty() && donkey.getInventory().getItem(0).isEmpty()
|
||||||
|
&& zombie.getItemBySlot(EquipmentSlot.MAINHAND).isEmpty(),
|
||||||
|
"Disarm must remove inventory contents and worn equipment");
|
||||||
|
var drops = level.getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(3));
|
||||||
|
helper.assertTrue(drops.stream().anyMatch(item -> item.getItem().is(Items.EMERALD))
|
||||||
|
&& drops.stream().anyMatch(item -> item.getItem().is(Items.SADDLE))
|
||||||
|
&& drops.stream().anyMatch(item -> item.getItem().is(Items.IRON_SWORD)),
|
||||||
|
"Carrier items and guaranteed-drop mob equipment must remain at departure");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@ import net.minecraft.network.chat.Component;
|
|||||||
/** Flags written into a Link Panel and carried by books assembled from it. */
|
/** Flags written into a Link Panel and carried by books assembled from it. */
|
||||||
public record LinkProperties(int flags) {
|
public record LinkProperties(int flags) {
|
||||||
public static final int DISARM = 1;
|
public static final int DISARM = 1;
|
||||||
/** Retained only to read pre-0.4.7.1 test books; no longer generated or applied. */
|
/** Linking Books do not create a platform unless this Legacy flag is enabled. */
|
||||||
public static final int GENERATE_PLATFORM = 2;
|
public static final int GENERATE_PLATFORM = 2;
|
||||||
public static final int MAINTAIN_MOMENTUM = 4;
|
public static final int MAINTAIN_MOMENTUM = 4;
|
||||||
public static final int INTRA_LINKING = 8;
|
public static final int INTRA_LINKING = 8;
|
||||||
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING;
|
public static final int INTRA_LINKING_ONLY = 16;
|
||||||
|
public static final int RELATIVE_LINK = 32;
|
||||||
|
public static final int DISABLE_PLATFORM = 64;
|
||||||
|
private static final int ALL_FLAGS = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING
|
||||||
|
| INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM;
|
||||||
public static final LinkProperties NONE = new LinkProperties(0);
|
public static final LinkProperties NONE = new LinkProperties(0);
|
||||||
public static final Codec<LinkProperties> CODEC = Codec.intRange(0, ALL_FLAGS).xmap(
|
public static final Codec<LinkProperties> CODEC = Codec.intRange(0, ALL_FLAGS).xmap(
|
||||||
LinkProperties::new, LinkProperties::flags);
|
LinkProperties::new, LinkProperties::flags);
|
||||||
@@ -35,13 +39,17 @@ public record LinkProperties(int flags) {
|
|||||||
|
|
||||||
/** Intra Linking only governs Linking Books; a Descriptive Book treats that panel as ordinary. */
|
/** Intra Linking only governs Linking Books; a Descriptive Book treats that panel as ordinary. */
|
||||||
public LinkProperties forDescriptiveBook() {
|
public LinkProperties forDescriptiveBook() {
|
||||||
return new LinkProperties(flags & ~INTRA_LINKING);
|
return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void appendTooltip(List<Component> lines) {
|
public void appendTooltip(List<Component> lines) {
|
||||||
if (has(DISARM)) lines.add(propertyName("disarm"));
|
if (has(DISARM)) lines.add(propertyName("disarm"));
|
||||||
if (has(MAINTAIN_MOMENTUM)) lines.add(propertyName("maintain_momentum"));
|
if (has(MAINTAIN_MOMENTUM)) lines.add(propertyName("maintain_momentum"));
|
||||||
if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking"));
|
if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking"));
|
||||||
|
if (has(INTRA_LINKING_ONLY)) lines.add(propertyName("intra_linking_only"));
|
||||||
|
if (has(RELATIVE_LINK)) lines.add(propertyName("relative_link"));
|
||||||
|
if (has(GENERATE_PLATFORM)) lines.add(propertyName("generate_platform"));
|
||||||
|
if (has(DISABLE_PLATFORM)) lines.add(propertyName("disable_platform"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Component propertyName(String name) {
|
private static Component propertyName(String name) {
|
||||||
@@ -54,6 +62,10 @@ public record LinkProperties(int flags) {
|
|||||||
case DISARM -> propertyName("disarm");
|
case DISARM -> propertyName("disarm");
|
||||||
case MAINTAIN_MOMENTUM -> propertyName("maintain_momentum");
|
case MAINTAIN_MOMENTUM -> propertyName("maintain_momentum");
|
||||||
case INTRA_LINKING -> propertyName("intra_linking");
|
case INTRA_LINKING -> propertyName("intra_linking");
|
||||||
|
case INTRA_LINKING_ONLY -> propertyName("intra_linking_only");
|
||||||
|
case RELATIVE_LINK -> propertyName("relative_link");
|
||||||
|
case GENERATE_PLATFORM -> propertyName("generate_platform");
|
||||||
|
case DISABLE_PLATFORM -> propertyName("disable_platform");
|
||||||
default -> throw new IllegalArgumentException("Unknown link property flag: " + flag);
|
default -> throw new IllegalArgumentException("Unknown link property flag: " + flag);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package net.dnorberg.rivencraft.book;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||||
|
import net.minecraft.core.particles.ParticleTypes;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.sounds.SoundEvent;
|
||||||
|
import net.minecraft.sounds.SoundSource;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/** Legacy-style sound and particle bursts at both ends of a successful link. */
|
||||||
|
public final class LinkVisualEffects {
|
||||||
|
private static final int PARTICLE_COUNT = 50;
|
||||||
|
|
||||||
|
private LinkVisualEffects() {}
|
||||||
|
|
||||||
|
public static SoundEvent sound(LinkProperties properties, boolean portal) {
|
||||||
|
// Legacy's Disarm flag takes precedence over a portal's sound override.
|
||||||
|
if (properties.has(LinkProperties.DISARM)) return ModSounds.LINK_DISARM.get();
|
||||||
|
if (portal) return ModSounds.LINK_PORTAL.get();
|
||||||
|
if (properties.has(LinkProperties.INTRA_LINKING)) return ModSounds.LINK_INTRA.get();
|
||||||
|
return ModSounds.LINK.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void play(ServerLevel source, Vec3 departure, ServerPlayer player,
|
||||||
|
LinkProperties properties, boolean portal) {
|
||||||
|
SoundEvent event = sound(properties, portal);
|
||||||
|
burst(source, departure, event);
|
||||||
|
burst(player.serverLevel(), player.position(), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void burst(ServerLevel level, Vec3 position, SoundEvent event) {
|
||||||
|
level.playSound(null, position.x, position.y, position.z, event,
|
||||||
|
SoundSource.PLAYERS, 0.8F, level.random.nextFloat() * 0.2F + 0.9F);
|
||||||
|
// Legacy emitted 50 small neutral particles in a roughly one-by-two-
|
||||||
|
// block space. Cloud particles are a vanilla gray-white stand-in for
|
||||||
|
// its removed fixed-function particle class.
|
||||||
|
level.sendParticles(ParticleTypes.CLOUD, position.x, position.y, position.z,
|
||||||
|
PARTICLE_COUNT, 0.5, 1.0, 0.5, 0.005);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,21 @@ import java.util.Optional;
|
|||||||
import java.util.stream.IntStream;
|
import java.util.stream.IntStream;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||||
|
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||||
|
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.Direction;
|
import net.minecraft.core.Direction;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.tags.BlockTags;
|
import net.minecraft.tags.BlockTags;
|
||||||
import net.minecraft.world.level.border.WorldBorder;
|
import net.minecraft.world.level.border.WorldBorder;
|
||||||
import net.minecraft.world.level.levelgen.Heightmap;
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
import net.minecraft.world.phys.Vec3;
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import net.minecraft.world.level.portal.DimensionTransition;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
|
|
||||||
/** Server-side validation and execution for book travel. */
|
/** Server-side validation and execution for book travel. */
|
||||||
@@ -32,6 +39,36 @@ public final class LinkingService {
|
|||||||
return travel(player, target, false, properties, false);
|
return travel(player, target, false, properties, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes only the active handheld book after a successful link, leaving it at the departure point. */
|
||||||
|
public static LinkResult travelHandheld(ServerPlayer player, InteractionHand hand, ItemStack activeBook,
|
||||||
|
LinkTarget target, LinkProperties properties, boolean requiresFooting) {
|
||||||
|
if (player.getItemInHand(hand) != activeBook || activeBook.isEmpty()) {
|
||||||
|
return LinkResult.NO_SAFE_DESTINATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerLevel sourceLevel = player.serverLevel();
|
||||||
|
Vec3 sourcePosition = player.position();
|
||||||
|
player.setItemInHand(hand, ItemStack.EMPTY);
|
||||||
|
boolean dropped = false;
|
||||||
|
try {
|
||||||
|
LinkResult result = requiresFooting
|
||||||
|
? travelWithFooting(player, target, properties)
|
||||||
|
: travel(player, target, properties);
|
||||||
|
if (result != LinkResult.SUCCESS) return result;
|
||||||
|
|
||||||
|
DroppedBookEntity book = new DroppedBookEntity(sourceLevel, sourcePosition, activeBook);
|
||||||
|
dropped = sourceLevel.addFreshEntity(book);
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
// Failed validation or a rejected entity spawn must never eat the book.
|
||||||
|
if (!dropped) {
|
||||||
|
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, activeBook);
|
||||||
|
else if (!player.getInventory().add(activeBook)) player.drop(activeBook, false);
|
||||||
|
}
|
||||||
|
player.containerMenu.broadcastChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
|
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
|
||||||
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
||||||
return travel(player, target, false, properties, true);
|
return travel(player, target, false, properties, true);
|
||||||
@@ -53,16 +90,19 @@ public final class LinkingService {
|
|||||||
if (destinationLevel == null) {
|
if (destinationLevel == null) {
|
||||||
return LinkResult.MISSING_DIMENSION;
|
return LinkResult.MISSING_DIMENSION;
|
||||||
}
|
}
|
||||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), true);
|
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
|
||||||
|
return LinkResult.INTRA_ONLY;
|
||||||
|
BlockPos origin = landingOrigin(player, destinationLevel, target, properties);
|
||||||
|
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, true);
|
||||||
if (destination.isEmpty()) {
|
if (destination.isEmpty()) {
|
||||||
return LinkResult.NO_SAFE_DESTINATION;
|
return LinkResult.NO_SAFE_DESTINATION;
|
||||||
}
|
}
|
||||||
|
|
||||||
Vec3 position = destination.get();
|
Vec3 position = destination.get();
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||||
teleport(player, destinationLevel, position, target, true);
|
teleport(player, destinationLevel, position, target, true);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
applyMomentum(player, momentum, target, properties);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -72,12 +112,15 @@ public final class LinkingService {
|
|||||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
||||||
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
||||||
boolean instant = destinationChunksLoaded(destinationLevel, target.location().pos());
|
boolean instant = destinationChunksLoaded(destinationLevel, target.location().pos());
|
||||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), true);
|
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
|
||||||
|
return LinkResult.INTRA_ONLY;
|
||||||
|
Optional<Vec3> destination = resolveDestination(destinationLevel,
|
||||||
|
landingOrigin(player, destinationLevel, target, properties), true);
|
||||||
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||||
teleport(player, destinationLevel, destination.get(), target, instant);
|
teleport(player, destinationLevel, destination.get(), target, instant);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
applyMomentum(player, momentum, target, properties);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -88,34 +131,93 @@ public final class LinkingService {
|
|||||||
if (destinationLevel == null) {
|
if (destinationLevel == null) {
|
||||||
return LinkResult.MISSING_DIMENSION;
|
return LinkResult.MISSING_DIMENSION;
|
||||||
}
|
}
|
||||||
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING))
|
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != player.serverLevel())
|
||||||
|
return LinkResult.INTRA_ONLY;
|
||||||
|
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING)
|
||||||
|
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY))
|
||||||
return LinkResult.INTRA_LINKING_REQUIRED;
|
return LinkResult.INTRA_LINKING_REQUIRED;
|
||||||
|
|
||||||
boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
|
boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
|
||||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting);
|
Optional<Vec3> destination = resolveDestination(destinationLevel,
|
||||||
|
landingOrigin(player, destinationLevel, target, properties), requiresFooting);
|
||||||
if (destination.isEmpty()) {
|
if (destination.isEmpty()) {
|
||||||
return LinkResult.NO_SAFE_DESTINATION;
|
return LinkResult.NO_SAFE_DESTINATION;
|
||||||
}
|
}
|
||||||
|
|
||||||
Vec3 position = destination.get();
|
Vec3 position = destination.get();
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||||
teleport(player, destinationLevel, position, target, instant);
|
teleport(player, destinationLevel, position, target, instant);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
if (properties.has(LinkProperties.GENERATE_PLATFORM))
|
||||||
|
generateArrivalPlatform(destinationLevel, position);
|
||||||
|
applyMomentum(player, momentum, target, properties);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop each inventory stack as an item entity at the departure point. */
|
/** Non-player portal travel follows the same link rules; Disarm may remove carried cargo. */
|
||||||
private static void dropInventoryAtSource(ServerPlayer player) {
|
public static LinkResult travelEntityPortal(Entity entity, LinkTarget target,
|
||||||
var inventory = player.getInventory();
|
LinkProperties properties, boolean requiresFooting) {
|
||||||
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
if (!(entity.level() instanceof ServerLevel source)) return LinkResult.MISSING_DIMENSION;
|
||||||
var stack = inventory.getItem(slot);
|
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
||||||
if (stack.isEmpty()) continue;
|
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
||||||
inventory.setItem(slot, net.minecraft.world.item.ItemStack.EMPTY);
|
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != source)
|
||||||
player.drop(stack, false);
|
return LinkResult.INTRA_ONLY;
|
||||||
}
|
if (destinationLevel == source && !properties.has(LinkProperties.INTRA_LINKING)
|
||||||
player.containerMenu.broadcastChanges();
|
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY)) return LinkResult.INTRA_LINKING_REQUIRED;
|
||||||
|
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity))
|
||||||
|
return LinkResult.DISARM_REJECTED;
|
||||||
|
if (!entity.canChangeDimensions(source, destinationLevel)) return LinkResult.NO_SAFE_DESTINATION;
|
||||||
|
BlockPos origin = landingOrigin(entity, destinationLevel, target, properties);
|
||||||
|
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, requiresFooting);
|
||||||
|
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||||
|
|
||||||
|
Vec3 momentum = entity.getDeltaMovement();
|
||||||
|
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(entity);
|
||||||
|
Entity arrived = entity.changeDimension(new DimensionTransition(destinationLevel, destination.get(),
|
||||||
|
properties.has(LinkProperties.MAINTAIN_MOMENTUM)
|
||||||
|
? rotatedMomentum(momentum, target.yaw()) : Vec3.ZERO,
|
||||||
|
target.yaw(), target.pitch(), DimensionTransition.DO_NOTHING));
|
||||||
|
if (arrived == null) return LinkResult.NO_SAFE_DESTINATION;
|
||||||
|
if (!requiresFooting && properties.has(LinkProperties.GENERATE_PLATFORM))
|
||||||
|
generateArrivalPlatform(destinationLevel, destination.get());
|
||||||
|
arrived.setXRot(target.pitch());
|
||||||
|
arrived.resetFallDistance();
|
||||||
|
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
|
||||||
|
return LinkResult.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Vec3 rotatedMomentum(Vec3 incoming, float destinationYaw) {
|
||||||
|
// Legacy rotates the horizontal speed to the destination yaw and adds a small upward lift.
|
||||||
|
double horizontalSpeed = Math.hypot(incoming.x, incoming.z);
|
||||||
|
double radians = Math.toRadians(destinationYaw);
|
||||||
|
return new Vec3(horizontalSpeed * Math.cos(radians), incoming.y + 0.2,
|
||||||
|
horizontalSpeed * Math.sin(radians));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyMomentum(ServerPlayer player, Vec3 incoming, LinkTarget target,
|
||||||
|
LinkProperties properties) {
|
||||||
|
player.setDeltaMovement(properties.has(LinkProperties.MAINTAIN_MOMENTUM)
|
||||||
|
? rotatedMomentum(incoming, target.yaw()) : Vec3.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void generateArrivalPlatform(ServerLevel level, Vec3 arrival) {
|
||||||
|
BlockPos feet = BlockPos.containing(arrival);
|
||||||
|
BlockPos floor = feet.below();
|
||||||
|
// Legacy only fills a missing floor when there is clear space above the arrival.
|
||||||
|
if (level.isEmptyBlock(floor) && level.isEmptyBlock(feet.above(2)))
|
||||||
|
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy relative links retain the traveller's offset from the source spawn. */
|
||||||
|
private static BlockPos landingOrigin(Entity player, ServerLevel destinationLevel,
|
||||||
|
LinkTarget target, LinkProperties properties) {
|
||||||
|
if (!properties.has(LinkProperties.RELATIVE_LINK)) return target.location().pos();
|
||||||
|
BlockPos sourceSpawn = ((ServerLevel) player.level()).getSharedSpawnPos();
|
||||||
|
BlockPos destinationSpawn = destinationLevel.getSharedSpawnPos();
|
||||||
|
BlockPos departure = player.blockPosition();
|
||||||
|
return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(),
|
||||||
|
departure.getY() - sourceSpawn.getY(), departure.getZ() - sourceSpawn.getZ());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void teleport(
|
private static void teleport(
|
||||||
@@ -230,6 +332,8 @@ public final class LinkingService {
|
|||||||
SUCCESS,
|
SUCCESS,
|
||||||
MISSING_DIMENSION,
|
MISSING_DIMENSION,
|
||||||
INTRA_LINKING_REQUIRED,
|
INTRA_LINKING_REQUIRED,
|
||||||
|
INTRA_ONLY,
|
||||||
|
DISARM_REJECTED,
|
||||||
NO_SAFE_DESTINATION
|
NO_SAFE_DESTINATION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
import com.mojang.math.Axis;
|
||||||
|
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.client.model.BookModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelLayers;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.RenderType;
|
||||||
|
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||||
|
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** Legacy's almost-closed book, oriented to the receptacle's mounting face. */
|
||||||
|
public final class BookReceptacleRenderer implements BlockEntityRenderer<BookReceptacleBlockEntity> {
|
||||||
|
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"rivencraft", "textures/entity/linking_book.png");
|
||||||
|
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"rivencraft", "textures/entity/descriptive_book.png");
|
||||||
|
private final BookModel model;
|
||||||
|
|
||||||
|
public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) {
|
||||||
|
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(BookReceptacleBlockEntity receptacle, float partialTick, PoseStack poseStack,
|
||||||
|
MultiBufferSource buffers, int packedLight, int packedOverlay) {
|
||||||
|
if (receptacle.getLevel() == null) return;
|
||||||
|
var state = receptacle.getLevel().getBlockState(receptacle.getBlockPos());
|
||||||
|
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())
|
||||||
|
|| !state.getValue(BookReceptacleBlock.HAS_BOOK)
|
||||||
|
|| receptacle.getBook().isEmpty()) return;
|
||||||
|
|
||||||
|
poseStack.pushPose();
|
||||||
|
poseStack.translate(0.5, 0.5, 0.5);
|
||||||
|
Direction facing = state.getValue(BookReceptacleBlock.FACING);
|
||||||
|
switch (facing) {
|
||||||
|
case UP -> {
|
||||||
|
// Rotate the whole assembled top-mounted receptacle/book pair
|
||||||
|
// around the block center, matching the x=90 block model.
|
||||||
|
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||||
|
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
|
||||||
|
poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||||
|
// Preserve the local half-turn that seats the book in its slot.
|
||||||
|
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||||
|
}
|
||||||
|
case DOWN -> {
|
||||||
|
// The ceiling-mounted model is the inverted top assembly.
|
||||||
|
poseStack.mulPose(Axis.XP.rotationDegrees(-90));
|
||||||
|
poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||||
|
poseStack.mulPose(Axis.ZP.rotationDegrees(180));
|
||||||
|
}
|
||||||
|
case NORTH -> poseStack.mulPose(Axis.YP.rotationDegrees(-90));
|
||||||
|
case SOUTH -> poseStack.mulPose(Axis.YP.rotationDegrees(90));
|
||||||
|
case EAST -> poseStack.mulPose(Axis.YP.rotationDegrees(180));
|
||||||
|
case WEST -> { }
|
||||||
|
}
|
||||||
|
poseStack.scale(0.8F, 0.8F, 0.8F);
|
||||||
|
model.setupAnim(0.0F, 0.1F, 0.1F, 0.005F);
|
||||||
|
ResourceLocation texture = receptacle.getBook().is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||||
|
? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
|
||||||
|
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(texture));
|
||||||
|
model.render(poseStack, vertices, packedLight, packedOverlay, -1);
|
||||||
|
poseStack.popPose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,58 @@ package net.dnorberg.rivencraft.client;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
import net.dnorberg.rivencraft.network.AgeCelestialAnglePayload;
|
import net.dnorberg.rivencraft.network.AgeCelestialAnglePayload;
|
||||||
import net.minecraft.resources.ResourceKey;
|
import net.minecraft.resources.ResourceKey;
|
||||||
import net.minecraft.world.level.Level;
|
import net.minecraft.world.level.Level;
|
||||||
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||||
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
import net.dnorberg.rivencraft.network.RequestAgeSkyPayload;
|
||||||
|
|
||||||
/** Client cache used by both the active world and remote preview renderers. */
|
/** Client cache used by both the active world and remote preview renderers. */
|
||||||
public final class ClientAgeCelestialAngles {
|
public final class ClientAgeCelestialAngles {
|
||||||
private static final Map<ResourceKey<Level>, Float> ANGLES = new ConcurrentHashMap<>();
|
private static final Map<ResourceKey<Level>, Float> ANGLES = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<ResourceKey<Level>, AgeSkyProfile> SKIES = new ConcurrentHashMap<>();
|
||||||
|
private static final Map<ResourceKey<Level>, Long> LAST_REQUEST = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private ClientAgeCelestialAngles() {
|
private ClientAgeCelestialAngles() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void handle(AgeCelestialAnglePayload payload, IPayloadContext context) {
|
public static void handle(AgeCelestialAnglePayload payload, IPayloadContext context) {
|
||||||
ANGLES.put(payload.dimension(), payload.angle());
|
ANGLES.put(payload.dimension(), payload.angle());
|
||||||
|
SKIES.put(payload.dimension(), payload.sky());
|
||||||
|
LAST_REQUEST.remove(payload.dimension());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void requestIfMissing(ResourceKey<Level> dimension) {
|
||||||
|
if (!dimension.location().getNamespace().equals("rivencraft") || SKIES.containsKey(dimension)) return;
|
||||||
|
long now = System.nanoTime();
|
||||||
|
Long previous = LAST_REQUEST.putIfAbsent(dimension, now);
|
||||||
|
if (previous != null && now - previous < 2_000_000_000L) return;
|
||||||
|
LAST_REQUEST.put(dimension, now);
|
||||||
|
PacketDistributor.sendToServer(new RequestAgeSkyPayload(dimension));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
ANGLES.clear();
|
||||||
|
SKIES.clear();
|
||||||
|
LAST_REQUEST.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static float get(ResourceKey<Level> dimension) {
|
public static float get(ResourceKey<Level> dimension) {
|
||||||
return ANGLES.getOrDefault(dimension, 0.0F);
|
return ANGLES.getOrDefault(dimension, 0.0F);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int skyColor(ResourceKey<Level> dimension) {
|
||||||
|
return sky(dimension).color();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean staticSky(ResourceKey<Level> dimension) {
|
||||||
|
return sky(dimension).style() == AgeSkyProfile.Style.STATIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AgeSkyProfile sky(ResourceKey<Level> dimension) {
|
||||||
|
return SKIES.getOrDefault(dimension,
|
||||||
|
new AgeSkyProfile(AgeSkyProfile.Style.INHERITED, -1, java.util.List.of()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
|
|||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModEntities;
|
||||||
import net.minecraft.client.renderer.item.ItemProperties;
|
import net.minecraft.client.renderer.item.ItemProperties;
|
||||||
import net.minecraft.client.renderer.ItemBlockRenderTypes;
|
import net.minecraft.client.renderer.ItemBlockRenderTypes;
|
||||||
import net.minecraft.client.renderer.RenderType;
|
import net.minecraft.client.renderer.RenderType;
|
||||||
@@ -18,6 +19,12 @@ import net.neoforged.neoforge.common.NeoForge;
|
|||||||
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
import net.neoforged.neoforge.client.event.EntityRenderersEvent;
|
||||||
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent;
|
||||||
import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
|
import net.neoforged.neoforge.client.event.RegisterClientReloadListenersEvent;
|
||||||
|
import net.neoforged.neoforge.client.event.RegisterDimensionSpecialEffectsEvent;
|
||||||
|
import net.neoforged.neoforge.client.event.RegisterColorHandlersEvent;
|
||||||
|
import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeEnvironmentCompiler;
|
||||||
import net.neoforged.neoforge.client.extensions.common.IClientItemExtensions;
|
import net.neoforged.neoforge.client.extensions.common.IClientItemExtensions;
|
||||||
import net.neoforged.neoforge.client.extensions.common.RegisterClientExtensionsEvent;
|
import net.neoforged.neoforge.client.extensions.common.RegisterClientExtensionsEvent;
|
||||||
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
|
import net.minecraft.client.renderer.BlockEntityWithoutLevelRenderer;
|
||||||
@@ -25,6 +32,7 @@ import net.dnorberg.rivencraft.client.screen.NotebookScreen;
|
|||||||
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
|
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
|
||||||
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
|
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
|
||||||
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
|
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
|
||||||
|
import net.dnorberg.rivencraft.client.screen.LinkModifierScreen;
|
||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
|
|
||||||
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
|
||||||
@@ -34,17 +42,37 @@ public final class ClientEvents {
|
|||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
||||||
|
event.registerEntityRenderer(ModEntities.DROPPED_BOOK.get(), DroppedBookRenderer::new);
|
||||||
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
|
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
|
||||||
|
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_RECEPTACLE.get(), BookReceptacleRenderer::new);
|
||||||
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
|
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
|
||||||
event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new);
|
event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void registerSkyEffects(RegisterDimensionSpecialEffectsEvent event) {
|
||||||
|
event.register(AgeEnvironmentCompiler.CUSTOM_SKY_EFFECTS, new RivencraftSkyEffects());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
public static void registerPortalColor(RegisterColorHandlersEvent.Block event) {
|
||||||
|
event.register((state, level, pos, tintIndex) -> {
|
||||||
|
if (level == null || pos == null || tintIndex != 0
|
||||||
|
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|
|| portal.receptacle() == null
|
||||||
|
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)
|
||||||
|
|| holder.destination() == null) return 0xFFFFFF;
|
||||||
|
return holder.skyColor();
|
||||||
|
}, ModBlocks.CRYSTAL_PORTAL.get());
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void registerScreens(RegisterMenuScreensEvent event) {
|
public static void registerScreens(RegisterMenuScreensEvent event) {
|
||||||
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
|
event.register(ModMenus.NOTEBOOK.get(), NotebookScreen::new);
|
||||||
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
|
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
|
||||||
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
|
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
|
||||||
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
|
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
|
||||||
|
event.register(ModMenus.LINK_MODIFIER.get(), LinkModifierScreen::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
@@ -78,13 +106,15 @@ public final class ClientEvents {
|
|||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout());
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout());
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout());
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CLOUD_PLATFORM.get(), RenderType.translucent());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.INK_MIXER.get(), RenderType.cutout());
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_PORTAL.get(), RenderType.translucent());
|
||||||
// Immersive Portals composes the remote view before translucent blocks.
|
// Immersive Portals composes the remote view before translucent blocks.
|
||||||
// Draw the opaque crystal frame in that later pass so it masks the view;
|
// Draw the opaque crystal frame in that later pass so it masks the view;
|
||||||
// the portal cell itself is invisible while its live view is active.
|
// the portal cell itself is invisible while its live view is active.
|
||||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent());
|
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent());
|
||||||
|
if (ModList.get().isLoaded("create"))
|
||||||
|
net.dnorberg.rivencraft.compat.RivencraftPonderGuide.register();
|
||||||
});
|
});
|
||||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
|
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
|
||||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
||||||
@@ -92,6 +122,11 @@ public final class ClientEvents {
|
|||||||
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onClientTickPost);
|
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onClientTickPost);
|
||||||
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onRenderGui);
|
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onRenderGui);
|
||||||
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onMouseButton);
|
NeoForge.EVENT_BUS.addListener(ClientBookExploreOverlay::onMouseButton);
|
||||||
|
NeoForge.EVENT_BUS.addListener(ClientEvents::onLogout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onLogout(ClientPlayerNetworkEvent.LoggingOut event) {
|
||||||
|
ClientAgeCelestialAngles.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
import com.mojang.math.Axis;
|
||||||
|
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.client.model.BookModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelLayers;
|
||||||
|
import net.minecraft.client.renderer.MultiBufferSource;
|
||||||
|
import net.minecraft.client.renderer.RenderType;
|
||||||
|
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||||
|
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** Renders the same open book model and textures as Rivencraft's book stands. */
|
||||||
|
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity> {
|
||||||
|
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"rivencraft", "textures/entity/linking_book.png");
|
||||||
|
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
"rivencraft", "textures/entity/descriptive_book.png");
|
||||||
|
private final BookModel model;
|
||||||
|
|
||||||
|
public DroppedBookRenderer(EntityRendererProvider.Context context) {
|
||||||
|
super(context);
|
||||||
|
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
|
||||||
|
shadowRadius = 0.25F;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(DroppedBookEntity entity, float yaw, float partialTick, PoseStack poseStack,
|
||||||
|
MultiBufferSource buffers, int packedLight) {
|
||||||
|
poseStack.pushPose();
|
||||||
|
poseStack.translate(0.0F, 0.0625F, 0.0F);
|
||||||
|
poseStack.mulPose(Axis.YP.rotationDegrees(yaw + 90.0F));
|
||||||
|
// The opened model's covers rotate toward the YZ plane. As in Legacy's
|
||||||
|
// dropped-book renderer, a Z-axis quarter-turn lays the pages skyward.
|
||||||
|
poseStack.mulPose(Axis.ZP.rotationDegrees(90.0F));
|
||||||
|
poseStack.scale(0.8F, 0.8F, 0.8F);
|
||||||
|
model.setupAnim(0.0F, 0.0F, 0.0F, 1.2F);
|
||||||
|
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(getTextureLocation(entity)));
|
||||||
|
model.render(poseStack, vertices, packedLight, net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY, -1);
|
||||||
|
poseStack.popPose();
|
||||||
|
super.render(entity, yaw, partialTick, poseStack, buffers, packedLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResourceLocation getTextureLocation(DroppedBookEntity entity) {
|
||||||
|
return entity.getItem().is(ModItems.DESCRIPTIVE_BOOK.get()) ? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package net.dnorberg.rivencraft.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.systems.RenderSystem;
|
||||||
|
import com.mojang.blaze3d.platform.GlStateManager;
|
||||||
|
import com.mojang.blaze3d.vertex.BufferBuilder;
|
||||||
|
import com.mojang.blaze3d.vertex.BufferUploader;
|
||||||
|
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.Tesselator;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexFormat;
|
||||||
|
import java.util.Random;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
|
import net.minecraft.client.Camera;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.client.renderer.DimensionSpecialEffects;
|
||||||
|
import net.minecraft.client.renderer.FogRenderer;
|
||||||
|
import net.minecraft.client.renderer.GameRenderer;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import com.mojang.math.Axis;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
|
||||||
|
/** An authored sky can use vanilla celestial rendering or Legacy-style moving TV static. */
|
||||||
|
public final class RivencraftSkyEffects extends DimensionSpecialEffects {
|
||||||
|
private static final int GRID = 16;
|
||||||
|
private static final int STATIC_GRID = 48;
|
||||||
|
private static final float RADIUS = 100.0F;
|
||||||
|
private static final ResourceLocation SUN = ResourceLocation.withDefaultNamespace("textures/environment/sun.png");
|
||||||
|
private static final ResourceLocation MOON = ResourceLocation.withDefaultNamespace("textures/environment/moon_phases.png");
|
||||||
|
|
||||||
|
public RivencraftSkyEffects() {
|
||||||
|
super(192.0F, true, SkyType.NORMAL, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Vec3 getBrightnessDependentFogColor(Vec3 color, float brightness) {
|
||||||
|
return color.multiply(brightness * 0.94F + 0.06F,
|
||||||
|
brightness * 0.94F + 0.06F, brightness * 0.91F + 0.09F);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isFoggyAt(int x, int y) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean renderSky(ClientLevel level, int ticks, float partialTick, Matrix4f modelViewMatrix,
|
||||||
|
Camera camera, Matrix4f projectionMatrix, boolean isFoggy, Runnable setupFog) {
|
||||||
|
AgeSkyProfile sky = ClientAgeCelestialAngles.sky(level.dimension());
|
||||||
|
if (sky.style() != AgeSkyProfile.Style.STATIC && sky.color() == -1 && sky.bodies().isEmpty()
|
||||||
|
&& sky.style() != AgeSkyProfile.Style.NETHER && sky.style() != AgeSkyProfile.Style.END)
|
||||||
|
return false;
|
||||||
|
setupFog.run();
|
||||||
|
if (isFoggy) return true;
|
||||||
|
|
||||||
|
PoseStack pose = new PoseStack();
|
||||||
|
pose.mulPose(modelViewMatrix);
|
||||||
|
Matrix4f matrix = pose.last().pose();
|
||||||
|
Random random = new Random((level.getGameTime() + (long) ticks) / 2L);
|
||||||
|
int tint = sky.color();
|
||||||
|
if (tint == -1) tint = switch (sky.style()) {
|
||||||
|
case NETHER -> 0x6B2725;
|
||||||
|
case END -> 0x211835;
|
||||||
|
case STATIC -> 0x929292;
|
||||||
|
default -> level.getBiome(camera.getBlockPosition()).value().getSkyColor();
|
||||||
|
};
|
||||||
|
double time = level.getDayTime() + partialTick;
|
||||||
|
float eclipseShade = 1.0F - 0.65F * sky.eclipseAt(time);
|
||||||
|
|
||||||
|
RenderSystem.depthMask(false);
|
||||||
|
RenderSystem.disableCull();
|
||||||
|
RenderSystem.setShader(GameRenderer::getPositionColorShader);
|
||||||
|
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||||
|
FogRenderer.setupNoFog();
|
||||||
|
BufferBuilder vertices = Tesselator.getInstance().begin(
|
||||||
|
VertexFormat.Mode.QUADS, DefaultVertexFormat.POSITION_COLOR);
|
||||||
|
int grid = sky.style() == AgeSkyProfile.Style.STATIC ? STATIC_GRID : GRID;
|
||||||
|
for (int axis = 0; axis < 3; axis++) {
|
||||||
|
for (int sign : new int[]{-1, 1}) {
|
||||||
|
for (int x = 0; x < grid; x++) {
|
||||||
|
for (int y = 0; y < grid; y++) {
|
||||||
|
int noise = sky.style() == AgeSkyProfile.Style.STATIC
|
||||||
|
? 65 + random.nextInt(160) : 255;
|
||||||
|
int red = (int) (((tint >> 16) & 255) * noise / 255.0F * eclipseShade);
|
||||||
|
int green = (int) (((tint >> 8) & 255) * noise / 255.0F * eclipseShade);
|
||||||
|
int blue = (int) ((tint & 255) * noise / 255.0F * eclipseShade);
|
||||||
|
int color = 0xFF000000 | red << 16 | green << 8 | blue;
|
||||||
|
float left = -RADIUS + x * (2 * RADIUS / grid);
|
||||||
|
float right = -RADIUS + (x + 1) * (2 * RADIUS / grid);
|
||||||
|
float bottom = -RADIUS + y * (2 * RADIUS / grid);
|
||||||
|
float top = -RADIUS + (y + 1) * (2 * RADIUS / grid);
|
||||||
|
vertex(vertices, matrix, axis, sign, left, bottom, color);
|
||||||
|
vertex(vertices, matrix, axis, sign, right, bottom, color);
|
||||||
|
vertex(vertices, matrix, axis, sign, right, top, color);
|
||||||
|
vertex(vertices, matrix, axis, sign, left, top, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BufferUploader.drawWithShader(vertices.buildOrThrow());
|
||||||
|
if (!sky.bodies().isEmpty()) {
|
||||||
|
RenderSystem.enableBlend();
|
||||||
|
RenderSystem.defaultBlendFunc();
|
||||||
|
// Draw suns first so an aligned moon can pass visibly in front of them.
|
||||||
|
for (AgeSkyProfile.Body body : sky.bodies())
|
||||||
|
if (body.kind() == AgeSkyProfile.Kind.SUN) drawBody(pose, body, time, sky.eclipseFor(body, time));
|
||||||
|
for (AgeSkyProfile.Body body : sky.bodies())
|
||||||
|
if (body.kind() == AgeSkyProfile.Kind.MOON) drawBody(pose, body, time, sky.eclipseFor(body, time));
|
||||||
|
RenderSystem.disableBlend();
|
||||||
|
}
|
||||||
|
setupFog.run();
|
||||||
|
RenderSystem.enableCull();
|
||||||
|
RenderSystem.depthMask(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawBody(PoseStack pose, AgeSkyProfile.Body body, double time, float eclipse) {
|
||||||
|
pose.pushPose();
|
||||||
|
pose.mulPose(Axis.YP.rotationDegrees(body.angle()));
|
||||||
|
double orbit = body.period() == 0 ? body.phase()
|
||||||
|
: (body.clockwise() ? 1.0D : -1.0D) * time
|
||||||
|
/ (24000.0D * body.period()) + body.phase();
|
||||||
|
pose.mulPose(Axis.XP.rotationDegrees((float) (orbit * 360.0D)));
|
||||||
|
float size = (body.kind() == AgeSkyProfile.Kind.SUN ? 30.0F : 20.0F) * body.size();
|
||||||
|
int color = body.color();
|
||||||
|
float red = ((color >> 16) & 255) / 255.0F;
|
||||||
|
float green = ((color >> 8) & 255) / 255.0F;
|
||||||
|
float blue = (color & 255) / 255.0F;
|
||||||
|
if (body.kind() == AgeSkyProfile.Kind.MOON) {
|
||||||
|
red *= 1.0F - eclipse;
|
||||||
|
green *= 1.0F - eclipse;
|
||||||
|
blue *= 1.0F - eclipse;
|
||||||
|
} else {
|
||||||
|
float corona = (float) Math.pow(eclipse, 8.0D) * 0.8F;
|
||||||
|
red += (1.0F - red) * corona;
|
||||||
|
green += (1.0F - green) * corona;
|
||||||
|
blue += (1.0F - blue) * corona;
|
||||||
|
}
|
||||||
|
RenderSystem.setShader(GameRenderer::getPositionTexShader);
|
||||||
|
RenderSystem.setShaderTexture(0, body.kind() == AgeSkyProfile.Kind.SUN ? SUN : MOON);
|
||||||
|
// Vanilla's sun/moon atlases have opaque dark pixels around the bright center.
|
||||||
|
// Additive blending makes those pixels invisible while retaining the halo.
|
||||||
|
RenderSystem.blendFunc(GlStateManager.SourceFactor.SRC_ALPHA, GlStateManager.DestFactor.ONE);
|
||||||
|
RenderSystem.setShaderColor(red, green, blue,
|
||||||
|
body.kind() == AgeSkyProfile.Kind.SUN ? 0.84F : 0.88F + 0.12F * eclipse);
|
||||||
|
float uMax = body.kind() == AgeSkyProfile.Kind.SUN ? 1.0F : 0.25F;
|
||||||
|
float vMax = body.kind() == AgeSkyProfile.Kind.SUN ? 1.0F : 0.5F;
|
||||||
|
drawTexturedQuad(pose.last().pose(), -size, size, 0.0F, uMax, 0.0F, vMax);
|
||||||
|
|
||||||
|
// The 8x8 center of each 32x32 sprite is the actual body. Draw it last
|
||||||
|
// with ordinary alpha blending and full opacity so the sky and other
|
||||||
|
// celestial glows cannot show through it, including during an eclipse.
|
||||||
|
RenderSystem.defaultBlendFunc();
|
||||||
|
RenderSystem.setShaderColor(red, green, blue, 1.0F);
|
||||||
|
drawTexturedQuad(pose.last().pose(), -size * 0.25F, size * 0.25F,
|
||||||
|
uMax * 0.375F, uMax * 0.625F, vMax * 0.375F, vMax * 0.625F);
|
||||||
|
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
|
||||||
|
pose.popPose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawTexturedQuad(Matrix4f matrix, float min, float max,
|
||||||
|
float uMin, float uMax, float vMin, float vMax) {
|
||||||
|
BufferBuilder vertices = Tesselator.getInstance().begin(VertexFormat.Mode.QUADS,
|
||||||
|
DefaultVertexFormat.POSITION_TEX);
|
||||||
|
vertices.addVertex(matrix, min, RADIUS - 1.0F, min).setUv(uMin, vMin);
|
||||||
|
vertices.addVertex(matrix, max, RADIUS - 1.0F, min).setUv(uMax, vMin);
|
||||||
|
vertices.addVertex(matrix, max, RADIUS - 1.0F, max).setUv(uMax, vMax);
|
||||||
|
vertices.addVertex(matrix, min, RADIUS - 1.0F, max).setUv(uMin, vMax);
|
||||||
|
BufferUploader.drawWithShader(vertices.buildOrThrow());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void vertex(BufferBuilder vertices, Matrix4f matrix, int axis, int sign,
|
||||||
|
float x, float y, int color) {
|
||||||
|
float fixed = sign * RADIUS;
|
||||||
|
switch (axis) {
|
||||||
|
case 0 -> vertices.addVertex(matrix, fixed, x, y).setColor(color);
|
||||||
|
case 1 -> vertices.addVertex(matrix, x, fixed, y).setColor(color);
|
||||||
|
default -> vertices.addVertex(matrix, x, y, fixed).setColor(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,17 @@ public final class SymbolPageTextures {
|
|||||||
y * base.getHeight() / SIZE));
|
y * base.getHeight() / SIZE));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String numberPath = id.getPath().startsWith("number/")
|
||||||
|
? id.getPath().substring("number/".length()) : "";
|
||||||
|
if (id.getNamespace().equals(Rivencraft.MOD_ID)
|
||||||
|
&& numberPath.matches("(?:[0-9]|1[0-9]|2[0-4])")) {
|
||||||
|
int digit = Integer.parseInt(numberPath);
|
||||||
|
int glyphSize = item ? GLYPH * ITEM_SYMBOL_SIZE / ICON_SIZE : GLYPH;
|
||||||
|
int origin = item ? scaleForItem(38) : 38;
|
||||||
|
for (int component : SymbolNumerals.componentsForDigit(digit))
|
||||||
|
blendComponent(image, component, origin, origin, glyphSize);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
|
String[] words = poem.isBlank() ? generatedWords(id) : poem.split(" ");
|
||||||
int[] offsetsX = {38, 77, 38, 0};
|
int[] offsetsX = {38, 77, 38, 0};
|
||||||
int[] offsetsY = {0, 38, 77, 38};
|
int[] offsetsY = {0, 38, 77, 38};
|
||||||
@@ -146,18 +157,22 @@ public final class SymbolPageTextures {
|
|||||||
int originX = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
|
int originX = item ? scaleForItem(offsetsX[word]) : offsetsX[word];
|
||||||
int originY = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
|
int originY = item ? scaleForItem(offsetsY[word]) : offsetsY[word];
|
||||||
for (int component : glyphs(words[word])) {
|
for (int component : glyphs(words[word])) {
|
||||||
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
|
blendComponent(image, component, originX, originY, glyphSize);
|
||||||
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
|
|
||||||
(component / 8) * 64 + y * 64 / glyphSize);
|
|
||||||
int alpha = pixel >>> 24;
|
|
||||||
if (alpha != 0) image.blendPixel(originX + x, originY + y,
|
|
||||||
(alpha << 24) | 0x001A212B);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void blendComponent(NativeImage image, int component, int originX, int originY, int glyphSize) {
|
||||||
|
for (int y = 0; y < glyphSize; y++) for (int x = 0; x < glyphSize; x++) {
|
||||||
|
int pixel = components.getPixelRGBA((component % 8) * 64 + x * 64 / glyphSize,
|
||||||
|
(component / 8) * 64 + y * 64 / glyphSize);
|
||||||
|
int alpha = pixel >>> 24;
|
||||||
|
if (alpha != 0) image.blendPixel(originX + x, originY + y,
|
||||||
|
(alpha << 24) | 0x001A212B);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int scaleForItem(int coordinate) {
|
private static int scaleForItem(int coordinate) {
|
||||||
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / ICON_SIZE);
|
return SIZE / 2 + Math.round((coordinate - SIZE / 2F) * ITEM_SYMBOL_SIZE / ICON_SIZE);
|
||||||
}
|
}
|
||||||
@@ -196,7 +211,7 @@ public final class SymbolPageTextures {
|
|||||||
graphics.setColor(1F, 1F, 1F, 1F);
|
graphics.setColor(1F, 1F, 1F, 1F);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy's single-number marks; numbers above 24 use positional base 25. */
|
/** Overlay each Legacy digit's components; place base-25 digits side by side. */
|
||||||
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
|
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
|
||||||
int[] digits = SymbolNumerals.digitsFor(number);
|
int[] digits = SymbolNumerals.digitsFor(number);
|
||||||
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
|||||||
}
|
}
|
||||||
graphics.disableScissor();
|
graphics.disableScissor();
|
||||||
drawScrollEnds(graphics);
|
drawScrollEnds(graphics);
|
||||||
var severity = menu.grammarSeverity().orElse(null);
|
var severity = menu.diagnosticsVisible() ? menu.grammarSeverity().orElse(null) : null;
|
||||||
Component status = severity == WritingGrammar.Severity.FATAL
|
Component status = severity == WritingGrammar.Severity.FATAL
|
||||||
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
|
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
|
||||||
: severity == WritingGrammar.Severity.INSTABILITY
|
: severity == WritingGrammar.Severity.INSTABILITY
|
||||||
@@ -117,9 +117,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
|||||||
if (status != null) graphics.drawString(font, status, leftPos + 77, topPos + 30,
|
if (status != null) graphics.drawString(font, status, leftPos + 77, topPos + 30,
|
||||||
severity == WritingGrammar.Severity.FATAL ? 0xFFAA3030
|
severity == WritingGrammar.Severity.FATAL ? 0xFFAA3030
|
||||||
: severity == WritingGrammar.Severity.INSTABILITY ? 0xFF924F18 : 0xFF50624B, false);
|
: severity == WritingGrammar.Severity.INSTABILITY ? 0xFF924F18 : 0xFF50624B, false);
|
||||||
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
|
Component grammarTooltip = menu.diagnosticsVisible() ? menu.grammarError().map(code -> Component.translatable(
|
||||||
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
||||||
errorStripIndex + 1)).orElse(null);
|
errorStripIndex + 1)).orElse(null) : null;
|
||||||
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|
||||||
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
|
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
|
||||||
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import net.minecraft.client.gui.components.Button;
|
|||||||
import net.minecraft.client.gui.components.EditBox;
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
import net.minecraft.client.gui.components.Tooltip;
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.sounds.SoundEvents;
|
import net.minecraft.sounds.SoundEvents;
|
||||||
import net.minecraft.util.FormattedCharSequence;
|
import net.minecraft.util.FormattedCharSequence;
|
||||||
@@ -48,6 +49,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
private static final Component EXPLORE_ICON = Component.literal("👁");
|
private static final Component EXPLORE_ICON = Component.literal("👁");
|
||||||
|
|
||||||
private final AgeReference age;
|
private final AgeReference age;
|
||||||
|
private final Component displayName;
|
||||||
private final ResourceLocation generationProfile;
|
private final ResourceLocation generationProfile;
|
||||||
private final AgeTraits traits;
|
private final AgeTraits traits;
|
||||||
private final OrderedWriting writing;
|
private final OrderedWriting writing;
|
||||||
@@ -68,6 +70,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
List<SymbolDisplayMetadata> writingSymbols) {
|
List<SymbolDisplayMetadata> writingSymbols) {
|
||||||
super(book.getHoverName());
|
super(book.getHoverName());
|
||||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
|
displayName = book.get(DataComponents.CUSTOM_NAME);
|
||||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||||
this.traits = traits;
|
this.traits = traits;
|
||||||
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||||
@@ -82,6 +85,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
List<SymbolDisplayMetadata> writingSymbols) {
|
List<SymbolDisplayMetadata> writingSymbols) {
|
||||||
super(book.getHoverName());
|
super(book.getHoverName());
|
||||||
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
age = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
|
displayName = book.get(DataComponents.CUSTOM_NAME);
|
||||||
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
|
||||||
this.traits = traits;
|
this.traits = traits;
|
||||||
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
|
||||||
@@ -245,7 +249,8 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
|
int nameEnd = drawWrappedWithoutShadow(graphics,
|
||||||
|
displayName == null ? Component.literal(age.name()) : displayName,
|
||||||
x, top + 40, 110);
|
x, top + 40, 110);
|
||||||
int detailsY = drawWrappedWithoutShadow(graphics,
|
int detailsY = drawWrappedWithoutShadow(graphics,
|
||||||
author == null || author.isBlank()
|
author == null || author.isBlank()
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package net.dnorberg.rivencraft.client.screen;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
|
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||||
|
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||||
|
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.client.gui.components.EditBox;
|
||||||
|
import net.minecraft.client.gui.components.Tooltip;
|
||||||
|
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.neoforged.neoforge.network.PacketDistributor;
|
||||||
|
|
||||||
|
/** Legacy single-slot layout, with server-owned link options. */
|
||||||
|
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
|
||||||
|
private static final ResourceLocation BACKGROUND = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png");
|
||||||
|
private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING,
|
||||||
|
LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM,
|
||||||
|
LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM};
|
||||||
|
private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA,
|
||||||
|
LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM,
|
||||||
|
LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM};
|
||||||
|
private EditBox titleInput;
|
||||||
|
private EditBox seedInput;
|
||||||
|
private ItemStack lastBook = ItemStack.EMPTY;
|
||||||
|
private long lastSeed = Long.MIN_VALUE;
|
||||||
|
|
||||||
|
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
|
||||||
|
super(menu, inventory, title);
|
||||||
|
imageWidth = 176;
|
||||||
|
imageHeight = 166;
|
||||||
|
inventoryLabelX = 8;
|
||||||
|
inventoryLabelY = 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void init() {
|
||||||
|
super.init();
|
||||||
|
titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14,
|
||||||
|
Component.translatable("screen.rivencraft.link_modifier.title"));
|
||||||
|
titleInput.setMaxLength(32);
|
||||||
|
titleInput.setResponder(value -> PacketDistributor.sendToServer(
|
||||||
|
new SetLinkModifierTitlePayload(menu.containerId, value)));
|
||||||
|
addRenderableWidget(titleInput);
|
||||||
|
seedInput = new EditBox(font, leftPos + 80, topPos + 15, 87, 14,
|
||||||
|
Component.translatable("screen.rivencraft.link_modifier.seed"));
|
||||||
|
seedInput.setMaxLength(21);
|
||||||
|
seedInput.setTooltip(Tooltip.create(Component.translatable("screen.rivencraft.link_modifier.seed_warning")));
|
||||||
|
addRenderableWidget(seedInput);
|
||||||
|
refreshControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void click(int id) {
|
||||||
|
if (minecraft != null && minecraft.gameMode != null)
|
||||||
|
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void containerTick() { refreshControls(); }
|
||||||
|
|
||||||
|
private void submitSeed() {
|
||||||
|
if (seedInput.visible && !seedInput.getValue().equals(Long.toString(menu.seed())))
|
||||||
|
PacketDistributor.sendToServer(new SetLinkModifierSeedPayload(menu.containerId, seedInput.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
|
||||||
|
if (seedInput.isFocused()) {
|
||||||
|
if (keyCode == 257 || keyCode == 335) submitSeed();
|
||||||
|
else if (keyCode != 256) seedInput.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
if (keyCode == 256 || keyCode == 257 || keyCode == 335) {
|
||||||
|
setFocused(null);
|
||||||
|
seedInput.setFocused(false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (titleInput.isFocused()) {
|
||||||
|
if (keyCode == 256) {
|
||||||
|
setFocused(null);
|
||||||
|
titleInput.setFocused(false);
|
||||||
|
} else titleInput.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.keyPressed(keyCode, scanCode, modifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||||
|
if (seedInput.isFocused() && (mouseX < seedInput.getX() || mouseX >= seedInput.getX() + seedInput.getWidth()
|
||||||
|
|| mouseY < seedInput.getY() || mouseY >= seedInput.getY() + seedInput.getHeight())) {
|
||||||
|
submitSeed();
|
||||||
|
setFocused(null);
|
||||||
|
seedInput.setFocused(false);
|
||||||
|
}
|
||||||
|
if (button == 0) {
|
||||||
|
for (int index = 0; index < FLAGS.length; index++) {
|
||||||
|
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
|
||||||
|
if (!menu.book().isEmpty()) click(IDS[index]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (menu.canDelete(minecraft.player)
|
||||||
|
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18)) {
|
||||||
|
click(LinkModifierMenu.DELETE_AGE);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.mouseClicked(mouseX, mouseY, button);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean inside(double mouseX, double mouseY, int x, int y, int width, int height) {
|
||||||
|
return mouseX >= leftPos + x && mouseX < leftPos + x + width
|
||||||
|
&& mouseY >= topPos + y && mouseY < topPos + y + height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshControls() {
|
||||||
|
ItemStack book = menu.book();
|
||||||
|
if (!ItemStack.isSameItemSameComponents(book, lastBook) && !titleInput.isFocused()) {
|
||||||
|
Component name = book.get(DataComponents.CUSTOM_NAME);
|
||||||
|
titleInput.setValue(name == null ? "" : name.getString());
|
||||||
|
lastBook = book.copy();
|
||||||
|
}
|
||||||
|
boolean present = !book.isEmpty();
|
||||||
|
titleInput.setEditable(present);
|
||||||
|
seedInput.visible = menu.hasSeed();
|
||||||
|
seedInput.setEditable(menu.hasSeed());
|
||||||
|
if (seedInput.visible && !seedInput.isFocused() && (lastSeed != menu.seed()
|
||||||
|
|| !ItemStack.isSameItemSameComponents(book, lastBook))) {
|
||||||
|
lastSeed = menu.seed();
|
||||||
|
seedInput.setValue(Long.toString(lastSeed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||||
|
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||||
|
LinkProperties properties = menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||||
|
for (int index = 0; index < FLAGS.length; index++) {
|
||||||
|
boolean selected = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||||
|
? platformEnabled(properties) : properties.has(FLAGS[index]);
|
||||||
|
square(graphics, 5 + (index / 3) * 20, 10 + (index % 3) * 20,
|
||||||
|
selected, false);
|
||||||
|
}
|
||||||
|
if (menu.canDelete(minecraft.player)) {
|
||||||
|
square(graphics, 120, 32, menu.awaitingDeletionConfirmation(), false);
|
||||||
|
if (menu.awaitingDeletionConfirmation()) square(graphics, 140, 32, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void square(GuiGraphics graphics, int x, int y, boolean selected, boolean danger) {
|
||||||
|
int left = leftPos + x;
|
||||||
|
int top = topPos + y;
|
||||||
|
graphics.fill(left, top, left + 18, top + 18, 0xFFAAAAAA);
|
||||||
|
graphics.fill(left + 2, top + 2, left + 17, top + 17,
|
||||||
|
danger ? 0xFFAA5555 : selected ? 0xFFBBBBBB : 0xFF777777);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
|
||||||
|
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||||
|
super.render(graphics, mouseX, mouseY, partialTick);
|
||||||
|
renderTooltip(graphics, mouseX, mouseY);
|
||||||
|
if (!menu.book().isEmpty()) {
|
||||||
|
for (int index = 0; index < FLAGS.length; index++) {
|
||||||
|
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
|
||||||
|
Component label = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||||
|
? Component.literal("Generate Platform: "
|
||||||
|
+ (platformEnabled(menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
|
||||||
|
LinkProperties.NONE)) ? "On" : "Off"))
|
||||||
|
: LinkProperties.name(FLAGS[index]);
|
||||||
|
graphics.renderTooltip(font, label, mouseX, mouseY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (menu.canDelete(minecraft.player)
|
||||||
|
&& inside(mouseX, mouseY, menu.awaitingDeletionConfirmation() ? 140 : 120, 32, 18, 18))
|
||||||
|
graphics.renderTooltip(font, Component.translatable(menu.awaitingDeletionConfirmation()
|
||||||
|
? "screen.rivencraft.link_modifier.confirm" : "screen.rivencraft.link_modifier.retire"),
|
||||||
|
mouseX, mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean platformEnabled(LinkProperties properties) {
|
||||||
|
return menu.book().is(ModItems.LINKING_BOOK.get())
|
||||||
|
? properties.has(LinkProperties.GENERATE_PLATFORM)
|
||||||
|
: !properties.has(LinkProperties.DISABLE_PLATFORM);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,9 +182,9 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
|
|||||||
|
|
||||||
private int displayedPageSlots() {
|
private int displayedPageSlots() {
|
||||||
if (menu.getCarried().is(ModItems.PAGE.get())) {
|
if (menu.getCarried().is(ModItems.PAGE.get())) {
|
||||||
return NotebookMenu.NOTEBOOK_SLOTS;
|
return menu.pageCapacity();
|
||||||
}
|
}
|
||||||
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
|
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
|
||||||
if (!menu.getSlot(index).getItem().isEmpty()) {
|
if (!menu.getSlot(index).getItem().isEmpty()) {
|
||||||
return index + 1;
|
return index + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
|||||||
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||||
import net.minecraft.client.gui.GuiGraphics;
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
@@ -150,8 +151,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
|||||||
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
|
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
|
||||||
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
||||||
graphics.setColor(1F, 1F, 1F, 1F);
|
graphics.setColor(1F, 1F, 1F, 1F);
|
||||||
// Encode the visible one-based tab number with Legacy's numeric word glyph.
|
// Legacy numbers its Writing Desk collections from zero.
|
||||||
SymbolPageTextures.drawNumeral(graphics, tab + 1, x + 8, tabY + 3, 19);
|
SymbolPageTextures.drawNumeral(graphics, tab, x + (tab < 25 ? 8 : 4), tabY + 3, 19);
|
||||||
ItemStack collection = menu.getSlot(tab).getItem();
|
ItemStack collection = menu.getSlot(tab).getItem();
|
||||||
if (!collection.isEmpty()) {
|
if (!collection.isEmpty()) {
|
||||||
String name = collection.getHoverName().getString();
|
String name = collection.getHoverName().getString();
|
||||||
@@ -226,7 +227,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
|||||||
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
|
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
|
||||||
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
|
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
|
||||||
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
|
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
|
||||||
tab + 1), mouseX, mouseY);
|
tab), mouseX, mouseY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +235,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
|||||||
private List<ItemStack> pages(int collectionIndex) {
|
private List<ItemStack> pages(int collectionIndex) {
|
||||||
ItemStack collection = menu.getSlot(collectionIndex).getItem();
|
ItemStack collection = menu.getSlot(collectionIndex).getItem();
|
||||||
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
|
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
|
||||||
if (collection.is(ModItems.NOTEBOOK.get())) {
|
if (CollationFolderItem.isCollection(collection)) {
|
||||||
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
}
|
}
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -301,9 +302,9 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
|||||||
|
|
||||||
private boolean canMovePage(DisplayPage source, DisplayPage target) {
|
private boolean canMovePage(DisplayPage source, DisplayPage target) {
|
||||||
return !source.stack().isEmpty()
|
return !source.stack().isEmpty()
|
||||||
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
&& (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|
||||||
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
|
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
|
||||||
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get())
|
&& (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|
||||||
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
|
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
|
||||||
&& (source.collection() != target.collection() || source.index() != target.index());
|
&& (source.collection() != target.collection() || source.index() != target.index());
|
||||||
}
|
}
|
||||||
@@ -317,10 +318,10 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
|||||||
if (!slot.mayPlace(held)) return -1;
|
if (!slot.mayPlace(held)) return -1;
|
||||||
ItemStack current = slot.getItem();
|
ItemStack current = slot.getItem();
|
||||||
if (current.isEmpty()) return index;
|
if (current.isEmpty()) return index;
|
||||||
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
|
if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
|
||||||
List<ItemStack> pages = current.getOrDefault(
|
List<ItemStack> pages = current.getOrDefault(
|
||||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
|
||||||
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty))
|
if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
|
||||||
return index;
|
return index;
|
||||||
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
|
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
|
||||||
&& current.getCount() < slot.getMaxStackSize(held)) return index;
|
&& current.getCount() < slot.getMaxStackSize(held)) return index;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
|||||||
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
|
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
|
||||||
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
|
||||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||||
|
import net.dnorberg.rivencraft.menu.BookBinderDiagnostics;
|
||||||
import net.minecraft.commands.Commands;
|
import net.minecraft.commands.Commands;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||||
@@ -29,6 +30,13 @@ public final class RivencraftCommands {
|
|||||||
public static void register(RegisterCommandsEvent event) {
|
public static void register(RegisterCommandsEvent event) {
|
||||||
event.getDispatcher().register(Commands.literal("rivencraft")
|
event.getDispatcher().register(Commands.literal("rivencraft")
|
||||||
.requires(source -> source.hasPermission(2))
|
.requires(source -> source.hasPermission(2))
|
||||||
|
.then(Commands.literal("binder")
|
||||||
|
.then(Commands.literal("messages")
|
||||||
|
.executes(context -> binderMessages(context.getSource(), null))
|
||||||
|
.then(Commands.literal("enable")
|
||||||
|
.executes(context -> binderMessages(context.getSource(), true)))
|
||||||
|
.then(Commands.literal("disable")
|
||||||
|
.executes(context -> binderMessages(context.getSource(), false)))))
|
||||||
.then(Commands.literal("library")
|
.then(Commands.literal("library")
|
||||||
.then(Commands.literal("nearest")
|
.then(Commands.literal("nearest")
|
||||||
.executes(context -> LibraryLocateService.start(context.getSource())))
|
.executes(context -> LibraryLocateService.start(context.getSource())))
|
||||||
@@ -94,6 +102,14 @@ public final class RivencraftCommands {
|
|||||||
.then(environmentCommand())));
|
.then(environmentCommand())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int binderMessages(CommandSourceStack source, Boolean enabled) {
|
||||||
|
BookBinderDiagnostics diagnostics = BookBinderDiagnostics.get(source.getLevel());
|
||||||
|
if (enabled != null) diagnostics.setEnabled(enabled);
|
||||||
|
source.sendSuccess(() -> Component.literal("Book Binder diagnostic messages are "
|
||||||
|
+ (diagnostics.enabled() ? "enabled for operators" : "disabled") + "."), false);
|
||||||
|
return diagnostics.enabled() ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
|
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
|
||||||
return Commands.literal("environment")
|
return Commands.literal("environment")
|
||||||
.then(Commands.literal("pulse")
|
.then(Commands.literal("pulse")
|
||||||
@@ -112,8 +128,9 @@ public final class RivencraftCommands {
|
|||||||
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
|
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
|
||||||
for (AgeDefinition age : ages) {
|
for (AgeDefinition age : ages) {
|
||||||
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
|
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
|
||||||
|
boolean retired = AgeRegistrySavedData.get(source.getLevel()).isDead(age.id());
|
||||||
source.sendSuccess(() -> Component.literal(
|
source.sendSuccess(() -> Component.literal(
|
||||||
(loaded ? "[loaded] " : "[unloaded] ")
|
(retired ? "[retired] " : loaded ? "[loaded] " : "[unloaded] ")
|
||||||
+ age.name() + " — " + age.dimensionId()
|
+ age.name() + " — " + age.dimensionId()
|
||||||
+ (age.seasonMode() == null ? "" : " (season: "
|
+ (age.seasonMode() == null ? "" : " (season: "
|
||||||
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
|
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import net.createmod.ponder.api.PonderPalette;
|
||||||
|
import net.createmod.ponder.api.registration.PonderPlugin;
|
||||||
|
import net.createmod.ponder.api.registration.PonderSceneRegistrationHelper;
|
||||||
|
import net.createmod.ponder.api.scene.SceneBuilder;
|
||||||
|
import net.createmod.ponder.api.scene.SceneBuildingUtil;
|
||||||
|
import net.createmod.ponder.foundation.PonderIndex;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
|
||||||
|
/** Create-only Ponder registration; this class is never loaded without Create. */
|
||||||
|
public final class RivencraftPonderGuide implements PonderPlugin {
|
||||||
|
private static final ResourceLocation DEPOT_TEMPLATE = ResourceLocation.fromNamespaceAndPath("create", "depot");
|
||||||
|
|
||||||
|
private RivencraftPonderGuide() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
PonderIndex.addPlugin(new RivencraftPonderGuide());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModId() {
|
||||||
|
return Rivencraft.MOD_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerScenes(PonderSceneRegistrationHelper<ResourceLocation> helper) {
|
||||||
|
// Create is already present when this plugin is registered. Its small
|
||||||
|
// depot schematic supplies an emptyable 6×4×6 canvas, so Rivencraft
|
||||||
|
// does not need to ship a separate binary Ponder template.
|
||||||
|
helper.forComponents(ModItems.BOOK_RECEPTACLE.getId(), ModItems.CRYSTAL_BLOCK.getId())
|
||||||
|
.addStoryBoard(DEPOT_TEMPLATE, RivencraftPonderGuide::crystalPortal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void crystalPortal(SceneBuilder scene, SceneBuildingUtil util) {
|
||||||
|
scene.title("crystal_portal", "Building a Crystal Portal");
|
||||||
|
scene.configureBasePlate(0, 0, 6);
|
||||||
|
scene.scaleSceneView(0.85F);
|
||||||
|
scene.showBasePlate();
|
||||||
|
scene.world().setBlocks(util.select().fromTo(0, 1, 0, 5, 3, 5),
|
||||||
|
Blocks.AIR.defaultBlockState(), false);
|
||||||
|
scene.idle(10);
|
||||||
|
|
||||||
|
BlockPos center = util.grid().at(3, 2, 3);
|
||||||
|
scene.overlay().showText(65).text("Build a closed crystal frame with an open center.")
|
||||||
|
.pointAt(util.vector().centerOf(center)).placeNearTarget();
|
||||||
|
for (int x = 2; x <= 4; x++) {
|
||||||
|
for (int y = 1; y <= 3; y++) {
|
||||||
|
if (x == 3 && y == 2) continue;
|
||||||
|
BlockPos pos = util.grid().at(x, y, 3);
|
||||||
|
scene.world().setBlock(pos, ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), false);
|
||||||
|
scene.world().showSection(util.select().position(pos), Direction.DOWN);
|
||||||
|
scene.idle(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scene.idle(30);
|
||||||
|
|
||||||
|
BlockPos receptacle = util.grid().at(2, 1, 2);
|
||||||
|
scene.overlay().showText(70).text("Attach a Book Receptacle to one crystal, facing outward.")
|
||||||
|
.pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
|
||||||
|
scene.world().setBlock(receptacle, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), false);
|
||||||
|
scene.world().showSection(util.select().position(receptacle), Direction.DOWN);
|
||||||
|
scene.idle(75);
|
||||||
|
|
||||||
|
scene.overlay().showText(65).text("Insert a bound Linking or Descriptive Book to activate the portal.")
|
||||||
|
.colored(PonderPalette.INPUT).pointAt(util.vector().centerOf(receptacle)).placeNearTarget();
|
||||||
|
scene.overlay().showControls(util.vector().centerOf(receptacle),
|
||||||
|
net.createmod.catnip.math.Pointing.RIGHT, 55)
|
||||||
|
.rightClick().withItem(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
scene.idle(65);
|
||||||
|
|
||||||
|
scene.world().setBlock(center, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState()
|
||||||
|
.setValue(CrystalPortalBlock.AXIS, Direction.Axis.Z), false);
|
||||||
|
scene.world().showSection(util.select().position(center), Direction.DOWN);
|
||||||
|
scene.effects().indicateSuccess(center);
|
||||||
|
scene.overlay().showText(80).text("Step through the portal. Remove the book to close it.")
|
||||||
|
.colored(PonderPalette.OUTPUT).pointAt(util.vector().centerOf(center)).placeNearTarget();
|
||||||
|
scene.idle(20);
|
||||||
|
var parrot = scene.world().createEntity(world -> {
|
||||||
|
var bird = EntityType.PARROT.create(world);
|
||||||
|
bird.setNoAi(true);
|
||||||
|
bird.setPos(3.5, 2.05, 1.5);
|
||||||
|
bird.setYRot(0);
|
||||||
|
return bird;
|
||||||
|
});
|
||||||
|
scene.idle(10);
|
||||||
|
for (int step = 1; step <= 7; step++) {
|
||||||
|
double z = 1.5 + step * 0.42;
|
||||||
|
scene.world().modifyEntity(parrot, bird -> bird.setPos(3.5, 2.05, z));
|
||||||
|
scene.idle(5);
|
||||||
|
}
|
||||||
|
scene.world().modifyEntity(parrot, bird -> bird.discard());
|
||||||
|
scene.effects().indicateSuccess(center);
|
||||||
|
scene.idle(20);
|
||||||
|
scene.markAsFinished();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
|
||||||
|
/** Small optional bridge between frozen Age pages and Ruins' dimension gate. */
|
||||||
|
public final class RuinsCompatibility {
|
||||||
|
private static final Set<ResourceLocation> KNOWN_AGES = ConcurrentHashMap.newKeySet();
|
||||||
|
private static final Map<ResourceLocation, Map<ResourceLocation, Integer>> AUTHORED_TEMPLATES =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private RuinsCompatibility() {}
|
||||||
|
|
||||||
|
public static void register() {
|
||||||
|
if (ModList.get().isLoaded("ruins")) RuinsEventAdapter.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void registerAge(AgeDefinition age) {
|
||||||
|
if (!ModList.get().isLoaded("ruins")) return;
|
||||||
|
KNOWN_AGES.add(age.dimensionId());
|
||||||
|
List<AgeFeatureSelection.Choice> choices = age.plan() == null
|
||||||
|
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
|
||||||
|
Map<ResourceLocation, Integer> selected = templateCounts(choices);
|
||||||
|
if (!selected.isEmpty()) {
|
||||||
|
AUTHORED_TEMPLATES.put(age.dimensionId(), selected);
|
||||||
|
} else {
|
||||||
|
AUTHORED_TEMPLATES.remove(age.dimensionId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called only by the optional Ruins FileHandler mixin. */
|
||||||
|
public static boolean allowsAge(ResourceLocation dimension) {
|
||||||
|
return AUTHORED_TEMPLATES.containsKey(dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isKnownAge(ResourceLocation dimension) {
|
||||||
|
return KNOWN_AGES.contains(dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean permits(ResourceLocation dimension, ResourceLocation template) {
|
||||||
|
return templateCount(dimension, template) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int templateCount(ResourceLocation dimension, ResourceLocation template) {
|
||||||
|
return AUTHORED_TEMPLATES.getOrDefault(dimension, Map.of()).getOrDefault(template, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra pages increase attempts, without making distinct template choices denser. */
|
||||||
|
public static int attemptsPerChunk(ResourceLocation dimension, int configuredAttempts) {
|
||||||
|
return attemptsPerChunk(configuredAttempts, AUTHORED_TEMPLATES.get(dimension));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int attemptsPerChunk(int configuredAttempts, Map<ResourceLocation, Integer> counts) {
|
||||||
|
if (counts == null || counts.isEmpty()) return configuredAttempts;
|
||||||
|
int duplicates = counts.values().stream().mapToInt(count -> count - 1).sum();
|
||||||
|
return (int) Math.min(Math.max(configuredAttempts, 32L), (long) configuredAttempts + duplicates);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<ResourceLocation, Integer> templateCounts(List<AgeFeatureSelection.Choice> choices) {
|
||||||
|
Map<ResourceLocation, Integer> counts = new java.util.HashMap<>();
|
||||||
|
for (AgeFeatureSelection.Choice choice : choices) {
|
||||||
|
if (RuinsTemplatePages.contains(choice.id())) counts.merge(choice.id(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
return Map.copyOf(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void onServerStopping() {
|
||||||
|
KNOWN_AGES.clear();
|
||||||
|
AUTHORED_TEMPLATES.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
|
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Each discoverable Ruins template must have an authorable page, or none do. */
|
||||||
|
@GameTestHolder("rivencraft")
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class RuinsCompatibilityGameTests {
|
||||||
|
private RuinsCompatibilityGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void ruinsPagesMatchInstalledTemplates(GameTestHelper helper) {
|
||||||
|
var templates = RuinsTemplatePages.templates();
|
||||||
|
if (!ModList.get().isLoaded("ruins")) {
|
||||||
|
helper.assertTrue(templates.isEmpty(), "Ruins pages must be absent without the optional mod");
|
||||||
|
} else {
|
||||||
|
var creative = PageItem.creativeSymbolPages(helper.getLevel().registryAccess(),
|
||||||
|
RuinsTemplatePages.CATEGORY);
|
||||||
|
helper.assertValueEqual(creative.size(), templates.size(),
|
||||||
|
"Creative and JEI must expose exactly one page per Ruins template");
|
||||||
|
for (String name : templates) {
|
||||||
|
var id = RuinsTemplatePages.symbolId(name);
|
||||||
|
helper.assertTrue(SymbolCatalog.isKnown(new SymbolReference(id)),
|
||||||
|
"Ruins template " + name + " must be authorable");
|
||||||
|
helper.assertTrue(PageItem.isUsableWriting(PageItem.createCreativeRuinsPage(name)),
|
||||||
|
"Ruins template " + name + " needs a usable page");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void duplicatePagesRetainTheirCount(GameTestHelper helper) {
|
||||||
|
if (RuinsTemplatePages.templates().isEmpty()) {
|
||||||
|
helper.succeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var template = RuinsTemplatePages.symbolId(RuinsTemplatePages.templates().getFirst());
|
||||||
|
var counts = RuinsCompatibility.templateCounts(List.of(
|
||||||
|
new AgeFeatureSelection.Choice(template, Optional.empty()),
|
||||||
|
new AgeFeatureSelection.Choice(template, Optional.empty()),
|
||||||
|
new AgeFeatureSelection.Choice(template, Optional.empty())));
|
||||||
|
helper.assertValueEqual(counts.get(template), 3,
|
||||||
|
"Three authored Ruins pages must triple that template's relative selection weight");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void duplicatePagesIncreaseAttemptsWithinBudget(GameTestHelper helper) {
|
||||||
|
var first = RuinsTemplatePages.symbolId("First.tml");
|
||||||
|
var second = RuinsTemplatePages.symbolId("Second.tml");
|
||||||
|
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 1)), 6,
|
||||||
|
"One Ruins page must retain the configured attempt count");
|
||||||
|
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 3, second, 1)), 8,
|
||||||
|
"Only duplicate pages must add one attempt each");
|
||||||
|
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(6, Map.of(first, 50)), 32,
|
||||||
|
"Duplicate pages must not exceed the per-chunk attempt budget");
|
||||||
|
helper.assertValueEqual(RuinsCompatibility.attemptsPerChunk(40, Map.of(first, 2)), 40,
|
||||||
|
"The budget must not lower an intentionally higher Ruins configuration");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void authoredTemplateBypassesOverworldOnlyGate(GameTestHelper helper) throws Exception {
|
||||||
|
if (!ModList.get().isLoaded("ruins")) {
|
||||||
|
helper.succeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String name = "tikihead1.tml";
|
||||||
|
var path = FMLPaths.GAMEDIR.get().resolve("config/ruins_config/generic").resolve(name);
|
||||||
|
if (!Files.exists(path) || !RuinsTemplatePages.contains(RuinsTemplatePages.symbolId(name))) {
|
||||||
|
helper.succeed(); // Custom Ruins packs need not include the bundled Tiki Head.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var writing = new OrderedWriting(List.of(
|
||||||
|
WritingEntry.linkPanel(),
|
||||||
|
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||||
|
"rivencraft:terrain/standard"))),
|
||||||
|
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||||
|
"rivencraft:biome/plains"))),
|
||||||
|
WritingEntry.symbol(new SymbolReference(net.minecraft.resources.ResourceLocation.parse(
|
||||||
|
"rivencraft:biome/single"))),
|
||||||
|
WritingEntry.symbol(new SymbolReference(RuinsTemplatePages.symbolId(name)))));
|
||||||
|
var age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||||
|
new AgeReference(UUID.randomUUID(), "Ruins Tiki Gate"), writing);
|
||||||
|
RuinsCompatibility.registerAge(age);
|
||||||
|
var ruinsClass = Class.forName("atomicstryker.ruins.common.RuinsMod");
|
||||||
|
Object ruins = ruinsClass.getMethod("getInstance").invoke(null);
|
||||||
|
var currentLevel = ruinsClass.getDeclaredField("lastLoadedLevel");
|
||||||
|
currentLevel.setAccessible(true);
|
||||||
|
Object previousLevel = currentLevel.get(ruins);
|
||||||
|
var templateClass = Class.forName("atomicstryker.ruins.common.RuinTemplate");
|
||||||
|
try {
|
||||||
|
currentLevel.set(ruins, helper.getLevel());
|
||||||
|
Object template = templateClass.getConstructor(String.class, String.class)
|
||||||
|
.newInstance(path.toString(), name);
|
||||||
|
boolean accepted = (boolean) templateClass.getMethod("acceptsDimension", String.class)
|
||||||
|
.invoke(template, age.dimensionId().getPath());
|
||||||
|
helper.assertTrue(accepted, "The authored Tiki Head must load despite dimensionsToSpawnIn=overworld");
|
||||||
|
} finally {
|
||||||
|
currentLevel.set(ruins, previousLevel);
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import atomicstryker.ruins.common.EventRuinTemplateSpawn;
|
||||||
|
import atomicstryker.ruins.common.RuinTemplate;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.util.RandomSource;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
|
||||||
|
/** Loaded only when Ruins is present. Its pre-spawn event can veto exact templates. */
|
||||||
|
public final class RuinsEventAdapter {
|
||||||
|
private RuinsEventAdapter() {}
|
||||||
|
|
||||||
|
static void register() {
|
||||||
|
NeoForge.EVENT_BUS.addListener(RuinsEventAdapter::onTemplateSpawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onTemplateSpawn(EventRuinTemplateSpawn event) {
|
||||||
|
if (!event.isPrePhase || event.testingRuin
|
||||||
|
|| !(event.getLevel() instanceof ServerLevel level)
|
||||||
|
|| !RuinsCompatibility.isKnownAge(level.dimension().location())) return;
|
||||||
|
|
||||||
|
var template = RuinsTemplatePages.featureId(event.template.getName());
|
||||||
|
if (!RuinsTemplatePages.contains(template)
|
||||||
|
|| !RuinsCompatibility.permits(level.dimension().location(), template)) event.setCancelled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve Ruins' weights and multiply each by its authored page count. */
|
||||||
|
public static Object chooseTemplate(net.minecraft.resources.ResourceLocation dimension,
|
||||||
|
Collection<?> pool, RandomSource random) {
|
||||||
|
if (pool == null || pool.isEmpty()) return null;
|
||||||
|
var candidates = pool.stream().filter(RuinTemplate.class::isInstance)
|
||||||
|
.map(RuinTemplate.class::cast)
|
||||||
|
.filter(template -> RuinsCompatibility.permits(dimension,
|
||||||
|
RuinsTemplatePages.featureId(template.getName())))
|
||||||
|
.sorted(Comparator.comparing(RuinTemplate::getName)).toList();
|
||||||
|
double total = candidates.stream().mapToDouble(template -> authoredWeight(dimension, template)).sum();
|
||||||
|
if (total <= 0.0) return null;
|
||||||
|
double roll = random.nextDouble() * total;
|
||||||
|
for (RuinTemplate candidate : candidates) {
|
||||||
|
roll -= authoredWeight(dimension, candidate);
|
||||||
|
if (roll < 0.0) return candidate;
|
||||||
|
}
|
||||||
|
return candidates.getLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double authoredWeight(net.minecraft.resources.ResourceLocation dimension,
|
||||||
|
RuinTemplate template) {
|
||||||
|
return Math.max(0.0, template.getWeight()) * RuinsCompatibility.templateCount(dimension,
|
||||||
|
RuinsTemplatePages.featureId(template.getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package net.dnorberg.rivencraft.compat;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.neoforged.fml.ModList;
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
|
||||||
|
/** Discovers Ruins' template filenames without making its jar mandatory. */
|
||||||
|
public final class RuinsTemplatePages {
|
||||||
|
public static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(
|
||||||
|
Rivencraft.MOD_ID, "ruins");
|
||||||
|
private static final String PREFIX = "ruins/";
|
||||||
|
private static volatile Map<ResourceLocation, String> names = Map.of();
|
||||||
|
private static volatile boolean initialized;
|
||||||
|
|
||||||
|
private RuinsTemplatePages() {}
|
||||||
|
|
||||||
|
public static void refresh() {
|
||||||
|
if (!ModList.get().isLoaded("ruins")) {
|
||||||
|
names = Map.of();
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<ResourceLocation, String> discovered = new HashMap<>();
|
||||||
|
Map<String, String> sources = new HashMap<>();
|
||||||
|
Set<String> duplicateNames = new HashSet<>();
|
||||||
|
// Ruins itself reads <gameDir>/config/ruins_config, not NeoForge's
|
||||||
|
// overrideable CONFIGDIR; mirror its active folder exactly.
|
||||||
|
Path configured = FMLPaths.GAMEDIR.get().resolve("config/ruins_config");
|
||||||
|
if (Files.isDirectory(configured)) {
|
||||||
|
try (var paths = Files.walk(configured)) {
|
||||||
|
paths.filter(Files::isRegularFile)
|
||||||
|
.filter(path -> configured.relativize(path).getNameCount() == 2)
|
||||||
|
.filter(path -> isTemplate(path.getFileName().toString()))
|
||||||
|
.sorted().forEach(path -> add(discovered, sources, duplicateNames,
|
||||||
|
path.getFileName().toString(), configured.relativize(path).toString()));
|
||||||
|
} catch (IOException exception) {
|
||||||
|
Rivencraft.LOGGER.warn("Could not read Ruins templates from {}", configured, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ruins extracts this archive on first launch. It also supplies the creative
|
||||||
|
// pages before extraction has completed on a fresh installation.
|
||||||
|
if (discovered.isEmpty() && !Files.isDirectory(configured)) {
|
||||||
|
try (InputStream stream = RuinsTemplatePages.class.getClassLoader()
|
||||||
|
.getResourceAsStream("assets/ruins_config.zip")) {
|
||||||
|
if (stream != null) {
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(stream)) {
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zip.getNextEntry()) != null) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
String name = entry.getName();
|
||||||
|
name = name.substring(name.lastIndexOf('/') + 1);
|
||||||
|
if (isTemplate(name)) add(discovered, sources, duplicateNames,
|
||||||
|
name, entry.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
Rivencraft.LOGGER.warn("Could not read bundled Ruins templates", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ruins' spawn event exposes only the basename. If two directories use
|
||||||
|
// that basename, Rivencraft cannot identify which structure was chosen.
|
||||||
|
// Disable the bridge instead of advertising pages with ambiguous effects.
|
||||||
|
if (!duplicateNames.isEmpty()) {
|
||||||
|
Rivencraft.LOGGER.error("Ruins compatibility disabled: duplicate template names {}. "
|
||||||
|
+ "Give every .tml file a unique basename.", duplicateNames);
|
||||||
|
names = Map.of();
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
names = Map.copyOf(discovered);
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> templates() {
|
||||||
|
if (!initialized) refresh();
|
||||||
|
List<String> templates = new ArrayList<>(names.values());
|
||||||
|
templates.sort(Comparator.comparing(String::toLowerCase));
|
||||||
|
return List.copyOf(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isSymbol(ResourceLocation id) {
|
||||||
|
return id.getNamespace().equals(Rivencraft.MOD_ID) && id.getPath().startsWith(PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean contains(ResourceLocation id) {
|
||||||
|
return isSymbol(id) && names.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResourceLocation symbolId(String templateName) {
|
||||||
|
if (templateName.isBlank()) throw new IllegalArgumentException("Ruins template has no usable name");
|
||||||
|
// Hex is reversible and collision-free even for case-sensitive or Unicode
|
||||||
|
// filenames; normalizing to lowercase would merge distinct templates.
|
||||||
|
String key = HexFormat.of().formatHex(templateName.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, PREFIX + "t_" + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResourceLocation featureId(String templateName) {
|
||||||
|
return symbolId(templateName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String fallbackName(ResourceLocation symbolId) {
|
||||||
|
String encoded = symbolId.getPath().substring(PREFIX.length());
|
||||||
|
if (!encoded.startsWith("t_")) return encoded + " Ruin";
|
||||||
|
String name;
|
||||||
|
try {
|
||||||
|
name = new String(HexFormat.of().parseHex(encoded.substring(2)), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
name = encoded;
|
||||||
|
}
|
||||||
|
return stripExtension(name).replace('_', ' ') + " Ruin";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SymbolDefinition definition(String templateName) {
|
||||||
|
ResourceLocation id = featureId(templateName);
|
||||||
|
return new SymbolDefinition(SymbolDefinition.CURRENT_SCHEMA_VERSION,
|
||||||
|
"symbol.rivencraft.ruins.template", "symbol.rivencraft.ruins.description",
|
||||||
|
CATEGORY, Optional.of(new SymbolSemantics(SymbolSemantics.Role.FEATURE,
|
||||||
|
new SymbolSemantics.ResourceValue(id))), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTemplate(String name) {
|
||||||
|
return name.toLowerCase(Locale.ROOT).endsWith(".tml");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripExtension(String name) {
|
||||||
|
return isTemplate(name) ? name.substring(0, name.length() - 4) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(Map<ResourceLocation, String> templates, Map<String, String> sources,
|
||||||
|
Set<String> duplicateNames, String name, String source) {
|
||||||
|
String previousSource = sources.putIfAbsent(name, source);
|
||||||
|
if (previousSource != null && !previousSource.equals(source)) duplicateNames.add(name);
|
||||||
|
ResourceLocation id = symbolId(name);
|
||||||
|
String previous = templates.putIfAbsent(id, name);
|
||||||
|
if (previous != null && !previous.equals(name)) {
|
||||||
|
Rivencraft.LOGGER.warn("Ruins templates {} and {} share page ID {}; using {}",
|
||||||
|
previous, name, id, previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,14 @@ import net.dnorberg.rivencraft.Rivencraft;
|
|||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||||
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
import net.minecraft.client.gui.GuiGraphics;
|
import net.minecraft.client.gui.GuiGraphics;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.network.chat.MutableComponent;
|
import net.minecraft.network.chat.MutableComponent;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
@@ -63,6 +65,11 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
|||||||
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
registration.registerSubtypeInterpreter(ModItems.SYMBOL_PORTFOLIO.get(),
|
||||||
|
(IIngredientSubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||||
|
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||||
|
return name == null ? "empty" : name.getString();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -72,6 +79,7 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
|||||||
var connection = Minecraft.getInstance().getConnection();
|
var connection = Minecraft.getInstance().getConnection();
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess()));
|
pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess()));
|
||||||
|
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
|
||||||
} else {
|
} else {
|
||||||
PageItem.bundledCreativeSymbols().stream().map(PageItem::createBundledCreativePage)
|
PageItem.bundledCreativeSymbols().stream().map(PageItem::createBundledCreativePage)
|
||||||
.forEach(pages::add);
|
.forEach(pages::add);
|
||||||
@@ -84,6 +92,8 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
|||||||
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
||||||
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||||
List.of(new ItemStack(ModItems.PAGE.get())), List.of(UidContext.Ingredient));
|
List.of(new ItemStack(ModItems.PAGE.get())), List.of(UidContext.Ingredient));
|
||||||
|
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||||
|
List.of(new ItemStack(ModItems.SYMBOL_PORTFOLIO.get())), List.of(UidContext.Ingredient));
|
||||||
var connection = Minecraft.getInstance().getConnection();
|
var connection = Minecraft.getInstance().getConnection();
|
||||||
if (connection == null) return;
|
if (connection == null) return;
|
||||||
Set<ResourceLocation> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
Set<ResourceLocation> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||||
@@ -98,6 +108,19 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
|||||||
if (!missing.isEmpty()) {
|
if (!missing.isEmpty()) {
|
||||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
||||||
}
|
}
|
||||||
|
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||||
|
.filter(stack -> stack.is(ModItems.SYMBOL_PORTFOLIO.get()))
|
||||||
|
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.map(Component::getString)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
|
||||||
|
.stream()
|
||||||
|
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
|
||||||
|
.toList();
|
||||||
|
if (!missingPortfolios.isEmpty()) {
|
||||||
|
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ public final class RivencraftConfig {
|
|||||||
"Controls automatic Return Link creation for Descriptive Book travel.",
|
"Controls automatic Return Link creation for Descriptive Book travel.",
|
||||||
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
|
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
|
||||||
)
|
)
|
||||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
|
||||||
builder.pop();
|
builder.pop();
|
||||||
builder.push("libraries");
|
builder.push("libraries");
|
||||||
LIBRARY_LOOT_MODE = builder.comment(
|
LIBRARY_LOOT_MODE = builder.comment(
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package net.dnorberg.rivencraft.entity;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.registry.ModEntities;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
import net.minecraft.world.entity.item.ItemEntity;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/** A recoverable, model-rendered book left behind by handheld linking. */
|
||||||
|
public final class DroppedBookEntity extends ItemEntity {
|
||||||
|
private static final String DECAY_TICKS_TAG = "BookDecayTicks";
|
||||||
|
private static final int SLOW_DECAY_INTERVAL = 10_000;
|
||||||
|
private int decayTicks;
|
||||||
|
|
||||||
|
public DroppedBookEntity(EntityType<DroppedBookEntity> type, Level level) {
|
||||||
|
super(type, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DroppedBookEntity(Level level, Vec3 position, ItemStack stack) {
|
||||||
|
this(ModEntities.DROPPED_BOOK.get(), level);
|
||||||
|
setPos(position.x, position.y + 0.25, position.z);
|
||||||
|
setDeltaMovement(Vec3.ZERO);
|
||||||
|
setItem(stack);
|
||||||
|
setUnlimitedLifetime();
|
||||||
|
setPickUpDelay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vanilla's pickup particle calls ItemEntity.copy() and renders the returned entity. */
|
||||||
|
@Override
|
||||||
|
public DroppedBookEntity copy() {
|
||||||
|
DroppedBookEntity copy = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), level());
|
||||||
|
copy.copyPosition(this);
|
||||||
|
copy.setItem(getItem().copy());
|
||||||
|
copy.setUnlimitedLifetime();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tick() {
|
||||||
|
super.tick();
|
||||||
|
if (level().isClientSide || isRemoved()) return;
|
||||||
|
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
|
||||||
|
decayTicks = 0;
|
||||||
|
hurt(damageSources().starve(), 1.0F);
|
||||||
|
}
|
||||||
|
if (!isRemoved() && isInWaterOrRain()) hurt(damageSources().drown(), 1.0F);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addAdditionalSaveData(CompoundTag tag) {
|
||||||
|
super.addAdditionalSaveData(tag);
|
||||||
|
tag.putInt(DECAY_TICKS_TAG, decayTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readAdditionalSaveData(CompoundTag tag) {
|
||||||
|
super.readAdditionalSaveData(tag);
|
||||||
|
decayTicks = Math.max(0, Math.min(tag.getInt(DECAY_TICKS_TAG), SLOW_DECAY_INTERVAL - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.event;
|
|||||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
import net.dnorberg.rivencraft.network.AgeCelestialAnglePayload;
|
import net.dnorberg.rivencraft.network.AgeCelestialAnglePayload;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
@@ -34,10 +35,10 @@ public final class AgeCelestialAngleSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void send(ServerPlayer player, AgeDefinition age) {
|
public static void send(ServerPlayer player, AgeDefinition age) {
|
||||||
if (!NetworkRegistry.hasChannel(player.connection, AgeCelestialAnglePayload.TYPE.id())) {
|
// GameTest mock players (and clients without this channel) cannot receive this payload.
|
||||||
return;
|
if (!NetworkRegistry.hasChannel(player.connection, AgeCelestialAnglePayload.TYPE.id())) return;
|
||||||
}
|
|
||||||
PacketDistributor.sendToPlayer(player, new AgeCelestialAnglePayload(
|
PacketDistributor.sendToPlayer(player, new AgeCelestialAnglePayload(
|
||||||
AgeDimensionService.dimensionKey(age), age.traits().celestialAngle()));
|
AgeDimensionService.dimensionKey(age), age.traits().celestialAngle(),
|
||||||
|
AgeSkyProfile.forAge(age)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.event;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||||
|
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
@@ -14,7 +15,10 @@ public final class AgeLifecycleEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||||
if (event.getEntity() instanceof ServerPlayer player) PortalTravelCooldown.forget(player);
|
if (event.getEntity() instanceof ServerPlayer player) {
|
||||||
|
PortalTravelCooldown.forget(player);
|
||||||
|
PortalTravelQueue.forget(player);
|
||||||
|
}
|
||||||
if (event.getEntity() instanceof ServerPlayer player
|
if (event.getEntity() instanceof ServerPlayer player
|
||||||
&& ModList.get().isLoaded("immersive_portals_core")) {
|
&& ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
ImmersivePortalsChunkLoaders.close(player);
|
ImmersivePortalsChunkLoaders.close(player);
|
||||||
@@ -23,12 +27,14 @@ public final class AgeLifecycleEvents {
|
|||||||
|
|
||||||
public static void onServerStopping(ServerStoppingEvent event) {
|
public static void onServerStopping(ServerStoppingEvent event) {
|
||||||
PortalTravelCooldown.clear();
|
PortalTravelCooldown.clear();
|
||||||
|
PortalTravelQueue.clear();
|
||||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
ImmersivePortalsChunkLoaders.closeAll();
|
ImmersivePortalsChunkLoaders.closeAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||||
|
PortalTravelQueue.process(event.getServer());
|
||||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||||
ImmersivePortalsChunkLoaders.expire(event.getServer());
|
ImmersivePortalsChunkLoaders.expire(event.getServer());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
|||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkingService;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
|
||||||
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
|
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
|
||||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||||
@@ -19,7 +22,11 @@ import net.minecraft.gametest.framework.GameTestHelper;
|
|||||||
import net.minecraft.nbt.CompoundTag;
|
import net.minecraft.nbt.CompoundTag;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||||
import net.minecraft.world.level.block.Blocks;
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.phys.BlockHitResult;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
@@ -46,6 +53,8 @@ public final class CrystalPortalGameTests {
|
|||||||
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
||||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
|
||||||
|
"An occupied receptacle must expose its book state to client renderers");
|
||||||
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
||||||
holder.setDestination(destination);
|
holder.setDestination(destination);
|
||||||
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
||||||
@@ -82,6 +91,12 @@ public final class CrystalPortalGameTests {
|
|||||||
"A north/south opening must only show its two player-facing sides");
|
"A north/south opening must only show its two player-facing sides");
|
||||||
CrystalPortalNetwork.shutdown(level, holderPos);
|
CrystalPortalNetwork.shutdown(level, holderPos);
|
||||||
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
||||||
|
helper.assertTrue(holder.takeBook().is(ModItems.LINKING_BOOK.get()),
|
||||||
|
"Removing the book must return the stored item");
|
||||||
|
helper.assertTrue(!level.getBlockState(holderPos).getValue(BookReceptacleBlock.HAS_BOOK),
|
||||||
|
"Removing the book must clear the client-visible model state");
|
||||||
|
helper.assertTrue(!holder.getUpdateTag(level.registryAccess()).contains("Book"),
|
||||||
|
"The block-entity update must also clear the old client-side book stack");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +169,54 @@ public final class CrystalPortalGameTests {
|
|||||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 2, 1));
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 2, 1));
|
||||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
|
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
|
||||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
LinkTarget destination = new LinkTarget(GlobalPos.of(level.dimension(), centerTarget(helper)), 0, 0);
|
||||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
|
||||||
|
book.set(ModDataComponents.LINK_TARGET.get(), destination);
|
||||||
|
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||||
|
holder.setBook(book);
|
||||||
|
holder.setDestination(destination);
|
||||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||||
"The horizontal crystal opening must activate");
|
"The horizontal crystal opening must activate");
|
||||||
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
||||||
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
||||||
"The fallback surface must face up and down");
|
"The fallback surface must face up and down");
|
||||||
|
helper.assertValueEqual(CrystalPortalSurfaces.group(level, planLoaded(level, holderPos)).getFirst().normal(),
|
||||||
|
Direction.UP, "The horizontal opening must provide an upward-facing destination view");
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
helper.assertValueEqual(LinkingService.travelPortal(player, destination, LinkProperties.NONE),
|
||||||
|
LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
|
||||||
|
"A normal book may show a portal but must not link within its own dimension");
|
||||||
|
helper.assertTrue(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
||||||
|
.is(ModBlocks.CRYSTAL_PORTAL.get()) && !holder.getBook().isEmpty(),
|
||||||
|
"Rejected intra-link travel must leave the visual portal and its book intact");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void undersideReceptaclePlacesAndActivatesHorizontalFrame(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, 2, z)),
|
||||||
|
x == 1 && z == 1 ? Blocks.AIR.defaultBlockState()
|
||||||
|
: ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
BlockPos support = helper.absolutePos(new BlockPos(0, 2, 1));
|
||||||
|
BlockPos holderPos = support.below();
|
||||||
|
BlockPlaceContext context = new BlockPlaceContext(level, null, InteractionHand.MAIN_HAND,
|
||||||
|
new ItemStack(ModItems.BOOK_RECEPTACLE.get()),
|
||||||
|
new BlockHitResult(Vec3.atCenterOf(support).add(0, -0.5, 0),
|
||||||
|
Direction.DOWN, support, false));
|
||||||
|
var placed = ModBlocks.BOOK_RECEPTACLE.get().getStateForPlacement(context);
|
||||||
|
helper.assertTrue(placed != null && placed.getValue(BookReceptacleBlock.FACING) == Direction.DOWN,
|
||||||
|
"Clicking the underside of a Crystal Block must place a downward-facing receptacle");
|
||||||
|
level.setBlock(holderPos, placed, 3);
|
||||||
|
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||||
|
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||||
|
"A ceiling-mounted receptacle must activate its horizontal crystal frame");
|
||||||
|
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 1)))
|
||||||
|
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
||||||
|
"The underside-mounted portal surface remains horizontal");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.menu.InkMixerMenu;
|
||||||
|
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.inventory.ClickType;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class Milestone7AdvancementGameTests {
|
||||||
|
private Milestone7AdvancementGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void progressionAndRecipeUnlocksLoad(GameTestHelper helper) {
|
||||||
|
var registry = helper.getLevel().getServer().getAdvancements();
|
||||||
|
for (String path : List.of("root", "page", "linking_book", "link_panel",
|
||||||
|
"copy_page", "bind_age", "enter_age", "open_portal")) {
|
||||||
|
helper.assertTrue(registry.get(id("progression/" + path)) != null,
|
||||||
|
"Progression advancement must load: " + path);
|
||||||
|
}
|
||||||
|
for (String path : List.of("book_binder", "book_receptacle", "crystal_block",
|
||||||
|
"ink_mixer", "ink_vial", "notebook", "writing_desk")) {
|
||||||
|
helper.assertTrue(registry.get(id("recipes/misc/" + path)) != null,
|
||||||
|
"Recipe-book unlock must load: " + path);
|
||||||
|
}
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
for (String path : List.of("book_binder", "ink_mixer", "notebook", "writing_desk")) {
|
||||||
|
player.getAdvancements().award(registry.get(id("recipes/misc/" + path)), "has_material");
|
||||||
|
helper.assertTrue(player.getRecipeBook().contains(id(path)),
|
||||||
|
"Discovering the material must unlock the " + path + " recipe");
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void actionAwardsAreServerOwnedAndPerPlayer(GameTestHelper helper) {
|
||||||
|
var first = helper.makeMockServerPlayerInLevel();
|
||||||
|
var second = helper.makeMockServerPlayerInLevel();
|
||||||
|
var advancement = helper.getLevel().getServer().getAdvancements().get(id("progression/copy_page"));
|
||||||
|
helper.assertFalse(first.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||||
|
"Opening a menu or preview must not award a copied Page");
|
||||||
|
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||||
|
"Another player must not inherit the advancement");
|
||||||
|
ProgressionAdvancements.awardAgeTravel(first);
|
||||||
|
var travel = helper.getLevel().getServer().getAdvancements().get(id("progression/enter_age"));
|
||||||
|
helper.assertFalse(first.getAdvancements().getOrStartProgress(travel).isDone(),
|
||||||
|
"Overworld travel or a preview must not count as entering an Age");
|
||||||
|
ProgressionAdvancements.award(first, ProgressionAdvancements.COPY_PAGE);
|
||||||
|
helper.assertTrue(first.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||||
|
"The server must award the player who completed the action");
|
||||||
|
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
|
||||||
|
"Advancement progress must remain per-player in multiplayer");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void mixerPreviewDoesNotAwardButTakingPanelDoes(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
|
||||||
|
var mixer = (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = nearbyPlayer(helper, pos);
|
||||||
|
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
|
||||||
|
helper.assertFalse(done(player, ProgressionAdvancements.LINK_PANEL),
|
||||||
|
"Opening the Ink Mixer must not award its milestone");
|
||||||
|
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(done(player, ProgressionAdvancements.LINK_PANEL),
|
||||||
|
"Taking the completed Link Panel must award its milestone");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void deskPreviewDoesNotAwardButCopyingDoes(GameTestHelper helper) {
|
||||||
|
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||||
|
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
|
||||||
|
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||||
|
desk.setItem(0, PageItem.createSymbolPage(new SymbolReference(id("terrain/standard"))));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
|
||||||
|
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
|
||||||
|
var player = nearbyPlayer(helper, pos);
|
||||||
|
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
|
||||||
|
helper.assertFalse(done(player, ProgressionAdvancements.COPY_PAGE),
|
||||||
|
"Opening the Writing Desk must not award a copied Page");
|
||||||
|
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
|
||||||
|
helper.assertTrue(done(player, ProgressionAdvancements.COPY_PAGE),
|
||||||
|
"Taking a copied Page must award its milestone");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServerPlayer nearbyPlayer(GameTestHelper helper, BlockPos pos) {
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean done(ServerPlayer player, String path) {
|
||||||
|
var advancement = player.getServer().getAdvancements().get(id("progression/" + path));
|
||||||
|
return player.getAdvancements().getOrStartProgress(advancement).isDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation id(String path) {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.item.crafting.CraftingInput;
|
||||||
|
import net.minecraft.world.item.crafting.RecipeType;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Data-driven Milestone 7 crafting checks; every result is obtained from the loaded recipe manager. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class Milestone7CraftingGameTests {
|
||||||
|
private Milestone7CraftingGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void crystalProgressionMatchesLegacyCounts(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
ItemStack shard = new ItemStack(ModItems.CRYSTAL_SHARD.get());
|
||||||
|
CraftingInput crystalInput = CraftingInput.of(2, 2, List.of(shard, shard, shard, shard));
|
||||||
|
var crystalRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, crystalInput, level)
|
||||||
|
.orElseThrow();
|
||||||
|
helper.assertValueEqual(crystalRecipe.id(), id("crystal_block"),
|
||||||
|
"Four shards must craft a Crystal Block");
|
||||||
|
ItemStack crystal = crystalRecipe.value().assemble(crystalInput, level.registryAccess());
|
||||||
|
helper.assertTrue(crystal.is(ModItems.CRYSTAL_BLOCK.get()) && crystal.getCount() == 1,
|
||||||
|
"The shard recipe must yield one Crystal Block");
|
||||||
|
|
||||||
|
List<ItemStack> ring = new ArrayList<>();
|
||||||
|
for (int slot = 0; slot < 9; slot++) ring.add(slot == 4 ? ItemStack.EMPTY : crystal.copy());
|
||||||
|
CraftingInput receptacleInput = CraftingInput.of(3, 3, ring);
|
||||||
|
var receptacleRecipe = level.getRecipeManager()
|
||||||
|
.getRecipeFor(RecipeType.CRAFTING, receptacleInput, level).orElseThrow();
|
||||||
|
helper.assertValueEqual(receptacleRecipe.id(), id("book_receptacle"),
|
||||||
|
"The Legacy eight-block ring must craft the Receptacle");
|
||||||
|
ItemStack result = receptacleRecipe.value().assemble(receptacleInput, level.registryAccess());
|
||||||
|
helper.assertTrue(result.is(ModItems.BOOK_RECEPTACLE.get()) && result.getCount() == 1,
|
||||||
|
"One ring must yield exactly one Book Receptacle");
|
||||||
|
helper.assertTrue(receptacleRecipe.value().getRemainingItems(receptacleInput).stream()
|
||||||
|
.allMatch(ItemStack::isEmpty), "Crystal crafting must not duplicate source blocks as remainders");
|
||||||
|
ring.set(4, crystal.copy());
|
||||||
|
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING,
|
||||||
|
CraftingInput.of(3, 3, ring), level).isEmpty(),
|
||||||
|
"A filled center must not match the Book Receptacle ring");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void bucketInkReturnsItsContainer(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
ItemStack dye = new ItemStack(Items.BLACK_DYE);
|
||||||
|
CraftingInput bucketInput = CraftingInput.of(2, 2, List.of(
|
||||||
|
dye, dye, new ItemStack(Items.GLASS_BOTTLE), new ItemStack(Items.WATER_BUCKET)));
|
||||||
|
var bucketRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, bucketInput, level)
|
||||||
|
.orElseThrow();
|
||||||
|
helper.assertValueEqual(bucketRecipe.id(), id("ink_vial"), "Bucket ink recipe must remain registered");
|
||||||
|
helper.assertTrue(bucketRecipe.value().assemble(bucketInput, level.registryAccess())
|
||||||
|
.is(ModItems.INK_VIAL.get()), "Bucket recipe must yield an Ink Vial");
|
||||||
|
helper.assertTrue(bucketRecipe.value().getRemainingItems(bucketInput).stream()
|
||||||
|
.anyMatch(stack -> stack.is(Items.BUCKET)), "The water bucket must return an empty bucket");
|
||||||
|
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void recipeUnlocksAreLoadedAndBlankPagesStayUncraftable(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
for (String path : List.of("crystal_block", "book_receptacle", "ink_vial")) {
|
||||||
|
helper.assertTrue(level.getServer().getAdvancements().get(id("recipes/misc/" + path)) != null,
|
||||||
|
"Recipe unlock must load for " + path);
|
||||||
|
}
|
||||||
|
var player = helper.makeMockServerPlayerInLevel();
|
||||||
|
var shardUnlock = level.getServer().getAdvancements().get(id("recipes/misc/crystal_block"));
|
||||||
|
player.getAdvancements().award(shardUnlock, "has_crystal_shard");
|
||||||
|
helper.assertTrue(player.getRecipeBook().contains(id("crystal_block")),
|
||||||
|
"Discovering a shard must unlock the Crystal Block recipe");
|
||||||
|
var receptacleUnlock = level.getServer().getAdvancements().get(id("recipes/misc/book_receptacle"));
|
||||||
|
player.getAdvancements().award(receptacleUnlock, "has_crystal_block");
|
||||||
|
helper.assertTrue(player.getRecipeBook().contains(id("book_receptacle")),
|
||||||
|
"Discovering a Crystal Block must unlock the Receptacle recipe");
|
||||||
|
CraftingInput blankInput = CraftingInput.of(2, 2, List.of(
|
||||||
|
new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get()),
|
||||||
|
ItemStack.EMPTY, ItemStack.EMPTY));
|
||||||
|
helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, blankInput, level).isEmpty(),
|
||||||
|
"Blank Pages and Link Panels must not become grid-craftable");
|
||||||
|
ItemStack panel = PageItem.createLinkPanelPage();
|
||||||
|
CraftingInput linkInput = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER)));
|
||||||
|
helper.assertValueEqual(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, linkInput, level)
|
||||||
|
.orElseThrow().id(), id("linking_book"),
|
||||||
|
"Link Panel crafting remains limited to the established Linking Book recipe");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceLocation id(String path) {
|
||||||
|
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModSounds;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class Milestone7PolishGameTests {
|
||||||
|
private Milestone7PolishGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void modifierFlagsKeepLegacyIntraRules(GameTestHelper helper) {
|
||||||
|
LinkProperties linking = new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING
|
||||||
|
| LinkProperties.INTRA_LINKING_ONLY | LinkProperties.RELATIVE_LINK
|
||||||
|
| LinkProperties.DISABLE_PLATFORM);
|
||||||
|
helper.assertTrue(linking.has(LinkProperties.INTRA_LINKING_ONLY)
|
||||||
|
&& linking.has(LinkProperties.RELATIVE_LINK)
|
||||||
|
&& linking.has(LinkProperties.DISABLE_PLATFORM),
|
||||||
|
"The new link options must retain independent flag values");
|
||||||
|
LinkProperties descriptive = linking.forDescriptiveBook();
|
||||||
|
helper.assertTrue(!descriptive.has(LinkProperties.INTRA_LINKING)
|
||||||
|
&& !descriptive.has(LinkProperties.INTRA_LINKING_ONLY),
|
||||||
|
"Intra-only controls must not constrain Descriptive Books");
|
||||||
|
helper.assertTrue(descriptive.has(LinkProperties.DISARM)
|
||||||
|
&& descriptive.has(LinkProperties.RELATIVE_LINK)
|
||||||
|
&& descriptive.has(LinkProperties.DISABLE_PLATFORM),
|
||||||
|
"Shared link options must remain active on Descriptive Books");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void linkSoundPriorityMatchesLegacy(GameTestHelper helper) {
|
||||||
|
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, false) == ModSounds.LINK.get(),
|
||||||
|
"Ordinary book travel uses the Legacy link sound");
|
||||||
|
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.INTRA_LINKING), false)
|
||||||
|
== ModSounds.LINK_INTRA.get(),
|
||||||
|
"Intra Linking uses its Legacy sound");
|
||||||
|
helper.assertTrue(LinkVisualEffects.sound(LinkProperties.NONE, true) == ModSounds.LINK_PORTAL.get(),
|
||||||
|
"Portal travel uses its Legacy sound");
|
||||||
|
helper.assertTrue(LinkVisualEffects.sound(new LinkProperties(LinkProperties.DISARM), true)
|
||||||
|
== ModSounds.LINK_DISARM.get(),
|
||||||
|
"Legacy Disarm takes precedence over the portal sound");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Small server-side guard against shipping missing model textures or the library template. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class Milestone7ResourceGameTests {
|
||||||
|
private Milestone7ResourceGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void coreModelsResolveBundledTextures(GameTestHelper helper) {
|
||||||
|
for (String model : List.of("book_binder", "ink_mixer", "writing_desk",
|
||||||
|
"book_receptacle", "crystal_block", "crystal_portal")) {
|
||||||
|
String path = "assets/rivencraft/models/block/" + model + ".json";
|
||||||
|
try (InputStream stream = resource(path)) {
|
||||||
|
helper.assertTrue(stream != null, "Missing packaged block model " + path);
|
||||||
|
JsonObject json = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8))
|
||||||
|
.getAsJsonObject();
|
||||||
|
if (!json.has("textures")) continue;
|
||||||
|
for (var entry : json.getAsJsonObject("textures").entrySet()) {
|
||||||
|
String texture = entry.getValue().getAsString();
|
||||||
|
if (!texture.startsWith("rivencraft:")) continue;
|
||||||
|
String texturePath = "assets/rivencraft/textures/"
|
||||||
|
+ texture.substring("rivencraft:".length()) + ".png";
|
||||||
|
helper.assertTrue(Milestone7ResourceGameTests.class.getClassLoader().getResource(texturePath) != null,
|
||||||
|
"Model " + model + " references missing texture " + texturePath);
|
||||||
|
}
|
||||||
|
} catch (java.io.IOException exception) {
|
||||||
|
throw new IllegalStateException("Cannot inspect " + path, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.assertTrue(Milestone7ResourceGameTests.class.getClassLoader()
|
||||||
|
.getResource("data/rivencraft/structure/mystcraft_library.nbt") != null,
|
||||||
|
"The Legacy library template must be included in the mod JAR");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InputStream resource(String path) {
|
||||||
|
return Milestone7ResourceGameTests.class.getClassLoader().getResourceAsStream(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package net.dnorberg.rivencraft.gametest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class SymbolPortfolioGameTests {
|
||||||
|
private SymbolPortfolioGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void portfoliosCoverEverySymbolCategoryWithoutTruncation(GameTestHelper helper) {
|
||||||
|
var registries = helper.getLevel().registryAccess();
|
||||||
|
List<ItemStack> creative = ModCreativeTabs.creativeSymbolPortfolios(registries);
|
||||||
|
helper.assertTrue(!creative.isEmpty(), "Creative tab must include prefilled portfolios");
|
||||||
|
for (ResourceLocation category : PageItem.bundledCreativeCategories()) {
|
||||||
|
List<ItemStack> expected = PageItem.creativeSymbolPages(registries, category);
|
||||||
|
List<ItemStack> volumes = SymbolPortfolioItem.createVolumes(category, expected);
|
||||||
|
helper.assertTrue(!volumes.isEmpty(), "Missing portfolio for " + category);
|
||||||
|
List<ItemStack> actual = new ArrayList<>();
|
||||||
|
for (ItemStack volume : volumes) {
|
||||||
|
helper.assertTrue(volume.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||||
|
&& CollationFolderItem.isCollection(volume)
|
||||||
|
&& creative.stream().anyMatch(candidate ->
|
||||||
|
candidate.getHoverName().getString().equals(volume.getHoverName().getString())),
|
||||||
|
"Portfolio must be an editable creative collection for " + category);
|
||||||
|
NotebookContents contents = volume.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
||||||
|
helper.assertTrue(contents != null && contents.pageCount() > 0
|
||||||
|
&& contents.pageCount() <= NotebookContents.MAX_PAGES,
|
||||||
|
"Each portfolio volume must contain valid pages");
|
||||||
|
helper.assertTrue(contents.pages().stream().allMatch(page ->
|
||||||
|
PageItem.content(page).kind() == PageContent.Kind.SYMBOL),
|
||||||
|
"Portfolios must contain symbol pages, not Link Panels");
|
||||||
|
actual.addAll(contents.pages());
|
||||||
|
}
|
||||||
|
helper.assertTrue(actual.size() == expected.size(), "Portfolio omitted pages in " + category);
|
||||||
|
for (int index = 0; index < expected.size(); index++) {
|
||||||
|
helper.assertTrue(ItemStack.isSameItemSameComponents(actual.get(index), expected.get(index)),
|
||||||
|
"Portfolio pages must match the category tab in " + category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,11 +151,23 @@ public final class WritingDataGameTests {
|
|||||||
"Zero must use Legacy's dedicated number mark");
|
"Zero must use Legacy's dedicated number mark");
|
||||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
|
||||||
"One must use Legacy's first bottom-row mark");
|
"One must use Legacy's first bottom-row mark");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(5), new int[] {60}),
|
||||||
|
"Five must use Legacy's first five-step mark");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(10), new int[] {61}),
|
||||||
|
"Ten must use Legacy's second five-step mark");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(14), new int[] {61, 59}),
|
||||||
|
"Fourteen must overlay the ten and four marks");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(20), new int[] {63}),
|
||||||
|
"Twenty must use Legacy's final five-step mark");
|
||||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
|
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
|
||||||
"Twenty-four must combine Legacy's five and unit marks");
|
"Twenty-four must combine Legacy's five and unit marks");
|
||||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
|
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
|
||||||
"Twenty-five must start a second base-25 place");
|
"Twenty-five must start a second base-25 place");
|
||||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(88), new int[] {3, 13}),
|
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(49), new int[] {1, 24}),
|
||||||
|
"Forty-nine must use twenty-four in its ones place");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(50), new int[] {2, 0}),
|
||||||
|
"Fifty must carry into the second base-25 place");
|
||||||
|
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(87), new int[] {3, 12}),
|
||||||
"Last writing-desk tab must retain its numeric value");
|
"Last writing-desk tab must retain its numeric value");
|
||||||
|
|
||||||
String display = "\"name\":\"symbol.example.test\","
|
String display = "\"name\":\"symbol.example.test\","
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/** Legacy's compact page collection, using the existing Notebook page editor. */
|
||||||
|
public final class CollationFolderItem extends NotebookItem {
|
||||||
|
public static final int MAX_PAGES = 27;
|
||||||
|
|
||||||
|
public CollationFolderItem(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isCollection(ItemStack stack) {
|
||||||
|
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get())
|
||||||
|
|| stack.is(ModItems.SYMBOL_PORTFOLIO.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int capacity(ItemStack stack) {
|
||||||
|
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
|
||||||
|
: stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||||
|
? NotebookContents.MAX_PAGES : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
package net.dnorberg.rivencraft.item;
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.dnorberg.rivencraft.book.AgeReference;
|
import net.dnorberg.rivencraft.book.AgeReference;
|
||||||
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
|
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
|
||||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
import net.dnorberg.rivencraft.book.LinkingService;
|
import net.dnorberg.rivencraft.book.LinkingService;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
|
||||||
import net.minecraft.ChatFormatting;
|
import net.minecraft.ChatFormatting;
|
||||||
import net.minecraft.core.component.DataComponents;
|
import net.minecraft.core.component.DataComponents;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.network.chat.MutableComponent;
|
import net.minecraft.network.chat.MutableComponent;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.sounds.SoundSource;
|
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
import net.minecraft.world.item.Item;
|
import net.minecraft.world.item.Item;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.item.TooltipFlag;
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A book that will eventually describe and link to a generated Age.
|
* A book that will eventually describe and link to a generated Age.
|
||||||
@@ -40,14 +42,18 @@ public final class DescriptiveBookItem extends Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
|
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
|
||||||
return useWrittenBook(player, stack, false);
|
return useWrittenBook(player, stack, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean useHandheldBook(ServerPlayer player, InteractionHand hand) {
|
||||||
|
return useWrittenBook(player, player.getItemInHand(hand), false, hand);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
|
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||||
return useWrittenBook(player, stack, true);
|
return useWrittenBook(player, stack, true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal) {
|
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal, InteractionHand hand) {
|
||||||
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
|
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
|
||||||
if (age == null) {
|
if (age == null) {
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
|
||||||
@@ -55,19 +61,21 @@ public final class DescriptiveBookItem extends Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LinkTarget returnTarget = LinkTarget.from(player);
|
LinkTarget returnTarget = LinkTarget.from(player);
|
||||||
LinkTarget ageTarget = AgeDimensionService.resolve(player, age).orElse(null);
|
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||||
|
.forDescriptiveBook();
|
||||||
|
LinkTarget ageTarget = AgeDimensionService.resolve(player, age, net.dnorberg.rivencraft.writing.OrderedWriting.EMPTY,
|
||||||
|
!properties.has(LinkProperties.DISABLE_PLATFORM)).orElse(null);
|
||||||
if (ageTarget == null) {
|
if (ageTarget == null) {
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable"), true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerLevel sourceLevel = player.serverLevel();
|
ServerLevel sourceLevel = player.serverLevel();
|
||||||
var sourcePosition = player.blockPosition();
|
Vec3 sourcePosition = player.position();
|
||||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
|
||||||
.forDescriptiveBook();
|
|
||||||
LinkingService.LinkResult result = portal
|
LinkingService.LinkResult result = portal
|
||||||
? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
|
? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
|
||||||
: LinkingService.travelWithFooting(player, ageTarget, properties);
|
: hand == null ? LinkingService.travelWithFooting(player, ageTarget, properties)
|
||||||
|
: LinkingService.travelHandheld(player, hand, stack, ageTarget, properties, true);
|
||||||
if (result != LinkingService.LinkResult.SUCCESS) {
|
if (result != LinkingService.LinkResult.SUCCESS) {
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
|
||||||
return false;
|
return false;
|
||||||
@@ -85,10 +93,8 @@ public final class DescriptiveBookItem extends Item {
|
|||||||
player.drop(returnBook, false);
|
player.drop(returnBook, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
|
||||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
ProgressionAdvancements.awardAgeTravel(player);
|
||||||
player.level().playSound(null, player.blockPosition(), (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
|
||||||
0.8F, player.level().random.nextFloat() * 0.2F + 0.9F);
|
|
||||||
player.displayClientMessage(Component.translatable(
|
player.displayClientMessage(Component.translatable(
|
||||||
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
|
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
|
||||||
age.name()
|
age.name()
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
package net.dnorberg.rivencraft.item;
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
import net.dnorberg.rivencraft.book.LinkingService;
|
import net.dnorberg.rivencraft.book.LinkingService;
|
||||||
|
import net.dnorberg.rivencraft.book.LinkVisualEffects;
|
||||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModSounds;
|
|
||||||
import net.minecraft.ChatFormatting;
|
import net.minecraft.ChatFormatting;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.sounds.SoundSource;
|
|
||||||
import net.minecraft.world.InteractionHand;
|
import net.minecraft.world.InteractionHand;
|
||||||
import net.minecraft.world.InteractionResultHolder;
|
import net.minecraft.world.InteractionResultHolder;
|
||||||
import net.minecraft.world.entity.player.Player;
|
import net.minecraft.world.entity.player.Player;
|
||||||
@@ -21,6 +21,7 @@ import net.minecraft.world.item.Item;
|
|||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.item.TooltipFlag;
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
import net.minecraft.world.level.Level;
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
public final class LinkingBookItem extends Item {
|
public final class LinkingBookItem extends Item {
|
||||||
public LinkingBookItem(Properties properties) {
|
public LinkingBookItem(Properties properties) {
|
||||||
@@ -42,37 +43,35 @@ public final class LinkingBookItem extends Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
|
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
|
||||||
return useBoundBook(player, stack, false);
|
return useBoundBook(player, stack, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InteractionResultHolder<ItemStack> useHandheldBook(ServerPlayer player, InteractionHand hand) {
|
||||||
|
return useBoundBook(player, player.getItemInHand(hand), false, hand);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
|
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
|
||||||
return useBoundBook(player, stack, true);
|
return useBoundBook(player, stack, true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal) {
|
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal,
|
||||||
|
InteractionHand hand) {
|
||||||
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
|
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
|
||||||
return InteractionResultHolder.fail(stack);
|
return InteractionResultHolder.fail(stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
Level sourceLevel = player.level();
|
ServerLevel sourceLevel = player.serverLevel();
|
||||||
BlockPos sourcePosition = player.blockPosition();
|
Vec3 sourcePosition = player.position();
|
||||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||||
LinkingService.LinkResult result = portal
|
LinkingService.LinkResult result = portal
|
||||||
? LinkingService.travelPortal(player, target, properties)
|
? LinkingService.travelPortal(player, target, properties)
|
||||||
: LinkingService.travel(player, target, properties);
|
: hand == null ? LinkingService.travel(player, target, properties)
|
||||||
|
: LinkingService.travelHandheld(player, hand, stack, target, properties, false);
|
||||||
if (result == LinkingService.LinkResult.SUCCESS) {
|
if (result == LinkingService.LinkResult.SUCCESS) {
|
||||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
|
||||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
ProgressionAdvancements.awardAgeTravel(player);
|
||||||
player.level().playSound(
|
|
||||||
null,
|
|
||||||
player.blockPosition(),
|
|
||||||
(portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(),
|
|
||||||
SoundSource.PLAYERS,
|
|
||||||
0.8F,
|
|
||||||
player.level().random.nextFloat() * 0.2F + 0.9F
|
|
||||||
);
|
|
||||||
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
|
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
|
||||||
return InteractionResultHolder.success(stack);
|
return InteractionResultHolder.success(stack);
|
||||||
}
|
}
|
||||||
@@ -80,6 +79,7 @@ public final class LinkingBookItem extends Item {
|
|||||||
String errorKey = switch (result) {
|
String errorKey = switch (result) {
|
||||||
case MISSING_DIMENSION -> "message.rivencraft.linking_book.missing_dimension";
|
case MISSING_DIMENSION -> "message.rivencraft.linking_book.missing_dimension";
|
||||||
case INTRA_LINKING_REQUIRED -> "message.rivencraft.linking_book.intra_required";
|
case INTRA_LINKING_REQUIRED -> "message.rivencraft.linking_book.intra_required";
|
||||||
|
case INTRA_ONLY -> "message.rivencraft.linking_book.intra_only";
|
||||||
default -> "message.rivencraft.linking_book.unsafe";
|
default -> "message.rivencraft.linking_book.unsafe";
|
||||||
};
|
};
|
||||||
player.displayClientMessage(Component.translatable(errorKey), true);
|
player.displayClientMessage(Component.translatable(errorKey), true);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import net.minecraft.world.item.ItemStack;
|
|||||||
import net.minecraft.world.item.TooltipFlag;
|
import net.minecraft.world.item.TooltipFlag;
|
||||||
import net.minecraft.world.level.Level;
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
public final class NotebookItem extends Item {
|
public class NotebookItem extends Item {
|
||||||
public NotebookItem(Properties properties) {
|
public NotebookItem(Properties properties) {
|
||||||
super(properties);
|
super(properties);
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,8 @@ public final class NotebookItem extends Item {
|
|||||||
NotebookContents contents = stack.getOrDefault(
|
NotebookContents contents = stack.getOrDefault(
|
||||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||||
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
|
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
|
||||||
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
|
contents.pageCount(), CollationFolderItem.capacity(stack))
|
||||||
|
.withStyle(ChatFormatting.GRAY));
|
||||||
if (contents.unresolvedCount() > 0) {
|
if (contents.unresolvedCount() > 0) {
|
||||||
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
|
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
|
||||||
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
|
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
||||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||||
@@ -71,6 +72,7 @@ public final class PageItem extends Item {
|
|||||||
categories.add(BlockSymbolPages.CATEGORY);
|
categories.add(BlockSymbolPages.CATEGORY);
|
||||||
categories.add(BlockSymbolPages.FLUID_CATEGORY);
|
categories.add(BlockSymbolPages.FLUID_CATEGORY);
|
||||||
categories.add(CAVE_BIOME_CATEGORY);
|
categories.add(CAVE_BIOME_CATEGORY);
|
||||||
|
if (!RuinsTemplatePages.templates().isEmpty()) categories.add(RuinsTemplatePages.CATEGORY);
|
||||||
return List.copyOf(categories);
|
return List.copyOf(categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +80,11 @@ public final class PageItem extends Item {
|
|||||||
if (category.equals(CAVE_BIOME_CATEGORY)) {
|
if (category.equals(CAVE_BIOME_CATEGORY)) {
|
||||||
return createCreativeBiomePage(ResourceLocation.withDefaultNamespace("lush_caves"));
|
return createCreativeBiomePage(ResourceLocation.withDefaultNamespace("lush_caves"));
|
||||||
}
|
}
|
||||||
|
if (category.equals(RuinsTemplatePages.CATEGORY)) {
|
||||||
|
return RuinsTemplatePages.templates().stream().findFirst()
|
||||||
|
.map(PageItem::createCreativeRuinsPage)
|
||||||
|
.orElseGet(() -> createBundledCreativePage("feature/villages"));
|
||||||
|
}
|
||||||
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
|
if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) {
|
||||||
return createCreativeBlockPage(ResourceLocation.withDefaultNamespace(
|
return createCreativeBlockPage(ResourceLocation.withDefaultNamespace(
|
||||||
category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone"));
|
category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone"));
|
||||||
@@ -93,6 +100,7 @@ public final class PageItem extends Item {
|
|||||||
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries) {
|
public static List<ItemStack> creativeSymbolPages(HolderLookup.Provider registries) {
|
||||||
List<ItemStack> pages = creativeBundledAndBiomePages(registries);
|
List<ItemStack> pages = creativeBundledAndBiomePages(registries);
|
||||||
BlockSymbolPages.availableBlocks().forEach(id -> pages.add(createCreativeBlockPage(id)));
|
BlockSymbolPages.availableBlocks().forEach(id -> pages.add(createCreativeBlockPage(id)));
|
||||||
|
RuinsTemplatePages.templates().forEach(name -> pages.add(createCreativeRuinsPage(name)));
|
||||||
return List.copyOf(pages);
|
return List.copyOf(pages);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +123,9 @@ public final class PageItem extends Item {
|
|||||||
return BlockSymbolPages.availableBlocks(category).stream()
|
return BlockSymbolPages.availableBlocks(category).stream()
|
||||||
.map(PageItem::createCreativeBlockPage).toList();
|
.map(PageItem::createCreativeBlockPage).toList();
|
||||||
}
|
}
|
||||||
|
if (category.equals(RuinsTemplatePages.CATEGORY)) {
|
||||||
|
return RuinsTemplatePages.templates().stream().map(PageItem::createCreativeRuinsPage).toList();
|
||||||
|
}
|
||||||
Set<ResourceLocation> caveBiomes = category.equals(BIOME_CATEGORY) || category.equals(CAVE_BIOME_CATEGORY)
|
Set<ResourceLocation> caveBiomes = category.equals(BIOME_CATEGORY) || category.equals(CAVE_BIOME_CATEGORY)
|
||||||
? caveBiomeIds(registries) : Set.of();
|
? caveBiomeIds(registries) : Set.of();
|
||||||
return creativeBundledAndBiomePages(registries).stream()
|
return creativeBundledAndBiomePages(registries).stream()
|
||||||
@@ -180,6 +191,14 @@ public final class PageItem extends Item {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ItemStack createCreativeRuinsPage(String templateName) {
|
||||||
|
var symbolId = RuinsTemplatePages.symbolId(templateName);
|
||||||
|
ItemStack page = create(PageContent.symbol(new SymbolReference(symbolId)));
|
||||||
|
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
|
||||||
|
RuinsTemplatePages.definition(templateName).display(symbolId));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, CreativeSymbol> loadCreativeSymbols() {
|
private static Map<String, CreativeSymbol> loadCreativeSymbols() {
|
||||||
String resource = "/assets/rivencraft/creative_symbol_pages.json";
|
String resource = "/assets/rivencraft/creative_symbol_pages.json";
|
||||||
try (var stream = PageItem.class.getResourceAsStream(resource)) {
|
try (var stream = PageItem.class.getResourceAsStream(resource)) {
|
||||||
@@ -321,6 +340,10 @@ public final class PageItem extends Item {
|
|||||||
return Component.translatable(metadata.nameTranslationKey()).withStyle(ChatFormatting.OBFUSCATED);
|
return Component.translatable(metadata.nameTranslationKey()).withStyle(ChatFormatting.OBFUSCATED);
|
||||||
}
|
}
|
||||||
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
return reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||||
|
&& RuinsTemplatePages.isSymbol(reference.id())
|
||||||
|
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||||
|
RuinsTemplatePages.fallbackName(reference.id()))
|
||||||
|
: reference.id().getNamespace().equals(Rivencraft.MOD_ID)
|
||||||
&& reference.id().getPath().startsWith("biome/")
|
&& reference.id().getPath().startsWith("biome/")
|
||||||
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
? Component.translatableWithFallback(metadata.nameTranslationKey(),
|
||||||
BiomeSymbolPages.fallbackName(reference.id()))
|
BiomeSymbolPages.fallbackName(reference.id()))
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||||
|
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.world.InteractionHand;
|
||||||
|
import net.minecraft.world.InteractionResultHolder;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.Item;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
|
/** Opens into a 16-page Collation Folder, as Legacy's booster item did. */
|
||||||
|
public final class SealedNotebookItem extends Item {
|
||||||
|
private static final int PAGE_COUNT = 16;
|
||||||
|
|
||||||
|
public SealedNotebookItem(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
|
||||||
|
ItemStack sealed = player.getItemInHand(hand);
|
||||||
|
if (level.isClientSide) return InteractionResultHolder.sidedSuccess(sealed, true);
|
||||||
|
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResultHolder.pass(sealed);
|
||||||
|
ItemStack folder = open(serverPlayer);
|
||||||
|
if (folder.isEmpty()) return InteractionResultHolder.fail(sealed);
|
||||||
|
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
|
||||||
|
if (sealed.isEmpty()) {
|
||||||
|
player.setItemInHand(hand, folder);
|
||||||
|
} else if (!player.getInventory().add(folder)) {
|
||||||
|
player.drop(folder, false);
|
||||||
|
}
|
||||||
|
return InteractionResultHolder.success(player.getItemInHand(hand));
|
||||||
|
}
|
||||||
|
|
||||||
|
static ItemStack open(ServerPlayer player) {
|
||||||
|
// Current symbol definitions have no Legacy rank. Draw from the authored
|
||||||
|
// symbol pool, excluding virtual block/biome pages and corrupted pages.
|
||||||
|
List<ResourceLocation> candidates = SymbolCatalog.symbols().entrySet().stream()
|
||||||
|
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
|
||||||
|
&& !entry.getKey().getPath().startsWith("block/")
|
||||||
|
&& !entry.getKey().getPath().startsWith("biome/")
|
||||||
|
&& !entry.getValue().category().getPath().equals("corrupted"))
|
||||||
|
.map(java.util.Map.Entry::getKey).sorted().toList();
|
||||||
|
if (candidates.isEmpty()) return ItemStack.EMPTY;
|
||||||
|
List<ItemStack> pages = new ArrayList<>(PAGE_COUNT);
|
||||||
|
for (int i = 0; i < PAGE_COUNT; i++) {
|
||||||
|
ResourceLocation id = candidates.get(player.getRandom().nextInt(candidates.size()));
|
||||||
|
pages.add(PageItem.createSymbolPage(new SymbolReference(id)));
|
||||||
|
}
|
||||||
|
ItemStack folder = new ItemStack(ModItems.COLLATION_FOLDER.get());
|
||||||
|
folder.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
|
||||||
|
return folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package net.dnorberg.rivencraft.item;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||||
|
import net.minecraft.core.component.DataComponents;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
|
||||||
|
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
|
||||||
|
public final class SymbolPortfolioItem extends NotebookItem {
|
||||||
|
public SymbolPortfolioItem(Properties properties) {
|
||||||
|
super(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<ItemStack> createVolumes(ResourceLocation category, List<ItemStack> pages) {
|
||||||
|
if (pages.isEmpty()) return List.of();
|
||||||
|
int capacity = NotebookContents.MAX_PAGES;
|
||||||
|
int volumeCount = (pages.size() + capacity - 1) / capacity;
|
||||||
|
List<ItemStack> volumes = new ArrayList<>(volumeCount);
|
||||||
|
for (int volume = 0; volume < volumeCount; volume++) {
|
||||||
|
ItemStack portfolio = new ItemStack(ModItems.SYMBOL_PORTFOLIO.get());
|
||||||
|
int first = volume * capacity;
|
||||||
|
int last = Math.min(first + capacity, pages.size());
|
||||||
|
portfolio.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||||
|
new NotebookContents(pages.subList(first, last)));
|
||||||
|
Component categoryName = Component.translatableWithFallback(
|
||||||
|
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
|
||||||
|
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
|
||||||
|
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
|
||||||
|
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
|
||||||
|
categoryName, volume + 1, volumeCount));
|
||||||
|
volumes.add(portfolio);
|
||||||
|
}
|
||||||
|
return List.copyOf(volumes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.util.datafix.DataFixTypes;
|
||||||
|
import net.minecraft.world.level.saveddata.SavedData;
|
||||||
|
|
||||||
|
/** World-persistent OP-only visibility switch for Book Binder writing diagnostics. */
|
||||||
|
public final class BookBinderDiagnostics extends SavedData {
|
||||||
|
private static final String DATA_NAME = "rivencraft_binder_diagnostics";
|
||||||
|
private static final Factory<BookBinderDiagnostics> FACTORY = new Factory<>(
|
||||||
|
BookBinderDiagnostics::new, BookBinderDiagnostics::load, DataFixTypes.SAVED_DATA_MAP_DATA);
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
public static BookBinderDiagnostics get(ServerLevel level) {
|
||||||
|
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() { return enabled; }
|
||||||
|
|
||||||
|
public boolean visibleTo(ServerPlayer player) {
|
||||||
|
return visibleTo(player.hasPermissions(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean visibleTo(boolean operator) { return enabled && operator; }
|
||||||
|
|
||||||
|
public boolean setEnabled(boolean enabled) {
|
||||||
|
if (this.enabled == enabled) return false;
|
||||||
|
this.enabled = enabled;
|
||||||
|
setDirty();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BookBinderDiagnostics load(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
BookBinderDiagnostics data = new BookBinderDiagnostics();
|
||||||
|
data.enabled = tag.getBoolean("Enabled");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
|
tag.putBoolean("Enabled", enabled);
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
|
/** Binder diagnostics are world-persistent but never visible to non-operators. */
|
||||||
|
@GameTestHolder(Rivencraft.MOD_ID)
|
||||||
|
@PrefixGameTestTemplate(false)
|
||||||
|
public final class BookBinderDiagnosticsGameTests {
|
||||||
|
private BookBinderDiagnosticsGameTests() {}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void binderMessagesDefaultOffAndPersist(GameTestHelper helper) {
|
||||||
|
BookBinderDiagnostics data = BookBinderDiagnostics.get(helper.getLevel());
|
||||||
|
helper.assertFalse(data.enabled(), "Binder diagnostic messages must default to off");
|
||||||
|
helper.assertTrue(data.setEnabled(true), "An OP toggle must change the world setting");
|
||||||
|
helper.assertFalse(data.setEnabled(true), "Repeating a toggle must not change the setting");
|
||||||
|
CompoundTag saved = data.save(new CompoundTag(), helper.getLevel().registryAccess());
|
||||||
|
BookBinderDiagnostics restored = BookBinderDiagnostics.load(saved, helper.getLevel().registryAccess());
|
||||||
|
helper.assertTrue(restored.enabled(), "The OP setting must survive world reload");
|
||||||
|
helper.assertTrue(restored.visibleTo(true), "An OP must see enabled binder diagnostics");
|
||||||
|
helper.assertFalse(restored.visibleTo(false), "A non-OP must not see enabled binder diagnostics");
|
||||||
|
helper.assertTrue(restored.setEnabled(false), "An OP toggle must disable messages");
|
||||||
|
helper.assertFalse(restored.visibleTo(true), "Disabled messages must be invisible even to OPs");
|
||||||
|
data.setEnabled(false);
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
package net.dnorberg.rivencraft.menu;
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
import net.dnorberg.rivencraft.writing.PageContent;
|
import net.dnorberg.rivencraft.writing.PageContent;
|
||||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.world.Container;
|
import net.minecraft.world.Container;
|
||||||
import net.minecraft.world.SimpleContainer;
|
import net.minecraft.world.SimpleContainer;
|
||||||
import net.minecraft.world.entity.player.Inventory;
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
@@ -40,6 +43,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
private int grammarErrorStripIndex = -1;
|
private int grammarErrorStripIndex = -1;
|
||||||
private int grammarInstability;
|
private int grammarInstability;
|
||||||
private int grammarIncompleteCount;
|
private int grammarIncompleteCount;
|
||||||
|
private int diagnosticsVisible;
|
||||||
private WritingGrammar.Result serverGrammar;
|
private WritingGrammar.Result serverGrammar;
|
||||||
|
|
||||||
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||||
@@ -59,34 +63,38 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
});
|
});
|
||||||
addDataSlot(new DataSlot() {
|
addDataSlot(new DataSlot() {
|
||||||
@Override public int get() {
|
@Override public int get() {
|
||||||
return clientSide || binder == null ? grammarErrorCode : binderDiagnostic()
|
return clientSide || binder == null ? grammarErrorCode : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
|
||||||
.map(diagnostic -> diagnostic.code().ordinal()).orElse(-1);
|
.map(diagnostic -> diagnostic.code().ordinal()).orElse(-1);
|
||||||
}
|
}
|
||||||
@Override public void set(int value) { grammarErrorCode = value; }
|
@Override public void set(int value) { grammarErrorCode = value; }
|
||||||
});
|
});
|
||||||
addDataSlot(new DataSlot() {
|
addDataSlot(new DataSlot() {
|
||||||
@Override public int get() {
|
@Override public int get() {
|
||||||
return clientSide || binder == null ? grammarErrorStripIndex : binderDiagnostic()
|
return clientSide || binder == null ? grammarErrorStripIndex : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
|
||||||
.map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1);
|
.map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1);
|
||||||
}
|
}
|
||||||
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
||||||
});
|
});
|
||||||
addDataSlot(new DataSlot() {
|
addDataSlot(new DataSlot() {
|
||||||
@Override public int get() {
|
@Override public int get() {
|
||||||
return clientSide || binder == null ? grammarInstability
|
return clientSide || binder == null ? grammarInstability : !diagnosticsVisibleToAuthor() ? 0
|
||||||
: serverGrammar().writingInstability();
|
: serverGrammar().writingInstability();
|
||||||
}
|
}
|
||||||
@Override public void set(int value) { grammarInstability = value; }
|
@Override public void set(int value) { grammarInstability = value; }
|
||||||
});
|
});
|
||||||
addDataSlot(new DataSlot() {
|
addDataSlot(new DataSlot() {
|
||||||
@Override public int get() {
|
@Override public int get() {
|
||||||
return clientSide || binder == null ? grammarIncompleteCount
|
return clientSide || binder == null ? grammarIncompleteCount : !diagnosticsVisibleToAuthor() ? 0
|
||||||
: (int) serverGrammar().diagnostics().stream()
|
: (int) serverGrammar().diagnostics().stream()
|
||||||
.filter(diagnostic -> diagnostic.severity()
|
.filter(diagnostic -> diagnostic.severity()
|
||||||
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
|
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
|
||||||
}
|
}
|
||||||
@Override public void set(int value) { grammarIncompleteCount = value; }
|
@Override public void set(int value) { grammarIncompleteCount = value; }
|
||||||
});
|
});
|
||||||
|
addDataSlot(new DataSlot() {
|
||||||
|
@Override public int get() { return clientSide ? diagnosticsVisible : diagnosticsVisibleToAuthor() ? 1 : 0; }
|
||||||
|
@Override public void set(int value) { diagnosticsVisible = value; }
|
||||||
|
});
|
||||||
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
||||||
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
||||||
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
||||||
@@ -127,7 +135,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
}
|
}
|
||||||
public int grammarInstability() { return grammarInstability; }
|
public int grammarInstability() { return grammarInstability; }
|
||||||
public int grammarIncompleteCount() { return grammarIncompleteCount; }
|
public int grammarIncompleteCount() { return grammarIncompleteCount; }
|
||||||
|
public boolean diagnosticsVisible() { return diagnosticsVisible != 0; }
|
||||||
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
||||||
|
private boolean diagnosticsVisibleToAuthor() {
|
||||||
|
return bookAuthor instanceof ServerPlayer serverPlayer
|
||||||
|
&& BookBinderDiagnostics.get(serverPlayer.serverLevel()).visibleTo(serverPlayer);
|
||||||
|
}
|
||||||
public String title() { return binder == null ? "" : binder.title(); }
|
public String title() { return binder == null ? "" : binder.title(); }
|
||||||
public boolean setTitle(Player player, String title) {
|
public boolean setTitle(Player player, String title) {
|
||||||
if (clientSide || !stillValid(player) || title == null
|
if (clientSide || !stillValid(player) || title == null
|
||||||
@@ -170,7 +183,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
|
|
||||||
private boolean clickStrip(int index) {
|
private boolean clickStrip(int index) {
|
||||||
ItemStack carried = getCarried();
|
ItemStack carried = getCarried();
|
||||||
if (carried.is(ModItems.NOTEBOOK.get())) {
|
if (CollationFolderItem.isCollection(carried)) {
|
||||||
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
|
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
|
||||||
binder.nextPageIndex()));
|
binder.nextPageIndex()));
|
||||||
if (emptied.isEmpty()) return false;
|
if (emptied.isEmpty()) return false;
|
||||||
@@ -210,7 +223,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
|
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
|
||||||
return ItemStack.EMPTY;
|
return ItemStack.EMPTY;
|
||||||
}
|
}
|
||||||
binder.consumeAssembly();
|
if (!binder.consumeAssembly()) return ItemStack.EMPTY;
|
||||||
|
if (player instanceof ServerPlayer serverPlayer)
|
||||||
|
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
|
||||||
refreshResult();
|
refreshResult();
|
||||||
return book;
|
return book;
|
||||||
}
|
}
|
||||||
@@ -223,7 +238,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
|
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
|
||||||
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
|
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
|
||||||
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
|
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
|
||||||
} else if (source.is(ModItems.NOTEBOOK.get())) {
|
} else if (CollationFolderItem.isCollection(source)) {
|
||||||
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
|
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
|
||||||
if (emptied.isEmpty()) return ItemStack.EMPTY;
|
if (emptied.isEmpty()) return ItemStack.EMPTY;
|
||||||
sourceSlot.setByPlayer(emptied);
|
sourceSlot.setByPlayer(emptied);
|
||||||
@@ -284,7 +299,11 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
|||||||
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
|
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
|
||||||
}
|
}
|
||||||
@Override public void onTake(Player player, ItemStack stack) {
|
@Override public void onTake(Player player, ItemStack stack) {
|
||||||
if (!clientSide && binder.consumeAssembly()) refreshResult();
|
if (!clientSide && binder.consumeAssembly()) {
|
||||||
|
if (player instanceof ServerPlayer serverPlayer)
|
||||||
|
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
|
||||||
|
refreshResult();
|
||||||
|
}
|
||||||
super.onTake(player, stack);
|
super.onTake(player, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package net.dnorberg.rivencraft.menu;
|
package net.dnorberg.rivencraft.menu;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
|
||||||
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.world.Container;
|
import net.minecraft.world.Container;
|
||||||
import net.minecraft.world.SimpleContainer;
|
import net.minecraft.world.SimpleContainer;
|
||||||
import net.minecraft.world.entity.player.Inventory;
|
import net.minecraft.world.entity.player.Inventory;
|
||||||
@@ -109,6 +111,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
|||||||
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
|
||||||
return ItemStack.EMPTY;
|
return ItemStack.EMPTY;
|
||||||
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
|
||||||
|
if (player instanceof ServerPlayer serverPlayer)
|
||||||
|
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||||
refreshResult();
|
refreshResult();
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -148,7 +152,11 @@ public final class InkMixerMenu extends AbstractContainerMenu {
|
|||||||
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
|
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
|
||||||
}
|
}
|
||||||
@Override public void onTake(Player player, ItemStack stack) {
|
@Override public void onTake(Player player, ItemStack stack) {
|
||||||
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult();
|
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
|
||||||
|
if (player instanceof ServerPlayer serverPlayer)
|
||||||
|
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
|
||||||
|
refreshResult();
|
||||||
|
}
|
||||||
super.onTake(player, stack);
|
super.onTake(player, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user