Public Access
This commit is contained in:
1 parent
3423dd25bf
commit
644d242bba
20 files changed
+1126
-7
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
|
The Ink Mixer GUI atlas and block textures are copied from Legacy's
|
||||||
assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under
|
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 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
|
The initial build layout is derived from the official NeoForge 1.21.1
|
||||||
ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle
|
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 |
@@ -15,6 +15,16 @@ Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mix
|
|||||||
|
|
||||||
## Crystal portals: targets for #77–#78
|
## 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.
|
- 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.
|
- [`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.
|
- [`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.
|
||||||
|
|||||||
+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.
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
mod_license=LGPL-3.0-or-later
|
mod_license=LGPL-3.0-or-later
|
||||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||||
mod_version=0.6.0.0
|
mod_version=0.6.1.11
|
||||||
immersive_portals_version=6.0.7
|
immersive_portals_version=6.0.7
|
||||||
terrablender_version=4.1.0.8
|
terrablender_version=4.1.0.8
|
||||||
serene_seasons_version_id=B6nTjh4b
|
serene_seasons_version_id=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.AgeCelestialAngleSync;
|
||||||
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
|
import net.dnorberg.rivencraft.event.AgeLifecycleEvents;
|
||||||
import net.dnorberg.rivencraft.command.RivencraftCommands;
|
import net.dnorberg.rivencraft.command.RivencraftCommands;
|
||||||
|
import net.dnorberg.rivencraft.command.LibraryLocateService;
|
||||||
import net.dnorberg.rivencraft.network.ModNetworking;
|
import net.dnorberg.rivencraft.network.ModNetworking;
|
||||||
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
import net.dnorberg.rivencraft.config.RivencraftConfig;
|
||||||
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
|
import net.dnorberg.rivencraft.config.RivencraftClientConfig;
|
||||||
@@ -80,6 +81,8 @@ public final class Rivencraft {
|
|||||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(RivencraftCommands::register);
|
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(AgeRuntimeProfiles::register);
|
||||||
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
|
NeoForge.EVENT_BUS.addListener(SymbolCatalog::register);
|
||||||
LOGGER.info("Initializing Rivencraft");
|
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.ImmersivePortalsChunkLoaders;
|
||||||
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
|
||||||
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
|
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.dnorberg.rivencraft.writing.OrderedWriting;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.GlobalPos;
|
import net.minecraft.core.GlobalPos;
|
||||||
@@ -312,6 +316,7 @@ public final class AgeDimensionService {
|
|||||||
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
|
private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) {
|
||||||
AgeRuntimeProfile profile = runtimeProfile(age);
|
AgeRuntimeProfile profile = runtimeProfile(age);
|
||||||
BiomeSource biomeSource = createBiomeSource(server, age, profile);
|
BiomeSource biomeSource = createBiomeSource(server, age, profile);
|
||||||
|
List<ResourceLocation> libraryAgeSymbols = libraryAgeSymbols(age, profile, biomeSource);
|
||||||
List<AgeFeatureSelection.Choice> features = age.plan() == null
|
List<AgeFeatureSelection.Choice> features = age.plan() == null
|
||||||
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
|
? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan());
|
||||||
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
|
if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) {
|
||||||
@@ -321,7 +326,7 @@ public final class AgeDimensionService {
|
|||||||
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE)
|
||||||
);
|
);
|
||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
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) {
|
if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) {
|
||||||
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
|
FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings(
|
||||||
@@ -331,7 +336,7 @@ public final class AgeDimensionService {
|
|||||||
);
|
);
|
||||||
settings.updateLayers();
|
settings.updateLayers();
|
||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
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);
|
var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS);
|
||||||
@@ -343,7 +348,40 @@ public final class AgeDimensionService {
|
|||||||
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION
|
||||||
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
? new NoiseBasedChunkGenerator(biomeSource, settings)
|
||||||
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
: new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(),
|
||||||
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of());
|
caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(),
|
||||||
|
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) {
|
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.BlockEntityRenderer;
|
||||||
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
|
||||||
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.item.ItemDisplayContext;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
import org.joml.Matrix4f;
|
import org.joml.Matrix4f;
|
||||||
|
|
||||||
@@ -49,6 +51,26 @@ public final class BookStandRenderer implements BlockEntityRenderer<BookStandBlo
|
|||||||
return;
|
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.pushPose();
|
||||||
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
poseStack.translate(0.5F, 1.0625F, 0.5F);
|
||||||
float rotation = state.getValue(BookStandBlock.FACING).getClockWise().toYRot();
|
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) {
|
public static void register(RegisterCommandsEvent event) {
|
||||||
event.getDispatcher().register(Commands.literal("rivencraft")
|
event.getDispatcher().register(Commands.literal("rivencraft")
|
||||||
.requires(source -> source.hasPermission(2))
|
.requires(source -> source.hasPermission(2))
|
||||||
|
.then(Commands.literal("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("age")
|
||||||
.then(Commands.literal("list")
|
.then(Commands.literal("list")
|
||||||
.executes(context -> listAges(context.getSource())))
|
.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.AgeDefinition;
|
||||||
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
|
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
|
||||||
import net.dnorberg.rivencraft.book.AgeTraits;
|
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.GameTest;
|
||||||
import net.minecraft.gametest.framework.GameTestHelper;
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
@@ -43,6 +45,38 @@ public final class RivencraftCommandsGameTests {
|
|||||||
helper.succeed();
|
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) {
|
private static AgeDefinition age(String name, String path) {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
|
return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import net.neoforged.neoforge.common.ModConfigSpec;
|
|||||||
|
|
||||||
/** Server-configurable gameplay policies. */
|
/** Server-configurable gameplay policies. */
|
||||||
public final class RivencraftConfig {
|
public final class RivencraftConfig {
|
||||||
|
public enum LibraryLootMode { LEGACY, AGE_ONLY }
|
||||||
public enum ReturnLinkMode {
|
public enum ReturnLinkMode {
|
||||||
/** Create one Return Link when an Age is first reached. */
|
/** Create one Return Link when an Age is first reached. */
|
||||||
ON_CREATION,
|
ON_CREATION,
|
||||||
@@ -19,6 +20,7 @@ public final class RivencraftConfig {
|
|||||||
|
|
||||||
public static final ModConfigSpec SPEC;
|
public static final ModConfigSpec SPEC;
|
||||||
private static final ModConfigSpec.EnumValue<ReturnLinkMode> RETURN_LINK_MODE;
|
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 SYMBOL_INK_COST;
|
||||||
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST;
|
||||||
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
|
||||||
@@ -75,6 +77,12 @@ public final class RivencraftConfig {
|
|||||||
)
|
)
|
||||||
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
|
||||||
builder.pop();
|
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");
|
builder.push("writing");
|
||||||
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
|
SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.")
|
||||||
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
.defineInRange("symbol_ink_cost", 1, 1, 16);
|
||||||
@@ -229,6 +237,8 @@ public final class RivencraftConfig {
|
|||||||
return AUTO_WHITELIST_SERENE_AGES.get();
|
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 boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); }
|
||||||
public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); }
|
public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); }
|
||||||
public static boolean instabilityProviderEnabled(ResourceLocation id) {
|
public static boolean instabilityProviderEnabled(ResourceLocation id) {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public final class BookStandConversionHandler {
|
|||||||
|
|
||||||
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) {
|
||||||
ItemStack heldBook = event.getItemStack();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
|||||||
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed),
|
||||||
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
|
Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials),
|
||||||
ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of())
|
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));
|
.apply(instance, AgeFeatureChunkGenerator::new));
|
||||||
|
|
||||||
private final Holder<NoiseGeneratorSettings> settings;
|
private final Holder<NoiseGeneratorSettings> settings;
|
||||||
@@ -68,16 +70,25 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
|||||||
private final long ageSeed;
|
private final long ageSeed;
|
||||||
private final boolean netherCaveMaterials;
|
private final boolean netherCaveMaterials;
|
||||||
private final List<ResourceLocation> netherBiomeIds;
|
private final List<ResourceLocation> netherBiomeIds;
|
||||||
|
private final List<ResourceLocation> libraryAgeSymbols;
|
||||||
|
|
||||||
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
public AgeFeatureChunkGenerator(BiomeSource biomes, Holder<NoiseGeneratorSettings> settings,
|
||||||
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
List<AgeFeatureSelection.Choice> features, long ageSeed,
|
||||||
boolean netherCaveMaterials, List<ResourceLocation> netherBiomeIds) {
|
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);
|
super(biomes, settings);
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.features = List.copyOf(features);
|
this.features = List.copyOf(features);
|
||||||
this.ageSeed = ageSeed;
|
this.ageSeed = ageSeed;
|
||||||
this.netherCaveMaterials = netherCaveMaterials;
|
this.netherCaveMaterials = netherCaveMaterials;
|
||||||
this.netherBiomeIds = List.copyOf(netherBiomeIds);
|
this.netherBiomeIds = List.copyOf(netherBiomeIds);
|
||||||
|
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
|
public List<AgeFeatureSelection.Choice> ageFeatures() { return features; }
|
||||||
@@ -253,5 +264,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
|
|||||||
^ (long) occurrence * 0xD1B54A32D192ED03L;
|
^ (long) occurrence * 0xD1B54A32D192ED03L;
|
||||||
authored.place(level, this, RandomSource.create(seed), origin);
|
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),
|
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings),
|
||||||
AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of())
|
AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of())
|
||||||
.forGetter(generator -> generator.features),
|
.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));
|
.apply(instance, AgeFlatChunkGenerator::new));
|
||||||
|
|
||||||
private final FlatLevelGeneratorSettings settings;
|
private final FlatLevelGeneratorSettings settings;
|
||||||
private final List<AgeFeatureSelection.Choice> features;
|
private final List<AgeFeatureSelection.Choice> features;
|
||||||
private final long ageSeed;
|
private final long ageSeed;
|
||||||
|
private final List<ResourceLocation> libraryAgeSymbols;
|
||||||
|
|
||||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
|
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
|
||||||
this(biomeSource, settings, List.of(), 0L);
|
this(biomeSource, settings, List.of(), 0L);
|
||||||
@@ -55,10 +58,17 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
|||||||
|
|
||||||
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings,
|
||||||
List<AgeFeatureSelection.Choice> features, long ageSeed) {
|
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));
|
super(biomeSource, Util.memoize(settings::adjustGenerationSettings));
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.features = List.copyOf(features);
|
this.features = List.copyOf(features);
|
||||||
this.ageSeed = ageSeed;
|
this.ageSeed = ageSeed;
|
||||||
|
this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FlatLevelGeneratorSettings settings() { return settings; }
|
public FlatLevelGeneratorSettings settings() { return settings; }
|
||||||
@@ -133,6 +143,7 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator {
|
|||||||
if (AgeFeatureSelection.obelisks(choice.id()))
|
if (AgeFeatureSelection.obelisks(choice.id()))
|
||||||
ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++);
|
ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++);
|
||||||
}
|
}
|
||||||
|
MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.
Reference in new issue
Block a user