Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644d242bba | ||
|
|
3423dd25bf | ||
|
|
c560e328de | ||
|
|
34ffd317aa |
No files matched your search
@@ -19,6 +19,10 @@ under the same license; desk wood and inlay textures are cropped from desk.png.
|
||||
The Ink Mixer GUI atlas and block textures are copied from Legacy's
|
||||
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
|
||||
the same license. Rivencraft's block model is original and uses those textures.
|
||||
The bundled mystcraft_library.nbt structure template is a block-layout adaptation
|
||||
of Mystcraft Legacy's ComponentScatteredFeatureSmallLibrary.java at commit
|
||||
9bc8ddc061845df0cd5ea47f4fdef773cff786ae, under LGPL-3.0. The conversion
|
||||
script at scripts/generate_legacy_library_template.py is included to rebuild it.
|
||||
|
||||
The initial build layout is derived from the official NeoForge 1.21.1
|
||||
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 510 KiB |
@@ -0,0 +1,57 @@
|
||||
# Milestone 7 Legacy audit
|
||||
|
||||
Baseline: [Mystcraft Legacy source at `9bc8ddc` (0.13.7.06)](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae), checked against the local 0.13.7.06 asset/class extraction. Source and reused assets are LGPL-3.0; see [asset provenance](ASSET_PROVENANCE.md). This records behavior targets, not byte-for-byte 1.12.2 world generation.
|
||||
|
||||
## Already implemented
|
||||
|
||||
Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mixer, link modifiers, safe travel, and optional previews. Crystal Formations, the blue Crystal Block, budding crystals, shards, and Star Fissures were completed in Milestone 5; #76 and #77 must not reimplement them. Vanilla and modern structures are separately page-gated. The Mystcraft library below is neither one of those structures nor a player-authorable page.
|
||||
|
||||
## Libraries: target for #76
|
||||
|
||||
- [`ChunkProviderMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/ChunkProviderMyst.java#L46-L47) installs a scattered-library generator in every Age. [`MapGenScatteredFeatureMyst`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/MapGenScatteredFeatureMyst.java#L34-L103) picks one candidate per 32×32-chunk region with 0–23 chunk offsets and salt `14357617`. Its biome restriction is commented out. There is no Library page and no nearest-structure implementation. Keep automatic sparse placement, modernized for current structure spacing and height APIs, without forcing distant chunks.
|
||||
- [`ComponentScatteredFeatureSmallLibrary`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L19-L70) makes one randomly rotated 11×10×11 building at average ground height, with downward cobblestone supports. Its palette is cobblestone, stone stairs/slabs, cobble walls, planks, bookshelves, webs, and air. Preserve its recognizable shape and interior; define safe placement for Cave/Void/Sky Island terrain rather than fabricating ground.
|
||||
- [Loot placement](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java#L762-L791) puts one chest at local `(4,1,2)` and up to five book-bearing lecterns at `(6,2,2)`, `(8,2,4..6)`, `(6,2,8)`. The [treasure table](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/loot_tables/mystcraft_treasure.json) rolls 4–8 plus 1–2 bonus rolls: dynamically registered symbol pages and static vial/booster/leather/paper. [`LootTableHandler`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/LootTableHandler.java#L69-L94) rerolls lectern contents up to 100 times and requires symbol rank ≥3. Use data-driven server loot, document substitutes for unavailable items, and prevent loot duplication after restart.
|
||||
- The attempted hook into desert pyramid, jungle temple, stronghold library, and dungeon loot uses [`TreasureGenWrapper`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/treasure/TreasureGenWrapper.java), whose `addLoot` is empty in this revision. It is not evidence that those vanilla chests actually yield pages.
|
||||
|
||||
## Crystal portals: targets for #77–#78
|
||||
|
||||
## Library implementation (#76)
|
||||
|
||||
The operator-only test controls are `/rivencraft library test-spacing 8` (roughly 16× normal candidate density in newly generated chunks), `/rivencraft library test-spacing` (show the current setting), and `/rivencraft library test-spacing reset` (return to Legacy spacing). The override affects only the current Age, lasts only until server restart, and never retroactively places libraries in existing chunks. Normal sites remain eligible. A spacing of `1` attempts a candidate in every new chunk and may be expensive. `/rivencraft library nearest` searches nearby test sites while the override is active. `/rivencraft library test-place` is a separate OP-only manual test: it places a library about 13 blocks ahead of the player in the current Age if an 11×11×11 volume is clear, building a one-block cobblestone platform below it. The command reports the chest coordinates; it does not alter natural spawning or make the manual library a locate candidate. Move to open ground if the command rejects the site.
|
||||
|
||||
Libraries now use Legacy's 32×32-chunk region and 0–23 offset with salt `14357617`, one candidate per region. A candidate builds only on a dry, sturdy, nearly level 11×11 surface with clear headroom; its entire rotated footprint stays in that one chunk. Cave terrain is skipped, Void naturally fails the ground check, and Sky Islands can host a library only on a suitable island top. Rejected candidate sites are not retried elsewhere in the region. This is intentionally safer and sparser than cutting through a modern village, tree, ocean, or cliff. The building now places `data/rivencraft/structure/mystcraft_library.nbt`, generated from all 694 ordered Legacy block placements. The old two-block lectern locations are emptied in the template and replaced at runtime by one-block Rivencraft Book Stands with page loot. The chest is also populated at runtime. Regenerate the template with `python scripts/generate_legacy_library_template.py`; the script pins and checksums the Legacy source.
|
||||
|
||||
The cobblestone supports, stone stairs/slabs, planks, shelves, webs, one chest, and five book stands now follow Legacy's block layout via the packaged template. Players can also place a page on an empty vanilla Lectern, converting it to a Rivencraft Book Stand; sneak-use retrieves the page. The stand renders the page item and its symbol name rather than a book model. `libraries.loot_mode` is a server config with `LEGACY` (default) and `AGE_ONLY` choices. In `LEGACY`, the chest uses `data/rivencraft/loot_table/chests/mystcraft_library.json`, with 4–8 main and 1–2 bonus rolls; Rivencraft Ink Vials replace Legacy vials, and ordinary books/experience bottles replace its unavailable booster item. Stands use random catalog symbol pages, excluding generated block/biome entries. In `AGE_ONLY`, the chest and stands contain only pages from the Age's frozen authored active symbols, or its selected random terrain/features/biome pool; there are no supplies. If an Age has no eligible symbols, the stand falls back to an ordinary book. The Legacy rank ≥3 gate cannot be carried over because Rivencraft symbols have no rank field. A stand's page and Age-only chest pages are saved in block entities; Legacy chests keep their assigned loot table/seed. Neither rerolls on player interaction or restart. Config changes affect only future libraries; existing generated chunks are never retroactively populated.
|
||||
|
||||
For a manual check, create a fresh surface Age and run `/rivencraft library nearest` as an operator. It examines up to 128 deterministic candidates in a six-region radius, one candidate chunk per server tick; this can generate those chunks. It teleports only to a marked intact library with clear floor/headroom, or reports that none was found. Confirm one chest and up to five page-bearing stands, take a page and chest contents, leave/restart, and confirm they do not reappear. Right-click an empty vanilla Lectern with a Symbol Page; it should become a stand displaying that page and symbol name. Sneak-right-click with an empty hand to retrieve it and restore the vanilla Lectern. Switch `libraries.loot_mode` to `AGE_ONLY`, create another fresh Age, and confirm its new library contains only pages that describe that Age (no paper, books, ink or bottles); switching modes must not refill an existing library. Repeat with a second player and with Flat, Cave, Void, and Sky Island Ages: only suitable exposed surfaces should host a library. The archived generation GameTest checks deterministic spacing, negative-coordinate regions, the single-chunk footprint, Age-only page filtering, stand storage, and locator marker detection.
|
||||
|
||||
- A [Book Receptacle](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockBookReceptacle.java#L58-L173) attaches to a Crystal Block on any face except downward and holds a Linking or Descriptive Book. The Legacy recipe is eight Crystal Blocks around an empty center. Removing the book or its crystal support deactivates the portal; breaking the receptacle drops the book. [`TileEntityBookReceptacle`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/tileentity/TileEntityBookReceptacle.java#L23-L40) activates/shuts down on server-side book changes.
|
||||
- [`PortalUtils`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/portal/PortalUtils.java#L23-L104) grows portal cells through a connected crystal network. Its tension test requires opposing crystal/portal neighbors on at least two axes; this is more flexible than a single fixed rectangular frame. Active cells trace a path to one receptacle. Modernize the unbounded flood/path walks with loaded-chunk and volume bounds, server validation, and persistent block-entity state.
|
||||
- [`BlockLinkPortal`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockLinkPortal.java#L208-L256) is translucent, non-solid, lit, validated on neighbor changes/random ticks, tinted by the book destination, and routes collisions to the receptacle's book. [`ItemLinking.onPortalCollision`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L106-L119) marks travel external, maintains momentum, disables platform generation, and uses the portal sound. The book remains in the receptacle. #77 owns core behavior; #78 owns restart recovery, protection, multiplayer, cooldowns, chunk tickets, and deleted destinations. A buildable portal is distinct from the existing Star Fissure.
|
||||
- [Legacy portal assets](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft) include `textures/blocks/book_receptacle.png`, `crystal.png`, animated `portal.png` with its `.mcmeta`, receptacle/crystal/portal block models and blockstates, `textures/entity/linkbook.png`, and `sounds/linking/link-portal.ogg`. Check these before creating art for #80, and add each reused file to [provenance](ASSET_PROVENANCE.md). Legacy's rendering pipeline itself cannot be copied unchanged to 1.21.1.
|
||||
|
||||
## Handheld books and Return Links
|
||||
|
||||
[`ItemLinking.activate`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L79-L103) calls `onLink` before travel. If the player uses the exact book in the selected main-hand slot and the Following flag is absent, it removes the book and spawns an [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L48-L65) in the **origin** world. Following books stay in hand. The entity can be opened, picked up by sneaking with an empty hand, damaged, and collected by a hopper minecart.
|
||||
|
||||
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
|
||||
|
||||
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
|
||||
|
||||
## Crafting, sounds, and progression
|
||||
|
||||
| Area | Legacy evidence and modern boundary |
|
||||
| --- | --- |
|
||||
| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. |
|
||||
| Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. |
|
||||
| Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. |
|
||||
|
||||
## Test boundaries and modern adaptations
|
||||
|
||||
- #76: deterministic sparse, automatic libraries with the 11×10×11 shape, chest and up to five book stands; test restart and nonstandard terrain without forcing distant chunks.
|
||||
- #77: receptacle/crystal activation, connected-frame validation, sound/color, travel through the existing book/Age service, and rejection of invalid books or frames.
|
||||
- #78: full-restart recovery, multiplayer, protection events, loop prevention, destination deletion, and bounded chunk activity.
|
||||
- #79–#81: data-driven recipes/optional JEI, then assets/audio, then advancements. #82–#83 verify NeoForge 1.21.1 compatibility and a focused manual/automated matrix.
|
||||
- #85: drop active non-Following books at origin, disable Return Links for **new** configs, preserve existing config choices, and separately test held, stand, and portal travel.
|
||||
|
||||
The 1.21.1 world height, biome/structure APIs, dynamic Age lifecycle, NeoForge protection events, and dedicated-server rendering boundaries preclude direct transplantation of Legacy's 1.12 generator, metadata, and fixed-function portal renderer. Keep those adaptations explicit in follow-up tests and documentation.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Milestone 5 acceptance: Age grammar
|
||||
|
||||
This is the completion record for [Gitea issue #42](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues/42) and release 0.5.0.0. Issues #28–#41, #45–#59, #61, and #62 are closed. #39 was closed with an explicit Ecliptic Seasons limitation, not with a working Ecliptic adapter. #42 is the final gate. Issues #43, #44, and #60 are separate open follow-ups, not hidden Milestone 5 completion claims.
|
||||
This is the completion record for [Gitea issue #42](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/42) and release 0.5.0.0. Issues #28–#41, #45–#59, #61, and #62 are closed. #39 was closed with an explicit Ecliptic Seasons limitation, not with a working Ecliptic adapter. #42 is the final gate. Issues #43, #44, and #60 are separate open follow-ups, not hidden Milestone 5 completion claims.
|
||||
|
||||
## Authoring and generation contract
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Milestone 6 covers authored and live block instability, deterministic provider
|
||||
selection, bounded Age effects, diagnostics, and migration. Its required issues
|
||||
#63–#73 are closed. This document is the release gate for **0.6.0.0**, not a
|
||||
#63–#73 are closed. This document records acceptance of **0.6.0.0**, not a
|
||||
claim of exact Mystcraft 1.12.2 parity or a claim that every optional-mod
|
||||
combination was tested. The player reported an in-game pass for the 0.6.0.0
|
||||
candidate on 2026-10-12; the broader manual matrix remains a future checklist.
|
||||
@@ -104,5 +104,7 @@ season/biome integrations. This is a release-risk judgment, not a claim that
|
||||
- Player in-game acceptance of completion candidate: reported pass on
|
||||
2026-10-12; individual optional-mod and multiplayer matrix rows were not
|
||||
separately signed off.
|
||||
- Gitea completion release, uploaded-jar checksum, #74 and milestone closure:
|
||||
pending publication and verification.
|
||||
- [Gitea V0.6.0.0 release](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases/tag/V0.6.0.0):
|
||||
published with changelog and jar; downloaded asset SHA-256 matched the local
|
||||
build. Issue #74 and Milestone 6 were closed with all 12 milestone issues
|
||||
closed and zero remaining open.
|
||||
+10
-10
@@ -25,13 +25,13 @@ must not silently change existing saved Ages.
|
||||
|
||||
Gitea is the source of truth for project status:
|
||||
|
||||
- [Issues](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues) contain
|
||||
- [Issues](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues) contain
|
||||
scoped implementation tasks, bugs, acceptance criteria, and technical
|
||||
discussion.
|
||||
- [Milestones](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestones)
|
||||
- [Milestones](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestones)
|
||||
group issues into the porting phases below and show their live completion
|
||||
state.
|
||||
- [Releases](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/releases)
|
||||
- [Releases](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/releases)
|
||||
contain user-facing changelogs for shipped versions.
|
||||
|
||||
New work should have an issue before implementation begins. Commits should
|
||||
@@ -48,22 +48,22 @@ acceptance issue and all required implementation issues are complete.
|
||||
3. **Ages** — persistent generated dimensions, deterministic traits,
|
||||
lifecycle management, data-pack integration, and optional destination
|
||||
renderer compatibility. **Complete.** See
|
||||
[Milestone 3](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/8).
|
||||
[Milestone 3](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/8).
|
||||
4. **Writing system** — pages, notebooks, desks, book assembly, and the baseline
|
||||
Ink Mixer for Link Panels. Symbol effects were deferred to Milestone 5.
|
||||
**Complete.** See [Milestone 4](https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/milestone/9)
|
||||
**Complete.** See [Milestone 4](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/milestone/9)
|
||||
and [writing-system data and migration](WRITING_DATA.md).
|
||||
5. **Age grammar** — translate supported symbol combinations into modern biome,
|
||||
noise, dimension-type, and world-generation configuration, with optional
|
||||
TerraBlender and Serene Seasons integration. **Complete for the supported
|
||||
subset.** See the [acceptance record](MILESTONE_5_ACCEPTANCE.md) and
|
||||
[Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md).
|
||||
6. **Instability** — deterministic validation and server-safe effects. **In
|
||||
progress.** Start with the
|
||||
[Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md), which separates
|
||||
source behavior from proposed 1.21.1 safety limits.
|
||||
6. **Instability** — deterministic validation and server-safe effects.
|
||||
**Complete.** See the [Legacy instability audit](LEGACY_INSTABILITY_AUDIT.md)
|
||||
and [Milestone 6 acceptance](MILESTONE_6_ACCEPTANCE.md).
|
||||
7. **Structures and polish** — libraries, crystals, portals, recipes, models,
|
||||
sounds, advancements, compatibility, and migration tests.
|
||||
sounds, advancements, compatibility, and migration tests. Start with the
|
||||
[Milestone 7 Legacy audit](LEGACY_M7_AUDIT.md).
|
||||
|
||||
## Architectural direction
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
mod_license=LGPL-3.0-or-later
|
||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||
mod_version=0.6.0.0
|
||||
mod_version=0.6.1.11
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=B6nTjh4b
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the bundled 1.21.1 structure template from Mystcraft Legacy's layout.
|
||||
|
||||
Run manually; world generation never downloads anything. The pinned Legacy source
|
||||
is LGPL-3.0 and its original block placement order is preserved here.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
import struct
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = ("https://raw.githubusercontent.com/Mystcraft/Mystcraft-Legacy/"
|
||||
"9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/"
|
||||
"com/xcompwiz/mystcraft/world/gen/structure/ComponentScatteredFeatureSmallLibrary.java")
|
||||
SHA256 = "487b3bef48dacfbea35ac2020dbb5543dab0c2a9d99bae4a11acb5cc6fc8bfac"
|
||||
OUTPUT = Path(__file__).resolve().parents[1] / "src/main/resources/data/rivencraft/structure/mystcraft_library.nbt"
|
||||
LECTERNS = {(6, 2), (8, 4), (8, 5), (8, 6), (6, 8)}
|
||||
BLOCKS = {
|
||||
"AIR": "air", "BOOKSHELF": "bookshelf", "COBBLESTONE": "cobblestone",
|
||||
"COBBLESTONE_WALL": "cobblestone_wall", "PLANKS": "oak_planks",
|
||||
"STONE_STAIRS": "stone_stairs", "STONE_SLAB": "cobblestone_slab", "WEB": "cobweb",
|
||||
}
|
||||
PATTERN = re.compile(r"setBlockState\(worldObj, Blocks\.(\w+)\.(.+?), "
|
||||
r"(\d+), (\d+), (\d+), boundingbox\);")
|
||||
|
||||
|
||||
def nbt_string(value):
|
||||
encoded = value.encode("utf-8")
|
||||
return struct.pack(">H", len(encoded)) + encoded
|
||||
|
||||
|
||||
def tag(kind, name, payload):
|
||||
return bytes([kind]) + nbt_string(name) + payload
|
||||
|
||||
|
||||
def compound(entries):
|
||||
return b"".join(entries) + b"\0"
|
||||
|
||||
|
||||
def list_tag(name, element_kind, elements):
|
||||
return tag(9, name, bytes([element_kind]) + struct.pack(">i", len(elements)) + b"".join(elements))
|
||||
|
||||
|
||||
def int_list(name, values):
|
||||
return list_tag(name, 3, [struct.pack(">i", value) for value in values])
|
||||
|
||||
|
||||
def main():
|
||||
source = urllib.request.urlopen(SOURCE, timeout=15).read()
|
||||
if hashlib.sha256(source).hexdigest() != SHA256:
|
||||
raise ValueError("Pinned Legacy source changed")
|
||||
placements = {}
|
||||
matches = PATTERN.findall(source.decode("utf-8"))
|
||||
if len(matches) != 694:
|
||||
raise ValueError(f"Expected 694 Legacy block placements, got {len(matches)}")
|
||||
for legacy_block, state, x, y, z in matches:
|
||||
x, y, z = int(x), int(y), int(z)
|
||||
if legacy_block not in BLOCKS or not (0 <= x <= 10 and 0 <= y <= 10 and 0 <= z <= 10):
|
||||
raise ValueError(f"Unsupported Legacy block/position: {legacy_block} {(x, y, z)}")
|
||||
properties = {}
|
||||
facing = re.search(r"BlockStairs.FACING, EnumFacing.(\w+)", state)
|
||||
if facing:
|
||||
properties["facing"] = facing.group(1).lower()
|
||||
if "BlockStairs.EnumHalf.TOP" in state:
|
||||
properties["half"] = "top"
|
||||
if legacy_block == "STONE_SLAB":
|
||||
meta = re.search(r"getStateFromMeta\((\d+)\)", state)
|
||||
if not meta or int(meta.group(1)) not in (3, 11):
|
||||
raise ValueError(f"Unknown Legacy slab metadata: {state}")
|
||||
properties["type"] = "top" if int(meta.group(1)) == 11 else "bottom"
|
||||
placements[x, y, z] = ("minecraft:" + BLOCKS[legacy_block], tuple(sorted(properties.items())))
|
||||
|
||||
# Legacy's lower lectern is a bookshelf at y=1. Remove that and its
|
||||
# two-block upper half; runtime places a one-block Rivencraft stand at y=1.
|
||||
for x, z in LECTERNS:
|
||||
placements.pop((x, 1, z), None)
|
||||
placements.pop((x, 2, z), None)
|
||||
# Chest and its inventory are assigned at runtime, never frozen in NBT.
|
||||
placements.pop((4, 1, 2), None)
|
||||
placements = {pos: state for pos, state in placements.items() if state[0] != "minecraft:air"}
|
||||
|
||||
palette = sorted(set(placements.values()))
|
||||
indexes = {state: index for index, state in enumerate(palette)}
|
||||
palette_tags = []
|
||||
for name, properties in palette:
|
||||
entries = [tag(8, "Name", nbt_string(name))]
|
||||
if properties:
|
||||
entries.append(tag(10, "Properties", compound(
|
||||
[tag(8, key, nbt_string(value)) for key, value in properties])))
|
||||
palette_tags.append(compound(entries))
|
||||
block_tags = [compound([int_list("pos", (x, y, z)),
|
||||
tag(3, "state", struct.pack(">i", indexes[state]))])
|
||||
for (x, y, z), state in sorted(placements.items())]
|
||||
root = compound([int_list("size", (11, 11, 11)), list_tag("palette", 10, palette_tags),
|
||||
list_tag("blocks", 10, block_tags), list_tag("entities", 10, []),
|
||||
tag(3, "DataVersion", struct.pack(">i", 3955))])
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_bytes(gzip.compress(tag(10, "", root), mtime=0))
|
||||
print(f"Wrote {OUTPUT}: {len(placements)} blocks, {len(palette)} palette states")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,7 @@ import net.dnorberg.rivencraft.event.BookStandConversionHandler;
|
||||
import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
|
||||
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
|
||||
import net.dnorberg.rivencraft.command.RivencraftCommands;
|
||||
import net.dnorberg.rivencraft.command.LibraryLocateService;
|
||||
import net.dnorberg.rivencraft.network.ModNetworking;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
|
||||
@@ -80,6 +81,8 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register);
|
||||
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
|
||||
LOGGER.info("Initializing Rivencraft");
|
||||
|
||||
@@ -17,6 +17,10 @@ import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
||||
import net.dnorberg.rivencraft.writing.BiomeSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.BlockSymbolPages;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.GlobalPos;
|
||||
@@ -312,6 +316,7 @@ public final class AgeDimensionService {
|
||||
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
|
||||
AgeRuntimeProfile profile = runtimeProfile(age);
|
||||
BiomeSource biomeSource = createBiomeSource(server, age, profile);
|
||||
List<ResourceLocation> libraryAgeSymbols = libraryAgeSymbols(age, profile, biomeSource);
|
||||
List<AgeFeatureSelection.Choice> features = age.plan() == null
|
||||
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
|
||||
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
|
||||
@@ -321,7 +326,7 @@ public final class AgeDimensionService {
|
||||
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||
);
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||
}
|
||||
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
|
||||
@@ -331,7 +336,7 @@ public final class AgeDimensionService {
|
||||
);
|
||||
settings.updateLayers();
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed());
|
||||
? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols);
|
||||
}
|
||||
|
||||
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||
@@ -343,7 +348,40 @@ public final class AgeDimensionService {
|
||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
||||
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
||||
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of());
|
||||
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
|
||||
libraryAgeSymbols);
|
||||
}
|
||||
|
||||
/** Freeze the Age's authoring/result pages into the generator; worldgen never reads SavedData. */
|
||||
public static List<ResourceLocation> libraryAgeSymbols(AgeDefinition age, AgeRuntimeProfile profile,
|
||||
BiomeSource biomes) {
|
||||
LinkedHashSet<ResourceLocation> ids = new LinkedHashSet<>();
|
||||
ResourceLocation effectiveTerrain = AgeRuntimeProfiles.profiles().entrySet().stream()
|
||||
.filter(entry -> entry.getValue().equals(profile))
|
||||
.map(java.util.Map.Entry::getKey).sorted().findFirst()
|
||||
.orElse(AgeGenerationProfiles.migrationFallback(age.generationProfile()));
|
||||
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
|
||||
"terrain/" + effectiveTerrain.getPath()));
|
||||
if (age.plan() != null) {
|
||||
age.plan().activeSymbols().forEach(symbol -> ids.add(symbol.reference().id()));
|
||||
} else {
|
||||
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
|
||||
"biome/" + age.traits().biomeDistribution().name().toLowerCase(java.util.Locale.ROOT)));
|
||||
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
|
||||
"lighting/" + age.traits().lighting().name().toLowerCase(java.util.Locale.ROOT)));
|
||||
ids.add(ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID,
|
||||
"weather/" + age.traits().weather().name().toLowerCase(java.util.Locale.ROOT)));
|
||||
age.randomFeatures().forEach(choice -> {
|
||||
ids.add(ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "feature/" + choice.id().getPath()));
|
||||
choice.material().ifPresent(material -> ids.add(BlockSymbolPages.symbolId(material)));
|
||||
});
|
||||
biomes.possibleBiomes().forEach(holder -> holder.unwrapKey().ifPresent(key -> {
|
||||
if (BiomeSymbolPages.isPublicPageBiome(key.location()))
|
||||
ids.add(BiomeSymbolPages.symbolId(key.location()));
|
||||
}));
|
||||
}
|
||||
return ids.stream().filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).toList();
|
||||
}
|
||||
|
||||
private static NoiseGeneratorSettings withBiomeMaterialRules(NoiseGeneratorSettings base) {
|
||||
|
||||
@@ -15,8 +15,10 @@ import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
|
||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemDisplayContext;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
@@ -49,6 +51,26 @@ public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlo
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockEntity.getBook().is(ModItems.PAGE.get())) {
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
||||
// The page's long axis follows the lectern top. The book model below uses
|
||||
// its own axis convention, but applying that yaw here turns a flat page 90°.
|
||||
float rotation = state.getValue(BookStandBlock.FACING).toYRot() - 180.0F;
|
||||
// The east/west page needs a horizontal half-turn, before the
|
||||
// lectern's incline is applied. A local Z turn spins the art only.
|
||||
if (state.getValue(BookStandBlock.FACING).getAxis() == Direction.Axis.X)
|
||||
rotation += 180.0F;
|
||||
poseStack.mulPose(Axis.YP.rotationDegrees(rotation));
|
||||
poseStack.mulPose(Axis.XP.rotationDegrees(67.5F));
|
||||
poseStack.scale(0.7F, 0.7F, 0.7F);
|
||||
Minecraft.getInstance().getItemRenderer().renderStatic(blockEntity.getBook(),
|
||||
ItemDisplayContext.FIXED, packedLight, packedOverlay, poseStack, bufferSource,
|
||||
blockEntity.getLevel(), 0);
|
||||
poseStack.popPose();
|
||||
return;
|
||||
}
|
||||
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
||||
float rotation = state.getValue(BookStandBlock.FACING).getClockWise().toYRot();
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package net.dnorberg.rivencraft.command;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfile;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/** Bounded one-candidate-per-tick search; only an operator can start it. */
|
||||
public final class LibraryLocateService {
|
||||
private static final int REGION_RADIUS = 6;
|
||||
private static final int MAX_CANDIDATES = 128;
|
||||
private static final ArrayDeque<Search> QUEUE = new ArrayDeque<>();
|
||||
private static final Map<UUID, Search> ACTIVE = new HashMap<>();
|
||||
|
||||
private LibraryLocateService() {}
|
||||
|
||||
private record Search(MinecraftServer server, UUID playerId, ServerLevel level, long seed,
|
||||
int spacing, List<ChunkPos> candidates, int next) {
|
||||
Search advance() {
|
||||
return new Search(server, playerId, level, seed, spacing, candidates, next + 1);
|
||||
}
|
||||
}
|
||||
|
||||
public static int testSpacing(CommandSourceStack source, Integer chunks) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(player.serverLevel())
|
||||
.findByDimension(player.serverLevel().dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
if (chunks != null) MystcraftLibraryFeature.setTestSpacing(age.seed(), chunks);
|
||||
int spacing = MystcraftLibraryFeature.spacing(age.seed());
|
||||
source.sendSuccess(() -> Component.literal(spacing == MystcraftLibraryFeature.REGION
|
||||
? "Library test spacing is off; Legacy 32-chunk spacing is active."
|
||||
: "Library test spacing: " + spacing + " chunks (about "
|
||||
+ (MystcraftLibraryFeature.REGION * MystcraftLibraryFeature.REGION / (spacing * spacing))
|
||||
+ "x normal candidate density). Only new chunks are affected; this resets on server restart."), false);
|
||||
return spacing;
|
||||
}
|
||||
|
||||
public static int testPlace(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
var symbols = AgeDimensionService.libraryAgeSymbols(age, profile,
|
||||
level.getChunkSource().getGenerator().getBiomeSource());
|
||||
var placed = MystcraftLibraryFeature.testPlace(level, player.blockPosition(),
|
||||
player.getDirection(), age.seed(), symbols);
|
||||
if (placed.isEmpty()) {
|
||||
source.sendFailure(Component.literal("Could not place a test library here. Move to an open area "
|
||||
+ "with clear sky, then try again."));
|
||||
return 0;
|
||||
}
|
||||
BlockPos chest = placed.orElseThrow();
|
||||
source.sendSuccess(() -> Component.literal("Placed a test library ahead of you. Chest: "
|
||||
+ chest.getX() + ", " + chest.getY() + ", " + chest.getZ()
|
||||
+ ". This placement does not change natural library spawning."), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Generate and inspect just the nearest deterministic candidate, without changing it. */
|
||||
public static int probe(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
source.sendFailure(Component.literal("This Age terrain does not generate surface libraries."));
|
||||
return 0;
|
||||
}
|
||||
ChunkPos candidate = candidates(age.seed(), player.blockPosition(),
|
||||
MystcraftLibraryFeature.spacing(age.seed())).getFirst();
|
||||
try {
|
||||
LevelChunk chunk = level.getChunk(candidate.x, candidate.z);
|
||||
String outcome;
|
||||
var chest = MystcraftLibraryFeature.libraryChest(chunk, age.seed());
|
||||
if (chest.isPresent()) {
|
||||
BlockPos arrival = MystcraftLibraryFeature.arrival(age.seed(), candidate, chest.orElseThrow().getY());
|
||||
outcome = "library generated; arrival at " + arrival.getX() + ", "
|
||||
+ arrival.getY() + ", " + arrival.getZ() + " is "
|
||||
+ (safeArrival(level, arrival) ? "safe" : "blocked");
|
||||
} else {
|
||||
var check = MystcraftLibraryFeature.check(level, candidate, age.seed());
|
||||
outcome = check.eligible()
|
||||
? "site passes current placement rules, but no library exists (possibly generated by an older build)"
|
||||
: "rejected: " + check.reason();
|
||||
}
|
||||
String message = "Library candidate chunk " + candidate.x + ", " + candidate.z
|
||||
+ ": " + outcome + ". Probe generates this chunk if needed.";
|
||||
source.sendSuccess(() -> Component.literal(message), false);
|
||||
return 1;
|
||||
} catch (RuntimeException exception) {
|
||||
source.sendFailure(Component.literal("Library probe failed: " + exception.getMessage()));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static int repairStands(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
int changed = MystcraftLibraryFeature.repairStandFacings(level,
|
||||
level.getChunkAt(player.blockPosition()), age.seed());
|
||||
if (changed < 0) {
|
||||
source.sendFailure(Component.literal("Stand inside a generated library and run the command again."));
|
||||
return 0;
|
||||
}
|
||||
source.sendSuccess(() -> Component.literal("Corrected " + changed
|
||||
+ " library stand facings; pages were retained."), false);
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static int start(CommandSourceStack source) {
|
||||
if (!source.hasPermission(2) || !(source.getEntity() instanceof ServerPlayer player)) {
|
||||
source.sendFailure(Component.literal("An operator player must run this command."));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel level = player.serverLevel();
|
||||
AgeDefinition age = AgeRegistrySavedData.get(level)
|
||||
.findByDimension(level.dimension().location()).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Run this command while inside a Rivencraft Age."));
|
||||
return 0;
|
||||
}
|
||||
AgeRuntimeProfile profile = age.plan() == null
|
||||
? AgeRuntimeProfiles.resolve(age.generationProfile()) : age.plan().runtimeProfile();
|
||||
if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||
source.sendFailure(Component.literal("This Age terrain cannot generate surface libraries."));
|
||||
return 0;
|
||||
}
|
||||
int spacing = MystcraftLibraryFeature.spacing(age.seed());
|
||||
List<ChunkPos> candidates = candidates(age.seed(), player.blockPosition(), spacing).stream()
|
||||
.filter(chunk -> level.getWorldBorder().isWithinBounds(new BlockPos(
|
||||
chunk.getMinBlockX() + 8, player.getBlockY(), chunk.getMinBlockZ() + 8)))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) {
|
||||
source.sendFailure(Component.literal("No library candidates lie inside this Age's world border."));
|
||||
return 0;
|
||||
}
|
||||
Search search = new Search(source.getServer(), player.getUUID(), level, age.seed(), spacing, candidates, 0);
|
||||
ACTIVE.put(player.getUUID(), search);
|
||||
QUEUE.addLast(search);
|
||||
source.sendSuccess(() -> Component.literal("Searching up to " + candidates.size()
|
||||
+ " nearby library candidate chunks. One will be checked per tick; the search may generate chunks."), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static List<ChunkPos> candidates(long seed, BlockPos origin) {
|
||||
return candidates(seed, origin, MystcraftLibraryFeature.REGION);
|
||||
}
|
||||
|
||||
static List<ChunkPos> candidates(long seed, BlockPos origin, int spacing) {
|
||||
int regionX = Math.floorDiv(origin.getX() >> 4, MystcraftLibraryFeature.REGION);
|
||||
int regionZ = Math.floorDiv(origin.getZ() >> 4, MystcraftLibraryFeature.REGION);
|
||||
LinkedHashSet<ChunkPos> sites = new LinkedHashSet<>();
|
||||
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
|
||||
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
|
||||
sites.add(MystcraftLibraryFeature.candidateChunk(seed, regionX + dx, regionZ + dz));
|
||||
if (spacing != MystcraftLibraryFeature.REGION) {
|
||||
int testRegionX = Math.floorDiv(origin.getX() >> 4, spacing);
|
||||
int testRegionZ = Math.floorDiv(origin.getZ() >> 4, spacing);
|
||||
for (int dx = -REGION_RADIUS; dx <= REGION_RADIUS; dx++)
|
||||
for (int dz = -REGION_RADIUS; dz <= REGION_RADIUS; dz++)
|
||||
sites.add(MystcraftLibraryFeature.candidateChunk(seed, testRegionX + dx, testRegionZ + dz, spacing));
|
||||
}
|
||||
List<ChunkPos> choices = new ArrayList<>(sites);
|
||||
choices.sort(Comparator.comparingLong(chunk -> {
|
||||
long x = (long) chunk.getMiddleBlockX() - origin.getX();
|
||||
long z = (long) chunk.getMiddleBlockZ() - origin.getZ();
|
||||
return x * x + z * z;
|
||||
}));
|
||||
return List.copyOf(choices.subList(0, Math.min(MAX_CANDIDATES, choices.size())));
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
if (QUEUE.isEmpty()) return;
|
||||
Search search = QUEUE.pollFirst();
|
||||
if (search.server() != event.getServer() || ACTIVE.get(search.playerId()) != search) return;
|
||||
ServerPlayer player = search.server().getPlayerList().getPlayer(search.playerId());
|
||||
if (player == null || player.serverLevel() != search.level()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
return;
|
||||
}
|
||||
if (MystcraftLibraryFeature.spacing(search.seed()) != search.spacing()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("Library search stopped because test spacing changed. Run nearest again."));
|
||||
return;
|
||||
}
|
||||
ChunkPos candidate = search.candidates().get(search.next());
|
||||
try {
|
||||
var generated = search.level().getChunkSource().getChunk(candidate.x, candidate.z,
|
||||
ChunkStatus.FULL, true);
|
||||
if (generated instanceof LevelChunk chunk) {
|
||||
var chest = MystcraftLibraryFeature.libraryChest(chunk, search.seed());
|
||||
if (chest.isPresent()) {
|
||||
BlockPos arrival = MystcraftLibraryFeature.arrival(search.seed(), candidate, chest.orElseThrow().getY());
|
||||
if (safeArrival(search.level(), arrival)) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.teleportTo(search.level(), arrival.getX() + 0.5D, arrival.getY(),
|
||||
arrival.getZ() + 0.5D, player.getYRot(), player.getXRot());
|
||||
player.fallDistance = 0;
|
||||
player.sendSystemMessage(Component.literal("Teleported to the nearest found library at "
|
||||
+ arrival.getX() + ", " + arrival.getY() + ", " + arrival.getZ() + "."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("Library search stopped: " + exception.getMessage()));
|
||||
return;
|
||||
}
|
||||
if (search.next() + 1 >= search.candidates().size()) {
|
||||
ACTIVE.remove(search.playerId());
|
||||
player.sendSystemMessage(Component.literal("No intact library found among "
|
||||
+ search.candidates().size() + " nearby candidates. Explore farther and try again."));
|
||||
} else {
|
||||
if ((search.next() + 1) % 32 == 0)
|
||||
player.sendSystemMessage(Component.literal("Library search: checked "
|
||||
+ (search.next() + 1) + "/" + search.candidates().size() + " candidates."));
|
||||
Search advanced = search.advance();
|
||||
ACTIVE.put(search.playerId(), advanced);
|
||||
QUEUE.addLast(advanced);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean safeArrival(ServerLevel level, BlockPos pos) {
|
||||
return level.isInWorldBounds(pos) && level.getBlockState(pos.below()).isFaceSturdy(
|
||||
level, pos.below(), net.minecraft.core.Direction.UP)
|
||||
&& level.getBlockState(pos).getCollisionShape(level, pos).isEmpty()
|
||||
&& level.getBlockState(pos.above()).getCollisionShape(level, pos.above()).isEmpty()
|
||||
&& level.getBlockState(pos).getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
QUEUE.removeIf(search -> search.server() == event.getServer());
|
||||
ACTIVE.entrySet().removeIf(entry -> entry.getValue().server() == event.getServer());
|
||||
MystcraftLibraryFeature.clearTestSpacing();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,22 @@ public final class RivencraftCommands {
|
||||
public static void register(RegisterCommandsEvent event) {
|
||||
event.getDispatcher().register(Commands.literal("rivencraft")
|
||||
.requires(source -> source.hasPermission(2))
|
||||
.then(Commands.literal("library")
|
||||
.then(Commands.literal("nearest")
|
||||
.executes(context -> LibraryLocateService.start(context.getSource())))
|
||||
.then(Commands.literal("test-place")
|
||||
.executes(context -> LibraryLocateService.testPlace(context.getSource())))
|
||||
.then(Commands.literal("probe")
|
||||
.executes(context -> LibraryLocateService.probe(context.getSource())))
|
||||
.then(Commands.literal("repair-stands")
|
||||
.executes(context -> LibraryLocateService.repairStands(context.getSource())))
|
||||
.then(Commands.literal("test-spacing")
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), null))
|
||||
.then(Commands.literal("reset")
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(), 32)))
|
||||
.then(Commands.argument("chunks", IntegerArgumentType.integer(1, 31))
|
||||
.executes(context -> LibraryLocateService.testSpacing(context.getSource(),
|
||||
IntegerArgumentType.getInteger(context, "chunks"))))))
|
||||
.then(Commands.literal("age")
|
||||
.then(Commands.literal("list")
|
||||
.executes(context -> listAges(context.getSource())))
|
||||
|
||||
@@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.book.AgeDefinition;
|
||||
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
|
||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
||||
import net.dnorberg.rivencraft.world.MystcraftLibraryFeature;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
@@ -43,6 +45,38 @@ public final class RivencraftCommandsGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void nearestLibraryCandidatesAreBoundedAndOrdered(GameTestHelper helper) {
|
||||
BlockPos origin = new BlockPos(-400, 74, 900);
|
||||
List<net.minecraft.world.level.ChunkPos> candidates = LibraryLocateService.candidates(7301L, origin);
|
||||
helper.assertValueEqual(candidates.size(), 128, "Library lookup must have a finite chunk budget");
|
||||
long previous = -1;
|
||||
java.util.Set<net.minecraft.world.level.ChunkPos> unique = new java.util.HashSet<>();
|
||||
for (var candidate : candidates) {
|
||||
helper.assertTrue(unique.add(candidate), "A candidate chunk must not be searched twice");
|
||||
long dx = (long) candidate.getMiddleBlockX() - origin.getX();
|
||||
long dz = (long) candidate.getMiddleBlockZ() - origin.getZ();
|
||||
long distance = dx * dx + dz * dz;
|
||||
helper.assertTrue(distance >= previous, "Nearest candidates must be tested first");
|
||||
previous = distance;
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void testSpacingAddsNearbyCandidatesWithoutRemovingNormalSites(GameTestHelper helper) {
|
||||
BlockPos origin = new BlockPos(0, 80, 0);
|
||||
long seed = 7302L;
|
||||
var normal = LibraryLocateService.candidates(seed, origin);
|
||||
var dense = LibraryLocateService.candidates(seed, origin, 8);
|
||||
helper.assertValueEqual(dense.size(), 128, "Dense lookup must still use the bounded search budget");
|
||||
helper.assertTrue(dense.stream().anyMatch(chunk -> !normal.contains(chunk)),
|
||||
"Test spacing must add additional nearby candidates");
|
||||
helper.assertTrue(dense.contains(MystcraftLibraryFeature.candidateChunk(seed, 0, 0, 8)),
|
||||
"Nearest test site should be searched");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static AgeDefinition age(String name, String path) {
|
||||
UUID id = UUID.randomUUID();
|
||||
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
|
||||
|
||||
@@ -8,6 +8,7 @@ import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** Server-configurable gameplay policies. */
|
||||
public final class RivencraftConfig {
|
||||
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||
public enum ReturnLinkMode {
|
||||
/** Create one Return Link when an Age is first reached. */
|
||||
ON_CREATION,
|
||||
@@ -19,6 +20,7 @@ public final class RivencraftConfig {
|
||||
|
||||
public static final ModConfigSpec SPEC;
|
||||
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
||||
private static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
|
||||
private static final ModConfigSpec.IntValue SYMBOL_INK_COST;
|
||||
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||
@@ -75,6 +77,12 @@ public final class RivencraftConfig {
|
||||
)
|
||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
||||
builder.pop();
|
||||
builder.push("libraries");
|
||||
LIBRARY_LOOT_MODE = builder.comment(
|
||||
"LEGACY gives libraries varied writing supplies and random symbol pages; AGE_ONLY gives only symbol pages used by this Age.",
|
||||
"Applies to newly generated libraries only; existing chests and stands keep their contents.")
|
||||
.defineEnum("loot_mode", LibraryLootMode.LEGACY);
|
||||
builder.pop();
|
||||
builder.push("writing");
|
||||
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
|
||||
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||
@@ -229,6 +237,8 @@ public final class RivencraftConfig {
|
||||
return AUTO_WHITELIST_SERENE_AGES.get();
|
||||
}
|
||||
|
||||
public static LibraryLootMode libraryLootMode() { return LIBRARY_LOOT_MODE.get(); }
|
||||
|
||||
public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
|
||||
public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); }
|
||||
public static boolean instabilityProviderEnabled(ResourceLocation id) {
|
||||
|
||||
@@ -19,7 +19,8 @@ public final class BookStandConversionHandler {
|
||||
|
||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||
ItemStack heldBook = event.getItemStack();
|
||||
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())) {
|
||||
if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())
|
||||
&& !heldBook.is(ModItems.PAGE.get())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
||||
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of())
|
||||
.forGetter(generator -> generator.netherBiomeIds))
|
||||
.forGetter(generator -> generator.netherBiomeIds),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
|
||||
.forGetter(generator -> generator.libraryAgeSymbols))
|
||||
.apply(instance, AgeFeatureChunkGenerator::new));
|
||||
|
||||
private final Holder<NoiseGeneratorSettings> settings;
|
||||
@@ -68,16 +70,25 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
private final long ageSeed;
|
||||
private final boolean netherCaveMaterials;
|
||||
private final List<ResourceLocation> netherBiomeIds;
|
||||
private final List<ResourceLocation> libraryAgeSymbols;
|
||||
|
||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds) {
|
||||
this(biomes, settings, features, ageSeed, netherCaveMaterials, netherBiomeIds, List.of());
|
||||
}
|
||||
|
||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds,
|
||||
List<ResourceLocation> libraryAgeSymbols) {
|
||||
super(biomes, settings);
|
||||
this.settings = settings;
|
||||
this.features = List.copyOf(features);
|
||||
this.ageSeed = ageSeed;
|
||||
this.netherCaveMaterials = netherCaveMaterials;
|
||||
this.netherBiomeIds = List.copyOf(netherBiomeIds);
|
||||
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||
}
|
||||
|
||||
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
|
||||
@@ -253,5 +264,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
||||
^ (long) occurrence * 0xD1B54A32D192ED03L;
|
||||
authored.place(level, this, RandomSource.create(seed), origin);
|
||||
}
|
||||
// Place after vanilla and authored decoration so later features cannot grow through
|
||||
// the building; the library check preserves existing solid structures.
|
||||
if (!netherCaveMaterials) MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,15 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings),
|
||||
AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of())
|
||||
.forGetter(generator -> generator.features),
|
||||
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed))
|
||||
Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed),
|
||||
ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of())
|
||||
.forGetter(generator -> generator.libraryAgeSymbols))
|
||||
.apply(instance, AgeFlatChunkGenerator::new));
|
||||
|
||||
private final FlatLevelGeneratorSettings settings;
|
||||
private final List<AgeFeatureSelection.Choice> features;
|
||||
private final long ageSeed;
|
||||
private final List<ResourceLocation> libraryAgeSymbols;
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
|
||||
this(biomeSource, settings, List.of(), 0L);
|
||||
@@ -55,10 +58,17 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed) {
|
||||
this(biomeSource, settings, features, ageSeed, List.of());
|
||||
}
|
||||
|
||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||
List<ResourceLocation> libraryAgeSymbols) {
|
||||
super(biomeSource, Util.memoize(settings::adjustGenerationSettings));
|
||||
this.settings = settings;
|
||||
this.features = List.copyOf(features);
|
||||
this.ageSeed = ageSeed;
|
||||
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||
}
|
||||
|
||||
public FlatLevelGeneratorSettings settings() { return settings; }
|
||||
@@ -133,6 +143,7 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
||||
if (AgeFeatureSelection.obelisks(choice.id()))
|
||||
ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++);
|
||||
}
|
||||
MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package net.dnorberg.rivencraft.world;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.ChestBlock;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */
|
||||
public final class MystcraftLibraryFeature {
|
||||
public static final int REGION = 32;
|
||||
static final int FOOTPRINT = 11;
|
||||
private static final int OFFSET_BOUND = 24;
|
||||
private static final int MAX_SUPPORT_DEPTH = 24;
|
||||
private static final int SALT = 14357617;
|
||||
private static final int TEST_SALT = 0x4C494252;
|
||||
private static final String CHEST_MARKER = "RivencraftLibrary";
|
||||
private static final ConcurrentHashMap<Long, Integer> TEST_SPACING = new ConcurrentHashMap<>();
|
||||
// x, y, z, clockwise turns relative to the left stand. The middle row
|
||||
// faces across the room; the far stand faces back toward the entrance.
|
||||
private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1},
|
||||
{8, 1, 6, 1}, {6, 1, 8, 2}};
|
||||
private static final ResourceLocation TEMPLATE = ResourceLocation.fromNamespaceAndPath(
|
||||
"rivencraft", "mystcraft_library");
|
||||
private static final ResourceKey<LootTable> CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE,
|
||||
ResourceLocation.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library"));
|
||||
|
||||
private MystcraftLibraryFeature() {}
|
||||
|
||||
record Site(int x, int z, int rotation) {}
|
||||
|
||||
public static int spacing(long seed) {
|
||||
return TEST_SPACING.getOrDefault(seed, REGION);
|
||||
}
|
||||
|
||||
/** Session-only testing override; normal 32-chunk sites always remain eligible. */
|
||||
public static void setTestSpacing(long seed, int chunks) {
|
||||
if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks);
|
||||
if (chunks == REGION) TEST_SPACING.remove(seed);
|
||||
else TEST_SPACING.put(seed, chunks);
|
||||
}
|
||||
|
||||
public static void clearTestSpacing() {
|
||||
TEST_SPACING.clear();
|
||||
}
|
||||
|
||||
/** The single deterministic candidate in one Legacy-sized region. */
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) {
|
||||
return candidateChunk(seed, regionX, regionZ, REGION);
|
||||
}
|
||||
|
||||
public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) {
|
||||
if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, regionX, regionZ,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
return new ChunkPos(regionX * spacing + random.nextInt(bound),
|
||||
regionZ * spacing + random.nextInt(bound));
|
||||
}
|
||||
|
||||
static Site site(long seed, ChunkPos chunk) {
|
||||
Site normal = site(seed, chunk, REGION);
|
||||
return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed));
|
||||
}
|
||||
|
||||
private static Site site(long seed, ChunkPos chunk, int spacing) {
|
||||
int rx = Math.floorDiv(chunk.x, spacing);
|
||||
int rz = Math.floorDiv(chunk.z, spacing);
|
||||
WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L));
|
||||
random.setLargeFeatureWithSalt(seed, rx, rz,
|
||||
spacing == REGION ? SALT : TEST_SALT ^ spacing);
|
||||
int bound = spacing == REGION ? OFFSET_BOUND : spacing;
|
||||
int candidateX = rx * spacing + random.nextInt(bound);
|
||||
int candidateZ = rz * spacing + random.nextInt(bound);
|
||||
if (chunk.x != candidateX || chunk.z != candidateZ) return null;
|
||||
return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4));
|
||||
}
|
||||
|
||||
/** A saved marker distinguishes a generated library from player-built chests. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk) {
|
||||
return chunk.getBlockEntities().entrySet().stream()
|
||||
.filter(entry -> entry.getValue() instanceof ChestBlockEntity chest
|
||||
&& chest.getPersistentData().getBoolean(CHEST_MARKER))
|
||||
.map(java.util.Map.Entry::getKey).findFirst();
|
||||
}
|
||||
|
||||
/** Recognize libraries generated before the chest marker was introduced. */
|
||||
public static Optional<BlockPos> libraryChest(LevelChunk chunk, long seed) {
|
||||
Optional<BlockPos> marked = libraryChest(chunk);
|
||||
if (marked.isPresent()) return marked;
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return Optional.empty();
|
||||
for (var entry : chunk.getBlockEntities().entrySet()) {
|
||||
if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue;
|
||||
int y = entry.getKey().getY();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2))
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF)
|
||||
|| !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS))
|
||||
continue;
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
return Optional.of(entry.getKey());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Open interior aisle, directly above the cobblestone floor. */
|
||||
public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk);
|
||||
return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable();
|
||||
}
|
||||
|
||||
public record PlacementCheck(String reason, int floorY, int[][] heights) {
|
||||
public boolean eligible() { return reason == null; }
|
||||
}
|
||||
|
||||
/** Inspect one candidate without placing blocks. The same test gates natural placement. */
|
||||
public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) {
|
||||
Site site = site(seed, chunk);
|
||||
if (site == null) return rejected("not a candidate chunk");
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int low = Integer.MAX_VALUE;
|
||||
int high = Integer.MIN_VALUE;
|
||||
int[][] heights = new int[FOOTPRINT][FOOTPRINT];
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
int wx = site.x() + x;
|
||||
int wz = site.z() + z;
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz);
|
||||
// The heightmap includes tree canopies. Libraries sit on terrain, not leaves.
|
||||
while (y > level.getMinBuildHeight() + 2) {
|
||||
BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!below.isAir() && !isVegetation(below)) break;
|
||||
y--;
|
||||
}
|
||||
if (y < level.getMinBuildHeight() + 2) return rejected("no surface footing");
|
||||
BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz));
|
||||
if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level");
|
||||
if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP))
|
||||
return rejected("ground is not sturdy terrain");
|
||||
heights[x][z] = y;
|
||||
low = Math.min(low, y);
|
||||
high = Math.max(high, y);
|
||||
}
|
||||
if (high + 10 >= level.getMaxBuildHeight()) return rejected("insufficient build height");
|
||||
if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth ("
|
||||
+ (high - low) + " > " + MAX_SUPPORT_DEPTH + ")");
|
||||
// Check the entire raised foundation and house. Foliage can be cleared, but not
|
||||
// structures, fluids, or block entities already occupying this volume.
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = heights[x][z]; y <= high + 10; y++) {
|
||||
BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z));
|
||||
if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint");
|
||||
if (state.hasBlockEntity()) return rejected("block entity inside footprint");
|
||||
if (!(state.isAir() || state.canBeReplaced() || isVegetation(state)))
|
||||
return rejected("solid obstruction inside footprint: "
|
||||
+ state.getBlock().getDescriptionId());
|
||||
}
|
||||
return new PlacementCheck(null, high, heights);
|
||||
}
|
||||
|
||||
private static PlacementCheck rejected(String reason) {
|
||||
return new PlacementCheck(reason, 0, null);
|
||||
}
|
||||
|
||||
private static boolean isVegetation(BlockState state) {
|
||||
return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS)
|
||||
|| state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS)
|
||||
|| !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty();
|
||||
}
|
||||
|
||||
static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed,
|
||||
List<ResourceLocation> ageSymbols) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null) return false;
|
||||
PlacementCheck check = check(level, chunk.getPos(), seed);
|
||||
if (!check.eligible()) return false;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2);
|
||||
}
|
||||
return build(level, site, check.floorY(), check.heights(), seed, ageSymbols);
|
||||
}
|
||||
|
||||
/** Explicit OP testing placement. Never overwrites existing blocks or entities. */
|
||||
public static Optional<BlockPos> testPlace(ServerLevel level, BlockPos playerPos, Direction facing,
|
||||
long seed, List<ResourceLocation> ageSymbols) {
|
||||
Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH;
|
||||
BlockPos center = playerPos.relative(horizontal, 13);
|
||||
int rotation = switch (horizontal) {
|
||||
case NORTH -> 2;
|
||||
case EAST -> 3;
|
||||
case WEST -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation);
|
||||
int floorY = playerPos.getY() + 2;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE,
|
||||
site.x() + x, site.z() + z) + 1);
|
||||
if (floorY + 10 >= level.getMaxBuildHeight() || floorY - playerPos.getY() > 20)
|
||||
return Optional.empty();
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++)
|
||||
for (int y = floorY; y <= floorY + 10; y++) {
|
||||
BlockPos pos = cursor.set(site.x() + x, y, site.z() + z);
|
||||
if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir())
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(),
|
||||
site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty())
|
||||
return Optional.empty();
|
||||
if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty();
|
||||
return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable());
|
||||
}
|
||||
|
||||
private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights,
|
||||
long seed, List<ResourceLocation> ageSymbols) {
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
var template = level.getLevel().getStructureManager().get(TEMPLATE).orElse(null);
|
||||
if (template == null || template.getSize().getX() != FOOTPRINT
|
||||
|| template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT)
|
||||
return false;
|
||||
for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) {
|
||||
for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++)
|
||||
place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState());
|
||||
}
|
||||
Rotation rotation = switch (site.rotation()) {
|
||||
case 1 -> Rotation.CLOCKWISE_90;
|
||||
case 2 -> Rotation.CLOCKWISE_180;
|
||||
case 3 -> Rotation.COUNTERCLOCKWISE_90;
|
||||
default -> Rotation.NONE;
|
||||
};
|
||||
BlockPos origin = new BlockPos(site.x(), floorY, site.z());
|
||||
var settings = new StructurePlaceSettings().setRotation(rotation)
|
||||
.setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true);
|
||||
long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).toLong();
|
||||
if (!template.placeInWorld(level, origin, origin, settings,
|
||||
RandomSource.create(seed ^ positionSalt), 2)) return false;
|
||||
|
||||
BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable();
|
||||
level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING,
|
||||
Direction.from2DDataValue(site.rotation())), 2);
|
||||
RivencraftConfig.LibraryLootMode mode = RivencraftConfig.libraryLootMode();
|
||||
boolean ageOnly = mode == RivencraftConfig.LibraryLootMode.AGE_ONLY;
|
||||
List<ResourceLocation> symbols = symbolsForLoot(ageSymbols, mode);
|
||||
RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT);
|
||||
if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) {
|
||||
chest.getPersistentData().putBoolean(CHEST_MARKER, true);
|
||||
chest.setChanged();
|
||||
if (ageOnly) {
|
||||
// Pages are frozen in the Age generator, then saved as real chest items.
|
||||
boolean[] occupied = new boolean[chest.getContainerSize()];
|
||||
for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) {
|
||||
int slot;
|
||||
do slot = random.nextInt(occupied.length); while (occupied[slot]);
|
||||
occupied[slot] = true;
|
||||
chest.setItem(slot, page(symbols, random));
|
||||
}
|
||||
} else {
|
||||
chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong());
|
||||
}
|
||||
}
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable();
|
||||
if (!level.getBlockState(pos).isAir()) continue;
|
||||
level.setBlock(pos, ModBlocks.BOOK_STAND.get().defaultBlockState()
|
||||
.setValue(BookStandBlock.FACING, standFacing(site, standCoords[3]))
|
||||
.setValue(BookStandBlock.HAS_BOOK, true), 2);
|
||||
if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) {
|
||||
stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Direction standFacing(Site site, int clockwiseTurns) {
|
||||
return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3);
|
||||
}
|
||||
|
||||
/** Correct older generated stands in place, retaining their block entities and pages. */
|
||||
public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) {
|
||||
Site site = site(seed, chunk.getPos());
|
||||
if (site == null || libraryChest(chunk, seed).isEmpty()) return -1;
|
||||
int changed = 0;
|
||||
BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
|
||||
int chestY = libraryChest(chunk, seed).orElseThrow().getY();
|
||||
for (int[] standCoords : STANDS) {
|
||||
BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]);
|
||||
BlockState state = level.getBlockState(pos);
|
||||
if (!state.is(ModBlocks.BOOK_STAND.get())) continue;
|
||||
Direction facing = standFacing(site, standCoords[3]);
|
||||
if (state.getValue(BookStandBlock.FACING) != facing) {
|
||||
level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static List<ResourceLocation> symbolsForLoot(List<ResourceLocation> ageSymbols,
|
||||
RivencraftConfig.LibraryLootMode mode) {
|
||||
return mode == RivencraftConfig.LibraryLootMode.AGE_ONLY ? ageSymbols.stream()
|
||||
.filter(id -> SymbolCatalog.isKnown(new SymbolReference(id))).distinct().toList()
|
||||
: SymbolCatalog.symbols().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
|
||||
&& entry.getValue().semantics().isPresent()
|
||||
&& !entry.getKey().getPath().startsWith("block/")
|
||||
&& !entry.getKey().getPath().startsWith("biome/"))
|
||||
.map(java.util.Map.Entry::getKey).sorted().toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.ItemStack page(List<ResourceLocation> symbols, RandomSource random) {
|
||||
SymbolReference reference = new SymbolReference(symbols.get(random.nextInt(symbols.size())));
|
||||
var page = PageItem.createSymbolPage(reference);
|
||||
page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
|
||||
return page;
|
||||
}
|
||||
|
||||
private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) {
|
||||
return switch (site.rotation()) {
|
||||
case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x);
|
||||
case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z);
|
||||
case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x);
|
||||
default -> cursor.set(site.x() + x, y, site.z() + z);
|
||||
};
|
||||
}
|
||||
|
||||
private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor,
|
||||
int x, int y, int z, BlockState state) {
|
||||
level.setBlock(mapped(site, cursor, x, y, z), state, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package net.dnorberg.rivencraft.world;
|
||||
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||
import net.dnorberg.rivencraft.block.BookStandBlock;
|
||||
import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder("rivencraft_archive_generation")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class MystcraftLibraryGameTests {
|
||||
private MystcraftLibraryGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void packagedLegacyLibraryTemplateHasCorrectLayout(GameTestHelper helper) {
|
||||
var id = ResourceLocation.fromNamespaceAndPath("rivencraft", "mystcraft_library");
|
||||
var template = helper.getLevel().getStructureManager().get(id).orElseThrow();
|
||||
helper.assertValueEqual(template.getSize().getX(), 11, "Legacy library must be 11 blocks wide");
|
||||
helper.assertValueEqual(template.getSize().getY(), 11, "Legacy library must reach its top slabs");
|
||||
helper.assertValueEqual(template.getSize().getZ(), 11, "Legacy library must be 11 blocks deep");
|
||||
var settings = new StructurePlaceSettings();
|
||||
var shelves = template.filterBlocks(BlockPos.ZERO, settings, Blocks.BOOKSHELF);
|
||||
helper.assertTrue(shelves.stream().anyMatch(block -> block.pos().equals(new BlockPos(4, 1, 3))),
|
||||
"Legacy shelves must be present in the template");
|
||||
helper.assertTrue(shelves.stream().noneMatch(block -> block.pos().equals(new BlockPos(6, 1, 2))),
|
||||
"The old lower lectern block must be omitted for the Rivencraft stand");
|
||||
var roof = template.filterBlocks(BlockPos.ZERO, settings, Blocks.COBBLESTONE_SLAB);
|
||||
helper.assertTrue(roof.stream().anyMatch(block -> block.pos().equals(new BlockPos(5, 10, 5))),
|
||||
"Legacy roof slab must be preserved");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void librarySitesAreSparseStableAndSingleChunk(GameTestHelper helper) {
|
||||
for (long seed : new long[]{0L, 87L, -99234L}) {
|
||||
for (int rx : new int[]{-2, -1, 0, 3}) {
|
||||
for (int rz : new int[]{-2, 0, 4}) {
|
||||
int found = 0;
|
||||
for (int x = rx * MystcraftLibraryFeature.REGION;
|
||||
x < (rx + 1) * MystcraftLibraryFeature.REGION; x++) {
|
||||
for (int z = rz * MystcraftLibraryFeature.REGION;
|
||||
z < (rz + 1) * MystcraftLibraryFeature.REGION; z++) {
|
||||
ChunkPos chunk = new ChunkPos(x, z);
|
||||
MystcraftLibraryFeature.Site site = MystcraftLibraryFeature.site(seed, chunk);
|
||||
helper.assertTrue(java.util.Objects.equals(
|
||||
MystcraftLibraryFeature.site(seed, chunk), site),
|
||||
"Library site must be deterministic across reloads");
|
||||
if (site == null) continue;
|
||||
found++;
|
||||
helper.assertTrue(site.x() >= chunk.getMinBlockX()
|
||||
&& site.x() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockX()
|
||||
&& site.z() >= chunk.getMinBlockZ()
|
||||
&& site.z() + MystcraftLibraryFeature.FOOTPRINT - 1 <= chunk.getMaxBlockZ(),
|
||||
"The complete rotated library must remain inside its candidate chunk");
|
||||
}
|
||||
}
|
||||
helper.assertValueEqual(found, 1,
|
||||
"Each 32x32 region must have exactly one candidate, including negative regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void libraryStandFacingsRotateWithTheStructure(GameTestHelper helper) {
|
||||
for (int rotation = 0; rotation < 4; rotation++) {
|
||||
var site = new MystcraftLibraryFeature.Site(0, 0, rotation);
|
||||
Direction left = Direction.from2DDataValue(rotation);
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 0), left,
|
||||
"Left stand must retain the structure's base facing");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 1), left.getClockWise(),
|
||||
"Middle stands must turn clockwise with the structure");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.standFacing(site, 2), left.getOpposite(),
|
||||
"Far stand must face back toward the left stand");
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ageOnlyLibraryPoolNeverAddsOtherPages(GameTestHelper helper) {
|
||||
ResourceLocation terrain = ResourceLocation.parse("rivencraft:terrain/standard");
|
||||
ResourceLocation feature = ResourceLocation.parse("rivencraft:feature/rocks");
|
||||
ResourceLocation unknown = ResourceLocation.parse("rivencraft:not_a_real_page");
|
||||
helper.assertTrue(!SymbolCatalog.symbols().isEmpty(), "Writing catalog must be loaded");
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.symbolsForLoot(
|
||||
List.of(terrain, feature, terrain, unknown), RivencraftConfig.LibraryLootMode.AGE_ONLY),
|
||||
List.of(terrain, feature), "Age-only loot must use only resolved current-Age symbols");
|
||||
helper.assertTrue(MystcraftLibraryFeature.symbolsForLoot(
|
||||
List.of(terrain), RivencraftConfig.LibraryLootMode.LEGACY).size() > 1,
|
||||
"Legacy loot should be drawn from the wider writing catalog");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void testSpacingIsSessionOnlyAndKeepsLegacySites(GameTestHelper helper) {
|
||||
long seed = 881L;
|
||||
ChunkPos normal = MystcraftLibraryFeature.candidateChunk(seed, 0, 0);
|
||||
ChunkPos dense = MystcraftLibraryFeature.candidateChunk(seed, 1, -1, 8);
|
||||
try {
|
||||
MystcraftLibraryFeature.setTestSpacing(seed, 8);
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), 8,
|
||||
"Test override must use the requested spacing");
|
||||
helper.assertTrue(MystcraftLibraryFeature.site(seed, normal) != null,
|
||||
"Legacy sites remain eligible while testing");
|
||||
helper.assertTrue(MystcraftLibraryFeature.site(seed, dense) != null,
|
||||
"Dense candidates must become eligible while testing");
|
||||
} finally {
|
||||
MystcraftLibraryFeature.setTestSpacing(seed, MystcraftLibraryFeature.REGION);
|
||||
}
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.spacing(seed), MystcraftLibraryFeature.REGION,
|
||||
"Reset restores Legacy spacing");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void libraryStandStoresAndReturnsSymbolPage(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
var state = ModBlocks.BOOK_STAND.get().defaultBlockState().setValue(BookStandBlock.HAS_BOOK, true);
|
||||
helper.getLevel().setBlock(pos, state, 3);
|
||||
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof BookStandBlockEntity,
|
||||
"Library stand must create a persistent book entity");
|
||||
BookStandBlockEntity stand = (BookStandBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
var page = PageItem.createSymbolPage(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")));
|
||||
stand.setBook(page);
|
||||
helper.assertTrue(stand.getBook().is(ModItems.PAGE.get()), "Stand must hold a symbol page");
|
||||
helper.assertValueEqual(stand.takeBook(), page, "Page must be recoverable from its stand");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void locatorRecognizesOnlyMarkedLibraryChests(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
|
||||
var chunk = (net.minecraft.world.level.chunk.LevelChunk) helper.getLevel().getChunk(pos);
|
||||
helper.assertTrue(MystcraftLibraryFeature.libraryChest(chunk).isEmpty(),
|
||||
"An ordinary player chest must not be mistaken for a library");
|
||||
helper.assertTrue(helper.getLevel().getBlockEntity(pos) instanceof ChestBlockEntity,
|
||||
"The test chest must have a block entity");
|
||||
ChestBlockEntity chest = (ChestBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
chest.getPersistentData().putBoolean("RivencraftLibrary", true);
|
||||
chest.setChanged();
|
||||
helper.assertValueEqual(MystcraftLibraryFeature.libraryChest(chunk).orElseThrow(), pos,
|
||||
"A marked library chest must be discoverable without loading another chunk");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": { "type": "minecraft:uniform", "min": 4, "max": 8 },
|
||||
"entries": [
|
||||
{ "type": "minecraft:item", "name": "minecraft:paper", "weight": 10,
|
||||
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 2, "max": 8 } }] },
|
||||
{ "type": "minecraft:item", "name": "minecraft:leather", "weight": 5,
|
||||
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 1, "max": 3 } }] },
|
||||
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 4 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 3 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": { "type": "minecraft:uniform", "min": 1, "max": 2 },
|
||||
"entries": [
|
||||
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 3 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 2 },
|
||||
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"random_sequence": "rivencraft:chests/mystcraft_library"
|
||||
}
|
||||
Binary file not shown.
@@ -15,7 +15,7 @@ loaderVersion="${loader_version_range}" #mandatory
|
||||
license="${mod_license}"
|
||||
|
||||
# A URL to refer people to when problems occur with this mod
|
||||
issueTrackerURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft/issues"
|
||||
issueTrackerURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues"
|
||||
|
||||
# A list of mods - how many allowed here is determined by the individual mod loader
|
||||
[[mods]] #mandatory
|
||||
@@ -33,7 +33,7 @@ displayName="${mod_name}" #mandatory
|
||||
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||
|
||||
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||
displayURL="https://gitea.tail180fb1.ts.net/dnorberg/rivencraft"
|
||||
displayURL="https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft"
|
||||
|
||||
# A file name (in the root of the mod JAR) containing a logo for display
|
||||
#logoFile="rivencraft.png" #optional
|
||||
|
||||
Reference in new issue
Block a user