diff --git a/NOTICE b/NOTICE index 3dc3d00..f6ad946 100644 --- a/NOTICE +++ b/NOTICE @@ -19,6 +19,10 @@ under the same license; desk wood and inlay textures are cropped from desk.png. The Ink Mixer GUI atlas and block textures are copied from Legacy's assets/mystcraft/gui/inkmixer.png and textures/blocks/inkmixer_*.png under the same license. Rivencraft's block model is original and uses those textures. +The bundled mystcraft_library.nbt structure template is a block-layout adaptation +of Mystcraft Legacy's ComponentScatteredFeatureSmallLibrary.java at commit +9bc8ddc061845df0cd5ea47f4fdef773cff786ae, under LGPL-3.0. The conversion +script at scripts/generate_legacy_library_template.py is included to rebuild it. The initial build layout is derived from the official NeoForge 1.21.1 ModDevGradle MDK: https://github.com/NeoForgeMDKs/MDK-1.21.1-ModDevGradle diff --git a/dev images/Pasted image (2).png b/dev images/Pasted image (2).png new file mode 100644 index 0000000..1c82a75 Binary files /dev/null and b/dev images/Pasted image (2).png differ diff --git a/dev images/Pasted image (3).png b/dev images/Pasted image (3).png deleted file mode 100644 index f0a595a..0000000 Binary files a/dev images/Pasted image (3).png and /dev/null differ diff --git a/docs/LEGACY_M7_AUDIT.md b/docs/LEGACY_M7_AUDIT.md index 16d70e8..0a36c7b 100644 --- a/docs/LEGACY_M7_AUDIT.md +++ b/docs/LEGACY_M7_AUDIT.md @@ -15,6 +15,16 @@ Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mix ## Crystal portals: targets for #77–#78 +## Library implementation (#76) + +The operator-only test controls are `/rivencraft library test-spacing 8` (roughly 16× normal candidate density in newly generated chunks), `/rivencraft library test-spacing` (show the current setting), and `/rivencraft library test-spacing reset` (return to Legacy spacing). The override affects only the current Age, lasts only until server restart, and never retroactively places libraries in existing chunks. Normal sites remain eligible. A spacing of `1` attempts a candidate in every new chunk and may be expensive. `/rivencraft library nearest` searches nearby test sites while the override is active. `/rivencraft library test-place` is a separate OP-only manual test: it places a library about 13 blocks ahead of the player in the current Age if an 11×11×11 volume is clear, building a one-block cobblestone platform below it. The command reports the chest coordinates; it does not alter natural spawning or make the manual library a locate candidate. Move to open ground if the command rejects the site. + +Libraries now use Legacy's 32×32-chunk region and 0–23 offset with salt `14357617`, one candidate per region. A candidate builds only on a dry, sturdy, nearly level 11×11 surface with clear headroom; its entire rotated footprint stays in that one chunk. Cave terrain is skipped, Void naturally fails the ground check, and Sky Islands can host a library only on a suitable island top. Rejected candidate sites are not retried elsewhere in the region. This is intentionally safer and sparser than cutting through a modern village, tree, ocean, or cliff. The building now places `data/rivencraft/structure/mystcraft_library.nbt`, generated from all 694 ordered Legacy block placements. The old two-block lectern locations are emptied in the template and replaced at runtime by one-block Rivencraft Book Stands with page loot. The chest is also populated at runtime. Regenerate the template with `python scripts/generate_legacy_library_template.py`; the script pins and checksums the Legacy source. + +The cobblestone supports, stone stairs/slabs, planks, shelves, webs, one chest, and five book stands now follow Legacy's block layout via the packaged template. Players can also place a page on an empty vanilla Lectern, converting it to a Rivencraft Book Stand; sneak-use retrieves the page. The stand renders the page item and its symbol name rather than a book model. `libraries.loot_mode` is a server config with `LEGACY` (default) and `AGE_ONLY` choices. In `LEGACY`, the chest uses `data/rivencraft/loot_table/chests/mystcraft_library.json`, with 4–8 main and 1–2 bonus rolls; Rivencraft Ink Vials replace Legacy vials, and ordinary books/experience bottles replace its unavailable booster item. Stands use random catalog symbol pages, excluding generated block/biome entries. In `AGE_ONLY`, the chest and stands contain only pages from the Age's frozen authored active symbols, or its selected random terrain/features/biome pool; there are no supplies. If an Age has no eligible symbols, the stand falls back to an ordinary book. The Legacy rank ≥3 gate cannot be carried over because Rivencraft symbols have no rank field. A stand's page and Age-only chest pages are saved in block entities; Legacy chests keep their assigned loot table/seed. Neither rerolls on player interaction or restart. Config changes affect only future libraries; existing generated chunks are never retroactively populated. + +For a manual check, create a fresh surface Age and run `/rivencraft library nearest` as an operator. It examines up to 128 deterministic candidates in a six-region radius, one candidate chunk per server tick; this can generate those chunks. It teleports only to a marked intact library with clear floor/headroom, or reports that none was found. Confirm one chest and up to five page-bearing stands, take a page and chest contents, leave/restart, and confirm they do not reappear. Right-click an empty vanilla Lectern with a Symbol Page; it should become a stand displaying that page and symbol name. Sneak-right-click with an empty hand to retrieve it and restore the vanilla Lectern. Switch `libraries.loot_mode` to `AGE_ONLY`, create another fresh Age, and confirm its new library contains only pages that describe that Age (no paper, books, ink or bottles); switching modes must not refill an existing library. Repeat with a second player and with Flat, Cave, Void, and Sky Island Ages: only suitable exposed surfaces should host a library. The archived generation GameTest checks deterministic spacing, negative-coordinate regions, the single-chunk footprint, Age-only page filtering, stand storage, and locator marker detection. + - A [Book Receptacle](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockBookReceptacle.java#L58-L173) attaches to a Crystal Block on any face except downward and holds a Linking or Descriptive Book. The Legacy recipe is eight Crystal Blocks around an empty center. Removing the book or its crystal support deactivates the portal; breaking the receptacle drops the book. [`TileEntityBookReceptacle`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/tileentity/TileEntityBookReceptacle.java#L23-L40) activates/shuts down on server-side book changes. - [`PortalUtils`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/portal/PortalUtils.java#L23-L104) grows portal cells through a connected crystal network. Its tension test requires opposing crystal/portal neighbors on at least two axes; this is more flexible than a single fixed rectangular frame. Active cells trace a path to one receptacle. Modernize the unbounded flood/path walks with loaded-chunk and volume bounds, server validation, and persistent block-entity state. - [`BlockLinkPortal`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/block/BlockLinkPortal.java#L208-L256) is translucent, non-solid, lit, validated on neighbor changes/random ticks, tinted by the book destination, and routes collisions to the receptacle's book. [`ItemLinking.onPortalCollision`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/item/ItemLinking.java#L106-L119) marks travel external, maintains momentum, disables platform generation, and uses the portal sound. The book remains in the receptacle. #77 owns core behavior; #78 owns restart recovery, protection, multiplayer, cooldowns, chunk tickets, and deleted destinations. A buildable portal is distinct from the existing Star Fissure. diff --git a/gradle.properties b/gradle.properties index 5dce396..6694a73 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.6.0.0 +mod_version=0.6.1.11 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/scripts/generate_legacy_library_template.py b/scripts/generate_legacy_library_template.py new file mode 100644 index 0000000..4b42b9a --- /dev/null +++ b/scripts/generate_legacy_library_template.py @@ -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() diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 5b93e3e..09b8fad 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -15,6 +15,7 @@ import net.dnorberg.rivencraft.event.BookStandConversionHandler; import net.dnorberg.rivencraft.event.AgeCelestialAngleSync; import net.dnorberg.rivencraft.event.AgeLifecycleEvents; import net.dnorberg.rivencraft.command.RivencraftCommands; +import net.dnorberg.rivencraft.command.LibraryLocateService; import net.dnorberg.rivencraft.network.ModNetworking; import net.dnorberg.rivencraft.config.RivencraftConfig; import net.dnorberg.rivencraft.config.RivencraftClientConfig; @@ -80,6 +81,8 @@ public final class Rivencraft { NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping); NeoForge.EVENT_BUS.addListener(AgeStatusEffectProviders::onServerStopping); NeoForge.EVENT_BUS.addListener(RivencraftCommands::register); + NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerTickPost); + NeoForge.EVENT_BUS.addListener(LibraryLocateService::onServerStopping); NeoForge.EVENT_BUS.addListener(AgeRuntimeProfiles::register); NeoForge.EVENT_BUS.addListener(SymbolCatalog::register); LOGGER.info("Initializing Rivencraft"); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index b5f8794..a700460 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -17,6 +17,10 @@ import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility; import net.dnorberg.rivencraft.compat.TerraBlenderBiomes; +import net.dnorberg.rivencraft.writing.BiomeSymbolPages; +import net.dnorberg.rivencraft.writing.BlockSymbolPages; +import net.dnorberg.rivencraft.writing.SymbolCatalog; +import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.minecraft.core.BlockPos; import net.minecraft.core.GlobalPos; @@ -312,6 +316,7 @@ public final class AgeDimensionService { private static ChunkGenerator createGenerator(MinecraftServer server, AgeDefinition age) { AgeRuntimeProfile profile = runtimeProfile(age); BiomeSource biomeSource = createBiomeSource(server, age, profile); + List libraryAgeSymbols = libraryAgeSymbols(age, profile, biomeSource); List features = age.plan() == null ? age.randomFeatures() : AgeFeatureSelection.resolved(age.plan()); if (profile.terrain() == AgeRuntimeProfile.Terrain.FLAT) { @@ -321,7 +326,7 @@ public final class AgeDimensionService { server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) ); return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION - ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed()); + ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols); } if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) { FlatLevelGeneratorSettings settings = new FlatLevelGeneratorSettings( @@ -331,7 +336,7 @@ public final class AgeDimensionService { ); settings.updateLayers(); return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION - ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed()); + ? new FlatLevelSource(settings) : new AgeFlatChunkGenerator(biomeSource, settings, features, age.seed(), libraryAgeSymbols); } var noiseSettings = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); @@ -343,7 +348,40 @@ public final class AgeDimensionService { return age.generationVersion() < AgeDefinition.CURRENT_GENERATION_VERSION ? new NoiseBasedChunkGenerator(biomeSource, settings) : new AgeFeatureChunkGenerator(biomeSource, settings, features, age.seed(), - caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of()); + caveTerrain, caveTerrain ? TerraBlenderBiomes.nether(server.registryAccess()) : List.of(), + libraryAgeSymbols); + } + + /** Freeze the Age's authoring/result pages into the generator; worldgen never reads SavedData. */ + public static List libraryAgeSymbols(AgeDefinition age, AgeRuntimeProfile profile, + BiomeSource biomes) { + LinkedHashSet 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) { diff --git a/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java index dcfe104..bd99215 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java +++ b/src/main/java/net/dnorberg/rivencraft/client/BookStandRenderer.java @@ -15,8 +15,10 @@ import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; +import net.minecraft.core.Direction; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemDisplayContext; import net.minecraft.world.level.block.state.BlockState; import org.joml.Matrix4f; @@ -49,6 +51,26 @@ public final class BookStandRenderer implements BlockEntityRenderer QUEUE = new ArrayDeque<>(); + private static final Map ACTIVE = new HashMap<>(); + + private LibraryLocateService() {} + + private record Search(MinecraftServer server, UUID playerId, ServerLevel level, long seed, + int spacing, List 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 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 candidates(long seed, BlockPos origin) { + return candidates(seed, origin, MystcraftLibraryFeature.REGION); + } + + static List 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 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 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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java index 9bd7213..761dadb 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -29,6 +29,22 @@ public final class RivencraftCommands { public static void register(RegisterCommandsEvent event) { event.getDispatcher().register(Commands.literal("rivencraft") .requires(source -> source.hasPermission(2)) + .then(Commands.literal("library") + .then(Commands.literal("nearest") + .executes(context -> LibraryLocateService.start(context.getSource()))) + .then(Commands.literal("test-place") + .executes(context -> LibraryLocateService.testPlace(context.getSource()))) + .then(Commands.literal("probe") + .executes(context -> LibraryLocateService.probe(context.getSource()))) + .then(Commands.literal("repair-stands") + .executes(context -> LibraryLocateService.repairStands(context.getSource()))) + .then(Commands.literal("test-spacing") + .executes(context -> LibraryLocateService.testSpacing(context.getSource(), null)) + .then(Commands.literal("reset") + .executes(context -> LibraryLocateService.testSpacing(context.getSource(), 32))) + .then(Commands.argument("chunks", IntegerArgumentType.integer(1, 31)) + .executes(context -> LibraryLocateService.testSpacing(context.getSource(), + IntegerArgumentType.getInteger(context, "chunks")))))) .then(Commands.literal("age") .then(Commands.literal("list") .executes(context -> listAges(context.getSource()))) diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommandsGameTests.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommandsGameTests.java index 9ce2a36..c0bd824 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommandsGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommandsGameTests.java @@ -6,6 +6,8 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.book.AgeDefinition; import net.dnorberg.rivencraft.book.AgeGenerationProfiles; import net.dnorberg.rivencraft.book.AgeTraits; +import net.dnorberg.rivencraft.world.MystcraftLibraryFeature; +import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; import net.neoforged.neoforge.gametest.GameTestHolder; @@ -43,6 +45,38 @@ public final class RivencraftCommandsGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void nearestLibraryCandidatesAreBoundedAndOrdered(GameTestHelper helper) { + BlockPos origin = new BlockPos(-400, 74, 900); + List candidates = LibraryLocateService.candidates(7301L, origin); + helper.assertValueEqual(candidates.size(), 128, "Library lookup must have a finite chunk budget"); + long previous = -1; + java.util.Set unique = new java.util.HashSet<>(); + for (var candidate : candidates) { + helper.assertTrue(unique.add(candidate), "A candidate chunk must not be searched twice"); + long dx = (long) candidate.getMiddleBlockX() - origin.getX(); + long dz = (long) candidate.getMiddleBlockZ() - origin.getZ(); + long distance = dx * dx + dz * dz; + helper.assertTrue(distance >= previous, "Nearest candidates must be tested first"); + previous = distance; + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void testSpacingAddsNearbyCandidatesWithoutRemovingNormalSites(GameTestHelper helper) { + BlockPos origin = new BlockPos(0, 80, 0); + long seed = 7302L; + var normal = LibraryLocateService.candidates(seed, origin); + var dense = LibraryLocateService.candidates(seed, origin, 8); + helper.assertValueEqual(dense.size(), 128, "Dense lookup must still use the bounded search budget"); + helper.assertTrue(dense.stream().anyMatch(chunk -> !normal.contains(chunk)), + "Test spacing must add additional nearby candidates"); + helper.assertTrue(dense.contains(MystcraftLibraryFeature.candidateChunk(seed, 0, 0, 8)), + "Nearest test site should be searched"); + helper.succeed(); + } + private static AgeDefinition age(String name, String path) { UUID id = UUID.randomUUID(); return new AgeDefinition(id, name, 1L, AgeGenerationProfiles.STANDARD, diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java index c3478e7..895baf4 100644 --- a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -8,6 +8,7 @@ import net.neoforged.neoforge.common.ModConfigSpec; /** Server-configurable gameplay policies. */ public final class RivencraftConfig { + public enum LibraryLootMode { LEGACY, AGE_ONLY } public enum ReturnLinkMode { /** Create one Return Link when an Age is first reached. */ ON_CREATION, @@ -19,6 +20,7 @@ public final class RivencraftConfig { public static final ModConfigSpec SPEC; private static final ModConfigSpec.EnumValue RETURN_LINK_MODE; + private static final ModConfigSpec.EnumValue LIBRARY_LOOT_MODE; private static final ModConfigSpec.IntValue SYMBOL_INK_COST; private static final ModConfigSpec.IntValue LINK_PANEL_INK_COST; private static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES; @@ -75,6 +77,12 @@ public final class RivencraftConfig { ) .defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION); builder.pop(); + builder.push("libraries"); + LIBRARY_LOOT_MODE = builder.comment( + "LEGACY gives libraries varied writing supplies and random symbol pages; AGE_ONLY gives only symbol pages used by this Age.", + "Applies to newly generated libraries only; existing chests and stands keep their contents.") + .defineEnum("loot_mode", LibraryLootMode.LEGACY); + builder.pop(); builder.push("writing"); SYMBOL_INK_COST = builder.comment("Ink units consumed when the Writing Desk copies one symbol page.") .defineInRange("symbol_ink_cost", 1, 1, 16); @@ -229,6 +237,8 @@ public final class RivencraftConfig { return AUTO_WHITELIST_SERENE_AGES.get(); } + public static LibraryLootMode libraryLootMode() { return LIBRARY_LOOT_MODE.get(); } + public static boolean instabilityEnabled() { return INSTABILITY_ENABLED.get(); } public static int instabilityDifficulty() { return INSTABILITY_DIFFICULTY.get(); } public static boolean instabilityProviderEnabled(ResourceLocation id) { diff --git a/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java b/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java index a54f548..88b37da 100644 --- a/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java +++ b/src/main/java/net/dnorberg/rivencraft/event/BookStandConversionHandler.java @@ -19,7 +19,8 @@ public final class BookStandConversionHandler { public static void onRightClickBlock(PlayerInteractEvent.RightClickBlock event) { ItemStack heldBook = event.getItemStack(); - if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get())) { + if (!heldBook.is(ModItems.LINKING_BOOK.get()) && !heldBook.is(ModItems.DESCRIPTIVE_BOOK.get()) + && !heldBook.is(ModItems.PAGE.get())) { return; } diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index 11fb865..509cc31 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -60,7 +60,9 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed), Codec.BOOL.optionalFieldOf("nether_cave_materials", false).forGetter(generator -> generator.netherCaveMaterials), ResourceLocation.CODEC.listOf().optionalFieldOf("nether_biomes", List.of()) - .forGetter(generator -> generator.netherBiomeIds)) + .forGetter(generator -> generator.netherBiomeIds), + ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of()) + .forGetter(generator -> generator.libraryAgeSymbols)) .apply(instance, AgeFeatureChunkGenerator::new)); private final Holder settings; @@ -68,16 +70,25 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { private final long ageSeed; private final boolean netherCaveMaterials; private final List netherBiomeIds; + private final List libraryAgeSymbols; public AgeFeatureChunkGenerator(BiomeSource biomes, Holder settings, List features, long ageSeed, boolean netherCaveMaterials, List netherBiomeIds) { + this(biomes, settings, features, ageSeed, netherCaveMaterials, netherBiomeIds, List.of()); + } + + public AgeFeatureChunkGenerator(BiomeSource biomes, Holder settings, + List features, long ageSeed, + boolean netherCaveMaterials, List netherBiomeIds, + List libraryAgeSymbols) { super(biomes, settings); this.settings = settings; this.features = List.copyOf(features); this.ageSeed = ageSeed; this.netherCaveMaterials = netherCaveMaterials; this.netherBiomeIds = List.copyOf(netherBiomeIds); + this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols); } public List ageFeatures() { return features; } @@ -253,5 +264,8 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { ^ (long) occurrence * 0xD1B54A32D192ED03L; authored.place(level, this, RandomSource.create(seed), origin); } + // Place after vanilla and authored decoration so later features cannot grow through + // the building; the library check preserves existing solid structures. + if (!netherCaveMaterials) MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols); } } diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java index 528f9bb..76d6a71 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java @@ -42,12 +42,15 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(AgeFlatChunkGenerator::settings), AgeFeatureSelection.Choice.CODEC.listOf().optionalFieldOf("age_features", List.of()) .forGetter(generator -> generator.features), - Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed)) + Codec.LONG.optionalFieldOf("age_seed", 0L).forGetter(generator -> generator.ageSeed), + ResourceLocation.CODEC.listOf().optionalFieldOf("library_age_symbols", List.of()) + .forGetter(generator -> generator.libraryAgeSymbols)) .apply(instance, AgeFlatChunkGenerator::new)); private final FlatLevelGeneratorSettings settings; private final List features; private final long ageSeed; + private final List libraryAgeSymbols; public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) { this(biomeSource, settings, List.of(), 0L); @@ -55,10 +58,17 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings, List features, long ageSeed) { + this(biomeSource, settings, features, ageSeed, List.of()); + } + + public AgeFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings, + List features, long ageSeed, + List libraryAgeSymbols) { super(biomeSource, Util.memoize(settings::adjustGenerationSettings)); this.settings = settings; this.features = List.copyOf(features); this.ageSeed = ageSeed; + this.libraryAgeSymbols = List.copyOf(libraryAgeSymbols); } public FlatLevelGeneratorSettings settings() { return settings; } @@ -133,6 +143,7 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { if (AgeFeatureSelection.obelisks(choice.id())) ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++); } + MystcraftLibraryFeature.generate(level, chunk, ageSeed, libraryAgeSymbols); } @Override diff --git a/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryFeature.java b/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryFeature.java new file mode 100644 index 0000000..3363684 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryFeature.java @@ -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 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 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 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 libraryChest(LevelChunk chunk, long seed) { + Optional 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 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 testPlace(ServerLevel level, BlockPos playerPos, Direction facing, + long seed, List 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 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 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 symbolsForLoot(List 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 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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryGameTests.java b/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryGameTests.java new file mode 100644 index 0000000..dd25455 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/MystcraftLibraryGameTests.java @@ -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(); + } +} diff --git a/src/main/resources/data/rivencraft/loot_table/chests/mystcraft_library.json b/src/main/resources/data/rivencraft/loot_table/chests/mystcraft_library.json new file mode 100644 index 0000000..b72ea94 --- /dev/null +++ b/src/main/resources/data/rivencraft/loot_table/chests/mystcraft_library.json @@ -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" +} diff --git a/src/main/resources/data/rivencraft/structure/mystcraft_library.nbt b/src/main/resources/data/rivencraft/structure/mystcraft_library.nbt new file mode 100644 index 0000000..175e7c3 Binary files /dev/null and b/src/main/resources/data/rivencraft/structure/mystcraft_library.nbt differ