Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98595c83ea | ||
|
|
e7e2cf7e5a | ||
|
|
77f15f8599 | ||
|
|
2b2605b277 | ||
|
|
2f1e6bd6f2 | ||
|
|
9d5ff2a2cb | ||
|
|
3ac4c499ab | ||
|
|
9044835ec5 | ||
|
|
7ae41da23e |
No files matched your search
@@ -1,267 +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`.
|
|
||||||
|
|
||||||
Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an
|
## Requirements and optional integrations
|
||||||
empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks
|
|
||||||
the block recipe, and picking up a block unlocks the Receptacle recipe. The
|
|
||||||
existing Ink Vial recipe uses two black dyes, a glass bottle, and a water
|
|
||||||
bucket; the empty bucket stays in the crafting grid. These JSON recipes appear
|
|
||||||
in JEI when it is installed and can be replaced by a data pack at
|
|
||||||
`data/rivencraft/recipe/<recipe_name>.json`. To test a replacement, use a fresh
|
|
||||||
world or `/reload`, then check the result in a crafting table and vanilla
|
|
||||||
Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft
|
|
||||||
book or page is placed on it; this in-world interaction has no JEI guide.
|
|
||||||
With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene
|
|
||||||
showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut
|
|
||||||
while hovering either item; Rivencraft does not require Create to run.
|
|
||||||
|
|
||||||
Craft a Book Binder from three iron ingots and five planks. Put leather or an
|
Install the same gameplay mods on the client and server:
|
||||||
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
|
- Minecraft **1.21.1**, NeoForge **21.1.264 or newer in the 21.1 line**, and
|
||||||
Panel, it describes a randomly generated Age. Authored writing is compiled once
|
Java 21.
|
||||||
into a versioned, server-owned Age plan. The plan freezes the Age seed, resolved
|
- **Dynamic Dimensions AR Edition 0.9.1** and its **Bad Packets 0.8.2**
|
||||||
symbol meanings, page order, and runtime profile, so later data-pack changes do
|
dependency. These are required for Age dimensions.
|
||||||
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
|
Rivencraft works without any of these optional integrations:
|
||||||
|
|
||||||
Craft an unwritten Linking Book from one Link Panel Page and one leather in any
|
| Mod | What it adds |
|
||||||
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
|
| JEI | Searchable Pages and guides for the Ink Mixer, Writing Desk, Book Binder, and Linking Book crafting. |
|
||||||
its preview panel is clicked. The destination is stored on the item and
|
| Create (bundles Ponder) | Ponder book snapshots and a crystal-portal construction guide. |
|
||||||
survives inventory and world saves. Travel only succeeds if the target
|
| Immersive Portals | Live destination views in book previews and crystal portals. In `AUTO` preview mode it takes priority over Ponder. |
|
||||||
dimension is loaded and a non-fluid, passable landing position can be found
|
| 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. |
|
||||||
nearby.
|
| Serene Seasons (with GlitchCore) | Independent per-Age seasons, including normal seasons, no seasons, and four eternal-season modes. |
|
||||||
The Linking Book uses the same Legacy page layout and bottom-corner page turns
|
| Ruins | Authorable Ruins-template Pages and Ruins structures in Ages. |
|
||||||
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
|
The optional builds used in the latest development checks are JEI
|
||||||
the departure and arrival locations.
|
19.69.0.457, Create 6.0.10, Immersive Portals 6.0.7, TerraBlender 4.1.0.8,
|
||||||
|
Serene Seasons 10.1.0.9, and Ruins 1.21.1.2NF. See the
|
||||||
|
[compatibility review](docs/MILESTONE_7_COMPATIBILITY_REVIEW.md) for the
|
||||||
|
evidence and limits of those combinations.
|
||||||
|
|
||||||
Right-click an empty vanilla lectern with a Rivencraft book to convert it into
|
Without Create or Immersive Portals, book previews use Rivencraft's built-in
|
||||||
a Book Stand and place the book on it. Use a Linking Book on the stand by
|
Legacy-style animation. Ecliptic Seasons is **not supported**: its 1.21.1 API
|
||||||
right-clicking with an empty hand. Sneak-right-click with an empty hand to take
|
does not offer the live dimension/season control Rivencraft needs.
|
||||||
the book back and restore the vanilla lectern. Breaking a Book Stand drops both
|
|
||||||
the stored book and a vanilla lectern.
|
|
||||||
|
|
||||||
Bound Linking Books show a rotating Ponder snapshot when Create is installed.
|
## Saves, settings, and current limits
|
||||||
Without Create, they use a built-in animated linking panel tinted by the
|
|
||||||
destination sky. The server independently validates every link.
|
|
||||||
|
|
||||||
## Descriptive Book
|
Ages and bound books use persistent UUIDs. Back up the **entire world folder**
|
||||||
|
before changing mod versions, data packs, or optional biome mods: the Age
|
||||||
|
registry and dimension data must be restored together. Rivencraft preserves
|
||||||
|
known older save formats where possible, but pre-public-release worlds are not
|
||||||
|
promised universal compatibility. Missing custom symbols retain their IDs in
|
||||||
|
books; missing registry entries or dimension providers can still prevent an
|
||||||
|
Age from loading. See [current implementation](docs/CURRENT_IMPLEMENTATION.md)
|
||||||
|
for supported behavior and deliberate limits.
|
||||||
|
|
||||||
Open a page-free Descriptive Book, name its Age, and press Create. Rivencraft
|
Server settings include `descriptive_books.return_link_mode` (default
|
||||||
assigns the book a permanent Age UUID, selects a deterministic base terrain,
|
`DISABLED`), `libraries.loot_mode` (`LEGACY` or `AGE_ONLY`), writing ink costs,
|
||||||
creates a dedicated runtime dimension, and prepares a five-by-five cobblestone
|
season enrollment, and instability controls. The client setting
|
||||||
arrival platform at the
|
`book_preview.mode` chooses `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
||||||
highest suitable nearby terrain. Solid blocks and tree leaves are valid
|
An operator can inspect Ages and instability with `/rivencraft age` and find
|
||||||
footing. Candidate columns are scanned from the top down so cave floors are not
|
libraries with `/rivencraft library nearest`. The creative-only Link Modifier
|
||||||
selected, and fluid-covered columns are excluded. If no suitable surface is
|
can edit link properties and retire an Age; use it carefully and keep backups.
|
||||||
available, the platform is generated at Y=63. Reopen the book and click its preview to travel.
|
Binder status messages are off by default. An OP can run
|
||||||
The book cannot reopen while its Age is being generated, whether held or on a
|
`/rivencraft binder messages enable` or `/rivencraft binder messages disable`
|
||||||
Book Stand. Once creation finishes, reopening shows the written book.
|
to control them; only OPs see the diagnostics even when enabled.
|
||||||
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
|
## Project and development
|
||||||
name. Conflicts receive incrementing suffixes such as `_2` and `_3`; existing
|
|
||||||
Age folders are never renamed.
|
|
||||||
|
|
||||||
Page-free Ages deterministically select one of six base terrains inspired by
|
This repository is the development source. See the [current implementation](docs/CURRENT_IMPLEMENTATION.md),
|
||||||
Mystcraft Legacy: Standard, Amplified, Island, Cave, Flat, or Void. The chosen
|
[milestone summaries](docs/MILESTONES.md), [technical document index](docs/README.md),
|
||||||
profile is saved with the Age and does not change when the server restarts.
|
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.
|
||||||
|
|
||||||
Linking creates a bound Return Link to the exact source position and orientation
|
```sh
|
||||||
according to the configured Return Link policy.
|
|
||||||
|
|
||||||
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
|
|
||||||
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
|
|
||||||
Age creation, persistence, lifecycle commands, configuration, backups, and
|
|
||||||
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
|
|
||||||
Data-pack runtime profiles are documented in
|
|
||||||
[docs/AGE_PROFILES.md](docs/AGE_PROFILES.md).
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- A Java 21 JDK
|
|
||||||
- Minecraft 1.21.1
|
|
||||||
- NeoForge 21.1.264 or newer in the 21.1 line
|
|
||||||
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
|
|
||||||
|
|
||||||
Create is optional. When installed, Rivencraft uses its Ponder renderer for
|
|
||||||
book previews when Immersive Portals is absent. Immersive Portals is also
|
|
||||||
optional and takes priority in the default `AUTO` mode, providing a live
|
|
||||||
rotating destination view. Its Explore button enables WASD and jump/sneak
|
|
||||||
camera movement plus left-drag camera look; right-click returns to the rotating
|
|
||||||
view. Without either integration, Rivencraft uses its built-in animated linking
|
|
||||||
panel. Clients can set `book_preview.mode` in
|
|
||||||
`rivencraft-client.toml` to `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`.
|
|
||||||
Unavailable optional renderers safely fall back to `LEGACY`.
|
|
||||||
The complete backend matrix, supported combinations, cleanup behavior, and
|
|
||||||
known upstream limits are documented in
|
|
||||||
[docs/PREVIEW_COMPATIBILITY.md](docs/PREVIEW_COMPATIBILITY.md).
|
|
||||||
|
|
||||||
## Build and run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
./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).
|
|
||||||
@@ -245,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()) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -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
|
||||||
@@ -101,8 +102,8 @@ Use this sequence when a book cannot load an existing Age:
|
|||||||
matching dimension data and authoritative world saved data from the same
|
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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -32,9 +32,10 @@ The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Le
|
|||||||
| `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. |
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ 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.7.2
|
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=pHEgQQUE
|
serene_seasons_version_id=pHEgQQUE
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -82,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);
|
||||||
|
|||||||
@@ -36,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;
|
||||||
@@ -44,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; }
|
||||||
@@ -64,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);
|
||||||
@@ -77,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);
|
||||||
}
|
}
|
||||||
@@ -153,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,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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -84,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);
|
||||||
@@ -141,6 +143,7 @@ public final class AgeDimensionService {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,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;
|
||||||
@@ -352,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) {
|
||||||
@@ -362,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,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(),
|
||||||
@@ -455,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
|
||||||
@@ -558,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(),
|
||||||
@@ -573,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,10 +2,16 @@ 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) {
|
||||||
@@ -19,6 +25,7 @@ public final class AgeEnvironmentCompiler {
|
|||||||
} else {
|
} else {
|
||||||
applied = applyLegacy(plan, seeded);
|
applied = applyLegacy(plan, seeded);
|
||||||
}
|
}
|
||||||
|
AgeSkyProfile.compile(plan, applied);
|
||||||
return forTerrain(applied, plan.runtimeProfile());
|
return forTerrain(applied, plan.runtimeProfile());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,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 -> { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,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())
|
||||||
@@ -92,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();
|
||||||
@@ -105,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;
|
||||||
@@ -51,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) {
|
||||||
@@ -82,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);
|
||||||
}
|
}
|
||||||
@@ -94,7 +110,8 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
|||||||
if (cave) {
|
if (cave) {
|
||||||
var role = symbol.semantics().role();
|
var role = symbol.semantics().role();
|
||||||
if (switch (role) {
|
if (switch (role) {
|
||||||
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER, ANGLE_MODIFIER -> true;
|
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER,
|
||||||
|
ANGLE_MODIFIER, DIRECTION_MODIFIER -> true;
|
||||||
default -> false;
|
default -> false;
|
||||||
}) {
|
}) {
|
||||||
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
|
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
|
||||||
@@ -134,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"));
|
||||||
@@ -152,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,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),
|
||||||
@@ -184,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),
|
||||||
@@ -207,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(),
|
||||||
@@ -216,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,
|
||||||
@@ -420,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,
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
|||||||
AgeTraits effectiveTraits = plan == null
|
AgeTraits effectiveTraits = plan == null
|
||||||
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
|
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
|
||||||
: AgeEnvironmentCompiler.apply(plan, traits);
|
: AgeEnvironmentCompiler.apply(plan, traits);
|
||||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
|
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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ public final class HandheldBookTravelGameTests {
|
|||||||
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
|
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
|
||||||
"An unrelated inventory item must remain with the player");
|
"An unrelated inventory item must remain with the player");
|
||||||
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
|
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
|
||||||
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
|
entity -> entity.getItem() == book);
|
||||||
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
|
helper.assertValueEqual(drops.size(), 1,
|
||||||
|
"Exactly one entity for this active book must remain at the departure point");
|
||||||
helper.assertTrue(drops.getFirst().getItem() == book,
|
helper.assertTrue(drops.getFirst().getItem() == book,
|
||||||
"The model-rendered entity must retain the exact active book stack");
|
"The model-rendered entity must retain the exact active book stack");
|
||||||
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
|
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,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;
|
||||||
@@ -38,10 +44,28 @@ public final class ClientEvents {
|
|||||||
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
|
||||||
event.registerEntityRenderer(ModEntities.DROPPED_BOOK.get(), DroppedBookRenderer::new);
|
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);
|
||||||
@@ -98,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,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ public final class CollationFolderItem extends NotebookItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isCollection(ItemStack stack) {
|
public static boolean isCollection(ItemStack stack) {
|
||||||
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get());
|
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get())
|
||||||
|
|| stack.is(ModItems.SYMBOL_PORTFOLIO.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int capacity(ItemStack stack) {
|
public static int capacity(ItemStack stack) {
|
||||||
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
|
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
|
||||||
: stack.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES : 0;
|
: stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||||
|
? NotebookContents.MAX_PAGES : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,8 +39,7 @@ public 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(), stack.is(net.dnorberg.rivencraft.registry.ModItems.COLLATION_FOLDER.get())
|
contents.pageCount(), CollationFolderItem.capacity(stack))
|
||||||
? CollationFolderItem.MAX_PAGES : NotebookContents.MAX_PAGES)
|
|
||||||
.withStyle(ChatFormatting.GRAY));
|
.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",
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,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) {
|
||||||
@@ -62,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));
|
||||||
@@ -130,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
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/** Combines multiple celestial light sources into Minecraft's one skylight channel. */
|
||||||
|
@Mixin(Level.class)
|
||||||
|
public abstract class LevelSkyBrightnessMixin {
|
||||||
|
@Shadow private int skyDarken;
|
||||||
|
|
||||||
|
@Inject(method = "updateSkyBrightness", at = @At("TAIL"))
|
||||||
|
private void rivencraft$stackCelestialLight(CallbackInfo callback) {
|
||||||
|
if (!((Object) this instanceof ServerLevel level) || !level.dimensionType().hasSkyLight()) return;
|
||||||
|
// ServerLevel updates sky brightness in its constructor, before the
|
||||||
|
// MinecraftServer has registered even the Overworld level. Only an
|
||||||
|
// established Rivencraft Age can have a saved celestial profile.
|
||||||
|
if (!level.dimension().location().getNamespace().equals(Rivencraft.MOD_ID)
|
||||||
|
|| !level.dimension().location().getPath().startsWith("age/")
|
||||||
|
|| level.getServer().overworld() == null) return;
|
||||||
|
AgeRegistrySavedData.get(level).definitions().stream()
|
||||||
|
.filter(age -> AgeDimensionService.dimensionKey(age).equals(level.dimension()))
|
||||||
|
.findFirst().ifPresent(age -> {
|
||||||
|
AgeSkyProfile sky = AgeSkyProfile.forAge(age);
|
||||||
|
if (sky.bodies().isEmpty()) return;
|
||||||
|
float light = sky.lightAt(level.getDayTime());
|
||||||
|
float weather = (1.0F - level.getRainLevel(1.0F) * 5.0F / 16.0F)
|
||||||
|
* (1.0F - level.getThunderLevel(1.0F) * 5.0F / 16.0F);
|
||||||
|
skyDarken = (int) ((1.0F - light * weather) * 11.0F);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.injector.ModifyExpressionValue;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
|
||||||
|
/** Increase Ruins' per-chunk attempt loop only for Ages with duplicated Ruins pages. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.RuinGenerator", remap = false)
|
||||||
|
public abstract class RuinsAttemptMixin {
|
||||||
|
@ModifyExpressionValue(method = "generateNormal", at = @At(value = "FIELD",
|
||||||
|
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNormal:I"), remap = false)
|
||||||
|
private int rivencraft$normalAttempts(int configured, Level level) {
|
||||||
|
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyExpressionValue(method = "generateNether", at = @At(value = "FIELD",
|
||||||
|
target = "Latomicstryker/ruins/common/FileHandler;triesPerChunkNether:I"), remap = false)
|
||||||
|
private int rivencraft$netherAttempts(int configured, Level level) {
|
||||||
|
return RuinsCompatibility.attemptsPerChunk(level.dimension().location(), configured);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsEventAdapter;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.util.RandomSource;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** Ruins defaults to a fixed vanilla whitelist, so authored Ages need a narrow override. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.FileHandler", remap = false)
|
||||||
|
public abstract class RuinsDimensionMixin {
|
||||||
|
@Shadow @Final private ResourceLocation dimension;
|
||||||
|
@Shadow @Final private HashMap<String, HashSet<?>> templates;
|
||||||
|
|
||||||
|
@Inject(method = "allowsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$allowAuthoredAge(String path, CallbackInfoReturnable<Boolean> result) {
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsCompatibility.allowsAge(dimension));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "getTemplate", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$selectAuthoredTemplate(RandomSource random, String biome,
|
||||||
|
CallbackInfoReturnable<Object> result) {
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsEventAdapter.chooseTemplate(dimension, templates.get(biome), random));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsCompatibility;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Pseudo;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** An authored Ruins page overrides a template's vanilla-only dimension list for that Age. */
|
||||||
|
@Pseudo
|
||||||
|
@Mixin(targets = "atomicstryker.ruins.common.RuinTemplate", remap = false)
|
||||||
|
public abstract class RuinsTemplateDimensionMixin {
|
||||||
|
@Shadow @Final private String name;
|
||||||
|
|
||||||
|
@Inject(method = "acceptsDimension", at = @At("HEAD"), cancellable = true, remap = false)
|
||||||
|
private void rivencraft$allowAuthoredAge(String dimensionPath, CallbackInfoReturnable<Boolean> result) {
|
||||||
|
ResourceLocation dimension = ResourceLocation.fromNamespaceAndPath("rivencraft", dimensionPath);
|
||||||
|
if (RuinsCompatibility.isKnownAge(dimension)) {
|
||||||
|
result.setReturnValue(RuinsCompatibility.permits(dimension, RuinsTemplatePages.featureId(name)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package net.dnorberg.rivencraft.mixin.client;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/** Keeps authored color pages visible in the sky without replacing vanilla sun and stars. */
|
||||||
|
@Mixin(ClientLevel.class)
|
||||||
|
public abstract class ClientLevelSkyColorMixin {
|
||||||
|
@Inject(method = "getSkyColor", at = @At("RETURN"), cancellable = true)
|
||||||
|
private void rivencraft$colorAuthoredSky(Vec3 position, float partialTick,
|
||||||
|
CallbackInfoReturnable<Vec3> result) {
|
||||||
|
ClientLevel level = (ClientLevel) (Object) this;
|
||||||
|
int color = ClientAgeCelestialAngles.skyColor(level.dimension());
|
||||||
|
if (color == -1 || ClientAgeCelestialAngles.staticSky(level.dimension())) return;
|
||||||
|
Vec3 natural = result.getReturnValue();
|
||||||
|
// Retain the vanilla day/night and precipitation darkening while replacing its hue.
|
||||||
|
AgeSkyProfile sky = ClientAgeCelestialAngles.sky(level.dimension());
|
||||||
|
double brightness = sky.bodies().isEmpty()
|
||||||
|
? Math.max(natural.x, Math.max(natural.y, natural.z))
|
||||||
|
: Math.max(0.08D, sky.lightAt(level.getDayTime() + partialTick));
|
||||||
|
result.setReturnValue(Vec3.fromRGB24(color).scale(brightness));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "getSkyDarken", at = @At("RETURN"), cancellable = true)
|
||||||
|
private void rivencraft$stackClientCelestialLight(float partialTick,
|
||||||
|
CallbackInfoReturnable<Float> result) {
|
||||||
|
ClientLevel level = (ClientLevel) (Object) this;
|
||||||
|
AgeSkyProfile sky = ClientAgeCelestialAngles.sky(level.dimension());
|
||||||
|
if (sky.bodies().isEmpty() || !level.dimensionType().hasSkyLight()) return;
|
||||||
|
float weather = (1.0F - level.getRainLevel(partialTick) * 5.0F / 16.0F)
|
||||||
|
* (1.0F - level.getThunderLevel(partialTick) * 5.0F / 16.0F);
|
||||||
|
result.setReturnValue(0.2F + 0.8F * sky.lightAt(level.getDayTime() + partialTick) * weather);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,37 @@ package net.dnorberg.rivencraft.mixin.client;
|
|||||||
|
|
||||||
import com.mojang.math.Axis;
|
import com.mojang.math.Axis;
|
||||||
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
|
import net.dnorberg.rivencraft.client.ClientAgeCelestialAngles;
|
||||||
|
import net.dnorberg.rivencraft.client.RivencraftSkyEffects;
|
||||||
|
import net.minecraft.client.Camera;
|
||||||
import net.minecraft.client.multiplayer.ClientLevel;
|
import net.minecraft.client.multiplayer.ClientLevel;
|
||||||
import net.minecraft.client.renderer.LevelRenderer;
|
import net.minecraft.client.renderer.LevelRenderer;
|
||||||
|
import org.joml.Matrix4f;
|
||||||
import org.joml.Quaternionf;
|
import org.joml.Quaternionf;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
/** Rotates the vanilla sun, moon, and star path by each Age's saved angle. */
|
/** Rotates the vanilla sun, moon, and star path by each Age's saved angle. */
|
||||||
@Mixin(LevelRenderer.class)
|
@Mixin(LevelRenderer.class)
|
||||||
public abstract class LevelRendererMixin {
|
public abstract class LevelRendererMixin {
|
||||||
|
private static final RivencraftSkyEffects RIVENCRAFT_SKY = new RivencraftSkyEffects();
|
||||||
|
|
||||||
@Shadow
|
@Shadow
|
||||||
private ClientLevel level;
|
private ClientLevel level;
|
||||||
|
|
||||||
|
@Inject(method = "renderSky", at = @At("HEAD"), cancellable = true)
|
||||||
|
private void rivencraft$renderAgeSky(Matrix4f frustumMatrix, Matrix4f projectionMatrix,
|
||||||
|
float partialTick, Camera camera, boolean isFoggy,
|
||||||
|
Runnable skyFogSetup, CallbackInfo callback) {
|
||||||
|
if (!level.dimension().location().getNamespace().equals("rivencraft")) return;
|
||||||
|
ClientAgeCelestialAngles.requestIfMissing(level.dimension());
|
||||||
|
if (RIVENCRAFT_SKY.renderSky(level, (int) level.getGameTime(), partialTick,
|
||||||
|
frustumMatrix, camera, projectionMatrix, isFoggy, skyFogSetup)) callback.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
@Redirect(
|
@Redirect(
|
||||||
method = "renderSky",
|
method = "renderSky",
|
||||||
at = @At(
|
at = @At(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.dnorberg.rivencraft.network;
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.book.AgeSkyProfile;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
import net.minecraft.network.codec.StreamCodec;
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
@@ -9,8 +10,8 @@ import net.minecraft.resources.ResourceKey;
|
|||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.world.level.Level;
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
/** Synchronizes an Age's saved celestial-plane rotation to clients. */
|
/** Synchronizes an Age's saved celestial-plane rotation and authored sky to clients. */
|
||||||
public record AgeCelestialAnglePayload(ResourceKey<Level> dimension, float angle)
|
public record AgeCelestialAnglePayload(ResourceKey<Level> dimension, float angle, AgeSkyProfile sky)
|
||||||
implements CustomPacketPayload {
|
implements CustomPacketPayload {
|
||||||
public static final Type<AgeCelestialAnglePayload> TYPE = new Type<>(
|
public static final Type<AgeCelestialAnglePayload> TYPE = new Type<>(
|
||||||
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_celestial_angle")
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_celestial_angle")
|
||||||
@@ -20,9 +21,34 @@ public record AgeCelestialAnglePayload(ResourceKey<Level> dimension, float angle
|
|||||||
(buffer, payload) -> {
|
(buffer, payload) -> {
|
||||||
buffer.writeResourceKey(payload.dimension());
|
buffer.writeResourceKey(payload.dimension());
|
||||||
buffer.writeFloat(payload.angle());
|
buffer.writeFloat(payload.angle());
|
||||||
|
buffer.writeEnum(payload.sky().style());
|
||||||
|
buffer.writeInt(payload.sky().color());
|
||||||
|
buffer.writeVarInt(payload.sky().bodies().size());
|
||||||
|
for (AgeSkyProfile.Body body : payload.sky().bodies()) {
|
||||||
|
buffer.writeEnum(body.kind());
|
||||||
|
buffer.writeInt(body.color());
|
||||||
|
buffer.writeFloat(body.size());
|
||||||
|
buffer.writeFloat(body.angle());
|
||||||
|
buffer.writeDouble(body.period());
|
||||||
|
buffer.writeFloat(body.phase());
|
||||||
|
buffer.writeBoolean(body.clockwise());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
buffer -> new AgeCelestialAnglePayload(
|
buffer -> {
|
||||||
buffer.readResourceKey(Registries.DIMENSION), buffer.readFloat())
|
ResourceKey<Level> dimension = buffer.readResourceKey(Registries.DIMENSION);
|
||||||
|
float angle = buffer.readFloat();
|
||||||
|
AgeSkyProfile.Style style = buffer.readEnum(AgeSkyProfile.Style.class);
|
||||||
|
int color = buffer.readInt();
|
||||||
|
int count = buffer.readVarInt();
|
||||||
|
if (count < 0 || count > 20) throw new IllegalArgumentException("Invalid celestial body count");
|
||||||
|
java.util.List<AgeSkyProfile.Body> bodies = new java.util.ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) bodies.add(new AgeSkyProfile.Body(
|
||||||
|
buffer.readEnum(AgeSkyProfile.Kind.class), buffer.readInt(),
|
||||||
|
buffer.readFloat(), buffer.readFloat(), buffer.readDouble(), buffer.readFloat(),
|
||||||
|
buffer.readBoolean()));
|
||||||
|
return new AgeCelestialAnglePayload(dimension, angle,
|
||||||
|
new AgeSkyProfile(style, color, bodies));
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public final class ModNetworking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void register(RegisterPayloadHandlersEvent event) {
|
public static void register(RegisterPayloadHandlersEvent event) {
|
||||||
PayloadRegistrar registrar = event.registrar("17");
|
PayloadRegistrar registrar = event.registrar("18");
|
||||||
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
|
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
|
||||||
ModNetworking::requestDescriptiveBookScreen);
|
ModNetworking::requestDescriptiveBookScreen);
|
||||||
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
|
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
|
||||||
@@ -81,6 +81,8 @@ public final class ModNetworking {
|
|||||||
ClientLinkPreviewHandler::handle);
|
ClientLinkPreviewHandler::handle);
|
||||||
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
|
registrar.playToClient(AgeCelestialAnglePayload.TYPE, AgeCelestialAnglePayload.STREAM_CODEC,
|
||||||
ClientAgeCelestialAngles::handle);
|
ClientAgeCelestialAngles::handle);
|
||||||
|
registrar.playToServer(RequestAgeSkyPayload.TYPE, RequestAgeSkyPayload.STREAM_CODEC,
|
||||||
|
ModNetworking::requestAgeSky);
|
||||||
registrar.playToClient(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC,
|
registrar.playToClient(SereneAgeWhitelistPayload.TYPE, SereneAgeWhitelistPayload.STREAM_CODEC,
|
||||||
ClientSereneAgeWhitelist::handle);
|
ClientSereneAgeWhitelist::handle);
|
||||||
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
|
registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC,
|
||||||
@@ -93,6 +95,16 @@ public final class ModNetworking {
|
|||||||
ModNetworking::setLinkModifierSeed);
|
ModNetworking::setLinkModifierSeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requestAgeSky(RequestAgeSkyPayload payload, IPayloadContext context) {
|
||||||
|
if (!(context.player() instanceof ServerPlayer player)) return;
|
||||||
|
// Portal renderers may request another Age. Only return registered Age data,
|
||||||
|
// never an arbitrary client-supplied dimension or generated terrain.
|
||||||
|
AgeRegistrySavedData.get(player.serverLevel()).definitions().stream()
|
||||||
|
.filter(age -> AgeDimensionService.dimensionKey(age).equals(payload.dimension()))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(age -> AgeCelestialAngleSync.send(player, age));
|
||||||
|
}
|
||||||
|
|
||||||
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
|
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
|
||||||
if (context.player() instanceof ServerPlayer player
|
if (context.player() instanceof ServerPlayer player
|
||||||
&& player.containerMenu instanceof BookBinderMenu menu
|
&& player.containerMenu instanceof BookBinderMenu menu
|
||||||
@@ -348,8 +360,7 @@ public final class ModNetworking {
|
|||||||
|
|
||||||
/** Sky effects, not dimension IDs, decide the backdrop for custom Ages. */
|
/** Sky effects, not dimension IDs, decide the backdrop for custom Ages. */
|
||||||
private static int previewSkyColor(ServerLevel level, BlockPos origin) {
|
private static int previewSkyColor(ServerLevel level, BlockPos origin) {
|
||||||
return AgeEnvironmentCompiler.previewSkyColor(level.dimensionType().effectsLocation(),
|
return AgeEnvironmentCompiler.previewSkyColor(level, origin);
|
||||||
level.getBiome(origin).value().getSkyColor());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InteractionHand hand(boolean offhand) {
|
private static InteractionHand hand(boolean offhand) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package net.dnorberg.rivencraft.network;
|
||||||
|
|
||||||
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
|
||||||
|
/** Requests the saved sky profile when a renderer reaches an Age before dimension-change sync. */
|
||||||
|
public record RequestAgeSkyPayload(ResourceKey<Level> dimension) implements CustomPacketPayload {
|
||||||
|
public static final Type<RequestAgeSkyPayload> TYPE = new Type<>(
|
||||||
|
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_age_sky"));
|
||||||
|
public static final StreamCodec<RegistryFriendlyByteBuf, RequestAgeSkyPayload> STREAM_CODEC = StreamCodec.of(
|
||||||
|
(buffer, payload) -> buffer.writeResourceKey(payload.dimension()),
|
||||||
|
buffer -> new RequestAgeSkyPayload(buffer.readResourceKey(Registries.DIMENSION)));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<? extends CustomPacketPayload> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
|
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||||
|
import net.minecraft.core.HolderLookup;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
@@ -28,6 +31,7 @@ public final class ModCreativeTabs {
|
|||||||
output.accept(ModItems.LINKING_BOOK.get());
|
output.accept(ModItems.LINKING_BOOK.get());
|
||||||
output.accept(ModItems.NOTEBOOK.get());
|
output.accept(ModItems.NOTEBOOK.get());
|
||||||
output.accept(ModItems.COLLATION_FOLDER.get());
|
output.accept(ModItems.COLLATION_FOLDER.get());
|
||||||
|
creativeSymbolPortfolios(parameters.holders()).forEach(output::accept);
|
||||||
output.accept(ModItems.SEALED_NOTEBOOK.get());
|
output.accept(ModItems.SEALED_NOTEBOOK.get());
|
||||||
output.accept(ModItems.INK_VIAL.get());
|
output.accept(ModItems.INK_VIAL.get());
|
||||||
output.accept(ModItems.WRITING_DESK.get());
|
output.accept(ModItems.WRITING_DESK.get());
|
||||||
@@ -54,19 +58,33 @@ public final class ModCreativeTabs {
|
|||||||
.build());
|
.build());
|
||||||
|
|
||||||
private static final List<String> CATEGORY_ORDER = List.of(
|
private static final List<String> CATEGORY_ORDER = List.of(
|
||||||
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "celestial", "block", "fluid", "feature", "environment", "corrupted");
|
"terrain", "biome", "cave_biome", "biome_generation", "weather", "season", "lighting", "sky", "color", "number", "celestial", "block", "fluid", "feature", "ruins", "environment", "corrupted");
|
||||||
|
|
||||||
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
|
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
|
||||||
registerSymbolPageTabs();
|
registerSymbolPageTabs();
|
||||||
|
|
||||||
private static Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> registerSymbolPageTabs() {
|
/** Prefilled portfolios mirror the symbol-page tabs, including data-pack and modded pages. */
|
||||||
|
public static List<ItemStack> creativeSymbolPortfolios(HolderLookup.Provider registries) {
|
||||||
|
List<ItemStack> portfolios = new ArrayList<>();
|
||||||
|
for (ResourceLocation category : orderedSymbolCategories()) {
|
||||||
|
portfolios.addAll(SymbolPortfolioItem.createVolumes(category,
|
||||||
|
PageItem.creativeSymbolPages(registries, category)));
|
||||||
|
}
|
||||||
|
return List.copyOf(portfolios);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ResourceLocation> orderedSymbolCategories() {
|
||||||
List<ResourceLocation> categories = new ArrayList<>(PageItem.bundledCreativeCategories());
|
List<ResourceLocation> categories = new ArrayList<>(PageItem.bundledCreativeCategories());
|
||||||
categories.sort(Comparator.comparingInt((ResourceLocation id) -> {
|
categories.sort(Comparator.comparingInt((ResourceLocation id) -> {
|
||||||
int index = CATEGORY_ORDER.indexOf(id.getPath());
|
int index = CATEGORY_ORDER.indexOf(id.getPath());
|
||||||
return index < 0 ? CATEGORY_ORDER.size() : index;
|
return index < 0 ? CATEGORY_ORDER.size() : index;
|
||||||
}).thenComparing(ResourceLocation::toString));
|
}).thenComparing(ResourceLocation::toString));
|
||||||
|
return List.copyOf(categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> registerSymbolPageTabs() {
|
||||||
Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> tabs = new LinkedHashMap<>();
|
Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> tabs = new LinkedHashMap<>();
|
||||||
for (ResourceLocation category : categories) {
|
for (ResourceLocation category : orderedSymbolCategories()) {
|
||||||
String key = "rivencraft_pages_" + category.getNamespace() + "_" + category.getPath().replace('/', '_');
|
String key = "rivencraft_pages_" + category.getNamespace() + "_" + category.getPath().replace('/', '_');
|
||||||
tabs.put(category, CREATIVE_TABS.register(key, () -> CreativeModeTab.builder()
|
tabs.put(category, CREATIVE_TABS.register(key, () -> CreativeModeTab.builder()
|
||||||
.title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + category.getPath(),
|
.title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + category.getPath(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.item.LinkingBookItem;
|
|||||||
import net.dnorberg.rivencraft.item.PageItem;
|
import net.dnorberg.rivencraft.item.PageItem;
|
||||||
import net.dnorberg.rivencraft.item.NotebookItem;
|
import net.dnorberg.rivencraft.item.NotebookItem;
|
||||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||||
|
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||||
import net.dnorberg.rivencraft.item.SealedNotebookItem;
|
import net.dnorberg.rivencraft.item.SealedNotebookItem;
|
||||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||||
import net.minecraft.world.item.Item;
|
import net.minecraft.world.item.Item;
|
||||||
@@ -39,6 +40,8 @@ public final class ModItems {
|
|||||||
);
|
);
|
||||||
public static final DeferredItem<CollationFolderItem> COLLATION_FOLDER = ITEMS.registerItem(
|
public static final DeferredItem<CollationFolderItem> COLLATION_FOLDER = ITEMS.registerItem(
|
||||||
"collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1));
|
"collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1));
|
||||||
|
public static final DeferredItem<SymbolPortfolioItem> SYMBOL_PORTFOLIO = ITEMS.registerItem(
|
||||||
|
"symbol_portfolio", SymbolPortfolioItem::new, new Item.Properties().stacksTo(1));
|
||||||
public static final DeferredItem<SealedNotebookItem> SEALED_NOTEBOOK = ITEMS.registerItem(
|
public static final DeferredItem<SealedNotebookItem> SEALED_NOTEBOOK = ITEMS.registerItem(
|
||||||
"sealed_notebook", SealedNotebookItem::new, new Item.Properties().stacksTo(16));
|
"sealed_notebook", SealedNotebookItem::new, new Item.Properties().stacksTo(16));
|
||||||
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
|
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.HashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
|
import net.dnorberg.rivencraft.compat.RuinsTemplatePages;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.core.RegistryAccess;
|
import net.minecraft.core.RegistryAccess;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
@@ -73,6 +74,9 @@ public final class SymbolCatalog {
|
|||||||
BiomeSymbolPages.definition(id)));
|
BiomeSymbolPages.definition(id)));
|
||||||
BlockSymbolPages.availableBlocks().forEach(id -> loaded.putIfAbsent(
|
BlockSymbolPages.availableBlocks().forEach(id -> loaded.putIfAbsent(
|
||||||
BlockSymbolPages.symbolId(id), BlockSymbolPages.definition(id)));
|
BlockSymbolPages.symbolId(id), BlockSymbolPages.definition(id)));
|
||||||
|
RuinsTemplatePages.refresh();
|
||||||
|
RuinsTemplatePages.templates().forEach(name -> loaded.putIfAbsent(
|
||||||
|
RuinsTemplatePages.symbolId(name), RuinsTemplatePages.definition(name)));
|
||||||
symbols = Map.copyOf(loaded);
|
symbols = Map.copyOf(loaded);
|
||||||
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
Rivencraft.LOGGER.info("Loaded {} writing symbols", loaded.size());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,20 @@ public record SymbolSemantics(Role role, Value value) {
|
|||||||
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
|
"native", "single", "tiled", "grid", "tiny", "small", "medium", "large", "huge"),
|
||||||
BIOME("biome", Kind.RESOURCE),
|
BIOME("biome", Kind.RESOURCE),
|
||||||
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
|
LIGHTING("lighting", Kind.CHOICE, "normal", "dark", "bright"),
|
||||||
SKY("sky", Kind.CHOICE, "overworld", "nether", "end"),
|
SKY("sky", Kind.CHOICE, "overworld", "nether", "end", "static"),
|
||||||
|
SKY_COLOR("sky_color", Kind.CHOICE,
|
||||||
|
"red", "orange", "yellow", "green", "blue", "purple", "white", "black"),
|
||||||
|
COLOR("color", Kind.CHOICE, "red", "green", "blue", "black", "white"),
|
||||||
|
COLOR_NUMBER("color_number", Kind.NUMBER),
|
||||||
WEATHER("weather", Kind.CHOICE,
|
WEATHER("weather", Kind.CHOICE,
|
||||||
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
|
"normal", "fast", "slow", "off", "always", "cloudy", "rain", "snow", "storm"),
|
||||||
SEASON("season", Kind.CHOICE,
|
SEASON("season", Kind.CHOICE,
|
||||||
"no_seasons", "seasons", "eternal_winter", "eternal_summer", "eternal_fall", "eternal_spring"),
|
"no_seasons", "seasons", "eternal_winter", "eternal_summer", "eternal_fall", "eternal_spring"),
|
||||||
CELESTIAL("celestial", Kind.CHOICE, "normal_time"),
|
CELESTIAL("celestial", Kind.CHOICE, "normal_time", "sun", "moon"),
|
||||||
|
CELESTIAL_COLOR("celestial_color", Kind.CHOICE,
|
||||||
|
"red", "orange", "yellow", "green", "blue", "purple", "white", "black"),
|
||||||
|
DIRECTION_MODIFIER("direction_modifier", Kind.CHOICE, "clockwise", "counterclockwise"),
|
||||||
|
SIZE_MODIFIER("size_modifier", Kind.NUMBER),
|
||||||
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
|
PERIOD_MODIFIER("period_modifier", Kind.NUMBER),
|
||||||
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
ANGLE_MODIFIER("angle_modifier", Kind.NUMBER),
|
||||||
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
PHASE_MODIFIER("phase_modifier", Kind.NUMBER),
|
||||||
@@ -109,6 +117,9 @@ public record SymbolSemantics(Role role, Value value) {
|
|||||||
if (!(value instanceof NumberValue number)) return false;
|
if (!(value instanceof NumberValue number)) return false;
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
|
case PERIOD_MODIFIER -> number.amount() >= 0.0 && number.amount() <= 2.0;
|
||||||
|
case SIZE_MODIFIER -> number.amount() >= 0.25 && number.amount() <= 3.0;
|
||||||
|
case COLOR_NUMBER -> number.amount() >= 0.0 && number.amount() <= 24.0
|
||||||
|
&& number.amount() == Math.floor(number.amount());
|
||||||
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
|
case ANGLE_MODIFIER, PHASE_MODIFIER -> number.amount() >= 0.0 && number.amount() < 360.0;
|
||||||
default -> false;
|
default -> false;
|
||||||
};
|
};
|
||||||
@@ -119,6 +130,8 @@ public record SymbolSemantics(Role role, Value value) {
|
|||||||
case RESOURCE -> "a canonical namespaced ID";
|
case RESOURCE -> "a canonical namespaced ID";
|
||||||
case CHOICE -> "one of " + choices.stream().sorted().toList();
|
case CHOICE -> "one of " + choices.stream().sorted().toList();
|
||||||
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
|
case NUMBER -> this == PERIOD_MODIFIER ? "a finite number in [0, 2]"
|
||||||
|
: this == SIZE_MODIFIER ? "a finite number in [0.25, 3]"
|
||||||
|
: this == COLOR_NUMBER ? "an integer D'ni digit in [0, 24]"
|
||||||
: "a finite number in [0, 360) degrees";
|
: "a finite number in [0, 360) degrees";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public final class WritingGrammar {
|
|||||||
int lastStage = -1;
|
int lastStage = -1;
|
||||||
int biomeCount = 0;
|
int biomeCount = 0;
|
||||||
ParsedSymbol pendingBlock = null;
|
ParsedSymbol pendingBlock = null;
|
||||||
|
ParsedSymbol pendingNumber = null;
|
||||||
for (int index = 0; index < writing.entries().size(); index++) {
|
for (int index = 0; index < writing.entries().size(); index++) {
|
||||||
WritingEntry entry = writing.entries().get(index);
|
WritingEntry entry = writing.entries().get(index);
|
||||||
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
||||||
@@ -73,6 +74,18 @@ public final class WritingGrammar {
|
|||||||
Role role = semantics.orElseThrow().role();
|
Role role = semantics.orElseThrow().role();
|
||||||
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
|
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
|
||||||
symbols.add(parsed);
|
symbols.add(parsed);
|
||||||
|
if (pendingNumber != null && !numberTarget(role) && role != Role.COLOR_NUMBER) {
|
||||||
|
diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingNumber.index(),
|
||||||
|
"A number page must be immediately followed by a color or celestial modifier page"));
|
||||||
|
pendingNumber = null;
|
||||||
|
}
|
||||||
|
if (role == Role.COLOR_NUMBER) {
|
||||||
|
if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER,
|
||||||
|
pendingNumber.index(), "A number page must be immediately followed by a color or celestial modifier page"));
|
||||||
|
pendingNumber = parsed;
|
||||||
|
} else if (numberTarget(role)) {
|
||||||
|
pendingNumber = null;
|
||||||
|
}
|
||||||
if (modifier(role)) {
|
if (modifier(role)) {
|
||||||
// Legacy modifier pages pop and combine an earlier modifier of the same
|
// Legacy modifier pages pop and combine an earlier modifier of the same
|
||||||
// kind. Only the final pending value can be left dangling.
|
// kind. Only the final pending value can be left dangling.
|
||||||
@@ -96,7 +109,7 @@ public final class WritingGrammar {
|
|||||||
}
|
}
|
||||||
pendingBlock = null;
|
pendingBlock = null;
|
||||||
}
|
}
|
||||||
if (role == Role.ANGLE_MODIFIER || role == Role.WEATHER
|
if (role == Role.WEATHER
|
||||||
&& parsed.semantics().value() instanceof ChoiceValue choice
|
&& parsed.semantics().value() instanceof ChoiceValue choice
|
||||||
&& UNSUPPORTED_WEATHER.contains(choice.id())) {
|
&& UNSUPPORTED_WEATHER.contains(choice.id())) {
|
||||||
diagnostics.add(new Diagnostic(Code.UNSUPPORTED_ENVIRONMENT, index,
|
diagnostics.add(new Diagnostic(Code.UNSUPPORTED_ENVIRONMENT, index,
|
||||||
@@ -115,13 +128,15 @@ public final class WritingGrammar {
|
|||||||
}
|
}
|
||||||
if (role == Role.BIOME) biomeCount++;
|
if (role == Role.BIOME) biomeCount++;
|
||||||
int stage = stage(role);
|
int stage = stage(role);
|
||||||
if (stage < lastStage && !modifier(role)) {
|
if (stage < lastStage && !modifier(role) && role != Role.COLOR && role != Role.COLOR_NUMBER) {
|
||||||
diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER,
|
diagnostics.add(new Diagnostic(Code.OUT_OF_ORDER,
|
||||||
index, "Move this " + role.id() + " page before later grammar categories"));
|
index, "Move this " + role.id() + " page before later grammar categories"));
|
||||||
} else {
|
} else if (role != Role.COLOR && role != Role.COLOR_NUMBER) {
|
||||||
lastStage = stage;
|
lastStage = stage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (pendingNumber != null) diagnostics.add(new Diagnostic(Code.DANGLING_MODIFIER,
|
||||||
|
pendingNumber.index(), "A number page must be followed by a color or celestial modifier page"));
|
||||||
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
|
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
|
||||||
"Add a Link Panel as the first page"));
|
"Add a Link Panel as the first page"));
|
||||||
for (ParsedSymbol dangling : pendingModifiers.values()) {
|
for (ParsedSymbol dangling : pendingModifiers.values()) {
|
||||||
@@ -142,7 +157,10 @@ public final class WritingGrammar {
|
|||||||
}
|
}
|
||||||
for (Role required : List.of(Role.TERRAIN, Role.BIOME_CONTROLLER, Role.WEATHER,
|
for (Role required : List.of(Role.TERRAIN, Role.BIOME_CONTROLLER, Role.WEATHER,
|
||||||
Role.LIGHTING, Role.CELESTIAL)) {
|
Role.LIGHTING, Role.CELESTIAL)) {
|
||||||
if (!singular.containsKey(required)) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
boolean present = required == Role.CELESTIAL
|
||||||
|
? symbols.stream().anyMatch(symbol -> symbol.semantics().role() == Role.CELESTIAL)
|
||||||
|
: singular.containsKey(required);
|
||||||
|
if (!present) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
||||||
"The Age compiler will fill the missing " + required.id() + " branch from the Age seed"));
|
"The Age compiler will fill the missing " + required.id() + " branch from the Age seed"));
|
||||||
}
|
}
|
||||||
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice) {
|
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice) {
|
||||||
@@ -160,13 +178,21 @@ public final class WritingGrammar {
|
|||||||
|
|
||||||
private static boolean singular(Role role) {
|
private static boolean singular(Role role) {
|
||||||
return switch (role) {
|
return switch (role) {
|
||||||
case TERRAIN, BIOME_CONTROLLER, WEATHER, SEASON, LIGHTING, SKY, CELESTIAL -> true;
|
case TERRAIN, BIOME_CONTROLLER, WEATHER, SEASON, LIGHTING, SKY -> true;
|
||||||
default -> false;
|
default -> false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean modifier(Role role) {
|
private static boolean modifier(Role role) {
|
||||||
return role == Role.PERIOD_MODIFIER || role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
|
return role == Role.PERIOD_MODIFIER || role == Role.ANGLE_MODIFIER
|
||||||
|
|| role == Role.PHASE_MODIFIER || role == Role.SIZE_MODIFIER
|
||||||
|
|| role == Role.CELESTIAL_COLOR || role == Role.DIRECTION_MODIFIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean numberTarget(Role role) {
|
||||||
|
return role == Role.COLOR || role == Role.PERIOD_MODIFIER
|
||||||
|
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER
|
||||||
|
|| role == Role.SIZE_MODIFIER;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int stage(Role role) {
|
private static int stage(Role role) {
|
||||||
@@ -177,8 +203,10 @@ public final class WritingGrammar {
|
|||||||
case WEATHER -> 3;
|
case WEATHER -> 3;
|
||||||
case SEASON -> 4;
|
case SEASON -> 4;
|
||||||
case LIGHTING -> 5;
|
case LIGHTING -> 5;
|
||||||
case SKY -> 6;
|
case SKY, SKY_COLOR -> 6;
|
||||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER -> 7;
|
case COLOR, COLOR_NUMBER -> 6;
|
||||||
|
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER, SIZE_MODIFIER,
|
||||||
|
CELESTIAL_COLOR, DIRECTION_MODIFIER -> 7;
|
||||||
case CELESTIAL -> 8;
|
case CELESTIAL -> 8;
|
||||||
case BLOCK, FEATURE, ENVIRONMENT, CORRUPTED -> 9;
|
case BLOCK, FEATURE, ENVIRONMENT, CORRUPTED -> 9;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
{"variants":{"facing=north":{"model":"rivencraft:block/book_receptacle","y":180},"facing=east":{"model":"rivencraft:block/book_receptacle","y":270},"facing=south":{"model":"rivencraft:block/book_receptacle"},"facing=west":{"model":"rivencraft:block/book_receptacle","y":90},"facing=up":{"model":"rivencraft:block/book_receptacle","x":270},"facing=down":{"model":"rivencraft:block/book_receptacle","x":90}}}
|
{
|
||||||
|
"multipart": [
|
||||||
|
{"when": {"facing": "north"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 180}},
|
||||||
|
{"when": {"facing": "east"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 270}},
|
||||||
|
{"when": {"facing": "south"}, "apply": {"model": "rivencraft:block/book_receptacle"}},
|
||||||
|
{"when": {"facing": "west"}, "apply": {"model": "rivencraft:block/book_receptacle", "y": 90}},
|
||||||
|
{"when": {"facing": "up"}, "apply": {"model": "rivencraft:block/book_receptacle", "x": 90}},
|
||||||
|
{"when": {"facing": "down"}, "apply": {"model": "rivencraft:block/book_receptacle", "x": 270, "y": 180}}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
|
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
|
||||||
"item.rivencraft.notebook": "Notebook",
|
"item.rivencraft.notebook": "Notebook",
|
||||||
"item.rivencraft.collation_folder": "Collation Folder",
|
"item.rivencraft.collation_folder": "Collation Folder",
|
||||||
|
"item.rivencraft.symbol_portfolio": "Symbol Portfolio",
|
||||||
|
"item.rivencraft.symbol_portfolio.named": "%s Portfolio",
|
||||||
|
"item.rivencraft.symbol_portfolio.volume": "%s Portfolio %s/%s",
|
||||||
"item.rivencraft.sealed_notebook": "Sealed Notebook",
|
"item.rivencraft.sealed_notebook": "Sealed Notebook",
|
||||||
"block.rivencraft.link_modifier": "Link Modifier",
|
"block.rivencraft.link_modifier": "Link Modifier",
|
||||||
"container.rivencraft.link_modifier": "Link Modifier",
|
"container.rivencraft.link_modifier": "Link Modifier",
|
||||||
@@ -220,6 +223,44 @@
|
|||||||
"symbol.rivencraft.sky.nether.description": "Use the Nether sky and red preview background.",
|
"symbol.rivencraft.sky.nether.description": "Use the Nether sky and red preview background.",
|
||||||
"symbol.rivencraft.sky.end": "End Sky",
|
"symbol.rivencraft.sky.end": "End Sky",
|
||||||
"symbol.rivencraft.sky.end.description": "Use the End sky and dark preview background.",
|
"symbol.rivencraft.sky.end.description": "Use the End sky and dark preview background.",
|
||||||
|
"symbol.rivencraft.sky.static": "Static Sky",
|
||||||
|
"symbol.rivencraft.sky.static.description": "Fill the sky with shifting television static.",
|
||||||
|
"symbol.rivencraft.color.red": "Red Color",
|
||||||
|
"symbol.rivencraft.color.red.description": "Add red to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
|
||||||
|
"symbol.rivencraft.color.green": "Green Color",
|
||||||
|
"symbol.rivencraft.color.green.description": "Add green to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
|
||||||
|
"symbol.rivencraft.color.blue": "Blue Color",
|
||||||
|
"symbol.rivencraft.color.blue.description": "Add blue to the next Sky, Sun, or Moon. A number page directly before this page sets its strength.",
|
||||||
|
"symbol.rivencraft.color.white": "White Color",
|
||||||
|
"symbol.rivencraft.color.white.description": "Add equal brightness to red, green, and blue in the next Sky, Sun, or Moon.",
|
||||||
|
"symbol.rivencraft.color.black": "Black Color",
|
||||||
|
"symbol.rivencraft.color.black.description": "Subtract equal brightness from red, green, and blue in the next Sky, Sun, or Moon.",
|
||||||
|
"symbol.rivencraft.number.0": "D'ni 0",
|
||||||
|
"symbol.rivencraft.number.1": "D'ni 1",
|
||||||
|
"symbol.rivencraft.number.2": "D'ni 2",
|
||||||
|
"symbol.rivencraft.number.3": "D'ni 3",
|
||||||
|
"symbol.rivencraft.number.4": "D'ni 4",
|
||||||
|
"symbol.rivencraft.number.5": "D'ni 5",
|
||||||
|
"symbol.rivencraft.number.6": "D'ni 6",
|
||||||
|
"symbol.rivencraft.number.7": "D'ni 7",
|
||||||
|
"symbol.rivencraft.number.8": "D'ni 8",
|
||||||
|
"symbol.rivencraft.number.9": "D'ni 9",
|
||||||
|
"symbol.rivencraft.number.10": "D'ni 10",
|
||||||
|
"symbol.rivencraft.number.11": "D'ni 11",
|
||||||
|
"symbol.rivencraft.number.12": "D'ni 12",
|
||||||
|
"symbol.rivencraft.number.13": "D'ni 13",
|
||||||
|
"symbol.rivencraft.number.14": "D'ni 14",
|
||||||
|
"symbol.rivencraft.number.15": "D'ni 15",
|
||||||
|
"symbol.rivencraft.number.16": "D'ni 16",
|
||||||
|
"symbol.rivencraft.number.17": "D'ni 17",
|
||||||
|
"symbol.rivencraft.number.18": "D'ni 18",
|
||||||
|
"symbol.rivencraft.number.19": "D'ni 19",
|
||||||
|
"symbol.rivencraft.number.20": "D'ni 20",
|
||||||
|
"symbol.rivencraft.number.21": "D'ni 21",
|
||||||
|
"symbol.rivencraft.number.22": "D'ni 22",
|
||||||
|
"symbol.rivencraft.number.23": "D'ni 23",
|
||||||
|
"symbol.rivencraft.number.24": "D'ni 24",
|
||||||
|
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, Length to × 0.08, or Celestial Size from 0.25× to 3×. A number page affects only the immediately following page.",
|
||||||
"symbol.rivencraft.weather.normal": "Normal Weather",
|
"symbol.rivencraft.weather.normal": "Normal Weather",
|
||||||
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
|
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
|
||||||
"symbol.rivencraft.weather.fast": "Fast Weather",
|
"symbol.rivencraft.weather.fast": "Fast Weather",
|
||||||
@@ -246,6 +287,28 @@
|
|||||||
"symbol.rivencraft.season.eternal_spring.description": "Hold this Age in midspring.",
|
"symbol.rivencraft.season.eternal_spring.description": "Hold this Age in midspring.",
|
||||||
"symbol.rivencraft.celestial.normal_time": "Normal Time",
|
"symbol.rivencraft.celestial.normal_time": "Normal Time",
|
||||||
"symbol.rivencraft.celestial.normal_time.description": "Use a normal-length celestial cycle.",
|
"symbol.rivencraft.celestial.normal_time.description": "Use a normal-length celestial cycle.",
|
||||||
|
"symbol.rivencraft.celestial.sun": "Sun",
|
||||||
|
"symbol.rivencraft.celestial.sun.description": "Add one independently moving sun to the sky.",
|
||||||
|
"symbol.rivencraft.celestial.moon": "Moon",
|
||||||
|
"symbol.rivencraft.celestial.moon.description": "Add one independently moving moon to the sky.",
|
||||||
|
"symbol.rivencraft.size.small": "Small Celestial",
|
||||||
|
"symbol.rivencraft.size.small.description": "Halve the apparent size of the next sun or moon.",
|
||||||
|
"symbol.rivencraft.size.normal": "Normal Celestial Size",
|
||||||
|
"symbol.rivencraft.size.normal.description": "Set the next sun or moon to normal apparent size.",
|
||||||
|
"symbol.rivencraft.size.large": "Large Celestial",
|
||||||
|
"symbol.rivencraft.size.large.description": "Double the apparent size of the next sun or moon.",
|
||||||
|
"symbol.rivencraft.angle.north": "North Celestial Angle",
|
||||||
|
"symbol.rivencraft.angle.north.description": "Orient the next sun or moon toward the north.",
|
||||||
|
"symbol.rivencraft.angle.east": "East Celestial Angle",
|
||||||
|
"symbol.rivencraft.angle.east.description": "Orient the next sun or moon toward the east.",
|
||||||
|
"symbol.rivencraft.angle.south": "South Celestial Angle",
|
||||||
|
"symbol.rivencraft.angle.south.description": "Orient the next sun or moon toward the south.",
|
||||||
|
"symbol.rivencraft.angle.west": "West Celestial Angle",
|
||||||
|
"symbol.rivencraft.angle.west.description": "Orient the next sun or moon toward the west.",
|
||||||
|
"symbol.rivencraft.direction.clockwise": "Clockwise Motion",
|
||||||
|
"symbol.rivencraft.direction.clockwise.description": "Move the next sun or moon clockwise in its orbital plane.",
|
||||||
|
"symbol.rivencraft.direction.counterclockwise": "Counterclockwise Motion",
|
||||||
|
"symbol.rivencraft.direction.counterclockwise.description": "Move the next sun or moon counterclockwise in its orbital plane.",
|
||||||
"symbol.rivencraft.period.zero": "Zero Length",
|
"symbol.rivencraft.period.zero": "Zero Length",
|
||||||
"symbol.rivencraft.period.zero.description": "Stop the celestial cycle at its written phase.",
|
"symbol.rivencraft.period.zero.description": "Stop the celestial cycle at its written phase.",
|
||||||
"symbol.rivencraft.period.half": "Half Length",
|
"symbol.rivencraft.period.half": "Half Length",
|
||||||
@@ -368,6 +431,8 @@
|
|||||||
"rivencraft.ponder.crystal_portal.text_3": "Insert a bound Linking or Descriptive Book to activate the portal.",
|
"rivencraft.ponder.crystal_portal.text_3": "Insert a bound Linking or Descriptive Book to activate the portal.",
|
||||||
"rivencraft.ponder.crystal_portal.text_4": "Step through the portal. Remove the book to close it.",
|
"rivencraft.ponder.crystal_portal.text_4": "Step through the portal. Remove the book to close it.",
|
||||||
"itemGroup.rivencraft": "Rivencraft",
|
"itemGroup.rivencraft": "Rivencraft",
|
||||||
|
"itemGroup.rivencraft.pages.ruins": "Rivencraft Ruins Pages",
|
||||||
|
"symbol.rivencraft.ruins.description": "Allows this Ruins template to appear in the Age.",
|
||||||
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
|
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
|
||||||
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
|
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
|
||||||
"itemGroup.rivencraft.pages.biome": "Rivencraft Biome Pages",
|
"itemGroup.rivencraft.pages.biome": "Rivencraft Biome Pages",
|
||||||
@@ -377,6 +442,8 @@
|
|||||||
"itemGroup.rivencraft.pages.season": "Rivencraft Season Pages",
|
"itemGroup.rivencraft.pages.season": "Rivencraft Season Pages",
|
||||||
"itemGroup.rivencraft.pages.lighting": "Rivencraft Lighting Pages",
|
"itemGroup.rivencraft.pages.lighting": "Rivencraft Lighting Pages",
|
||||||
"itemGroup.rivencraft.pages.sky": "Rivencraft Sky Pages",
|
"itemGroup.rivencraft.pages.sky": "Rivencraft Sky Pages",
|
||||||
|
"itemGroup.rivencraft.pages.color": "Rivencraft Color Pages",
|
||||||
|
"itemGroup.rivencraft.pages.number": "Rivencraft Number Pages",
|
||||||
"itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages",
|
"itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages",
|
||||||
"itemGroup.rivencraft.pages.block": "Rivencraft Block Pages",
|
"itemGroup.rivencraft.pages.block": "Rivencraft Block Pages",
|
||||||
"itemGroup.rivencraft.pages.fluid": "Rivencraft Fluid Pages",
|
"itemGroup.rivencraft.pages.fluid": "Rivencraft Fluid Pages",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"render_type":"minecraft:translucent","ambientocclusion":false,"textures":{"particle":"rivencraft:block/crystal_portal","portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,6],"to":[16,16,10],"shade":false,"faces":{"north":{"texture":"#portal"},"south":{"texture":"#portal"}}}]}
|
{"render_type":"minecraft:translucent","ambientocclusion":false,"textures":{"particle":"rivencraft:block/crystal_portal","portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,6],"to":[16,16,10],"shade":false,"faces":{"north":{"texture":"#portal","tintindex":0},"south":{"texture":"#portal","tintindex":0}}}]}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"parent": "minecraft:item/generated",
|
||||||
|
"textures": { "layer0": "rivencraft:item/symbol_portfolio" }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1 @@
|
|||||||
|
{"schema_version":2,"name":"symbol.rivencraft.angle.east","description":"symbol.rivencraft.angle.east.description","category":"rivencraft:celestial","semantics":{"role":"angle_modifier","value":90.0},"poem":"Transform East Illuminate Motion"}
|
||||||