From c223f3fdca0e0b5e78c8298ef8ff6eb5e6ef1dcb Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 2 Nov 2026 01:51:20 -0500 Subject: [PATCH] Port authored Age features and Star Fissures to 26.3 beta Advance to 1.0.1.54-beta.1 after player validation. Port material, vegetation, lake, terrain-volume, crystal, Dense Ores and fissure features with saved plans. Update beta documentation and release notes. Refs #98, #104 --- README.md | 11 +- docs/MINECRAFT_26_3_PORT.md | 162 +++++++++++ ports/mc263/README.md | 140 +++++++++ ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md | 51 ++++ ports/mc263/build.gradle | 3 + ports/mc263/gradle.properties | 2 +- .../net/dnorberg/rivencraft/Rivencraft.java | 15 + .../rivencraft/age/AgeLevelIndex.java | 51 +++- .../rivencraft/age/PortAgeCommands.java | 268 ++++++++++++++++- .../rivencraft/age/PortAgeTravel.java | 58 +++- .../age/PortCrystalFormationsFeature.java | 78 +++++ .../rivencraft/age/PortDenseOres.java | 90 ++++++ .../age/PortFeatureChunkGenerator.java | 269 ++++++++++++++++++ .../age/PortFloatingIslandFeature.java | 83 ++++++ .../dnorberg/rivencraft/age/PortLakePlan.java | 46 +++ .../rivencraft/age/PortObelisksFeature.java | 91 ++++++ .../age/PortRockChunkGenerator.java | 134 --------- .../dnorberg/rivencraft/age/PortRockPlan.java | 20 +- .../rivencraft/age/PortSkylandsFeature.java | 87 ++++++ .../rivencraft/age/PortSpheresFeature.java | 122 ++++++++ .../rivencraft/age/PortSpikesFeature.java | 56 ++++ .../age/PortStarFissureFeature.java | 107 +++++++ .../rivencraft/age/PortTendrilFeature.java | 142 +++++++++ .../age/PortTerrainFeaturePlan.java | 79 +++++ .../rivencraft/age/PortVegetationPlan.java | 38 +++ .../rivencraft/age/RuntimeAgeBackend.java | 59 +++- .../rivencraft/block/StarFissureBlock.java | 61 ++++ .../block/entity/StarFissureBlockEntity.java | 12 + .../client/PortBookBinderClientEvents.java | 6 + .../client/StarFissureRenderer.java | 34 +++ .../network/PortBookNetworking.java | 6 +- .../registry/PortChunkGenerators.java | 5 +- .../writing/PortAuthoredWriting.java | 177 +++++++++++- .../rivencraft/writing/PortRegistryPages.java | 22 +- .../resources/assets/rivencraft/sounds.json | 5 + .../worldgen/placed_feature/dungeons.json | 15 + scripts/smoke_server_263.sh | 147 +++++++++- 37 files changed, 2559 insertions(+), 193 deletions(-) create mode 100644 ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCrystalFormationsFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDenseOres.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFloatingIslandFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLakePlan.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortObelisksFeature.java delete mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockChunkGenerator.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkylandsFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpheresFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpikesFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStarFissureFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTendrilFeature.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortVegetationPlan.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/sounds.json create mode 100644 ports/mc263/src/main/resources/data/rivencraft/worldgen/placed_feature/dungeons.json diff --git a/README.md b/README.md index 661e9df..c8481fd 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,18 @@ of [Mystcraft Legacy](https://github.com/Mystcraft/Mystcraft-Legacy) to Minecraft 1.21.1 on NeoForge. Mystcraft veterans will recognize the writing flow, D'ni-inspired glyphs, libraries, crystals, and instability. Rivencraft also lets you author modern terrain, biomes, structures, seasons, and celestial -bodies. It is an active pre-public-release project, not a byte-for-byte copy of +bodies. It is an independently developed project, not a byte-for-byte copy of the 1.12.2 mod. +The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate, +incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99, +and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now +supports authored terrain/biomes and a growing set of generation features, +but does not yet provide full 1.21.1 gameplay or optional-integration parity. +See the [26.3 beta guide](ports/mc263/README.md) and +[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md). +Keep the builds and their saves separate, and back up worlds before updating. + ## Getting started 1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket. diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 59e2515..2c23610 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -522,6 +522,165 @@ terrain, biome, and structure generation. Cave and Void are intentionally excluded until their feature placement rules are ported. This is not yet the full 1.21.1 feature grammar or instability system. +The local `1.0.1.42-beta.1` follow-up resolves unmodified Rocks to a +seed-shuffled, full-block material and accepts up to 32 ordered Rocks pages. +Each page adds an independent, deterministic surface placement attempt. +Explicit Block modifiers still bind only to the immediately following Rocks +page. The material sequence persists in the Age index; the codec also reads +the single-material format from the previous beta. The remaining feature +families and instability scoring are not yet ported. + +The local `1.0.1.43-beta.1` slice uses 26.3's `minecraft:forest_rock` +placed-feature rules and `BlockBlobFeature` for authored Rocks, supplying the +Age's selected material and removing only the vanilla biome filter. This +replaces the port's temporary 3×3 pile algorithm and follows the 1.21.1 +generator's authored-placement approach. It affects new chunks only. + +The local `1.0.1.44-beta.1` slice extends this Age-scoped feature wrapper to +Flowers, Trees, and Huge Trees. Ordered authored pages retain independent +attempts and use vanilla 26.3 placed-feature rules after biome decoration. +The vegetation plan is persisted alongside terrain, biome, and Rocks plans; +the dedicated-server smoke compares generated flowers and logs against a +same-seed Flat control and checks persistence after restart. + +The local `1.0.1.45-beta.1` slice adds authored Dungeons separately from +surface vegetation. Its 26.3 placed-feature JSON mirrors the 1.21.1 eight +monster-room attempts over the world height; ordered duplicate pages add +attempts. The count persists in the Age index and generator codec. The server +smoke creates and samples a dungeon Age, counts monster spawners across a +4×4-chunk area, and verifies the count after restart. Room appearance and +normal book authoring remain in-game checks. + +The local `1.0.1.46-beta.1` slice ports Surface Lakes, Deep Lakes, and Small +Lakes, including ordered Fluid modifiers. The 26.3 LakeFeature retains its +vanilla shape and replacement predicates while using the Age's frozen fluid +provider. Surface placement keeps Rivencraft 1.21.1's one-in-twelve rarity; +underground lakes use vanilla placement. Repeated unmodified pages resolve +their fluids independently from a seeded, shuffled source-fluid pool. Lake +plans participate in restore, unload/reload, reseed, and retirement rollback. +The server smoke compares water generation against a same-seed control and +checks explicit/random plans and fluid blocks across restart. + +The local `1.0.1.47-beta.1` slice adapts the 1.21.1 FloatingIslandFeature +algorithm to 26.3 chunk access and the biomes already assigned to each chunk. +It retains source range 3, one-in-128 candidate rarity, Y=168..215 center +selection, radii 24..41, coordinate-noise contours, air-only filling, and the +biome-dependent soil cap. It supports Flat/Void in addition to noise terrains; +Block modifiers and independent seeded random materials are frozen in an +ordered terrain-feature plan. Rocks and islands share material resolution. + +26.3's final NoiseBasedChunkGenerator merges noise, surface, and carving and +does not expose a pre-surface hook. This port appends islands to the completed +terrain future before decoration; existing terrain is preserved, but base +carvers do not cut the added islands. The smoke verifies actual high-altitude +material placement on noise, Flat, and Void terrain, distinct random material +choices, authored biome preservation, and block fingerprints across restart. + +The local `1.0.1.48-beta.1` slice ports TendrilFeature's Legacy-derived +branching solid volumes using the 26.3 chunk mutation and height APIs. Source +range 8, rate 15, size 18, nested node/height selection, branch splitting, and +bedrock preservation match the 1.21.1 implementation. Chunk-local writes and +neighbor-source seeds preserve continuous paths across chunk borders. + +The existing terrain-feature plan now supports Tendrils on all terrain types, +including Cave and Void. Compilation retains mixed feature order and applies +the 32-occurrence limit per feature. Block modifiers and seeded random choices +use the shared solid-material resolver; existing save/lifecycle handling carries +both feature types. World validation is manual at the player's request; no +development server is started for this slice. + +The local `1.0.1.49-beta.1` slice ports the Legacy-derived Skylands filter +from Rivencraft 1.21.1 with 26.3 chunk height/mutation APIs. The cutoff uses +the same salt, seven octaves, initial cell width 128, amplitude 12, and base +Y=76. Solid/liquid removal and world-height coverage match the reference. +It runs once after base terrain construction and before authored solid +volumes, retaining the feature ordering used by the 1.21.1 implementation. + +Skylands is a material-free feature, accepted alongside every terrain type. +Repeated pages retain the same idempotent effect. The existing terrain-feature +plan stores an optional Skylands flag, defaulting to false for earlier beta +Ages; no separate backend/lifecycle path is introduced. A Skylands-only plan +can contain no material choices. Build and static checks are automated; world +generation and restart validation are left to the player. + +The local `1.0.1.50-beta.1` slice adapts the 1.21.1 SpheresFeature and +SpikesFeature to 26.3. Spheres retain their source range 8, five-percent +source chance, nested Y selection starting at 32, node geometry, and bedrock +protection. They run after the base terrain/Skylands filter and work on every +terrain type. Spikes retain their one-in-18 chunk chance, 6..37 height roll, +width 1..4, solid-footprint checks, and chunk-local writes during decoration. +They remain unavailable on Cave and Void terrain, matching 1.21.1. + +Both page types use ordered saved solid-feature choices, preceding Block +modifiers, and independent seeded materials/occurrences. No backend lifecycle +branch or generated artwork is added. Compilation and static review are the +automated checks; the player handles generation and restart validation. + +The local `1.0.1.51-beta.1` slice ports ObelisksFeature using the 26.3 chunk +coordinates and world-height APIs. The site salt, seed mixing, one-in-128 +chance, chunk-local site selection, 4×4 plinth, and 2×12×2 shaft match 1.21.1. +Every footing column must be sturdy, dry, and free of block entities; the +base surface can vary by at most two blocks and the full shaft must be open. +No partial structure is written when a placement check fails. + +The page joins the existing material-feature plan and shared material resolver. +It runs during surface decoration, with its own occurrence counter, on all +surface terrain types including Flat; Cave and Void remain unsupported. +Spikes and Obelisks use shared stage/restriction classification. Build/static +checks are automated; generation and restart checks remain with the player. + +The local `1.0.1.52-beta.1` slice ports CrystalFormationsFeature directly +from 1.21.1 with only registry, ChunkPos, and height API substitutions. It +retains the site salt, one-in-fifteen rarity, 3×3 ground/clearance checks, +three-block center height, randomized outer heights, budding hearts, and +initial small buds. Existing blue crystal growth, sprites, drops, and sounds +are reused without adding assets. + +Crystal Formations is an ordered surface feature with fixed crystal material, +not a consumer of Block/Fluid modifiers. It shares the existing saved feature +plan and surface-terrain restrictions (including Flat, excluding Cave/Void), +with independent occurrence seeds and the current 32-page cap. No backend +lifecycle changes are needed. The Obelisks slice passed the player's in-game +check; Crystal Formations build/static checks pass, with generation, growth, +and restart validation handed to the player. No test server is started. + +The local `1.0.1.53-beta.1` slice adapts 1.21.1 AgeDenseOres to modern +`AbstractOreFeature` targets and `FeaturePlacer.placeWithBiomeCheck`. It keeps +the common ore tag/name fallback, explicit `rivencraft:dense_ores` placed- +feature opt-in tag, nearby biome discovery, stable registry-ID ordering, +and independent per-occurrence seeds. Registered modded ore features remain +eligible without depending on an ore-providing mod. Biomes and ore placements +are collected once per chunk rather than once per repeated page. + +The optional Dense Ores count is saved in the existing terrain-feature plan; +older plans default to zero. Grammar accepts 1–32 occurrences on noise terrain +(including Cave), rejects Flat/Void and material modifiers, and checks explicit +biome pools for at least one eligible ore feature. No extra lifecycle path or +asset is introduced. Random feature selection and instability remain later +port work. Crystal Formations passed the player's in-game check; this slice +passes build/static checks, with ore generation and restart validation manual. + +The local `1.0.1.54-beta.1` slice ports Star Fissures. Its footprint matches +1.21.1 exactly (including Legacy attribution); carving uses the dimension's +minimum Y, clears fluids, and protects Cave terrain's roof. The authored flag +is optional in the existing feature-plan codec. Duplicate pages retain the +1.21.1 single-fissure result; all terrains are accepted, with no material +modifier. Random feature selection is not introduced in this slice. + +First arrival generates the fissure and stores its spawn/completion in per-Age +SavedData. Re-entry/restart does not repeat carving; an obstructed saved arrival +is rechecked for safe footing. The return block ports modern entity-inside and +TeleportTransition APIs, has a registered Rivencraft block-entity type, and +uses Minecraft's starfield renderer with 1.21.1 face offsets. Shared Overworld +spawn safety search and fallback match 1.21.1, rather than respawning at a bed. +The existing fissure OGG/model are reused. Client renderer registration stays +client-only; the block has no item/drop and is inert in the Overworld. + +Dense Ores passed the player's in-game check. Build, footprint comparison, +resource packaging, and static checks pass for this slice. Generation, visual +rendering, actual return travel, and restart checks are left to the player; +no development server is started. + ## Verification of this first slice The 1.21.1 `compileJava` and `build` tasks passed offline after the backend @@ -530,3 +689,6 @@ extraction. The GameTest server ran 93 tests; 92 passed, while desks instead of one. That desk failure is outside the dimension-backend edit and must be resolved or rechecked before a release. The 1.21.1 `build` task passed again after the 26.3 registry slice. +On 2026-11-01, the default 1.21.1 GameTest server was rerun and all 93 +required tests passed, including the desk test. Issue #97 was then closed; +remaining 26.3 parity work stays under #98–#104. diff --git a/ports/mc263/README.md b/ports/mc263/README.md index c965024..b2d25a4 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -211,6 +211,146 @@ first surface-feature path; unsupported combinations stay in the Binder. Random feature selection, unpaired materials, duplicate feature pages, and instability scoring remain future port steps. +The local `1.0.1.42-beta.1` follow-up lets a Rocks page choose a deterministic +full-block material when no Block page precedes it. Repeated Rocks pages now +add independent placement attempts (up to 32) instead of being rejected. The +ordered material choices are frozen in the Age index and restored after a +restart; old single-material beta Ages still load. An explicit Block page +must still directly precede the Rocks page it modifies. Most other feature +pages and material-feature pairings remain unported. + +The local `1.0.1.43-beta.1` slice replaces the temporary 3×3 Rocks piles +with Minecraft 26.3's forest-rock block-blob feature and its placement rules, +matching the 1.21.1 Age feature path more closely. The selected material and +repeat count still come from the frozen Age plan. Only newly generated chunks +use the new rock shape; previously generated chunks remain unchanged. + +The local `1.0.1.44-beta.1` slice ports ordered Flowers, Trees, and Huge +Trees pages to surface Ages. Their 26.3 vanilla placed features run after +biome decoration, with the Age's own seed; repeated pages make independent +placement attempts. Cave/Void terrain and cave/Nether/End biome pools are +not treated as surface vegetation. The feature plan survives server restart; +already generated chunks do not gain new decoration. + +The local `1.0.1.45-beta.1` slice ports the Dungeons page for Standard, +Amplified, and Island noise terrain. It uses the 1.21.1 placement definition +(eight monster-room attempts per page per chunk across the build height), +stores repeat count with the Age, and rejects Dungeons pages on Flat, Void, +or Cave terrain. New chunks only; existing chunks are not regenerated. + +The local `1.0.1.46-beta.1` slice adds Surface Lakes, Deep Lakes, and Small +Lakes on surface noise terrain. A Fluid page immediately before a lake page +selects its fluid. Unmodified pages choose a seeded fluid from the same source- +fluid pool exposed in Creative, with repeated choices and their order saved +with the Age. Surface/Small Lakes retain the 1.21.1 one-in-twelve attempt rule; +Deep Lakes retain vanilla underground placement. These pages currently require +Standard, Amplified, or Island terrain. Existing chunks do not redecorate. + +The local `1.0.1.47-beta.1` slice adds authored Floating Islands alongside +Standard, Amplified, Island, Flat, and Void terrain. A preceding Block page +sets the island core material; otherwise each occurrence resolves a seeded +material from the full-block pool. The 1.21.1 shape, high altitude, rarity, +and biome-dependent grass/dirt cap are retained. Repeated pages use independent +island seeds. Cave terrain remains unsupported for this feature. + +26.3 combines base noise, surface, and cave carving in one terrain operation. +Floating Islands run after that operation and before decoration, so the added +islands are not cut by the base terrain's carvers. The saved feature plan is +used again after restart and for new chunks; existing chunks keep their blocks. + +The local `1.0.1.48-beta.1` slice adds authored Tendrils on every terrain +type, including Cave and Void. A preceding Block page selects the material; +unmodified occurrences resolve seeded materials from the full-block pool. +Repeated pages use independent paths, with up to 32 occurrences per feature. +Mixed Floating Islands/Tendrils retain their authored order in the saved plan. + +Tendrils retain the 1.21.1 branching path algorithm, source rates, and vertical +distribution concentrated below Y=128. They can replace existing terrain while +preserving bedrock. As with Floating Islands, they run after 26.3's combined +terrain operation and before decoration. Build checks are automated; in-game +generation, appearance, and restart validation are handed to the player. + +The local `1.0.1.49-beta.1` slice adds the Skylands feature page. Its seeded +seven-octave filter removes base terrain at/below a varying cutoff near Y=76 +and removes liquid above it, preserving the upper solid terrain. It accepts +all terrain types and no Block modifier. On low Flat terrain this removes +the base layers; on Void it leaves the empty base unchanged. + +The filter runs once before authored Floating Islands and Tendrils, matching +1.21.1; duplicate Skylands pages do not compound the filter. Its flag is saved +with the terrain-feature plan. Existing chunks are unchanged. Try Amplified +terrain for a clear visual check; generation and restart validation are manual. + +The local `1.0.1.50-beta.1` slice ports Spheres and Spikes using the 1.21.1 +generation algorithms. Spheres add solid nodes on all terrain types, including +Cave and Void, while preserving bedrock. Spikes require solid surface terrain: +Standard, Amplified, Island, or Flat. Their circular footprint stays in one +chunk and must have solid ground beneath every column. + +Each page accepts a preceding Block material or resolves a seeded random +material when unmodified. Repeated pages have independent placement seeds, +up to 32 per feature. Spheres run during terrain construction, after Skylands; +Spikes run after biome decoration. Both use the existing saved solid-feature +plan. New chunks only; in-game appearance and restart checks remain manual. + +The local `1.0.1.51-beta.1` slice ports Obelisks. It preserves the 1.21.1 +one-in-128 candidate chance per chunk/occurrence, 4×4 plinth, and 2×2 shaft +extending 12 blocks above it. Placement requires dry, sturdy ground across +the base, at most two blocks of surface variation, and clear/replaceable +space for the complete structure. The footprint stays within its chunk. + +Obelisks support Standard, Amplified, Island, and Flat terrain. A preceding +Block page sets their material; otherwise a seeded full-block material is +selected. Repeated pages make independent attempts, up to 32 occurrences. +Spikes and Obelisks share surface-stage classification and preserve their +authored order within that stage. World and restart validation are manual. + +The local `1.0.1.52-beta.1` slice ports Crystal Formations using the 1.21.1 +surface-mound algorithm and the existing blue crystal blocks. Each occurrence +has a one-in-fifteen candidate chance per chunk; suitable dry, nearly level +ground produces a 3×3 mound with budding crystal and occasional small buds. +The existing budding blocks continue growing crystals through random ticks. + +Crystal Formations accepts Standard, Amplified, Island, and Flat terrain, +but not Cave/Void terrain or Block/Fluid modifiers. Up to 32 repeated pages +make independent attempts. The ordered feature plan survives restart, and +only newly generated chunks receive formations. For a quick manual test, +bind a Flat/Plains Age with several Crystal Formations pages, inspect the +blue mounds and bud growth, then revisit after restarting the world. + +The local `1.0.1.53-beta.1` slice ports Dense Ores. Each page replays eligible +ore placements from the Age's actual biomes, preserving their height ranges, +replacement rules, and biome filters. Vanilla and modded ore features are +recognized by their ore targets; custom placements can opt in through the +`rivencraft:dense_ores` placed-feature tag, just as in 1.21.1. + +Up to 32 pages add independent passes on Standard, Amplified, Island, or Cave +terrain. Flat/Void and Block/Fluid modifiers are unsupported; an explicitly +authored biome pool must contain at least one ore-producing biome. The count +survives restart. Only new chunks gain extra ore, and this slice does not yet +port the later instability system or random-Age feature selection. + +For manual validation, create a Standard/Plains Age with several Dense Ores +pages and inspect underground ore abundance, then restart and explore more +chunks. A Cave Age with a Nether biome can check Nether ore placements too. + +The local `1.0.1.54-beta.1` slice ports the Star Fissures page. On first book/ +operator arrival, it carves the 1.21.1 Legacy-shaped rift near the arrival chunk +and places a starfield portal at the dimension's minimum Y. Cave terrain keeps +its bedrock roof. All terrain types are supported; repeated pages still create +one fissure, and Block/Fluid modifiers are not accepted. + +The first arrival and completed generation are saved per Age, so revisits and +restarts do not recarve the rift. Touching its bottom returns entities to a safe +location near the shared Overworld spawn, not a bed, with the original fissure +sound. The portal is unobtainable and inert in the Overworld. Random-Age feature +selection remains later work; author a Star Fissures page to test this slice. + +For manual validation, create a Flat/Plains Age with Star Fissures. Look near +the arrival point (generally east/south), descend through the rift, and touch +the starfield to return. Re-enter after restarting; check that the same fissure +and its portal still work. A Cave Age checks that carving leaves the roof intact. + From the repository root: ```sh diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md new file mode 100644 index 0000000..3a823c1 --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.54-beta.1.md @@ -0,0 +1,51 @@ +# Rivencraft 26.3 beta 1.0.1.54-beta.1 + +This beta collects the player-validated Age feature port slices since +1.0.1.41-beta.1. It is not yet feature-complete with Minecraft 1.21.1. + +## Age generation + +- Rocks now use the established 1.21.1 forest-rock placement. Unmodified + material-bearing features choose seeded materials independently; a preceding + compatible Block or Fluid page selects the material explicitly. +- Added Flowers, Trees, Huge Trees, Dungeons, Surface Lakes, Deep Lakes, and + Small Lakes using the 1.21.1 placement rules adapted to 26.3. +- Added Floating Islands, Tendrils, Skylands, Spheres, Spikes, and Obelisks + using the existing Rivencraft generation algorithms. +- Added blue Crystal Formations with budding crystal and initially seeded buds. +- Added Dense Ores: repeated pages replay eligible biome ore placements, + including compatible modded ores and explicitly tagged custom placements. +- Added Star Fissures: one Legacy-shaped rift near the first arrival point, + a starfield at the dimension's minimum Y, and return travel to the shared + Overworld spawn with the original fissure sound. Cave roofs are preserved. +- Feature plans, materials, repeated occurrences, and fissure completion + persist across restart. Existing generated chunks are not redecorated. + +## Current limits + +- Authored features retain terrain restrictions. Most repeated features allow + up to 32 occurrences; exceeding the current authoring cap blocks assembly. + Skylands and Star Fissures retain their single-effect behavior when repeated. +- Put a material page immediately before a compatible feature. Dangling + material assignment, full Legacy grammar recovery, instability, and random + feature selection are not yet ported. Pageless books randomize terrain and + biome distribution, not the newly authored feature list. +- 26.3 combines base terrain, surface, and carving. Added solid volumes run + after that combined operation and before decoration, so base carvers do not + cut those added volumes. +- The full 1.21.1 writing/discovery loop, structures, environmental systems, + crystal portals, and optional integration parity remain in progress. The + presence of a Creative page is not a promise that its effect is ported. + +Build and static checks passed. The player reported in-game passes for these +slices. Earlier server smoke coverage was expanded, but the later generation +slices were manually validated; this release does not claim a new full +dedicated-server regression pass. + +Requires Minecraft **26.3**, NeoForge **26.3.0.99** or a compatible 26.3 build, +and **Java 25**. Dynamic Dimensions and Bad Packets are not required by this +26.3 build. Do not use Minecraft 1.21.1 dependencies or saves with it. Back up +the entire world before updating; beta-save compatibility is not guaranteed. + +Report issues and help with documentation at +[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft). diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index e43f6e3..f8841a9 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -124,6 +124,9 @@ sourceSets.main.resources.srcDir generatePortLanguage tasks.named('processResources', ProcessResources).configure { // Reuse the existing sprite and base model without copying binary assets. from('../../src/main/resources') { + include 'assets/rivencraft/models/block/star_fissure.json' + include 'assets/rivencraft/blockstates/star_fissure.json' + include 'assets/rivencraft/sounds/linking/link-fissure.ogg' include 'assets/rivencraft/textures/item/crystal_shard.png' include 'assets/rivencraft/textures/item/page.png' include 'assets/rivencraft/textures/item/page_link_panel.png' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 7bafdde..3d5f937 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -7,4 +7,4 @@ neo_version=26.3.0.99 mod_id=rivencraft mod_name=Rivencraft mod_license=LGPL-3.0-or-later -mod_version=1.0.1.41-beta.1 +mod_version=1.0.1.54-beta.1 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 00c319d..5f34d05 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -8,6 +8,8 @@ import net.dnorberg.rivencraft.age.AgeCatalogSync; import net.dnorberg.rivencraft.block.BuddingCrystalBlock; import net.dnorberg.rivencraft.block.PortBookBinderBlock; import net.dnorberg.rivencraft.block.InkMixerBlock; +import net.dnorberg.rivencraft.block.StarFissureBlock; +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; import net.dnorberg.rivencraft.block.entity.PortBookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.menu.PortBookBinderMenu; @@ -26,6 +28,7 @@ import net.dnorberg.rivencraft.writing.LinkingBookRecipe; import net.minecraft.core.component.DataComponentType; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; @@ -54,6 +57,11 @@ public final class Rivencraft { public static final Logger LOGGER = LogUtils.getLogger(); private static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks(MOD_ID); private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID); + private static final DeferredRegister SOUNDS = + DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID); + public static final Supplier LINK_FISSURE = SOUNDS.register( + "linking.link-fissure", () -> SoundEvent.createVariableRangeEvent( + Identifier.fromNamespaceAndPath(MOD_ID, "linking.link-fissure"))); private static final DeferredRegister> BLOCK_ENTITY_TYPES = DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID); private static final DeferredRegister> MENUS = @@ -94,6 +102,12 @@ public final class Rivencraft { public static final DeferredBlock CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block", Block::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK)); + public static final DeferredBlock STAR_FISSURE = + BLOCKS.registerBlock("star_fissure", StarFissureBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6)); + public static final Supplier> + STAR_FISSURE_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register("star_fissure", () -> new BlockEntityType<>( + StarFissureBlockEntity::new, STAR_FISSURE.get())); public static final DeferredBlock BOOK_BINDER = BLOCKS.registerBlock( "book_binder", PortBookBinderBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()); @@ -146,6 +160,7 @@ public final class Rivencraft { public Rivencraft(IEventBus modEventBus) { BLOCKS.register(modEventBus); + SOUNDS.register(modEventBus); ITEMS.register(modEventBus); BLOCK_ENTITY_TYPES.register(modEventBus); MENUS.register(modEventBus); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java index f253a7d..aa47b0f 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeLevelIndex.java @@ -26,7 +26,15 @@ final class AgeLevelIndex extends SavedData { Codec.STRING.xmap(PortAgeTerrain::valueOf, PortAgeTerrain::name)) .optionalFieldOf("terrain", Map.of()).forGetter(index -> index.terrain), Codec.unboundedMap(Identifier.CODEC, PortRockPlan.CODEC).optionalFieldOf("rocks", Map.of()) - .forGetter(index -> index.rocks) + .forGetter(index -> index.rocks), + Codec.unboundedMap(Identifier.CODEC, PortVegetationPlan.CODEC) + .optionalFieldOf("vegetation", Map.of()).forGetter(index -> index.vegetation), + Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, 32)) + .optionalFieldOf("dungeons", Map.of()).forGetter(index -> index.dungeons), + Codec.unboundedMap(Identifier.CODEC, PortLakePlan.CODEC) + .optionalFieldOf("lakes", Map.of()).forGetter(index -> index.lakes), + Codec.unboundedMap(Identifier.CODEC, PortTerrainFeaturePlan.CODEC) + .optionalFieldOf("terrain_features", Map.of()).forGetter(index -> index.terrainFeatures) ).apply(instance, AgeLevelIndex::new)); static final SavedDataType TYPE = new SavedDataType<>( @@ -41,6 +49,10 @@ final class AgeLevelIndex extends SavedData { private final Map biomePlans; private final Map terrain; private final Map rocks; + private final Map vegetation; + private final Map dungeons; + private final Map lakes; + private final Map terrainFeatures; private AgeLevelIndex() { this.ages = new LinkedHashMap<>(); @@ -49,19 +61,31 @@ final class AgeLevelIndex extends SavedData { this.biomePlans = new LinkedHashMap<>(); this.terrain = new LinkedHashMap<>(); this.rocks = new LinkedHashMap<>(); + this.vegetation = new LinkedHashMap<>(); + this.dungeons = new LinkedHashMap<>(); + this.lakes = new LinkedHashMap<>(); + this.terrainFeatures = new LinkedHashMap<>(); } private AgeLevelIndex(Map ages, List retired, Map biomeOverrides, Map biomePlans, Map terrain, - Map rocks) { + Map rocks, + Map vegetation, + Map dungeons, + Map lakes, + Map terrainFeatures) { this.ages = new LinkedHashMap<>(ages); this.retired = new LinkedHashSet<>(retired); this.biomeOverrides = new LinkedHashMap<>(biomeOverrides); this.biomePlans = new LinkedHashMap<>(biomePlans); this.terrain = new LinkedHashMap<>(terrain); this.rocks = new LinkedHashMap<>(rocks); + this.vegetation = new LinkedHashMap<>(vegetation); + this.dungeons = new LinkedHashMap<>(dungeons); + this.lakes = new LinkedHashMap<>(lakes); + this.terrainFeatures = new LinkedHashMap<>(terrainFeatures); } Map entries() { @@ -88,9 +112,14 @@ final class AgeLevelIndex extends SavedData { } PortRockPlan rocks(Identifier id) { return rocks.get(id); } + PortVegetationPlan vegetation(Identifier id) { return vegetation.get(id); } + int dungeons(Identifier id) { return dungeons.getOrDefault(id, 0); } + PortLakePlan lakes(Identifier id) { return lakes.get(id); } + PortTerrainFeaturePlan terrainFeatures(Identifier id) { return terrainFeatures.get(id); } void add(Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain ageTerrain, - PortRockPlan rockPlan) { + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeonCount, + PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan) { if (contains(id)) { throw new IllegalArgumentException("Age already exists: " + id); } @@ -98,6 +127,10 @@ final class AgeLevelIndex extends SavedData { if (biomePlan != null) biomePlans.put(id, biomePlan); if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain); if (rockPlan != null) rocks.put(id, rockPlan); + if (vegetationPlan != null) vegetation.put(id, vegetationPlan); + if (dungeonCount > 0) dungeons.put(id, dungeonCount); + if (lakePlan != null) lakes.put(id, lakePlan); + if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan); setDirty(); } @@ -107,17 +140,27 @@ final class AgeLevelIndex extends SavedData { biomePlans.remove(id); terrain.remove(id); rocks.remove(id); + vegetation.remove(id); + dungeons.remove(id); + lakes.remove(id); + terrainFeatures.remove(id); retired.add(id); setDirty(); } void restoreAfterFailedRetirement(Identifier id, long seed, PortBiomePlan biomePlan, - PortAgeTerrain ageTerrain, PortRockPlan rockPlan) { + PortAgeTerrain ageTerrain, PortRockPlan rockPlan, + PortVegetationPlan vegetationPlan, int dungeonCount, + PortLakePlan lakePlan, PortTerrainFeaturePlan terrainFeaturePlan) { if (!retired.remove(id)) throw new IllegalStateException("Age is not retired: " + id); ages.put(id, seed); if (biomePlan != null) biomePlans.put(id, biomePlan); if (ageTerrain != PortAgeTerrain.STANDARD) terrain.put(id, ageTerrain); if (rockPlan != null) rocks.put(id, rockPlan); + if (vegetationPlan != null) vegetation.put(id, vegetationPlan); + if (dungeonCount > 0) dungeons.put(id, dungeonCount); + if (lakePlan != null) lakes.put(id, lakePlan); + if (terrainFeaturePlan != null) terrainFeatures.put(id, terrainFeaturePlan); setDirty(); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java index 53d7056..8800c84 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java @@ -18,6 +18,8 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.util.Prediction; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.block.Blocks; import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.fml.ModList; import java.util.Map; @@ -130,6 +132,51 @@ public final class PortAgeCommands { .then(Commands.argument("seed", LongArgumentType.longArg()) .executes(context -> createAge(context, PortAgeTerrain.FLAT, null, new PortRockPlan(Identifier.withDefaultNamespace("gold_block"))))))) + .then(Commands.literal("create_rocks_multi") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.FLAT, null, + fourGoldRocks(LongArgumentType.getLong(context, "seed"))))))) + .then(Commands.literal("create_rocks_random") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.FLAT, null, + net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan( + java.util.List.of(Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "feature/rocks")), + LongArgumentType.getLong(context, "seed"))))))) + .then(Commands.literal("create_vegetation") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.FLAT, null, + null, testVegetation()))))) + .then(Commands.literal("create_dungeons") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.STANDARD, + null, null, null, testDungeons()))))) + .then(Commands.literal("create_lakes") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.STANDARD, + null, null, null, 0, testLakes( + LongArgumentType.getLong(context, "seed"), false)))))) + .then(Commands.literal("create_lakes_random") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createAge(context, PortAgeTerrain.STANDARD, + null, null, null, 0, testLakes( + LongArgumentType.getLong(context, "seed"), true)))))) + .then(Commands.literal("create_islands") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .executes(context -> createIslandAge(context, false)))))) + .then(Commands.literal("create_islands_random") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .executes(context -> createIslandAge(context, true)))))) .then(Commands.literal("list") .executes(context -> { var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer()); @@ -236,6 +283,18 @@ public final class PortAgeCommands { .then(Commands.literal("rocks_probe") .then(Commands.argument("name", StringArgumentType.word()) .executes(PortAgeCommands::probeRocks))) + .then(Commands.literal("vegetation_probe") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeVegetation))) + .then(Commands.literal("dungeons_probe") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeDungeons))) + .then(Commands.literal("lakes_probe") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeLakes))) + .then(Commands.literal("islands_probe") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeIslands))) .then(Commands.literal("biome") .then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("x", IntegerArgumentType.integer()) @@ -255,30 +314,229 @@ public final class PortAgeCommands { private static int probeRocks(CommandContext context) { Identifier id = ageId(context); ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); - if (level == null || !(level.getChunkSource().getGenerator() instanceof PortRockChunkGenerator rocks)) { + if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features) + || features.rocks() == null) { context.getSource().sendFailure(Component.literal("Age has no loaded Rocks generator: " + id)); return 0; } - var material = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(rocks.rocks().material()); + var materials = features.rocks().materials().stream() + .map(net.minecraft.core.registries.BuiltInRegistries.BLOCK::getValue).toList(); int count = 0; for (int x = 0; x < 64; x++) for (int z = 0; z < 64; z++) { int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) - 1; - if (level.getBlockState(new net.minecraft.core.BlockPos(x, y, z)).is(material)) count++; + var state = level.getBlockState(new net.minecraft.core.BlockPos(x, y, z)); + if (materials.stream().anyMatch(state::is)) count++; } int placed = count; context.getSource().sendSuccess(() -> Component.literal( - "Rocks probe " + id + " material=" + rocks.rocks().material() + " count=" + placed), false); + "Rocks probe " + id + " materials=" + features.rocks().materials() + " count=" + placed), false); return placed; } + private static int probeVegetation(CommandContext context) { + Identifier id = ageId(context); + ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded: " + id)); + return 0; + } + int flowers = 0; + int logs = 0; + for (int x = 0; x < 64; x++) for (int z = 0; z < 64; z++) { + for (int y = 4; y < 32; y++) { + var state = level.getBlockState(new net.minecraft.core.BlockPos(x, y, z)); + if (state.is(BlockTags.FLOWERS)) flowers++; + if (state.is(BlockTags.LOGS)) logs++; + } + } + int flowerCount = flowers; + int logCount = logs; + context.getSource().sendSuccess(() -> Component.literal( + "Vegetation probe " + id + " flowers=" + flowerCount + " logs=" + logCount), false); + return flowerCount + logCount; + } + + private static PortVegetationPlan testVegetation() { + java.util.List pages = new java.util.ArrayList<>(); + for (int i = 0; i < 10; i++) { + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/flowers")); + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/trees")); + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/huge_trees")); + } + return net.dnorberg.rivencraft.writing.PortAuthoredWriting.vegetationPlan(pages); + } + + private static int testDungeons() { + Identifier page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dungeons"); + return net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount( + java.util.Collections.nCopies(8, page)); + } + + private static int probeDungeons(CommandContext context) { + Identifier id = ageId(context); + ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)) { + context.getSource().sendFailure(Component.literal("Age has no loaded feature generator: " + id)); + return 0; + } + int count = features.dungeons(); + int spawners = 0; + for (int chunkX = 0; chunkX < 4; chunkX++) for (int chunkZ = 0; chunkZ < 4; chunkZ++) { + var chunk = level.getChunk(chunkX, chunkZ); + for (var pos : chunk.getBlockEntitiesPos()) { + if (chunk.getBlockState(pos).is(Blocks.SPAWNER)) spawners++; + } + } + int placed = spawners; + context.getSource().sendSuccess(() -> Component.literal( + "Dungeons probe " + id + " pages=" + count + " spawners=" + placed), false); + return placed; + } + + private static PortLakePlan testLakes(long seed, boolean randomFluids) { + java.util.List pages = new java.util.ArrayList<>(); + for (int i = 0; i < 8; i++) { + for (String kind : java.util.List.of("surface_lakes", "deep_lakes", "lava_lakes")) { + if (!randomFluids) pages.add(net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId( + Identifier.withDefaultNamespace("water"))); + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + kind)); + } + } + return net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(pages, seed); + } + + private static int probeLakes(CommandContext context) { + Identifier id = ageId(context); + ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded: " + id)); + return 0; + } + PortLakePlan lakes = level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features + ? features.lakes() : null; + // Finish neighboring features before counting, since lake bowls can + // cross a chunk boundary. Include underground lakes in the probe. + for (int x = -1; x <= 4; x++) for (int z = -1; z <= 4; z++) level.getChunk(x, z); + int[] fluids = new int[2]; + for (int x = 0; x < 4; x++) for (int z = 0; z < 4; z++) { + level.getChunk(x, z).findBlocks(state -> state.is(Blocks.WATER) || state.is(Blocks.LAVA), + (pos, state) -> fluids[state.is(Blocks.WATER) ? 0 : 1]++); + } + int waterCount = fluids[0]; + int lavaCount = fluids[1]; + context.getSource().sendSuccess(() -> Component.literal( + "Lakes probe " + id + " pages=" + (lakes == null ? 0 : lakes.choices().size()) + + " fluids=" + (lakes == null ? java.util.List.of() : lakes.choices().stream() + .map(PortLakePlan.Choice::fluid).distinct().toList()) + + " plan=" + (lakes == null ? 0 : lakes.choices().hashCode()) + + " water=" + waterCount + " lava=" + lavaCount), false); + return waterCount + lavaCount; + } + + private static int createIslandAge(CommandContext context, boolean randomMaterials) { + PortAgeTerrain terrain; + try { + terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal("Unknown island test terrain")); + return 0; + } + java.util.List pages = new java.util.ArrayList<>(); + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + terrain.name().toLowerCase(Locale.ROOT))); + pages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/forest")); + for (int i = 0; i < 4; i++) { + if (!randomMaterials) pages.add(net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId( + Identifier.withDefaultNamespace("gold_block"))); + pages.add(PortTerrainFeaturePlan.FLOATING_ISLANDS); + } + try { + long seed = LongArgumentType.getLong(context, "seed"); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan( + pages, context.getSource().getServer().registryAccess()); + var features = net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed); + return createAge(context, terrain, biomes, null, null, 0, null, features); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int probeIslands(CommandContext context) { + Identifier id = ageId(context); + ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); + if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features) + || features.terrainFeatures() == null) { + context.getSource().sendFailure(Component.literal("Age has no loaded island generator: " + id)); + return 0; + } + var choices = features.terrainFeatures().choices(); + var materials = choices.stream().map(PortTerrainFeaturePlan.Choice::material).distinct().toList(); + var blocks = materials.stream().map(net.minecraft.core.registries.BuiltInRegistries.BLOCK::getValue).toList(); + for (int x = -1; x <= 4; x++) for (int z = -1; z <= 4; z++) level.getChunk(x, z); + int[] count = {0}; + long[] fingerprint = {0}; + for (int x = 0; x < 4; x++) for (int z = 0; z < 4; z++) { + level.getChunk(x, z).findBlocks(state -> !state.isAir(), (pos, state) -> { + if (pos.getY() < 128 || pos.getY() >= 240) return; + if (blocks.stream().anyMatch(state::is)) count[0]++; + fingerprint[0] += pos.asLong() * 0x9E3779B97F4A7C15L ^ state.toString().hashCode(); + }); + } + context.getSource().sendSuccess(() -> Component.literal( + "Islands probe " + id + " pages=" + choices.size() + " materials=" + materials + + " count=" + count[0] + " fingerprint=" + fingerprint[0]), false); + return count[0]; + } + + /** Exercise the actual ordered writing parser, not only the generator codec. */ + private static PortRockPlan fourGoldRocks(long seed) { + Identifier block = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId( + Identifier.withDefaultNamespace("gold_block")); + Identifier feature = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/rocks"); + java.util.List pages = new java.util.ArrayList<>(); + for (int i = 0; i < 4; i++) { + pages.add(block); + pages.add(feature); + } + return net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(pages, seed); + } + private static int createAge(CommandContext context, PortAgeTerrain terrain, PortBiomePlan biomePlan, PortRockPlan rockPlan) { + return createAge(context, terrain, biomePlan, rockPlan, null); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan) { + return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, 0); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, + int dungeons) { + return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, null); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, + int dungeons, PortLakePlan lakes) { + return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, null); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, + int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures) { String name = StringArgumentType.getString(context, "name"); Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); long seed = LongArgumentType.getLong(context, "seed"); ServerLevel level; try { - level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, terrain, rockPlan); + level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, + terrain, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures); } catch (IllegalArgumentException | IllegalStateException exception) { context.getSource().sendFailure(Component.literal(exception.getMessage())); return 0; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java index 39e091a..907255c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java @@ -25,8 +25,13 @@ public final class PortAgeTravel { if (!AgeCatalogSync.ready(player, id)) { return "Age catalog not acknowledged by client; remaining in source dimension"; } - BlockPos landing = findLanding(target); + boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features + && features.terrainFeatures() != null && features.terrainFeatures().starFissures(); + BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null; + if (landing != null) landing = findFootedDestination(target, landing).orElse(null); + if (landing == null) landing = findLanding(target); if (landing == null) return "No safe test landing available; remaining in source dimension"; + if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target)); boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, Set.of(), player.getYRot(), player.getXRot(), true); return moved ? null : "Teleportation failed; remaining in source dimension"; @@ -34,12 +39,8 @@ public final class PortAgeTravel { private static BlockPos findLanding(ServerLevel target) { net.minecraft.world.level.chunk.ChunkGenerator generator = target.getChunkSource().getGenerator(); - if (generator instanceof PortRockChunkGenerator rocks) generator = rocks.base(); - if (generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise - && noise.generatorSettings().value().noiseSettings().equals(target.registryAccess() - .lookupOrThrow(Registries.NOISE_SETTINGS) - .getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES) - .value().noiseSettings())) { + if (generator instanceof PortFeatureChunkGenerator features) generator = features.base(); + if (isCaveTerrain(target)) { // The cave roof is not a valid arrival surface. Search down from // Legacy's Y=64 start, then construct the normal safety platform // at that height only when no footed opening can be found. @@ -88,6 +89,16 @@ public final class PortAgeTravel { return makePlatform(target, new BlockPos(0, y, 0)); } + private static boolean isCaveTerrain(ServerLevel target) { + var generator = target.getChunkSource().getGenerator(); + if (generator instanceof PortFeatureChunkGenerator features) generator = features.base(); + return generator instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise + && noise.generatorSettings().value().noiseSettings().equals(target.registryAccess() + .lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES) + .value().noiseSettings()); + } + private static BlockPos makePlatform(ServerLevel target, BlockPos foot) { if (foot.getY() <= target.getMinY() || foot.getY() + 2 >= target.getMaxY()) return null; // A carved pocket inside a flooded cave immediately refills. Refuse @@ -112,7 +123,36 @@ public final class PortAgeTravel { return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP) && target.getFluidState(foot).isEmpty() && target.getFluidState(foot.above()).isEmpty() - && target.getBlockState(foot).getCollisionShape(target, foot).isEmpty() - && target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty(); + && passable(target, foot) && passable(target, foot.above()); + } + + private static boolean passable(ServerLevel level, BlockPos pos) { + var state = level.getBlockState(pos); + return state.getCollisionShape(level, pos).isEmpty() || state.is(net.minecraft.tags.BlockTags.LEAVES); + } + + /** Same local-then-surface safe-return search used by 1.21.1 fissures. */ + public static java.util.Optional findFootedDestination(ServerLevel level, BlockPos origin) { + var local = BlockPos.betweenClosedStream(origin.offset(-4, -2, -4), origin.offset(4, 2, 4)) + .map(BlockPos::immutable).sorted(java.util.Comparator.comparingDouble(pos -> pos.distSqr(origin))) + .filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos)) + .filter(pos -> hasFooting(level, pos)).findFirst(); + if (local.isPresent()) return local; + for (int radius = 1; radius <= 64; radius++) { + final int ring = radius; + var found = BlockPos.betweenClosedStream(origin.offset(-ring, 0, -ring), origin.offset(ring, 0, ring)) + .filter(pos -> Math.max(Math.abs(pos.getX() - origin.getX()), Math.abs(pos.getZ() - origin.getZ())) == ring) + .map(BlockPos::immutable) + .flatMap(pos -> { + BlockPos surface = new BlockPos(pos.getX(), + level.getHeight(Heightmap.Types.MOTION_BLOCKING, pos.getX(), pos.getZ()), pos.getZ()); + return java.util.stream.Stream.of(surface, surface.above(), surface.below(), + surface.above(2), surface.below(2)); + }) + .filter(pos -> level.isInWorldBounds(pos) && level.getWorldBorder().isWithinBounds(pos)) + .filter(pos -> hasFooting(level, pos)).findFirst(); + if (found.isPresent()) return found; + } + return java.util.Optional.empty(); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCrystalFormationsFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCrystalFormationsFeature.java new file mode 100644 index 0000000..b571bc5 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCrystalFormationsFeature.java @@ -0,0 +1,78 @@ +package net.dnorberg.rivencraft.age; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.AmethystClusterBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** 26.3 port of the 1.21.1 surface crystal mound with budding hearts and seeded buds. */ +final class PortCrystalFormationsFeature { + private static final long SALT = 0x4352595354414C53L; + private static final int CHANCE = 15; + + private PortCrystalFormationsFeature() {} + + record Site(int x, int z, long seed) {} + + static Site site(long ageSeed, ChunkPos chunk, int occurrence) { + long seed = mix(ageSeed ^ SALT ^ (long) chunk.x() * 341873128712L + ^ (long) chunk.z() * 132897987541L ^ (long) occurrence * 0x9E3779B97F4A7C15L); + RandomSource random = RandomSource.create(seed); + if (random.nextInt(CHANCE) != 0) return null; + return new Site(chunk.getMinBlockX() + 5 + random.nextInt(6), + chunk.getMinBlockZ() + 5 + random.nextInt(6), seed); + } + + static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, int occurrence) { + Site site = site(ageSeed, chunk.getPos(), occurrence); + if (site == null) return; + RandomSource random = RandomSource.create(site.seed() ^ 0x42554444494E474CL); + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, site.x(), site.z()); + if (y <= level.getMinY() + 1 || y + 4 >= level.getMaxY()) return; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + int x = site.x() + dx; + int z = site.z() + dz; + if (Math.abs(level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - y) > 1) return; + BlockState floor = level.getBlockState(pos.set(x, y - 1, z)); + if (!floor.isFaceSturdy(level, pos, Direction.UP) || !floor.getFluidState().isEmpty() + || floor.hasBlockEntity()) return; + for (int h = 0; h < 4; h++) { + BlockState existing = level.getBlockState(pos.set(x, y + h, z)); + if (!existing.isAir() && !existing.canBeReplaced()) return; + } + } + } + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + boolean center = dx == 0 && dz == 0; + int height = center ? 3 : random.nextInt(3) == 0 ? 2 : 1; + for (int h = 0; h < height; h++) { + boolean budding = h == height - 1 && (center || random.nextBoolean()); + level.setBlock(pos.set(site.x() + dx, y + h, site.z() + dz), + budding ? Rivencraft.BUDDING_CRYSTAL.get().defaultBlockState() + : Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 2); + } + if (level.getBlockState(pos.set(site.x() + dx, y + height - 1, site.z() + dz)) + .is(Rivencraft.BUDDING_CRYSTAL) && random.nextBoolean()) { + level.setBlock(pos.set(site.x() + dx, y + height, site.z() + dz), + Rivencraft.SMALL_CRYSTAL_BUD.get().defaultBlockState() + .setValue(AmethystClusterBlock.FACING, Direction.UP), 2); + } + } + } + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDenseOres.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDenseOres.java new file mode 100644 index 0000000..61e2b49 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDenseOres.java @@ -0,0 +1,90 @@ +package net.dnorberg.rivencraft.age; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.tags.TagKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.feature.AbstractOreFeature; +import net.minecraft.world.level.levelgen.placement.FeaturePlacer; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.neoforged.neoforge.common.Tags; + +/** Replays each eligible biome ore placement once per page, matching Rivencraft 1.21.1. */ +public final class PortDenseOres { + public static final TagKey EXTRA_ORES = TagKey.create(Registries.PLACED_FEATURE, + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dense_ores")); + + private PortDenseOres() {} + + public static boolean hasOres(Holder biome) { + return oreFeatures(biome).anyMatch(PortDenseOres::isOre); + } + + /** Modern ore features carry their targets directly rather than an OreConfiguration. */ + public static boolean isOre(Holder holder) { + if (holder.is(EXTRA_ORES)) return true; + if (!(holder.value().feature().value() instanceof AbstractOreFeature ore)) return false; + return ore.targetStates().stream().anyMatch(target -> isOreBlock(target.state())); + } + + private static boolean isOreBlock(BlockState state) { + if (state.is(Tags.BlockItems.ORES.block())) return true; + String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath(); + return path.endsWith("_ore") || path.contains("_ore_"); + } + + private static java.util.stream.Stream> oreFeatures(Holder biome) { + return biome.value().getGenerationSettings().features().stream().flatMap(HolderSet::stream); + } + + public static void place(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator generator, + long ageSeed, int count) { + if (count == 0) return; + Set> biomes = new HashSet<>(); + ChunkPos.rangeClosed(chunk.getPos(), 1).forEach(pos -> { + for (var section : level.getChunk(pos.x(), pos.z()).getSections()) + section.getBiomes().getAll(biomes::add); + }); + biomes.retainAll(generator.getBiomeSource().possibleBiomes()); + // Collect once per chunk, not once per repeated page. Stable registry-ID + // order and per-occurrence seeds retain the 1.21.1 placement sequence. + Map ores = new TreeMap<>(); + for (Holder biome : biomes) { + oreFeatures(biome).filter(PortDenseOres::isOre).forEach(feature -> + feature.unwrapKey().ifPresent(key -> ores.putIfAbsent(key.identifier(), feature.value()))); + } + BlockPos origin = new BlockPos(chunk.getPos().getMinBlockX(), level.getMinY(), + chunk.getPos().getMinBlockZ()); + FeaturePlacer placer = new FeaturePlacer(level, generator); + for (int occurrence = 0; occurrence < count; occurrence++) { + for (var entry : ores.entrySet()) { + long seed = mix(ageSeed ^ (long) origin.getX() * 341873128712L + ^ (long) origin.getZ() * 132897987541L + ^ (long) entry.getKey().hashCode() * 0x9E3779B97F4A7C15L + ^ (long) occurrence * 0xD1B54A32D192ED03L); + placer.placeWithBiomeCheck(entry.getValue(), RandomSource.create(seed), origin); + } + } + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java new file mode 100644 index 0000000..412cc08 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java @@ -0,0 +1,269 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.writing.PortRegistryPages; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.tags.TagKey; +import net.minecraft.tags.BiomeTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.blockpredicates.BlockPredicate; +import net.minecraft.world.level.levelgen.feature.BlockBlobFeature; +import net.minecraft.world.level.levelgen.feature.LakeFeature; +import net.minecraft.world.level.levelgen.feature.stateproviders.BlockStateProvider; +import net.minecraft.world.level.levelgen.placement.BiomeFilter; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.levelgen.placement.RarityFilter; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.HolderSet; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Block; + +/** Delegates terrain generation and applies frozen Age-scoped features. */ +public final class PortFeatureChunkGenerator extends ChunkGenerator { + private static final ResourceKey FOREST_ROCK = ResourceKey.create( + Registries.PLACED_FEATURE, Identifier.withDefaultNamespace("forest_rock")); + private static final ResourceKey DUNGEONS = ResourceKey.create( + Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "dungeons")); + private static final TagKey ROCK_GROUND = TagKey.create( + Registries.BLOCK, Identifier.withDefaultNamespace("forest_rock_can_place_on")); + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base), + PortRockPlan.CODEC.optionalFieldOf("rocks").forGetter(generator -> Optional.ofNullable(generator.rocks)), + PortVegetationPlan.CODEC.optionalFieldOf("vegetation") + .forGetter(generator -> Optional.ofNullable(generator.vegetation)), + com.mojang.serialization.Codec.intRange(0, 32).optionalFieldOf("dungeons", 0) + .forGetter(generator -> generator.dungeons), + PortLakePlan.CODEC.optionalFieldOf("lakes") + .forGetter(generator -> Optional.ofNullable(generator.lakes)), + PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features") + .forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)), + com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed) + ).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) -> + new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null), + dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed))); + + private final ChunkGenerator base; + private final PortRockPlan rocks; + private final PortVegetationPlan vegetation; + private final int dungeons; + private final PortLakePlan lakes; + private final PortTerrainFeaturePlan terrainFeatures; + private final long ageSeed; + + public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures, long ageSeed) { + super(base.getBiomeSource(), base::getBiomeGenerationSettings); + this.base = base; + this.rocks = rocks; + this.vegetation = vegetation; + this.dungeons = dungeons; + this.lakes = lakes; + this.terrainFeatures = terrainFeatures; + this.ageSeed = ageSeed; + } + + public ChunkGenerator base() { return base; } + public PortRockPlan rocks() { return rocks; } + public PortVegetationPlan vegetation() { return vegetation; } + public int dungeons() { return dungeons; } + public PortLakePlan lakes() { return lakes; } + public PortTerrainFeaturePlan terrainFeatures() { return terrainFeatures; } + + @Override protected MapCodec codec() { return CODEC; } + @Override public void validate() { base.validate(); } + @Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); } + @Override public java.util.Optional getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); } + @Override public CompletableFuture createBiomes(RandomState random, Blender blender, + StructureManager structures, ChunkAccess chunk) { + return base.createBiomes(random, blender, structures, chunk); + } + @Override public ChunkGeneratorStructureState createState(HolderLookup sets, + RandomState random, long seed) { + return base.createState(sets, random, seed); + } + @Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, + StructureManager structures, ChunkAccess chunk, + StructureTemplateManager templates, ResourceKey dimension) { + base.createStructures(registries, state, structures, chunk, templates, dimension); + } + @Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) { + base.createReferences(level, structures, chunk); + } + @Override public Pair> findNearestMapStructure(ServerLevel level, + HolderSet targets, BlockPos origin, int radius, boolean skipKnown) { + return base.findNearestMapStructure(level, targets, origin, radius, skipKnown); + } + @Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); } + @Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); } + @Override public int getGenDepth() { return base.getGenDepth(); } + @Override public int getSeaLevel() { return base.getSeaLevel(); } + @Override public int getMinY() { return base.getMinY(); } + @Override public CompletableFuture buildTerrain(ChunkAccess chunk, Blender blender, + RandomState random, StructureManager structures, BiomeManager biomes, + WorldGenRegion carverBiomeRegion, Set> possibleBiomes) { + CompletableFuture built = base.buildTerrain( + chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes); + if (terrainFeatures == null || !terrainFeatures.skylands() && terrainFeatures.choices().isEmpty()) return built; + return built.thenApply(result -> { + // Match 1.21.1: filter the base terrain once, then add the authored + // solid volumes, irrespective of the Skylands page's written position. + if (terrainFeatures.skylands()) PortSkylandsFeature.filter(result, ageSeed); + java.util.Map occurrences = new java.util.HashMap<>(); + for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) { + if (PortTerrainFeaturePlan.isSurface(choice.page())) continue; + int occurrence = occurrences.merge(choice.page(), 1, Integer::sum) - 1; + long featureSeed = ageSeed ^ (long) occurrence * 0x9E3779B97F4A7C15L; + BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState(); + if (choice.page().equals(PortTerrainFeaturePlan.FLOATING_ISLANDS)) + PortFloatingIslandFeature.generate(result, featureSeed, material); + else if (choice.page().equals(PortTerrainFeaturePlan.TENDRILS)) + PortTendrilFeature.generate(result, featureSeed, material); + else if (choice.page().equals(PortTerrainFeaturePlan.SPHERES)) + PortSpheresFeature.generate(result, featureSeed, material); + else throw new IllegalStateException("Unsupported terrain feature: " + choice.page()); + } + return result; + }); + } + @Override public int getBaseHeight(int x, int z, Heightmap.Types type, + LevelHeightAccessor height, RandomState random) { + return base.getBaseHeight(x, z, type, height, random); + } + @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) { + return base.getBaseColumn(x, z, height, random); + } + @Override public void addDebugScreenInfo(List info, RandomState random, + BlockPos pos, SamplerContext context) { + base.addDebugScreenInfo(info, random, pos, context); + } + @Override public void refreshFeaturesPerStep() { + base.refreshFeaturesPerStep(); + super.refreshFeaturesPerStep(); + } + + @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, + StructureManager structures) { + base.applyBiomeDecoration(level, chunk, structures); + if (terrainFeatures != null) { + PortDenseOres.place(level, chunk, this, ageSeed, terrainFeatures.denseOres()); + int spikeOccurrence = 0; + int obeliskOccurrence = 0; + int crystalOccurrence = 0; + for (PortTerrainFeaturePlan.Choice choice : terrainFeatures.choices()) { + if (!PortTerrainFeaturePlan.isSurface(choice.page())) continue; + BlockState material = BuiltInRegistries.BLOCK.getValue(choice.material()).defaultBlockState(); + if (choice.page().equals(PortTerrainFeaturePlan.SPIKES)) + PortSpikesFeature.generate(level, chunk, ageSeed, material, spikeOccurrence++); + else if (choice.page().equals(PortTerrainFeaturePlan.OBELISKS)) + PortObelisksFeature.generate(level, chunk, ageSeed, material, obeliskOccurrence++); + else if (choice.page().equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS)) + PortCrystalFormationsFeature.generate(level, chunk, ageSeed, crystalOccurrence++); + else throw new IllegalStateException("Unsupported surface feature: " + choice.page()); + } + } + ChunkPos chunkPos = chunk.getPos(); + long chunkSeed = ageSeed ^ (long) chunkPos.x() * 341873128712L + ^ (long) chunkPos.z() * 132897987541L; + BlockPos origin = new BlockPos(chunkPos.getMinBlockX(), level.getMinY(), chunkPos.getMinBlockZ()); + if (rocks != null) { + PlacedFeature vanilla = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) + .getOrThrow(FOREST_ROCK).value(); + var placement = vanilla.placement().stream() + .filter(modifier -> !(modifier instanceof BiomeFilter)).toList(); + for (int occurrence = 0; occurrence < rocks.materials().size(); occurrence++) { + RandomSource random = RandomSource.create(chunkSeed + ^ (long) occurrence * 0xD1B54A32D192ED03L); + BlockState material = BuiltInRegistries.BLOCK.getValue( + rocks.materials().get(occurrence)).defaultBlockState(); + PlacedFeature authored = new PlacedFeature(Holder.direct(new BlockBlobFeature( + material, BlockPredicate.matchesTag(ROCK_GROUND))), placement); + authored.place(level, this, random, origin); + } + } + if (dungeons > 0) { + PlacedFeature authored = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) + .getOrThrow(DUNGEONS).value(); + for (int occurrence = 0; occurrence < dungeons; occurrence++) { + RandomSource random = RandomSource.create(chunkSeed + ^ 0x44554E47454F4E53L ^ (long) occurrence * 0xD1B54A32D192ED03L); + authored.place(level, this, random, origin); + } + } + if (lakes != null) { + var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE); + java.util.Map occurrences = new java.util.HashMap<>(); + for (PortLakePlan.Choice choice : lakes.choices()) { + PlacedFeature vanilla = registry.getOrThrow(ResourceKey.create( + Registries.PLACED_FEATURE, PortLakePlan.placedFeature(choice.page()))).value(); + if (!(vanilla.feature().value() instanceof LakeFeature lake)) + throw new IllegalStateException("Lake placement no longer references a LakeFeature: " + choice.page()); + BlockState fluid = BuiltInRegistries.BLOCK.getValue(choice.fluid()).defaultBlockState(); + LakeFeature feature = new LakeFeature(BlockStateProvider.holderOf(fluid), lake.barrier(), + lake.canPlaceFeature(), lake.canReplaceWithAirOrFluid(), lake.canReplaceWithBarrier()); + PlacedFeature authored = new PlacedFeature(Holder.direct(feature), vanilla.placement().stream() + .filter(modifier -> !(modifier instanceof BiomeFilter)) + .map(modifier -> !PortLakePlan.isDeep(choice.page()) && modifier instanceof RarityFilter + ? RarityFilter.onAverageOnceEvery(12) : modifier) + .toList()); + int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1; + RandomSource random = RandomSource.create(chunkSeed + ^ (long) choice.hashCode() * 0x9E3779B97F4A7C15L + ^ (long) occurrence * 0xD1B54A32D192ED03L); + authored.place(level, this, random, origin); + } + } + if (vegetation != null) { + int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8); + Holder biome = level.getBiome(new BlockPos( + chunkPos.getMinBlockX() + 8, surface, chunkPos.getMinBlockZ() + 8)); + if (biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome)) return; + var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE); + for (int occurrence = 0; occurrence < vegetation.pages().size(); occurrence++) { + Identifier page = vegetation.pages().get(occurrence); + Identifier placedId = PortVegetationPlan.placedFeature(page); + PlacedFeature vanilla = registry.getOrThrow( + ResourceKey.create(Registries.PLACED_FEATURE, placedId)).value(); + PlacedFeature authored = new PlacedFeature(vanilla.feature(), vanilla.placement().stream() + .filter(modifier -> !(modifier instanceof BiomeFilter)).toList()); + RandomSource random = RandomSource.create(chunkSeed + ^ (long) page.hashCode() * 0x9E3779B97F4A7C15L + ^ (long) occurrence * 0xD1B54A32D192ED03L); + authored.place(level, this, random, origin); + } + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFloatingIslandFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFloatingIslandFeature.java new file mode 100644 index 0000000..75d18a8 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFloatingIslandFeature.java @@ -0,0 +1,83 @@ +package net.dnorberg.rivencraft.age; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.QuartPos; +import net.minecraft.tags.BiomeTags; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; +import net.dnorberg.rivencraft.writing.PortRegistryPages; + +/** 1.21.1 floating-island shape adapted to the 26.3 chunk terrain stage. */ +final class PortFloatingIslandFeature { + private static final long SALT = 0x464C4F4154494E47L; + private static final int SOURCE_RANGE = 3; + private static final int CHANCE = 128; + + private PortFloatingIslandFeature() {} + + static void generate(ChunkAccess chunk, long ageSeed, BlockState material) { + var target = chunk.getPos(); + for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) { + for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) { + RandomSource random = RandomSource.create(mix(ageSeed ^ SALT + ^ (long) sourceX * 341873128712L ^ (long) sourceZ * 132897987541L)); + if (random.nextInt(CHANCE) != 0) continue; + int centerX = sourceX * 16 + random.nextInt(16); + int centerZ = sourceZ * 16 + random.nextInt(16); + int centerY = Math.min(chunk.getMaxY() - 30, 168 + random.nextInt(48)); + if (centerY < chunk.getMinY() + 24) continue; + int radiusX = 24 + random.nextInt(18); + int radiusZ = 24 + random.nextInt(18); + fill(chunk, ageSeed, material, centerX, centerY, centerZ, radiusX, radiusZ); + } + } + } + + private static void fill(ChunkAccess chunk, long ageSeed, BlockState material, + int centerX, int centerY, int centerZ, int radiusX, int radiusZ) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int localX = 0; localX < 16; localX++) { + int worldX = originX + localX; + double dx = (worldX - centerX) / (double) radiusX; + for (int localZ = 0; localZ < 16; localZ++) { + int worldZ = originZ + localZ; + double dz = (worldZ - centerZ) / (double) radiusZ; + double distance = dx * dx + dz * dz; + if (distance >= 1.0) continue; + long detail = mix(ageSeed ^ (long) centerX * 341873128712L + ^ (long) centerZ * 132897987541L ^ (long) worldX * 42317861L + ^ (long) worldZ * 374761393L); + int top = centerY + (int) Math.round(3 * (1 - distance)) + Math.floorMod(detail, 3) - 1; + int depth = Math.max(1, (int) Math.round(3 + 17 * Math.pow(1 - distance, 1.5))); + // Biomes are already populated by createBiomes in 26.3, so + // sampling this chunk preserves its authored biome distribution. + var biome = chunk.getNoiseBiome(QuartPos.fromBlock(worldX), QuartPos.fromBlock(top), + QuartPos.fromBlock(worldZ)); + BlockState soil = biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome) + ? material : Blocks.DIRT.defaultBlockState(); + BlockState cap = soil == material ? material : Blocks.GRASS_BLOCK.defaultBlockState(); + for (int y = Math.max(top - depth, chunk.getMinY()); y <= top; y++) { + pos.set(worldX, y, worldZ); + if (!chunk.getBlockState(pos).isAir()) continue; + BlockState state = y == top ? cap : y >= top - 3 ? soil : material; + chunk.setBlockState(pos, state); + surface.update(localX, y, localZ, state); + floor.update(localX, y, localZ, state); + } + } + } + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLakePlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLakePlan.java new file mode 100644 index 0000000..ccd12ff --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLakePlan.java @@ -0,0 +1,46 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; + +/** Ordered lake pages with fluid choices frozen when the Age is bound. */ +public record PortLakePlan(List choices) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Choice.CODEC.listOf().fieldOf("choices").forGetter(PortLakePlan::choices) + ).apply(instance, PortLakePlan::new)); + + public record Choice(Identifier page, Identifier fluid) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("page").forGetter(Choice::page), + Identifier.CODEC.fieldOf("fluid").forGetter(Choice::fluid) + ).apply(instance, Choice::new)); + + public Choice { + if (!isLake(page) || fluid == null) throw new IllegalArgumentException("Invalid lake choice"); + } + } + + public PortLakePlan { + choices = List.copyOf(choices); + if (choices.isEmpty() || choices.size() > 32) + throw new IllegalArgumentException("Lake plan must contain 1–32 choices"); + } + + public static boolean isLake(Identifier page) { + if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false; + return switch (page.getPath()) { + case "feature/surface_lakes", "feature/deep_lakes", "feature/lava_lakes" -> true; + default -> false; + }; + } + + public static boolean isDeep(Identifier page) { return page.getPath().equals("feature/deep_lakes"); } + + public static Identifier placedFeature(Identifier page) { + if (!isLake(page)) throw new IllegalArgumentException("Unsupported lake page " + page); + return Identifier.withDefaultNamespace(isDeep(page) ? "lake_lava_underground" : "lake_lava_surface"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortObelisksFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortObelisksFeature.java new file mode 100644 index 0000000..fcc1077 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortObelisksFeature.java @@ -0,0 +1,91 @@ +package net.dnorberg.rivencraft.age; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** 26.3 port of Rivencraft 1.21.1's Legacy-style 4×4 plinth and 2×12×2 shaft. */ +final class PortObelisksFeature { + private static final long SALT = 0x4F42454C49534B53L; + private static final int CHANCE = 128; + private static final int BASE = 4; + private static final int HEIGHT = 12; + + private PortObelisksFeature() {} + + record Site(int x, int z) {} + + static Site site(long ageSeed, ChunkPos chunk, int occurrence) { + RandomSource random = RandomSource.create(mix(ageSeed ^ SALT + ^ (long) chunk.x() * 341873128712L ^ (long) chunk.z() * 132897987541L + ^ (long) occurrence * 0x9E3779B97F4A7C15L)); + if (random.nextInt(CHANCE) != 0) return null; + return new Site(chunk.getMinBlockX() + 4 + random.nextInt(8), + chunk.getMinBlockZ() + 4 + random.nextInt(8)); + } + + static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) { + Site site = site(ageSeed, chunk.getPos(), occurrence); + if (site == null) return; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minSurface = Integer.MAX_VALUE; + int maxSurface = Integer.MIN_VALUE; + int[][] surface = new int[BASE][BASE]; + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + int x = site.x() + dx; + int z = site.z() + dz; + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z); + if (y <= level.getMinY() + 1) return; + BlockState ground = level.getBlockState(pos.set(x, y - 1, z)); + if (!ground.isFaceSturdy(level, pos, Direction.UP) + || !ground.getFluidState().isEmpty() || ground.hasBlockEntity()) return; + surface[dx][dz] = y; + minSurface = Math.min(minSurface, y); + maxSurface = Math.max(maxSurface, y); + } + } + if (maxSurface - minSurface > 2 || maxSurface + HEIGHT >= level.getMaxY()) return; + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + for (int y = surface[dx][dz]; y <= maxSurface; y++) { + if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return; + } + } + } + for (int dx = 1; dx <= 2; dx++) { + for (int dz = 1; dz <= 2; dz++) { + for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) { + if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return; + } + } + } + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + for (int y = surface[dx][dz]; y <= maxSurface; y++) + level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2); + } + } + for (int dx = 1; dx <= 2; dx++) { + for (int dz = 1; dz <= 2; dz++) { + for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) + level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2); + } + } + } + + private static boolean open(BlockState state) { + return state.getFluidState().isEmpty() && (state.isAir() || state.canBeReplaced()); + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockChunkGenerator.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockChunkGenerator.java deleted file mode 100644 index 715ef8d..0000000 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockChunkGenerator.java +++ /dev/null @@ -1,134 +0,0 @@ -package net.dnorberg.rivencraft.age; - -import com.mojang.serialization.MapCodec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Holder; -import net.minecraft.core.HolderLookup; -import net.minecraft.core.RegistryAccess; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.WorldGenRegion; -import net.minecraft.util.RandomSource; -import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.LevelHeightAccessor; -import net.minecraft.world.level.NoiseColumn; -import net.minecraft.world.level.StructureManager; -import net.minecraft.world.level.WorldGenLevel; -import net.minecraft.world.level.biome.Biome; -import net.minecraft.world.level.biome.BiomeManager; -import net.minecraft.world.level.chunk.ChunkAccess; -import net.minecraft.world.level.chunk.ChunkGenerator; -import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; -import net.minecraft.world.level.levelgen.Heightmap; -import net.minecraft.world.level.levelgen.RandomState; -import net.minecraft.world.level.levelgen.blending.Blender; -import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; -import net.minecraft.world.level.levelgen.structure.StructureSet; -import net.minecraft.world.level.levelgen.structure.Structure; -import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; -import com.mojang.datafixers.util.Pair; -import net.minecraft.core.HolderSet; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.state.BlockState; - -/** Delegates 26.3 terrain generation and adds one Age-scoped Rocks decoration. */ -public final class PortRockChunkGenerator extends ChunkGenerator { - public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( - ChunkGenerator.CODEC.fieldOf("base").forGetter(generator -> generator.base), - PortRockPlan.CODEC.fieldOf("rocks").forGetter(generator -> generator.rocks), - com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed) - ).apply(instance, PortRockChunkGenerator::new)); - - private final ChunkGenerator base; - private final PortRockPlan rocks; - private final long ageSeed; - - public PortRockChunkGenerator(ChunkGenerator base, PortRockPlan rocks, long ageSeed) { - super(base.getBiomeSource(), base::getBiomeGenerationSettings); - this.base = base; - this.rocks = rocks; - this.ageSeed = ageSeed; - } - - public ChunkGenerator base() { return base; } - public PortRockPlan rocks() { return rocks; } - - @Override protected MapCodec codec() { return CODEC; } - @Override public void validate() { base.validate(); } - @Override public ChunkPos getOrigin(RandomState random) { return base.getOrigin(random); } - @Override public java.util.Optional getTypeNameForDataFixer() { return base.getTypeNameForDataFixer(); } - @Override public CompletableFuture createBiomes(RandomState random, Blender blender, - StructureManager structures, ChunkAccess chunk) { - return base.createBiomes(random, blender, structures, chunk); - } - @Override public ChunkGeneratorStructureState createState(HolderLookup sets, - RandomState random, long seed) { - return base.createState(sets, random, seed); - } - @Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, - StructureManager structures, ChunkAccess chunk, - StructureTemplateManager templates, ResourceKey dimension) { - base.createStructures(registries, state, structures, chunk, templates, dimension); - } - @Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) { - base.createReferences(level, structures, chunk); - } - @Override public Pair> findNearestMapStructure(ServerLevel level, - HolderSet targets, BlockPos origin, int radius, boolean skipKnown) { - return base.findNearestMapStructure(level, targets, origin, radius, skipKnown); - } - @Override public void spawnOriginalMobs(WorldGenRegion level) { base.spawnOriginalMobs(level); } - @Override public int getSpawnHeight(LevelHeightAccessor height) { return base.getSpawnHeight(height); } - @Override public int getGenDepth() { return base.getGenDepth(); } - @Override public int getSeaLevel() { return base.getSeaLevel(); } - @Override public int getMinY() { return base.getMinY(); } - @Override public CompletableFuture buildTerrain(ChunkAccess chunk, Blender blender, - RandomState random, StructureManager structures, BiomeManager biomes, - WorldGenRegion carverBiomeRegion, Set> possibleBiomes) { - return base.buildTerrain(chunk, blender, random, structures, biomes, carverBiomeRegion, possibleBiomes); - } - @Override public int getBaseHeight(int x, int z, Heightmap.Types type, - LevelHeightAccessor height, RandomState random) { - return base.getBaseHeight(x, z, type, height, random); - } - @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) { - return base.getBaseColumn(x, z, height, random); - } - @Override public void addDebugScreenInfo(List info, RandomState random, - BlockPos pos, SamplerContext context) { - base.addDebugScreenInfo(info, random, pos, context); - } - @Override public void refreshFeaturesPerStep() { - base.refreshFeaturesPerStep(); - super.refreshFeaturesPerStep(); - } - - @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, - StructureManager structures) { - base.applyBiomeDecoration(level, chunk, structures); - ChunkPos chunkPos = chunk.getPos(); - long seed = ageSeed ^ (long) chunkPos.x() * 341873128712L ^ (long) chunkPos.z() * 132897987541L; - RandomSource random = RandomSource.create(seed); - if (random.nextInt(5) != 0) return; - BlockState material = BuiltInRegistries.BLOCK.getValue(rocks.material()).defaultBlockState(); - int centerX = chunkPos.getMinBlockX() + 2 + random.nextInt(12); - int centerZ = chunkPos.getMinBlockZ() + 2 + random.nextInt(12); - for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++) { - if (random.nextInt(4) == 0) continue; - int x = centerX + dx; - int z = centerZ + dz; - int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z); - BlockPos place = new BlockPos(x, y, z); - if (level.getBlockState(place).isAir() - && level.getFluidState(place).isEmpty() - && level.getBlockState(place.below()).isFaceSturdy(level, place.below(), net.minecraft.core.Direction.UP)) - level.setBlock(place, material, 2); - } - } -} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockPlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockPlan.java index d3b9771..deb32e9 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockPlan.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRockPlan.java @@ -2,11 +2,23 @@ package net.dnorberg.rivencraft.age; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.List; +import java.util.Optional; import net.minecraft.resources.Identifier; -/** First frozen authored feature: one Rocks page with an explicit block material. */ -public record PortRockPlan(Identifier material) { +/** Frozen, ordered Rocks materials. Old single-material beta Ages remain readable. */ +public record PortRockPlan(List materials) { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("material").forGetter(PortRockPlan::material) - ).apply(instance, PortRockPlan::new)); + Identifier.CODEC.optionalFieldOf("material").forGetter(plan -> Optional.empty()), + Identifier.CODEC.listOf().optionalFieldOf("materials", List.of()).forGetter(PortRockPlan::materials) + ).apply(instance, (old, current) -> new PortRockPlan(current.isEmpty() + ? old.map(List::of).orElse(List.of()) : current))); + + public PortRockPlan { + materials = List.copyOf(materials); + if (materials.isEmpty() || materials.size() > 32) + throw new IllegalArgumentException("Rocks plan must contain 1–32 materials"); + } + + public PortRockPlan(Identifier material) { this(List.of(material)); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkylandsFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkylandsFeature.java new file mode 100644 index 0000000..27171d3 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkylandsFeature.java @@ -0,0 +1,87 @@ +package net.dnorberg.rivencraft.age; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * 26.3 port of Rivencraft's subtractive Skylands filter, adapted from + * Mystcraft Legacy's SymbolSkylands (LGPL-3.0-or-later). Keeps upper solid + * terrain above a seven-octave cutoff near Y=76 and removes liquids. + */ +final class PortSkylandsFeature { + private static final long SALT = 0x534B594C414E4453L; + private static final int BASE_CUTOFF = 76; + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private PortSkylandsFeature() {} + + static void filter(ChunkAccess chunk, long ageSeed) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + int minY = chunk.getMinY(); + int maxY = chunk.getMaxY(); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ); + for (int y = minY; y < maxY; y++) { + pos.set(originX + localX, y, originZ + localZ); + BlockState state = chunk.getBlockState(pos); + if (!removes(state, y, cutoff)) continue; + chunk.setBlockState(pos, AIR); + surface.update(localX, y, localZ, AIR); + floor.update(localX, y, localZ, AIR); + } + } + } + } + + static boolean removes(BlockState state, int y, int cutoff) { + return !state.isAir() && (y <= cutoff || state.liquid()); + } + + static int cutoff(long ageSeed, int x, int z) { + double variation = 0.0D; + int cellSize = 128; + double amplitude = 12.0D; + for (int octave = 0; octave < 7; octave++) { + variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L, + x, z, cellSize) * amplitude; + cellSize /= 2; + amplitude /= 2.0D; + } + return BASE_CUTOFF + (int) Math.round(variation); + } + + private static double valueNoise(long seed, int x, int z, int cellSize) { + int cellX = Math.floorDiv(x, cellSize); + int cellZ = Math.floorDiv(z, cellSize); + double u = smooth((x - cellX * cellSize) / (double) cellSize); + double v = smooth((z - cellZ * cellSize) / (double) cellSize); + double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u); + double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u); + return lerp(north, south, v); + } + + private static double value(long seed, int x, int z) { + long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L ^ (long) z * 0xABC98388FB8FAC03L); + return (hash >>> 11) * 0x1.0p-52 - 1.0D; + } + + private static double smooth(double value) { return value * value * (3.0D - 2.0D * value); } + + private static double lerp(double first, double second, double amount) { + return first + (second - first) * amount; + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpheresFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpheresFeature.java new file mode 100644 index 0000000..3060596 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpheresFeature.java @@ -0,0 +1,122 @@ +package net.dnorberg.rivencraft.age; + +import java.util.Random; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * 26.3 adaptation of Rivencraft 1.21.1's solid sphere nodes, derived from + * Mystcraft Legacy's MapGenSpheresMyst (Mystcraft contributors, + * LGPL-3.0-or-later, commit 9bc8ddc). Writes stay within the target chunk. + */ +final class PortSpheresFeature { + private static final int SOURCE_RANGE = 8; + private static final long SALT = 0x53504845524553L; + + private PortSpheresFeature() {} + + static void generate(ChunkAccess chunk, long ageSeed, BlockState material) { + var target = chunk.getPos(); + Random master = new Random(ageSeed ^ SALT); + long xSeed = master.nextLong(); + long zSeed = master.nextLong(); + for (int sx = target.x() - SOURCE_RANGE; sx <= target.x() + SOURCE_RANGE; sx++) { + for (int sz = target.z() - SOURCE_RANGE; sz <= target.z() + SOURCE_RANGE; sz++) { + Random source = new Random((long) sx * xSeed ^ (long) sz * zSeed ^ ageSeed ^ SALT); + if (source.nextFloat() > 0.05F) continue; + double x = sx * 16 + source.nextInt(16); + double y = source.nextInt(source.nextInt(192) + 1) + 32; + double z = sz * 16 + source.nextInt(16); + trace(chunk, material, source.nextLong(), x, y, z, + 1.0F + source.nextFloat() * 4.0F, 0, 0, -1, -1); + } + } + } + + private static void trace(ChunkAccess chunk, BlockState material, long seed, + double x, double y, double z, float radius, float yaw, float pitch, + int step, int length) { + Random random = new Random(seed); + if (length <= 0) { + int maximum = SOURCE_RANGE * 16 - 16; + length = maximum - random.nextInt(maximum / 4); + } + boolean singleNode = step == -1; + if (singleNode) step = length / 2; + int splitAt = random.nextInt(length / 2) + length / 4; + boolean slowPitch = random.nextInt(6) == 0; + float yawDrift = 0; + float pitchDrift = 0; + double middleX = chunk.getPos().getMinBlockX() + 8; + double middleZ = chunk.getPos().getMinBlockZ() + 8; + for (; step < length; step++) { + double nodeRadius = 1.5D + Math.sin(step * Math.PI / length) * radius; + float horizontal = Mth.cos(pitch); + x += Mth.cos(yaw) * horizontal; + y += Mth.sin(pitch); + z += Mth.sin(yaw) * horizontal; + pitch *= slowPitch ? 0.92F : 0.7F; + pitch += pitchDrift * 0.1F; + yaw += yawDrift * 0.1F; + pitchDrift = pitchDrift * 0.9F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F; + yawDrift = yawDrift * 0.75F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F; + if (!singleNode && step == splitAt && radius > 1.0F) { + trace(chunk, material, random.nextLong(), x, y, z, + random.nextFloat() * 0.5F + 0.5F, yaw - Mth.HALF_PI, pitch / 3, step, length); + trace(chunk, material, random.nextLong(), x, y, z, + random.nextFloat() * 0.5F + 0.5F, yaw + Mth.HALF_PI, pitch / 3, step, length); + return; + } + if (!singleNode && random.nextInt(4) == 0) continue; + double dx = x - middleX; + double dz = z - middleZ; + double remaining = length - step; + double reach = radius + 18; + if (dx * dx + dz * dz - remaining * remaining > reach * reach) return; + if (Math.abs(dx) <= 16 + nodeRadius * 2 && Math.abs(dz) <= 16 + nodeRadius * 2) + fillNode(chunk, material, x, y, z, nodeRadius); + if (singleNode) break; + } + } + + private static void fillNode(ChunkAccess chunk, BlockState material, + double centerX, double centerY, double centerZ, double radius) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + int minX = Math.max(0, Mth.floor(centerX - radius) - originX - 1); + int maxX = Math.min(16, Mth.floor(centerX + radius) - originX + 2); + int minZ = Math.max(0, Mth.floor(centerZ - radius) - originZ - 1); + int maxZ = Math.min(16, Mth.floor(centerZ + radius) - originZ + 2); + int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - radius) - 1); + int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + radius) + 2); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int worldY = minY; worldY < maxY; worldY++) { + double dy = (worldY + 0.5D - centerY) / radius; + for (int localZ = minZ; localZ < maxZ; localZ++) { + double dz = (originZ + localZ + 0.5D - centerZ) / radius; + for (int localX = minX; localX < maxX; localX++) { + double dx = (originX + localX + 0.5D - centerX) / radius; + if (!insideSphere(dx, dy, dz)) continue; + pos.set(originX + localX, worldY, originZ + localZ); + BlockState previous = chunk.getBlockState(pos); + if (previous == material || previous.is(Blocks.BEDROCK)) continue; + chunk.setBlockState(pos, material); + surface.update(localX, worldY, localZ, material); + floor.update(localX, worldY, localZ, material); + } + } + } + } + + static boolean insideSphere(double dx, double dy, double dz) { + return dx * dx + dy * dy + dz * dz < 1.0D; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpikesFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpikesFeature.java new file mode 100644 index 0000000..2ca25ae --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSpikesFeature.java @@ -0,0 +1,56 @@ +package net.dnorberg.rivencraft.age; + +import net.minecraft.core.BlockPos; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * 26.3 port of Rivencraft 1.21.1's rare surface spikes, adapted from + * Mystcraft Legacy's WorldGenSpikesAdv (Mystcraft contributors, + * LGPL-3.0-or-later, commit 9bc8ddc). The footprint stays within one chunk. + */ +final class PortSpikesFeature { + private static final long SALT = 0x5350494B4553L; + + private PortSpikesFeature() {} + + static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, BlockState material, int occurrence) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + RandomSource random = RandomSource.create(ageSeed ^ SALT + ^ (long) originX * 341873128712L ^ (long) originZ * 132897987541L + ^ (long) occurrence * 0x9E3779B97F4A7C15L); + if (random.nextInt(18) != 0) return; + int x = originX + 4 + random.nextInt(8); + int z = originZ + 4 + random.nextInt(8); + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z); + int height = 6 + random.nextInt(32); + int width = 1 + random.nextInt(4); + if (y < level.getMinY() + 1 || y + height >= level.getMaxY()) return; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + if (!level.isEmptyBlock(pos.set(x, y, z))) return; + for (int px = x - width; px <= x + width; px++) { + for (int pz = z - width; pz <= z + width; pz++) { + if (!insideFootprint(px - x, pz - z, width)) continue; + if (!level.getBlockState(pos.set(px, y - 1, pz)).isSolid()) return; + } + } + for (int px = x - width; px <= x + width; px++) { + for (int pz = z - width; pz <= z + width; pz++) { + if (!insideFootprint(px - x, pz - z, width)) continue; + int top = y + 1 + random.nextInt(random.nextInt(height) + 1); + for (int py = y; py < top; py++) { + pos.set(px, py, pz); + if (level.isEmptyBlock(pos)) level.setBlock(pos, material, 2); + } + } + } + } + + static boolean insideFootprint(int dx, int dz, int width) { + return dx * dx + dz * dz <= width * width + 1; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStarFissureFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStarFissureFeature.java new file mode 100644 index 0000000..2224b38 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStarFissureFeature.java @@ -0,0 +1,107 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** + * Ports Rivencraft 1.21.1's Legacy fissure footprint/carving. + * Derived from Mystcraft's WorldGenMystStarFissure, LGPL-3.0, + * copyright XCompWiz and Mystcraft contributors. + */ +public final class PortStarFissureFeature { + private static final long SALT = 0x5354415246495353L; + + private PortStarFissureFeature() {} + + /** Per-Age data prevents revisits/restarts from carving the same fissure again. */ + private static final class Arrival extends SavedData { + private static final Codec CODEC = BlockPos.CODEC.optionalFieldOf("spawn").codec() + .xmap(Arrival::new, data -> Optional.ofNullable(data.spawn)); + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "star_fissure_arrival"), + () -> new Arrival(Optional.empty()), CODEC); + private BlockPos spawn; + + private Arrival(Optional spawn) { this.spawn = spawn.orElse(null); } + } + + public static BlockPos savedSpawn(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(Arrival.TYPE).spawn; + } + + public static void generateOnce(ServerLevel level, BlockPos spawn, boolean caveTerrain) { + Arrival data = level.getDataStorage().computeIfAbsent(Arrival.TYPE); + if (data.spawn != null) return; + generate(level, level.getSeed(), spawn, caveTerrain); + data.spawn = spawn.immutable(); + data.setDirty(); + } + + /** Legacy chooses 8–23 blocks after the spawn chunk, then grows along +Z. */ + public static List footprint(long ageSeed, BlockPos spawn) { + long chunkX = spawn.getX() >> 4; + long chunkZ = spawn.getZ() >> 4; + Random random = new Random(ageSeed ^ SALT ^ chunkX ^ (chunkZ << 32)); + int originX = ((int) chunkX << 4) + random.nextInt(16) + 8; + int originZ = ((int) chunkZ << 4) + random.nextInt(16) + 8; + int length = random.nextInt(8) + 10; + int[][] intervals = new int[length][2]; + List columns = new ArrayList<>(); + addRow(columns, originX, originZ, 0, 0, 0); + for (int row = 1; row < length; row++) { + int range = length + Math.min(row, length - row) + 1; + int right = (random.nextInt(range) - (range >> 1) + intervals[row - 1][1]) / 2; + int left = (random.nextInt(range) - (range >> 1) + intervals[row - 1][0]) / 2; + if (left > right) { + int swap = left; + left = right; + right = swap; + } + left = Math.min(left, intervals[row - 1][1]); + right = Math.max(right, intervals[row - 1][0]); + intervals[row][0] = left; + intervals[row][1] = right; + addRow(columns, originX, originZ, row, left, right); + } + return List.copyOf(columns); + } + + private static void addRow(List columns, int originX, int originZ, int row, int left, int right) { + for (int x = left; x <= right; x++) { + // Only X/Z are used; the real floor is the dimension's minimum Y. + columns.add(new BlockPos(originX + x, 0, originZ + row * 2)); + columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1)); + } + } + + private static void generate(ServerLevel level, long ageSeed, BlockPos spawn, boolean caveTerrain) { + int floorY = level.getMinY(); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + for (BlockPos column : footprint(ageSeed, spawn)) { + int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ()); + if (caveTerrain) { + for (int y = Math.max(spawn.getY(), floorY + 1); y < top; y++) { + if (level.getBlockState(mutable.set(column.getX(), y, column.getZ())).is(Blocks.BEDROCK)) { + top = y; + break; + } + } + } + for (int y = floorY + 1; y < top; y++) + level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2); + level.setBlock(mutable.set(column.getX(), floorY, column.getZ()), + Rivencraft.STAR_FISSURE.get().defaultBlockState(), 2); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTendrilFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTendrilFeature.java new file mode 100644 index 0000000..25743c0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTendrilFeature.java @@ -0,0 +1,142 @@ +package net.dnorberg.rivencraft.age; + +import java.util.Random; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * 26.3 adaptation of Rivencraft 1.21.1's winding solid volumes, originally + * adapted from Mystcraft Legacy's SymbolTendrils and MapGenCavesMyst (LGPL-3.0-or-later). + * Each chunk reconstructs the neighboring source paths to preserve continuous borders. + */ +final class PortTendrilFeature { + private static final long SALT = 0x54454E4452494C53L; + private static final int SOURCE_RANGE = 8; + private static final int SOURCE_RATE = 15; + private static final int SOURCE_SIZE = 18; + + private PortTendrilFeature() {} + + static void generate(ChunkAccess chunk, long ageSeed, BlockState material) { + var target = chunk.getPos(); + Random master = new Random(ageSeed ^ SALT); + long xSeed = master.nextLong(); + long zSeed = master.nextLong(); + for (int sourceX = target.x() - SOURCE_RANGE; sourceX <= target.x() + SOURCE_RANGE; sourceX++) { + for (int sourceZ = target.z() - SOURCE_RANGE; sourceZ <= target.z() + SOURCE_RANGE; sourceZ++) { + Random source = new Random((long) sourceX * xSeed ^ (long) sourceZ * zSeed ^ ageSeed ^ SALT); + int nodes = source.nextInt(source.nextInt(source.nextInt(SOURCE_SIZE) + 1) + 1); + if (source.nextInt(SOURCE_RATE) != 0) continue; + for (int node = 0; node < nodes; node++) { + double x = sourceX * 16 + source.nextInt(16); + double y = source.nextInt(source.nextInt(120) + 8); + double z = sourceZ * 16 + source.nextInt(16); + int branches = 1; + if (source.nextInt(4) == 0) { + trace(chunk, material, source.nextLong(), x, y, z, + 1.0F + source.nextFloat() * 6.0F, 0.0F, 0.0F, -1, -1, 0.5D); + branches += source.nextInt(4); + } + for (int branch = 0; branch < branches; branch++) { + float yaw = source.nextFloat() * Mth.TWO_PI; + float pitch = (source.nextFloat() - 0.5F) / 4.0F; + float radius = source.nextFloat() * 2.0F + source.nextFloat(); + if (source.nextInt(10) == 0) + radius *= source.nextFloat() * source.nextFloat() * 3.0F + 1.0F; + trace(chunk, material, source.nextLong(), x, y, z, + radius, yaw, pitch, 0, 0, 1.0D); + } + } + } + } + } + + private static void trace(ChunkAccess chunk, BlockState material, long seed, + double x, double y, double z, float radius, float yaw, float pitch, + int step, int length, double verticalScale) { + Random random = new Random(seed); + if (length <= 0) { + int maximum = SOURCE_RANGE * 16 - 16; + length = maximum - random.nextInt(maximum / 4); + } + boolean singleNode = step == -1; + if (singleNode) step = length / 2; + int splitAt = random.nextInt(length / 2) + length / 4; + boolean slowPitch = random.nextInt(6) == 0; + float yawDrift = 0.0F; + float pitchDrift = 0.0F; + double middleX = chunk.getPos().getMinBlockX() + 8; + double middleZ = chunk.getPos().getMinBlockZ() + 8; + for (; step < length; step++) { + double horizontalRadius = 1.5D + Math.sin(step * Math.PI / length) * radius; + double verticalRadius = horizontalRadius * verticalScale; + float horizontalMotion = Mth.cos(pitch); + x += Mth.cos(yaw) * horizontalMotion; + y += Mth.sin(pitch); + z += Mth.sin(yaw) * horizontalMotion; + pitch *= slowPitch ? 0.92F : 0.7F; + pitch += pitchDrift * 0.1F; + yaw += yawDrift * 0.1F; + pitchDrift = pitchDrift * 0.9F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 2.0F; + yawDrift = yawDrift * 0.75F + + (random.nextFloat() - random.nextFloat()) * random.nextFloat() * 4.0F; + if (!singleNode && step == splitAt && radius > 1.0F) { + trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F, + yaw - Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D); + trace(chunk, material, random.nextLong(), x, y, z, random.nextFloat() * 0.5F + 0.5F, + yaw + Mth.HALF_PI, pitch / 3.0F, step, length, 1.0D); + return; + } + if (!singleNode && random.nextInt(4) == 0) continue; + double distanceX = x - middleX; + double distanceZ = z - middleZ; + double remaining = length - step; + double reach = radius + 18.0D; + if (distanceX * distanceX + distanceZ * distanceZ - remaining * remaining > reach * reach) return; + if (x >= middleX - 16.0D - horizontalRadius * 2.0D + && x <= middleX + 16.0D + horizontalRadius * 2.0D + && z >= middleZ - 16.0D - horizontalRadius * 2.0D + && z <= middleZ + 16.0D + horizontalRadius * 2.0D) { + fillNode(chunk, material, x, y, z, horizontalRadius, verticalRadius); + } + if (singleNode) break; + } + } + + private static void fillNode(ChunkAccess chunk, BlockState material, double centerX, double centerY, + double centerZ, double horizontalRadius, double verticalRadius) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + int minX = Math.max(0, Mth.floor(centerX - horizontalRadius) - originX - 1); + int maxX = Math.min(16, Mth.floor(centerX + horizontalRadius) - originX + 2); + int minZ = Math.max(0, Mth.floor(centerZ - horizontalRadius) - originZ - 1); + int maxZ = Math.min(16, Mth.floor(centerZ + horizontalRadius) - originZ + 2); + int minY = Math.max(chunk.getMinY() + 1, Mth.floor(centerY - verticalRadius) - 1); + int maxY = Math.min(chunk.getMaxY(), Mth.floor(centerY + verticalRadius) + 2); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int worldY = minY; worldY < maxY; worldY++) { + double dy = (worldY + 0.5D - centerY) / verticalRadius; + if (dy <= -0.7D || dy * dy >= 1.0D) continue; + for (int localZ = minZ; localZ < maxZ; localZ++) { + double dz = (originZ + localZ + 0.5D - centerZ) / horizontalRadius; + for (int localX = minX; localX < maxX; localX++) { + double dx = (originX + localX + 0.5D - centerX) / horizontalRadius; + if (dx * dx + dy * dy + dz * dz >= 1.0D) continue; + pos.set(originX + localX, worldY, originZ + localZ); + BlockState previous = chunk.getBlockState(pos); + if (previous == material || previous.is(Blocks.BEDROCK)) continue; + chunk.setBlockState(pos, material); + surface.update(localX, worldY, localZ, material); + floor.update(localX, worldY, localZ, material); + } + } + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java new file mode 100644 index 0000000..947ff28 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java @@ -0,0 +1,79 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.List; +import java.util.HashMap; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; + +/** Frozen terrain filters and ordered solid/surface features for an Age. */ +public record PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, boolean starFissures) { + public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "feature/floating_islands"); + public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils"); + public static final Identifier SKYLANDS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/skylands"); + public static final Identifier SPHERES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spheres"); + public static final Identifier SPIKES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/spikes"); + public static final Identifier OBELISKS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/obelisks"); + public static final Identifier CRYSTAL_FORMATIONS = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "feature/crystal_formations"); + public static final Identifier CRYSTAL_MATERIAL = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "crystal_block"); + public static final Identifier DENSE_ORES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dense_ores"); + public static final Identifier STAR_FISSURES = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/star_fissures"); + public static final int MAX_PER_FEATURE = 32; + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Choice.CODEC.listOf().fieldOf("choices").forGetter(PortTerrainFeaturePlan::choices), + Codec.BOOL.optionalFieldOf("skylands", false).forGetter(PortTerrainFeaturePlan::skylands), + Codec.intRange(0, MAX_PER_FEATURE).optionalFieldOf("dense_ores", 0) + .forGetter(PortTerrainFeaturePlan::denseOres), + Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures) + ).apply(instance, PortTerrainFeaturePlan::new)); + + public record Choice(Identifier page, Identifier material) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("page").forGetter(Choice::page), + Identifier.CODEC.fieldOf("material").forGetter(Choice::material) + ).apply(instance, Choice::new)); + + public Choice { + if (!isSupported(page) || material == null) + throw new IllegalArgumentException("Invalid terrain feature choice"); + if (CRYSTAL_FORMATIONS.equals(page) && !CRYSTAL_MATERIAL.equals(material)) + throw new IllegalArgumentException("Crystal formations use Rivencraft crystal, not a Block modifier"); + } + } + + public PortTerrainFeaturePlan { + choices = List.copyOf(choices); + if (denseOres < 0 || denseOres > MAX_PER_FEATURE) + throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences"); + if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures) + throw new IllegalArgumentException("Terrain feature plan must not be empty"); + var counts = new HashMap(); + for (Choice choice : choices) { + if (counts.merge(choice.page(), 1, Integer::sum) > MAX_PER_FEATURE) + throw new IllegalArgumentException("Terrain feature plan permits at most 32 occurrences per feature"); + } + } + + public PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres) { + this(choices, skylands, denseOres, false); + } + + public PortTerrainFeaturePlan(List choices, boolean skylands) { this(choices, skylands, 0, false); } + + public PortTerrainFeaturePlan(List choices) { this(choices, false, 0, false); } + + public static boolean isSupported(Identifier page) { + return FLOATING_ISLANDS.equals(page) || TENDRILS.equals(page) || SPHERES.equals(page) || isSurface(page); + } + + public static boolean acceptsMaterial(Identifier page) { + return isSupported(page) && !CRYSTAL_FORMATIONS.equals(page); + } + + public static boolean isSurface(Identifier page) { + return SPIKES.equals(page) || OBELISKS.equals(page) || CRYSTAL_FORMATIONS.equals(page); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortVegetationPlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortVegetationPlan.java new file mode 100644 index 0000000..dea2fac --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortVegetationPlan.java @@ -0,0 +1,38 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; + +/** Ordered, frozen authored vegetation pages for surface Ages. */ +public record PortVegetationPlan(List pages) { + public static final Identifier FLOWERS = page("flowers"); + public static final Identifier TREES = page("trees"); + public static final Identifier HUGE_TREES = page("huge_trees"); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.listOf().fieldOf("pages").forGetter(PortVegetationPlan::pages) + ).apply(instance, PortVegetationPlan::new)); + + public PortVegetationPlan { + pages = List.copyOf(pages); + if (pages.isEmpty() || pages.size() > 32 || pages.stream().anyMatch(page -> !isVegetation(page))) + throw new IllegalArgumentException("Vegetation plan requires 1–32 supported pages"); + } + + public static boolean isVegetation(Identifier page) { + return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES); + } + + public static Identifier placedFeature(Identifier page) { + if (page.equals(FLOWERS)) return Identifier.withDefaultNamespace("flower_default"); + if (page.equals(TREES)) return Identifier.withDefaultNamespace("trees_plains"); + if (page.equals(HUGE_TREES)) return Identifier.withDefaultNamespace("trees_old_growth_spruce_taiga"); + throw new IllegalArgumentException("Unsupported vegetation page " + page); + } + + private static Identifier page(String name) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + name); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index bd11eb8..cf2f07c 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -57,7 +57,8 @@ public final class RuntimeAgeBackend { for (Map.Entry age : index.entries().entrySet()) { try { load(server, age.getKey(), age.getValue(), index.biomePlan(age.getKey()), - index.terrain(age.getKey()), index.rocks(age.getKey())); + index.terrain(age.getKey()), index.rocks(age.getKey()), index.vegetation(age.getKey()), + index.dungeons(age.getKey()), index.lakes(age.getKey()), index.terrainFeatures(age.getKey())); } catch (RuntimeException exception) { Rivencraft.LOGGER.error("Could not restore Age {}. Its saved index remains intact.", age.getKey(), exception); } @@ -80,12 +81,37 @@ public final class RuntimeAgeBackend { public static ServerLevel create(MinecraftServer server, Identifier id, long seed, PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) { + return create(server, id, seed, biomePlan, terrain, rocks, null); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation) { + return create(server, id, seed, biomePlan, terrain, rocks, vegetation, 0); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons) { + return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, null); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes) { + return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, null); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures) { checkServerThread(server); ageName(id); AgeLevelIndex index = index(server); if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); - ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks); - index.add(id, seed, biomePlan, terrain, rocks); + ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures); + index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures); // A newly bound book may be used immediately. Persist its Age identity // before publishing the catalog so a later crash cannot leave that // book pointing at a world omitted from the on-disk index. @@ -106,7 +132,8 @@ public final class RuntimeAgeBackend { checkServerThread(server); long seed = requireSeed(server, id); ServerLevel level = load(server, id, seed, index(server).biomePlan(id), index(server).terrain(id), - index(server).rocks(id)); + index(server).rocks(id), index(server).vegetation(id), index(server).dungeons(id), index(server).lakes(id), + index(server).terrainFeatures(id)); AgeCatalogSync.onCatalogChanged(server); return level; } @@ -119,18 +146,22 @@ public final class RuntimeAgeBackend { PortBiomePlan biomePlan = index.biomePlan(id); PortAgeTerrain terrain = index.terrain(id); PortRockPlan rocks = index.rocks(id); + PortVegetationPlan vegetation = index.vegetation(id); + int dungeons = index.dungeons(id); + PortLakePlan lakes = index.lakes(id); + PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id); if (previousSeed == seed) return; boolean loaded = server.getLevel(dimensionKey(id)) != null; if (loaded) unloadLoaded(server, id); try { - if (loaded) load(server, id, seed, biomePlan, terrain, rocks); + if (loaded) load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures); index.reseed(id, seed); server.overworld().getDataStorage().saveAndJoin(); if (loaded) AgeCatalogSync.onCatalogChanged(server); } catch (RuntimeException exception) { if (loaded) { try { - load(server, id, previousSeed, biomePlan, terrain, rocks); + load(server, id, previousSeed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures); AgeCatalogSync.onCatalogChanged(server); } catch (RuntimeException rollbackFailure) { exception.addSuppressed(rollbackFailure); @@ -149,6 +180,10 @@ public final class RuntimeAgeBackend { PortBiomePlan biomePlan = index.biomePlan(id); PortAgeTerrain terrain = index.terrain(id); PortRockPlan rocks = index.rocks(id); + PortVegetationPlan vegetation = index.vegetation(id); + int dungeons = index.dungeons(id); + PortLakePlan lakes = index.lakes(id); + PortTerrainFeaturePlan terrainFeatures = index.terrainFeatures(id); boolean loaded = server.getLevel(dimensionKey(id)) != null; ArchivePaths paths = archivePaths(server, name); if (loaded) unloadLoaded(server, id); @@ -167,14 +202,15 @@ public final class RuntimeAgeBackend { } } catch (IOException | RuntimeException exception) { try { - index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks); + index.restoreAfterFailedRetirement(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, + terrainFeatures); server.overworld().getDataStorage().saveAndJoin(); } catch (RuntimeException rollbackFailure) { exception.addSuppressed(rollbackFailure); } if (loaded) { try { - load(server, id, seed, biomePlan, terrain, rocks); + load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures); } catch (RuntimeException rollbackFailure) { exception.addSuppressed(rollbackFailure); } @@ -290,7 +326,9 @@ public final class RuntimeAgeBackend { } private static ServerLevel load(MinecraftServer server, Identifier id, long seed, - PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks) { + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures) { ResourceKey key = ResourceKey.create(Registries.DIMENSION, id); if (server.getLevel(key) != null) throw new IllegalArgumentException("Level already loaded: " + id); @@ -361,7 +399,8 @@ public final class RuntimeAgeBackend { }; generator = new NoiseBasedChunkGenerator(source, settings); } - if (rocks != null) generator = new PortRockChunkGenerator(generator, rocks, seed); + if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null) + generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed); LevelStem stem = new LevelStem(overworldStem.type(), generator, OptionalLong.of(seed)); long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed()); ServerLevel level = new ServerLevel(server, server.executor, server.storageSource, diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java new file mode 100644 index 0000000..349e6ea --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java @@ -0,0 +1,61 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.InsideBlockEffectApplier; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.EndPortalBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** Non-obtainable starfield portal returning entities to the shared Overworld spawn. */ +public final class StarFissureBlock extends EndPortalBlock { + private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16); + + public StarFissureBlock(BlockBehaviour.Properties properties) { super(properties); } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new StarFissureBlockEntity(pos, state); + } + + @Override + public void animateTick(BlockState state, Level level, BlockPos pos, net.minecraft.util.RandomSource random) { + // Legacy fissures have a starfield, not vanilla End portal smoke. + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + @Override + protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, + InsideBlockEffectApplier effects, boolean precise) { + // Do not inherit End credits or End-dimension travel from the vanilla block. + if (entity.canUsePortal(false)) entity.setAsInsidePortal(this, pos); + } + + @Override + public TeleportTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) { + if (level.dimension() == Level.OVERWORLD) return null; + ServerLevel overworld = level.getServer().overworld(); + if (overworld == null) return null; + BlockPos spawn = overworld.getRespawnData().pos(); + BlockPos landing = net.dnorberg.rivencraft.age.PortAgeTravel.findFootedDestination(overworld, spawn).orElse(spawn); + level.playSound(null, pos, Rivencraft.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F); + return new TeleportTransition(overworld, Vec3.atBottomCenterOf(landing), entity.getDeltaMovement(), + entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java new file mode 100644 index 0000000..bd0d825 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java @@ -0,0 +1,12 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +public final class StarFissureBlockEntity extends TheEndPortalBlockEntity { + public StarFissureBlockEntity(BlockPos pos, BlockState state) { + super(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), pos, state); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java index b4d3895..016e7f1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java @@ -14,6 +14,12 @@ import net.minecraft.server.packs.resources.ResourceManagerReloadListener; public final class PortBookBinderClientEvents { private PortBookBinderClientEvents() {} + @SubscribeEvent + public static void registerBlockEntityRenderers( + net.neoforged.neoforge.client.event.EntityRenderersEvent.RegisterRenderers event) { + event.registerBlockEntityRenderer(Rivencraft.STAR_FISSURE_BLOCK_ENTITY.get(), context -> new StarFissureRenderer()); + } + @SubscribeEvent public static void registerScreens(RegisterMenuScreensEvent event) { event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java new file mode 100644 index 0000000..0839b31 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java @@ -0,0 +1,34 @@ +package net.dnorberg.rivencraft.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import java.util.List; +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.AbstractEndPortalRenderer; +import net.minecraft.client.renderer.blockentity.state.EndPortalRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.core.Direction; + +/** Retains 1.21.1's UP-at-zero/DOWN-at-one planes with the modern starfield renderer. */ +public final class StarFissureRenderer extends AbstractEndPortalRenderer { + @Override + public EndPortalRenderState createRenderState() { return new EndPortalRenderState(); } + + @Override + public void submit(EndPortalRenderState state, PoseStack pose, SubmitNodeCollector collector, + CameraRenderState camera) { + if (state.facesToShow.contains(Direction.UP)) { + pose.pushPose(); + pose.translate(0, -1, 0); + submitCube(List.of(Direction.UP), RenderTypes.endPortal(), pose, collector); + pose.popPose(); + } + if (state.facesToShow.contains(Direction.DOWN)) { + pose.pushPose(); + pose.translate(0, 1, 0); + submitCube(List.of(Direction.DOWN), RenderTypes.endPortal(), pose, collector); + pose.popPose(); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 97e4ca1..549ccf3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -135,7 +135,11 @@ public final class PortBookNetworking { biomePlan = random.biomes(); } RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain, - PortAuthoredWriting.rockPlan(pages)); + PortAuthoredWriting.rockPlan(pages, seed), + PortAuthoredWriting.vegetationPlan(pages), + PortAuthoredWriting.dungeonCount(pages), + PortAuthoredWriting.lakePlan(pages, seed), + PortAuthoredWriting.terrainFeaturePlan(pages, seed)); } catch (IllegalArgumentException | IllegalStateException exception) { player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage())); return null; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortChunkGenerators.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortChunkGenerators.java index 22b8f7c..a591250 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortChunkGenerators.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/PortChunkGenerators.java @@ -3,7 +3,7 @@ package net.dnorberg.rivencraft.registry; import com.mojang.serialization.MapCodec; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.age.PortFlatChunkGenerator; -import net.dnorberg.rivencraft.age.PortRockChunkGenerator; +import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.bus.api.IEventBus; @@ -16,7 +16,8 @@ public final class PortChunkGenerators { static { GENERATORS.register("age_flat", () -> PortFlatChunkGenerator.CODEC); - GENERATORS.register("age_rocks", () -> PortRockChunkGenerator.CODEC); + // Keep the beta codec ID so previously saved Rocks Ages remain readable. + GENERATORS.register("age_rocks", () -> PortFeatureChunkGenerator.CODEC); } private PortChunkGenerators() {} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java index 6d87847..410f30d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java @@ -2,18 +2,30 @@ package net.dnorberg.rivencraft.writing; import java.util.List; import java.util.ArrayList; +import java.util.Collections; +import java.util.Random; +import java.util.HashMap; +import java.util.Map; import net.dnorberg.rivencraft.age.PortBiomePlan; import net.dnorberg.rivencraft.age.PortAgeTerrain; import net.dnorberg.rivencraft.age.PortRockPlan; +import net.dnorberg.rivencraft.age.PortVegetationPlan; +import net.dnorberg.rivencraft.age.PortLakePlan; +import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan; import net.dnorberg.rivencraft.Rivencraft; import net.minecraft.core.HolderLookup; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; +import net.minecraft.tags.BiomeTags; /** Authored noise terrain and biome distribution supported by the 26.3 backend. */ public final class PortAuthoredWriting { private static final int MAX_AUTHORED_BIOMES = 252; + private static final int MAX_ROCKS = 32; + private static final int MAX_VEGETATION = 32; + private static final int MAX_DUNGEONS = 32; + private static final int MAX_LAKES = 32; private static final Identifier STANDARD_TERRAIN = Identifier.fromNamespaceAndPath( Rivencraft.MOD_ID, "terrain/standard"); private static final Identifier AMPLIFIED_TERRAIN = Identifier.fromNamespaceAndPath( @@ -28,6 +40,8 @@ public final class PortAuthoredWriting { Rivencraft.MOD_ID, "terrain/cave"); private static final Identifier ROCKS = Identifier.fromNamespaceAndPath( Rivencraft.MOD_ID, "feature/rocks"); + private static final Identifier DUNGEONS = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "feature/dungeons"); private PortAuthoredWriting() {} @@ -36,19 +50,51 @@ public final class PortAuthoredWriting { boolean terrain = false; int biomes = 0; int controllers = 0; - boolean material = false; - boolean rocks = false; + String pendingMaterial = null; + int rocks = 0; + int vegetation = 0; + int dungeons = 0; + int lakes = 0; + int skylands = 0; + int denseOres = 0; + int starFissures = 0; + Map terrainFeatures = new HashMap<>(); for (Identifier page : pages) { + if (pendingMaterial != null && !(pendingMaterial.equals("rivencraft:block") + && (page.equals(ROCKS) || PortTerrainFeaturePlan.acceptsMaterial(page)) + || pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false; if (isTerrain(page) && !terrain) { terrain = true; } else if (controller(page) != null && controllers++ == 0) { // An explicit controller needs an authored pool. - } else if (isBlockPage(page) && !material && !rocks + } else if (isBlockPage(page) && pendingMaterial == null && PortSymbolPages.find(page) != null - && PortSymbolPages.find(page).category().equals("rivencraft:block")) { - material = true; - } else if (page.equals(ROCKS) && material && !rocks) { - rocks = true; + && (PortSymbolPages.find(page).category().equals("rivencraft:block") + || PortSymbolPages.find(page).category().equals("rivencraft:fluid"))) { + pendingMaterial = PortSymbolPages.find(page).category(); + } else if (page.equals(ROCKS) && rocks < MAX_ROCKS) { + pendingMaterial = null; + rocks++; + } else if (PortLakePlan.isLake(page) && lakes < MAX_LAKES) { + pendingMaterial = null; + lakes++; + } else if (PortTerrainFeaturePlan.isSupported(page) + && terrainFeatures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) { + pendingMaterial = null; + terrainFeatures.merge(page, 1, Integer::sum); + } else if (page.equals(PortTerrainFeaturePlan.SKYLANDS) + && skylands < PortTerrainFeaturePlan.MAX_PER_FEATURE) { + skylands++; + } else if (page.equals(PortTerrainFeaturePlan.DENSE_ORES) + && denseOres < PortTerrainFeaturePlan.MAX_PER_FEATURE) { + denseOres++; + } else if (page.equals(PortTerrainFeaturePlan.STAR_FISSURES) + && starFissures < PortTerrainFeaturePlan.MAX_PER_FEATURE) { + starFissures++; + } else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) { + vegetation++; + } else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) { + dungeons++; } else if (page.getNamespace().equals(Rivencraft.MOD_ID) && page.getPath().startsWith("biome/") && biomes < MAX_AUTHORED_BIOMES && PortSymbolPages.find(page) != null @@ -57,7 +103,14 @@ public final class PortAuthoredWriting { biomes++; } else return false; } - if (material != rocks || rocks && (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; + if (pendingMaterial != null || (rocks > 0 || vegetation > 0) + && (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; + if ((dungeons > 0 || lakes > 0) && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN) + || pages.contains(CAVE_TERRAIN))) return false; + if (denseOres > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; + if (pages.contains(PortTerrainFeaturePlan.FLOATING_ISLANDS) && pages.contains(CAVE_TERRAIN)) return false; + if (pages.stream().anyMatch(PortTerrainFeaturePlan::isSurface) + && (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; if (controllers != 0) { PortBiomePlan.Distribution distribution = pages.stream().map(PortAuthoredWriting::controller) .filter(value -> value != null).findFirst().orElseThrow(); @@ -107,11 +160,94 @@ public final class PortAuthoredWriting { return PortAgeTerrain.STANDARD; } - public static PortRockPlan rockPlan(List pages) { + public static PortRockPlan rockPlan(List pages, long seed) { if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); - return pages.contains(ROCKS) ? new PortRockPlan(pages.stream() - .filter(PortAuthoredWriting::isBlockPage).map(PortRegistryPages::blockId) - .findFirst().orElseThrow()) : null; + List materials = solidMaterials(pages, seed, ROCKS); + return materials.isEmpty() ? null : new PortRockPlan(materials); + } + + public static PortTerrainFeaturePlan terrainFeaturePlan(List pages, long seed) { + if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + Map> materials = new HashMap<>(); + Map occurrences = new HashMap<>(); + List choices = new ArrayList<>(); + for (Identifier page : pages) { + if (!PortTerrainFeaturePlan.isSupported(page)) continue; + if (page.equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS)) { + choices.add(new PortTerrainFeaturePlan.Choice(page, PortTerrainFeaturePlan.CRYSTAL_MATERIAL)); + continue; + } + List selected = materials.computeIfAbsent(page, feature -> solidMaterials(pages, seed, feature)); + int occurrence = occurrences.merge(page, 1, Integer::sum) - 1; + choices.add(new PortTerrainFeaturePlan.Choice(page, selected.get(occurrence))); + } + boolean skylands = pages.contains(PortTerrainFeaturePlan.SKYLANDS); + int denseOres = (int) pages.stream().filter(PortTerrainFeaturePlan.DENSE_ORES::equals).count(); + boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES); + return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures ? null + : new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures); + } + + private static List solidMaterials(List pages, long seed, Identifier feature) { + if (!pages.contains(feature)) return List.of(); + List randomPool = new ArrayList<>(PortRegistryPages.solidMaterialIds()); + if (randomPool.isEmpty()) throw new IllegalStateException("No full-block materials are registered"); + Collections.shuffle(randomPool, new Random(mix(seed ^ (long) feature.hashCode() * 0x9E3779B97F4A7C15L))); + List materials = new ArrayList<>(); + Identifier pending = null; + int randomIndex = 0; + for (Identifier page : pages) { + if (isBlockPage(page)) pending = PortRegistryPages.blockId(page); + else if (page.equals(feature)) { + materials.add(pending != null ? pending : randomPool.get(randomIndex++ % randomPool.size())); + pending = null; + } else pending = null; + } + return List.copyOf(materials); + } + + public static PortLakePlan lakePlan(List pages, long seed) { + if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + if (pages.stream().noneMatch(PortLakePlan::isLake)) return null; + List fluidPool = PortRegistryPages.fluidMaterialIds(); + if (fluidPool.isEmpty()) throw new IllegalStateException("No source-fluid materials are registered"); + Map> orders = new HashMap<>(); + Map occurrences = new HashMap<>(); + List choices = new ArrayList<>(); + Identifier pending = null; + for (Identifier page : pages) { + if (isBlockPage(page)) pending = PortRegistryPages.blockId(page); + else if (PortLakePlan.isLake(page)) { + int occurrence = occurrences.merge(page, 1, Integer::sum) - 1; + List fluids = orders.computeIfAbsent(page, id -> { + List shuffled = new ArrayList<>(fluidPool); + Collections.shuffle(shuffled, new Random(mix(seed ^ (long) id.hashCode() + * 0x9E3779B97F4A7C15L))); + return shuffled; + }); + choices.add(new PortLakePlan.Choice(page, + pending != null ? pending : fluids.get(occurrence % fluids.size()))); + pending = null; + } else pending = null; + } + return new PortLakePlan(choices); + } + + public static PortVegetationPlan vegetationPlan(List pages) { + if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + List vegetation = pages.stream().filter(PortVegetationPlan::isVegetation).toList(); + return vegetation.isEmpty() ? null : new PortVegetationPlan(vegetation); + } + + public static int dungeonCount(List pages) { + if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + return (int) pages.stream().filter(DUNGEONS::equals).count(); + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; } /** Resolves all authored biomes from the active registry before creating a level. */ @@ -121,8 +257,15 @@ public final class PortAuthoredWriting { List biomes = new ArrayList<>(); PortBiomePlan.Distribution distribution = null; boolean caveTerrain = pages.contains(CAVE_TERRAIN); + boolean hasSurfaceBiome = false; + boolean hasOreBiome = false; for (Identifier page : pages) { - if (isTerrain(page) || page.equals(ROCKS) || isBlockPage(page)) continue; + if (isTerrain(page) || page.equals(ROCKS) || page.equals(DUNGEONS) + || page.equals(PortTerrainFeaturePlan.SKYLANDS) + || page.equals(PortTerrainFeaturePlan.DENSE_ORES) + || page.equals(PortTerrainFeaturePlan.STAR_FISSURES) + || PortLakePlan.isLake(page) || PortTerrainFeaturePlan.isSupported(page) || isBlockPage(page) + || PortVegetationPlan.isVegetation(page)) continue; PortBiomePlan.Distribution selected = controller(page); if (selected != null) { distribution = selected; continue; } Identifier biome = PortRegistryPages.biomeId(page); @@ -131,8 +274,16 @@ public final class PortAuthoredWriting { .orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome)); if (caveTerrain && !PortRegistryPages.isCaveBiome(holder)) throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biome); + if (!PortRegistryPages.isCaveBiome(holder) && !holder.is(BiomeTags.IS_END)) + hasSurfaceBiome = true; + if (net.dnorberg.rivencraft.age.PortDenseOres.hasOres(holder)) hasOreBiome = true; biomes.add(biome); } + if (pages.stream().anyMatch(PortVegetationPlan::isVegetation) + && !biomes.isEmpty() && !hasSurfaceBiome) + throw new IllegalArgumentException("Vegetation pages need at least one surface Overworld biome"); + if (pages.contains(PortTerrainFeaturePlan.DENSE_ORES) && !biomes.isEmpty() && !hasOreBiome) + throw new IllegalArgumentException("Dense Ores needs at least one biome with registered ore placements"); if (distribution == PortBiomePlan.Distribution.NATIVE) return new PortBiomePlan(distribution, List.of()); if (biomes.isEmpty()) return null; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java index c6faced..14ea07b 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java @@ -76,13 +76,27 @@ public final class PortRegistryPages { public static List materialPages(boolean fluids) { String category = fluids ? "rivencraft:fluid" : "rivencraft:block"; - return BuiltInRegistries.BLOCK.keySet().stream().sorted() - .filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id)))) + return materialIds(category).stream() .map(id -> PageItem.createSymbolPage(blockPageId(id))).toList(); } - public static boolean isCaveBiome(net.minecraft.core.Holder.Reference holder) { - String path = holder.key().identifier().getPath(); + /** The same full-block pool used by Creative pages, in stable registry-ID order. */ + public static List solidMaterialIds() { + return materialIds("rivencraft:block"); + } + + public static List fluidMaterialIds() { + return materialIds("rivencraft:fluid"); + } + + private static List materialIds(String category) { + return BuiltInRegistries.BLOCK.keySet().stream().sorted() + .filter(id -> category.equals(materialCategory(BuiltInRegistries.BLOCK.getValue(id)))) + .toList(); + } + + public static boolean isCaveBiome(net.minecraft.core.Holder holder) { + String path = holder.unwrapKey().map(key -> key.identifier().getPath()).orElse(""); return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER) || path.equals("deep_dark") || path.endsWith("_caves"); } diff --git a/ports/mc263/src/main/resources/assets/rivencraft/sounds.json b/ports/mc263/src/main/resources/assets/rivencraft/sounds.json new file mode 100644 index 0000000..27995c8 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/sounds.json @@ -0,0 +1,5 @@ +{ + "linking.link-fissure": { + "sounds": [{"name": "rivencraft:linking/link-fissure"}] + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/worldgen/placed_feature/dungeons.json b/ports/mc263/src/main/resources/data/rivencraft/worldgen/placed_feature/dungeons.json new file mode 100644 index 0000000..c1c8ac1 --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/worldgen/placed_feature/dungeons.json @@ -0,0 +1,15 @@ +{ + "feature": "minecraft:monster_room", + "placement": [ + { "type": "minecraft:count", "count": 8 }, + { "type": "minecraft:in_square" }, + { + "type": "minecraft:height_range", + "height": { + "type": "minecraft:uniform", + "min_inclusive": { "above_bottom": 1 }, + "max_inclusive": { "below_top": 1 } + } + } + ] +} diff --git a/scripts/smoke_server_263.sh b/scripts/smoke_server_263.sh index 6bb7359..fb8a1f7 100755 --- a/scripts/smoke_server_263.sh +++ b/scripts/smoke_server_263.sh @@ -77,6 +77,18 @@ send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Cr send_and_wait 'rivencraft_port age create_multi smoke_cave_multi 112234 cave' 'Created rivencraft:age/smoke_cave_multi with seed 112234 terrain=CAVE' send_and_wait 'rivencraft_port age create_random smoke_random 889900' 'Random Age plan: terrain=' send_and_wait 'rivencraft_port age create_rocks smoke_rocks 445566' 'Created rivencraft:age/smoke_rocks with seed 445566 terrain=FLAT' +send_and_wait 'rivencraft_port age create_rocks_multi smoke_rocks_multi 445566' 'Created rivencraft:age/smoke_rocks_multi with seed 445566 terrain=FLAT' +send_and_wait 'rivencraft_port age create_rocks_random smoke_rocks_random 445566' 'Created rivencraft:age/smoke_rocks_random with seed 445566 terrain=FLAT' +send_and_wait 'rivencraft_port age create_vegetation smoke_vegetation 24680' 'Created rivencraft:age/smoke_vegetation with seed 24680 terrain=FLAT' +send_and_wait 'rivencraft_port age create_dungeons smoke_dungeons 135791' 'Created rivencraft:age/smoke_dungeons with seed 135791 terrain=STANDARD' +send_and_wait 'rivencraft_port age create smoke_lake_control 667788' 'Created rivencraft:age/smoke_lake_control with seed 667788 terrain=STANDARD' +send_and_wait 'rivencraft_port age create_lakes smoke_lakes 667788' 'Created rivencraft:age/smoke_lakes with seed 667788 terrain=STANDARD' +send_and_wait 'rivencraft_port age create_lakes_random smoke_lakes_random 667788' 'Created rivencraft:age/smoke_lakes_random with seed 667788 terrain=STANDARD' +# This fixture includes a rare island candidate near the origin at normal rarity. +send_and_wait 'rivencraft_port age create_islands smoke_islands 887767 void' 'Created rivencraft:age/smoke_islands with seed 887767 terrain=VOID' +send_and_wait 'rivencraft_port age create_islands smoke_islands_flat 887767 flat' 'Created rivencraft:age/smoke_islands_flat with seed 887767 terrain=FLAT' +send_and_wait 'rivencraft_port age create_islands smoke_islands_noise 887767 standard' 'Created rivencraft:age/smoke_islands_noise with seed 887767 terrain=STANDARD' +send_and_wait 'rivencraft_port age create_islands_random smoke_islands_random 887767 void' 'Created rivencraft:age/smoke_islands_random with seed 887767 terrain=VOID' # The index must reach disk while the server is still running. A graceful # shutdown would hide the interrupted-save regression this checks. index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat" @@ -94,6 +106,15 @@ gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi' gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave_multi' gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_random' gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_multi' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_random' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_vegetation' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_dungeons' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes' +gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes_random' +for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do + gzip -dc "$index_file" | strings | grep -Fq "rivencraft:age/$island_age" +done send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' @@ -104,6 +125,52 @@ send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smok send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' +dungeons_before=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//') +[[ "$dungeons_before" =~ ^[1-9][0-9]*$ ]] || { + printf 'Authored Dungeons placed no monster spawners: %s\n' "$dungeons_before" >&2 + exit 1 +} +send_and_wait 'rivencraft_port age lakes_probe smoke_lake_control' 'Lakes probe rivencraft:age/smoke_lake_control pages=0' +lake_control=$(grep -F 'Lakes probe rivencraft:age/smoke_lake_control pages=0' "$log_file" | tail -1 | sed 's/.* water=//; s/ lava=.*//') +send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]' +lakes_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /') +lake_water=$(printf '%s' "$lakes_before" | sed 's/.* water=//; s/ lava=.*//') +[[ "$lake_water" -gt "$lake_control" ]] || { + printf 'Water lake pages did not add water: %s -> %s\n%s\n' "$lake_control" "$lake_water" "$lakes_before" >&2 + exit 1 +} +send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' +lakes_random_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /') +[[ "$lakes_random_before" == *minecraft:water* && "$lakes_random_before" == *minecraft:lava* ]] || { + printf 'Unmodified lake pages did not resolve both vanilla fluids: %s\n' "$lakes_random_before" >&2 + exit 1 +} +declare -A islands_before +for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do + send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4" + island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /') + island_blocks=$(printf '%s' "$island_result" | sed 's/.* count=//; s/ fingerprint=.*//') + [[ "$island_blocks" =~ ^[1-9][0-9]*$ ]] || { + printf 'Floating Islands placed no selected material: %s\n' "$island_result" >&2 + exit 1 + } + islands_before[$island_age]=$island_result +done +island_materials=${islands_before[smoke_islands_random]#*materials=[} +island_materials=${island_materials%%]*} +IFS=',' read -r -a island_material_list <<< "$island_materials" +[[ ${#island_material_list[@]} -eq 4 ]] || { + printf 'Repeated unmodified islands did not choose independent materials: %s\n' "$island_materials" >&2 + exit 1 +} +send_and_wait 'execute in rivencraft:age/smoke_islands if block 0 0 0 minecraft:air run say Island Void remains empty below' 'Island Void remains empty below' +send_and_wait 'execute in rivencraft:age/smoke_islands_flat if block 0 0 0 minecraft:bedrock run say Island Flat retains its floor' 'Island Flat retains its floor' +send_and_wait 'rivencraft_port age biome smoke_islands 128 128' 'rivencraft:age/smoke_islands biome at 128,128=minecraft:forest' send_and_wait 'execute in rivencraft:age/smoke_flat run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat to be force loaded' send_and_wait 'execute in rivencraft:age/smoke_void run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void to be force loaded' send_and_wait 'execute in rivencraft:age/smoke_flat_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat_multi to be force loaded' @@ -111,12 +178,40 @@ send_and_wait 'execute in rivencraft:age/smoke_void_multi run forceload add 0 0' send_and_wait 'execute in rivencraft:age/smoke_cave run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave to be force loaded' send_and_wait 'execute in rivencraft:age/smoke_cave_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave_multi to be force loaded' send_and_wait 'execute in rivencraft:age/smoke_rocks run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks to be force loaded' -send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' -rocks_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' "$log_file" | tail -1 | sed 's/.*count=//') +send_and_wait 'execute in rivencraft:age/smoke_rocks_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_multi to be force loaded' +send_and_wait 'execute in rivencraft:age/smoke_rocks_random run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_random to be force loaded' +send_and_wait 'execute in rivencraft:age/smoke_vegetation run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_vegetation to be force loaded' +send_and_wait 'rivencraft_port age vegetation_probe smoke_flat' 'Vegetation probe rivencraft:age/smoke_flat flowers=' +flat_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_flat flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//') +send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' +authored_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//') +flat_flowers=${flat_vegetation%% logs=*} +flat_logs=${flat_vegetation##* logs=} +authored_flowers=${authored_vegetation%% logs=*} +authored_logs=${authored_vegetation##* logs=} +[[ "$authored_flowers" -gt "$flat_flowers" && "$authored_logs" -gt "$flat_logs" ]] || { + printf 'Authored vegetation did not increase flowers and logs: %s -> %s\n' \ + "$flat_vegetation" "$authored_vegetation" >&2 + exit 1 +} +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' +rocks_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//') [[ "$rocks_before" =~ ^[1-9][0-9]*$ ]] || { printf 'Authored Rocks placed no gold blocks: %s\n' "$rocks_before" >&2 exit 1 } +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=[minecraft:gold_block, minecraft:gold_block, minecraft:gold_block, minecraft:gold_block] count=' +rocks_multi_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//') +[[ "$rocks_multi_before" =~ ^[1-9][0-9]*$ && "$rocks_multi_before" -gt "$rocks_before" ]] || { + printf 'Repeated Rocks pages did not increase placement: %s -> %s\n' "$rocks_before" "$rocks_multi_before" >&2 + exit 1 +} +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=[' +rocks_random_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /') +[[ "$rocks_random_before" =~ count=[1-9][0-9]*$ ]] || { + printf 'Unmodified Rocks page placed no material: %s\n' "$rocks_random_before" >&2 + exit 1 +} send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 0 0 minecraft:bedrock run say Flat bedrock starts at Y0' 'Flat bedrock starts at Y0' send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 3 0 minecraft:grass_block run say Flat surface is Y3' 'Flat surface is Y3' send_and_wait 'execute in rivencraft:age/smoke_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty' @@ -155,8 +250,32 @@ send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smok send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain=' send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain=' -send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' -rocks_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks material=minecraft:gold_block count=' "$log_file" | tail -1 | sed 's/.*count=//') +send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain=' +send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' +dungeons_after=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//') +send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]' +lakes_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /') +send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' +lakes_random_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /') +for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do + send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4" + island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /') + [[ "${islands_before[$island_age]}" == "$island_result" ]] || { + printf 'Floating Islands changed across restart: %s\n%s\n' "${islands_before[$island_age]}" "$island_result" >&2 + exit 1 + } +done +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' +rocks_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//') +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=[' +rocks_multi_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//') +send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=[' +rocks_random_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /') +send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' +authored_vegetation_after=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//') send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest' send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest' send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves' @@ -185,5 +304,23 @@ random_after=$(grep -F 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 te "$rocks_before" "$rocks_after" >&2 exit 1 } +[[ "$rocks_multi_before" == "$rocks_multi_after" && "$rocks_random_before" == "$rocks_random_after" ]] || { + printf 'Repeated or unmodified Rocks changed across restart\n' >&2 + exit 1 +} +[[ "$authored_vegetation" == "$authored_vegetation_after" ]] || { + printf 'Authored vegetation changed across restart: %s -> %s\n' \ + "$authored_vegetation" "$authored_vegetation_after" >&2 + exit 1 +} +[[ "$dungeons_before" == "$dungeons_after" ]] || { + printf 'Authored Dungeons changed across restart: %s -> %s\n' \ + "$dungeons_before" "$dungeons_after" >&2 + exit 1 +} +[[ "$lakes_before" == "$lakes_after" && "$lakes_random_before" == "$lakes_random_after" ]] || { + printf 'Explicit or random lake plans/blocks changed across restart\n' >&2 + exit 1 +} stop_server -printf '26.3 dedicated-server smoke passed: Standard, Flat, Void, Cave, random and authored Rocks Ages persisted across restart.\n' +printf '26.3 dedicated-server smoke passed: terrain, biomes, Rocks, vegetation, Dungeons, lakes, and Floating Islands persisted across restart.\n'