Compare commits

...
4 Commits
Author SHA1 Message Date
dnorberg 644d242bba Generate Legacy-style libraries in Ages (#76)
Build / build (push) Canceled after 0s
2026-10-17 11:34:57 -04:00
dnorberg 3423dd25bf Audit Legacy Milestone 7 parity for issue 75
Build / build (push) Canceled after 0s
2026-10-17 02:30:35 -04:00
dnorberg c560e328de Update Gitea URLs after server move
Build / build (push) Canceled after 0s
2026-10-16 16:21:32 -04:00
dnorberg 34ffd317aa Record verified Milestone 6 closure (#74)
Build / build (push) Canceled after 0s
2026-10-12 15:10:17 -04:00
24 changed files with 1191 additions and 23 deletions

No files matched your search

+4
View File
@@ -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

+57
View File
@@ -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 -1
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+106
View File
@@ -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"
}
@@ -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