diff --git a/README.md b/README.md index de67953..a5c0f4e 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ 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, structure and vegetation pages, independent Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies, -Normal Time length/phase/direction/bearing and sun-size controls, -and a growing set of generation features, -but does not yet provide full 1.21.1 gameplay or optional-integration parity. +independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and +feature completion, and random structure selection. Nether/End noise-biome +materials now follow their native rules. The beta 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.78-beta.1.md). +[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.93-beta.1.md). Keep the builds and their saves separate, and back up worlds before updating. ## Getting started diff --git a/dev images/Pasted image (2).png b/dev images/Pasted image (2).png deleted file mode 100644 index cfb83c0..0000000 Binary files a/dev images/Pasted image (2).png and /dev/null differ diff --git a/dev images/Pasted image.png b/dev images/Pasted image.png new file mode 100644 index 0000000..6764c5a Binary files /dev/null and b/dev images/Pasted image.png differ diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index ca0c116..25b4dce 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -1303,6 +1303,504 @@ unchanged moon, and travel isolation still require the user's client check. The server stopped cleanly and its temporary world was deleted. Nothing is committed, pushed or published without an explicit request. +## Normal Time shared celestial color + +`1.0.1.79-beta.1` ports the root `AgeSkyProfile` behavior that consumes a +pending shared color group at Normal Time and applies it to both paired +bodies. `PortAgeColors` now saves an optional `celestial` RGB value, default +-1 (native/untinted), with catalog protocol 7 for synchronization. Existing +sky/ambient fields and old profiles retain their values. Color accumulation +passes through non-color celestial modifiers, clamps at the target, resets +after consumption, and leaves trailing colors assigned to sky as before. + +Extraction copies the saved tint into `SkyRenderState`. A narrowly scoped +native sun/moon uniform modification multiplies RGB while retaining alpha; +there are no live world reads during drawing, new textures, pipelines or GPU +buffers. Native weather fading, transparent halos, moon phases, stars and +sunrise glow remain unchanged. Time and effective illumination are not tied +to RGB. State reset/defaults prevent cross-dimension tint leakage. Independent +Sun/Moon pages, multiple bodies and per-body colors remain unported. + +The `create_celestial_color` fixture checks three separate target groups, +completed-group clamping, modifier passthrough, consumed-group reset, seeded +unnumbered colors, old codec defaults and catalog round trips. Client checks +must cover a frozen Zenith sun, Nadir moon, mixed colors, transparent halos, +restart and return to an untinted dimension. Server probes validate saved +profiles and attributes, not rendered output. Build passed; disposable-server +fixtures retained separate sky/ambient/body colors through reload, reseed and +restart, including zero/black colors and untouched native defaults. Sampled +time/light remained unchanged. The server stopped cleanly and its temporary +world was deleted. No commit/push/publication is performed without an explicit +request. + +## Standalone Sun and Moon controllers + +`1.0.1.80-beta.1` extends the existing cycle profile with optional `body_mode` +(paired=0, Sun=1, Moon=2); old profiles default to the paired renderer. +Catalog protocol 8 includes this field. One celestial controller is accepted +per book, with existing Length/Phase/Direction/Angle/Size and shared Color +groups. Multiple controllers and dangling/duplicate modifiers retain the +interim strict rejection rather than losing written pages. + +Standalone bodies use a linear angular orbit as in root `AgeSkyProfile.Body`. +Both native body-angle attributes point at that orbit; render-state mode +suppresses the absent counterpart. The selected Moon consumes Size itself; +Normal Time retains its original sun-only sizing. Moon-only sunrise glow is +suppressed before any horizon pose is pushed. Shared RGB/alpha handling and +native buffers/pipelines are reused, with extraction/reset defaults protecting +other dimensions. Missing Sky completion uses Overworld, as before. Explicit +Nether/End/Static still does not draw bodies in this native-renderer slice. + +The gameplay skylight attribute follows root `lightAt` and +`LevelSkyBrightnessMixin`: positive cosine altitude, 0.18 for a moon, weather +attenuation, then the 11-level sky-darkening range. It does not infer light +from color or size, and does not bypass Bright/Dark or no-skylight terrain. +Native background/star/moon-phase schedules remain, rather than claiming a +full custom sky port. Seeded default phase/bearing completion, multiple +independent bodies and eclipses remain future work. + +The op `create_body` fixture checks all named phases for both bodies, light +weights, numeric size, reverse motion, order rejection, old-profile defaults, +compatible missing Sky and catalog synchronization. Actual body visibility, +moon size, glow transparency and travel isolation require manual client +validation. Build and disposable-server probes passed: Zenith sun/moon +skylight was 15/6, Nadir and horizon were 4; named phase angles were correct. +The paired fixture retained its opposite moon and native lighting. Reload, +reseed and full restart preserved body mode, color, size, bearing and phase. +The test server stopped and its temporary world was deleted. No commit/push/ +publication is performed without an explicit request. + +## Multiple independent authored celestials + +`1.0.1.81-beta.1` adds an optional immutable `PortCelestialBody` list to the +cycle profile and catalog protocol 9. Existing profiles default to an empty +list and use their validated native paired/single path. Books with multiple +controllers compile ordered modifier groups into independent body orbits; +Normal Time expands into opposite-phase sun/moon entries. Colors are consumed +at each controller, with original page indices retained for seeded amounts. +Uncolored bodies reset to native white rather than inheriting the previous +body's tint. Size/phase/length/bearing/direction also reset per group. + +The profile constructor and authoring gate enforce the root ten-sun/ten-moon +limit, with Normal Time counting toward both. Duplicate modifiers within a +group and dangling modifiers remain strict errors until grammar recovery is +ported. The first controller continues to govern the shared day timeline and +native background/star/moon-phase schedules. Positive-altitude sunlight sums +to at most 1; only if no sunlight contributes is summed 0.18-weight moonlight +used. Weather and Bright/Dark remain separate. No eclipse attenuation is +included yet. + +Extraction resolves body angles into immutable presentation snapshots. The +native body pass is replaced only for a nonempty multi-body list: draw suns +first, then moons with their own color, size and bearing, then native stars +once. Native buffers, pipelines and alpha are reused. Temporary renderer +parameters and pose transforms restore in finally blocks; no world lookup is +performed during drawing. Explicit Nether/End/Static rendering remains a +separate limitation. Eclipse disc occlusion, corona and sky/light attenuation +will be the following slice, not a claim of this implementation. + +`create_celestials` checks independent motion/color/size, modifier reset, Normal +Time expansion, ten-pair acceptance/overflow rejection, profile codecs and +catalog round trips. Manual checks cover body placement/alpha, multiple +controllers, restart and ordinary-dimension isolation. Build passed, as did +disposable-server profile/catalog checks, combined-light invariants, reload, +reseed and full restart. Single-body, paired and tinted fixtures retained +their earlier behavior. The test server stopped and its temporary world was +deleted. No commit/push/release is performed without explicit instruction. + +## Eclipse effects + +`1.0.1.82-beta.1` adapts root `AgeSkyProfile.eclipseFor`, `eclipseAt` and +`lightAt`, plus `RivencraftSkyEffects.drawBody`. The dot-product separation +of normalized orbit directions is compared with the sum of quarter-disc +angular radii; each body takes the strongest opposite-kind contact. This is +root's radial proximity formula, not a new physical disc-area model. Bearing, +phase, size, motion and direction all affect contact. Same-kind and isolated +bodies have no eclipse; only an above-horizon sun contributes global shading. + +Sunlight is multiplied by `1 - 0.85 * overlap` before the existing light +aggregation and weather handling. Extraction shades sky RGB by +`1 - 0.65 * strongestVisibleSolarOverlap`. Moon RGB scales by `1 - overlap`; +sun RGB moves toward white by `pow(overlap, 8) * 0.8`. Values are derived from +saved profiles, requiring neither a new saved schema nor packet fields. +Existing multi-body Ages gain the effects on update; single/paired native +paths stay unchanged. Overlap calculations are bounded by the 20-body limit, +and extracted presentation values are reused without live-world drawing reads. + +Native transparent glows draw before each body's central ordinary-alpha disc. +A registered pipeline cloned from native Celestial uses conventional alpha +blending; cached center-cropped atlas meshes retain native UV orientation. +An eclipsed moon uses the full-moon core to avoid transparent phase pixels +exposing the sun (root always drew that full-moon center). Weather alpha is +retained. The helper drops meshes on atlas-generation change and closes them +with the renderer. There is no generated art or per-frame mesh allocation. +Explicit Nether/End/Static body rendering remains outside this slice. + +`create_eclipse` supplies aligned, partial, clear, below-horizon and moving +fixtures; it checks overlap/color/light constants, same-kind exclusion and +motion out of contact. The multi-body op fixture is renamed +`create_celestials` to avoid merging with the unrelated existing terrain +`create_multi` command. Manual checks must confirm disc opacity, halo, +corona/sky darkening, resource reload and travel isolation. Build passed. +Disposable-server fixtures passed with overlap 1/0.293/0 for aligned/partial/ +clear cases and no global shading for below-horizon alignment. Aligned +sunlight was 0.15, clear sunlight 1; sampled skylight was 6/12/15 for aligned/ +partial/clear. Moving contact ended as expected. Reload, reseed, catalog +round trips and full restart retained profiles and derived light behavior; +earlier standalone/paired fixtures remained unchanged. The test server stopped +and its temporary world was deleted. No commit/push/publication is performed +without an explicit request. + +## Authored moon sprite correction + +`1.0.1.83-beta.1` removes the mixed native-phase/full-core adaptation introduced +with the opaque eclipse pass. Standalone and multi-body authored moons now +draw full-moon glow and center consistently, matching root's custom renderer, +which always samples the first (full-moon) atlas cell. Under ordinary alpha, +native dark phase pixels can produce black opaque centers; the old overlay +glow had made those dark pixels effectively invisible. The ordinary native +Normal Time pair retains its calendar phases. No eclipse math, saved profile, +catalog protocol or world data changes are needed. + +The user's `celest` Age was read without modification. Its body colors are +all -1 (native white), not corrupted black values. Its first Moon and second +Sun both have period 1, phase 0, origin 0, clockwise direction and native +bearing -1; they remain exactly coincident. That moon's full eclipse and its +occlusion of the sun are expected under the newly added root overlap rules, +independent of the phase-sprite correction. Do not silently alter player-authored +orbits to conceal the intentional alignment. Manual checks cover nonaligned +moons, the known permanent eclipse, Native Normal Time, resource reload and +cross-dimension isolation. No user save was modified, and nothing is committed, +pushed or published without explicit instruction. + +## Celestials over explicit non-Overworld skies + +`1.0.1.84-beta.1` removes the presentation exclusion that hid authored bodies +over Nether/End/Static. Root `RivencraftSkyEffects` draws its body list after +any chosen background. The native 26.3 paths instead returned after End or +skipped a NONE sky entirely. An extracted overlay-mode flag now schedules an +authored body layer without changing the actual dimension type or gameplay. +Single Sun/Moon and Normal Time profiles expand into temporary presentation +bodies on these backgrounds; saved profiles are not rewritten. Existing +Overworld single/paired/multi-body paths stay unchanged. + +End uses a RETURN hook that includes the native early return, after its pass +is closed. Static closes its own background pass before drawing the overlay. +Fog-only Nether presentation schedules an extracted END sky pass, suppresses +its End background/flash drawing, and retains the fog clear color. Overlay +passes load existing targets without clearing, reuse native body resources +and suppress additional stars/horizon glow on non-Overworld backgrounds. +Weather alpha is extracted even when the native NONE/END branch omits it. +All presentation flags reset when the render state is reused. + +Eclipse shading modulates End/Static background tint; Nether-style fog color +uses the same root shade factor through an environment attribute layer. +Native untinted fog and authored sky colors are both supported; other +backgrounds retain their existing fog behavior. Body lighting and eclipse +geometry do not depend on style. Missing Sky selection still chooses +Overworld for authored celestials. No schema/protocol changes or new assets +are required. The eclipse fixture accepts an optional explicit Sky; server +checks cover saved choices, fog/light behavior and persistence, while visual +layering, no duplication, resource reload and travel isolation require manual +client checks. Build and disposable-server checks passed for all four sky +styles: aligned eclipse skylight stayed 6, clear stayed 15, and Nether fog +scaled by 0.35 only during overlap. No-celestial and tinted-Nether fixtures +retained their expected colors. Saved profiles, style, light and fog survived +reload/reseed and full restart. The test server stopped and its temporary +world was deleted. No commit/push/publication is performed without explicit +request. + +## Bare celestial seeded randomization + +`1.0.1.85-beta.1` adds per-controller randomization specifically for bare +Sun/Moon groups, as requested for easier testing. This deliberately uses the +root **random-Age** ranges rather than its authored compile defaults (which +reuse shared seeded phase/bearing and default size/color). A dedicated stream +mixes Age seed with controller ordinal, independent of unrelated page indices, +terrain/weather/lighting RNG and sky selection. Palette/mixing and 0.75–1.75 +size come from `AgeSkyProfile.random`; 0.2–2 periods with 1/7 stopped chance +come from `AgeTraits.select`. Bearing and phase use the port's existing degree/ +tick granularity. Direction is random per body. Counts and body-kind limits +still follow explicitly written controller pages. + +The compiler recognizes whether a controller has any written celestial +modifier. Colors already consumed by Sky/Ambient do not suppress randomization. +Any written celestial modifier retains the previous authored path as a whole, +not just individual field overrides; partially authored groups do not gain +new random missing fields in this slice. Normal Time and absent controllers +are unchanged. Bare controllers materialize immutable full body profiles, +including a one-body list for standalone cases. Multi-body compilation carries +their colors/orbits without replacing them with native defaults. No new +saved schema, protocol or client renderer code is needed. + +Only newly created Ages compile these defaults. Older saved profiles load +unchanged; GUI opening, restart and terrain reseeding do not reroll them. +`create_random_celestials` checks reproducibility, ordinal independence, +unrelated-page stability, 128-seed bounds/pool coverage and unchanged authored/ +Normal Time behavior. The regular fixtures still exercise explicit groups. +Manual checks cover visible variation, moving/stopped bodies, mixed authored +groups and restart. Build and disposable-server checks passed, including +single bare bodies, multi-body/128-seed coverage, catalog round trips, +unrelated-page stability, explicit/eclipsed fixtures, reload, terrain reseed +and full restart. Saved random parameters remained unchanged while moving +orbits continued with the clock. The server stopped and its temporary world +was deleted. No commit/push/publication occurs without explicit request. + +## Missing celestial branch completion + +`1.0.1.86-beta.1` adds creation-only `PortDayCycle.resolve`. Written controllers +delegate to the validated compiler unchanged. If none exist, the root +`AgeSkyProfile.random` draw positions select 0–3 suns and 0–3 moons: consume +the sky-style draw and mixed sky-color draws, then the two counts. Body +settings reuse .85's independent per-controller streams. This matches root's +unrestricted count model, not bit-identical per-body values; the .85 per-body +length/direction independence is retained. Missing sky tint completion and +random-page policy parity are not claimed by this slice. + +The shared resolver is used by player book creation and the `create_random` +operator command. Other exact diagnostic fixtures continue using `compile` +or native profiles to isolate their subject. A saved NONE body mode (3) +represents a resolved zero-body sky. Its sun/moon/horizon drawing is suppressed +without switching to Dark lighting or changing the native background/star +timeline. Old missing profiles still decode to NATIVE rather than being +retroactively completed. The mode-domain extension uses catalog protocol 10. +No generation runs or rerolls happen during GUI rendering, load or reseed. + +`create_completed_sky` tests 256-seed count bounds and coverage of empty, +sun-only, moon-only and mixed results; reproducibility and unrelated-page +stability; explicit-controller precedence; old defaults; and catalog round +trips. Manual checks cover varied new books, empty skies, explicit-controller +counts, all backgrounds, restart and Overworld isolation. No commit/push/ +publication is performed without explicit instruction. + +The .86 build and disposable dedicated-server checks passed: four background +fixtures, the seed-14 empty sky, random Age creation, explicit controllers, +catalog round trips, unload/reload, terrain reseeding and full restart. Saved +body parameters survived unchanged. The test server was stopped and its world +deleted; client visual confirmation remains a manual check. + +## Random Age sky-color completion + +`1.0.1.87-beta.1` uses creation-only `PortAgeColors.resolve` for player books +and `age create_random`. Empty symbol lists choose a sky tint from root +`AgeSkyProfile.random`'s palette and draw order, including its rounded channel +averages. Nonempty symbol lists delegate to the existing compiler unchanged. +This intentionally distinguishes wholly random Ages from authored books with +an omitted sky-color branch, as 1.21.1 does. The existing .85 per-body random +color truncation is preserved by a shared palette helper; celestial count and +body random streams do not change. + +Colors are stored in the existing profile and catalog fields. Old native +colors are not retroactively randomized; reload, restart and terrain reseed +do not rerun completion. No protocol update is necessary. The completed-sky +diagnostic checks six root seed/color vectors, codec persistence, authored +precedence (including explicit black) and old-profile defaults. Random-page +blocklist parity remains separate pending work. + +Build and disposable dedicated-server checks passed: root vectors and authored +precedence, color attributes/catalog round trips for all four background +styles, explicit sky/ambient colors, unload/reload, reseed and full restart. +Saved random colors stayed unchanged after reseeding and restart. The test +server was stopped and its disposable world deleted. Client rendering is +still a manual check. No commit, push or publication was performed. + +## Authored biome branch completion + +`1.0.1.88-beta.1` ports the explicit-controller missing-pool behavior from +1.21.1 `AgeBiomeSelection.compile`: Single requires one biome; other nonnative +controllers require two distinct biomes. An entirely omitted pool uses the +seeded 2:1 continuation/stop draw up to the eligible pool size. Authored pools +fill only to the minimum, preserve order and duplicate weights, and never add +an unrequested third biome once the minimum is met. Native still disallows +biome pages. The binder's availability validation accepts incomplete pools; +the server resolves actual registry eligibility before creating the Age. + +Player creation now passes the actual Age seed to biome compilation. Exact +older diagnostics retain the registry-only compiler; the new completion +fixture exercises the production seeded path. Cave uses recognized Cave and +Nether biomes. Void does not inject The Void alongside authored biomes and +reports a clear distinct-biome error when completion is impossible. Pools +are stored in existing `PortBiomePlan` fields and never rerolled on restart +or terrain reseed. No protocol change is needed. Broader writing recovery, +structure-driven missing-biome selection and random-page policies remain +separate parity work. + +The build and disposable dedicated-server checks passed. The completion +fixture checked every distribution over 32 seeds, partial/full/weighted pools +and codec round trips. Created Standard Single, Flat Tiny, Cave Tiny and Void +Single plans; Cave included the new 26.3 Sulfur Caves biome. An impossible +controller-only Void Tiny pool was refused with the distinct-biome message. +Generator pools survived unload/reload, reseed and full server restart. +The server was stopped and the temporary world deleted. Binder interaction +and in-game biome layouts still require manual validation. No commit, push +or publication was performed. + +## Unpaired material completion + +`1.0.1.89-beta.1` adapts 1.21.1 `AgeFeatureSelection.implied`. A shared pure +normalizer expands an unpaired material into an internal material/feature +pair, keyed by Age seed, material ID and its original book index. Rock, lake +and terrain-feature compilers all use the same completed view. Original book +pages stay unchanged and no random selection happens at render/load time. +Explicit feature targets suppress completion, including incompatible targets +that must still be rejected by the current strict grammar. + +Candidate ordering and terrain restrictions follow root's material-feature +list: Rocks/Floating Islands/Tendrils/Spheres/Spikes/Obelisks for solids and +the three lake types for fluids, filtered by supported terrain. Cave only +permits the two terrain-independent solid features; Void also permits floating +islands; Flat excludes Rocks. No fallback fluid feature exists on Flat, Cave +or Void. This does not add page-free random features, nor remove the existing +per-feature caps or introduce instability recovery. Resulting plans use their +existing save codecs, so no catalog/protocol change is needed. + +The operator fixture checks 64 seeds on each terrain: independent consecutive +materials, deterministic/idempotent completion, explicit precedence, candidate +coverage, fluid retention and incompatible-fluid rejection. Persistence and +actual generator plans are inspected with `feature_plan` without generating +chunks. Client binder interaction and feature visibility remain manual checks. + +The .89 build and disposable server checks passed: seeded fixture assertions, +Standard Glowstone Spikes, Cave Diamond Spheres, Void Stone Tendrils, Flat +Glowstone Obelisks and Standard Water Deep Lakes. Cave Water was refused. +Feature plans and materials survived unload/reload, terrain reseed and full +restart. The server was stopped and its temporary world deleted. No commit, +push or publication was performed. + +## Page-free random material features + +`1.0.1.90-beta.1` adapts the material and rare-formation portion of 1.21.1 +`AgeFeatureSelection.randomChoices`. A new creation-only selector shares the +production authored plan compilers rather than duplicating generator logic. +It rolls 0–2 unique material feature types, uses root's candidate ordering and +Legacy rank chooser, removes chosen types, and selects full-collision solids +or source fluids from the registered sorted pools. Terrain eligibility uses +the current port validator. The separate seeded Skylands and Star Fissure +rolls and the 1-in-10 Crystal Formations roll follow root's rules. + +Player Link-Panel-only creation and `age create_random` share this selector. +Authored books still use their own feature compilers, including implied +materials from .89. Plans are frozen in existing save fields, without extra +book pages or a protocol change. Old Ages keep their original plans. +Random structures, random-page policies and instability are not claimed by +this slice. Material values can be valuable/hazardous, matching the root pool; +the instability port must account for that before stable release. + +`create_random_features` checks 64 seeds across all terrain types: seeded +determinism, counts 0/1/2, unique feature types, all nine candidate families, +terrain compatibility, rare-feature coverage, and plan codec round trips. +`feature_plan` treats an absent feature generator as a valid empty result. +Client feature visibility remains a manual check. + +The .90 build and disposable server checks passed. The seeded matrix covered +all six terrains and all nine material feature families. Created surface, +Cave, Flat and Void fixtures, including lakes, rare Skylands and a Star +Fissure, and verified the production `create_random` plan. Seed 0 Standard +correctly produced no features; seed 1 produced a single lake. Saved plans +survived unload/reload, reseed and full restart. The server was stopped and +its temporary world deleted. Nothing was committed, pushed or published. + +## Page-free random structures + +`1.0.1.91-beta.1` ports `AgeStructureSelection.randomChoices`: an independent +seed stream rolls 0–2 unique candidates using Legacy ranks. Terrain and +registry availability are checked, then the selected biome pool must intersect +the structure set's normal biome eligibility. This is deliberately stricter +than authored pages, which retain their existing biome overrides. Native +Flat uses Plains, Native Cave uses the backend's Lush Caves fallback, and +other native noise terrain uses the Overworld LevelStem biome source. A +non-Native plan uses its saved biome IDs. Abandoned Camps are added after +the root candidate list at rank 3. Cave Dungeons remain excluded by current +port support, whereas root permits that combination; it needs a separate +placement validation before enabling it. + +The combined random feature resolver preserves .90 material/rare choices, +adds Dungeons separately, and merges structure pages into the saved terrain +feature plan. A new optional `structure_control` field defaults false for +old profiles and is true for new random Ages, even for an empty selection. +The generator applies its existing controlled-set filter when that flag is +true. This prevents zero choices from accidentally retaining all vanilla +controlled sets. Other native structure families still use their normal +biome gate. Existing authored/old plans are not rewritten, and no catalog +protocol change is needed. + +The operator fixture checks seeded counts/uniqueness, all terrains, fixed +Plains/End/Nether biome pools, native defaults, biome eligibility, the empty +controlled-set gate, explicit Village precedence, merge/codec persistence, +and old-profile defaults. Actual placement remains an in-game check. + +The .91 build and disposable server checks passed. Fixtures exercised Plains, +Native Flat, Nether-biome Cave, End-biome surface and empty Void selection, +plus the production random-Age command. Village, Fortress and Dungeon plans, +and an intentionally empty controlled plan, survived unload/reload, reseed +and full restart. Namespaced biome arguments were checked. The server was +stopped and its temporary world deleted. No commit, push or publication was +performed; generated structure placement remains a manual client check. + +## Cave Dungeon parity + +`1.0.1.92-beta.1` closes the Cave Dungeon terrain exclusion documented in .91. +Root `AgeStructureSelection.error` permits Dungeons on noise terrain, which +includes Cave. The 26.3 feature and placement implementation were already in +place; the port's shared lake/dungeon Cave rejection blocked authoring and, +through the availability check, random selection. Splitting that validation +permits Cave Dungeons without allowing Cave lakes or Flat/Void Dungeons. + +The two targets' `worldgen/placed_feature/dungeons.json` files compare exactly: +vanilla monster_room, count 8, in-square and full-height uniform placement +using above-bottom/below-top anchors. No legacy Y=0 assumption is introduced. +Vanilla's solid floor/ceiling and opening-count requirements still determine +successful placement. The 32-page cap and per-occurrence deterministic seed +remain unchanged. New random Cave Ages now consider Dungeons automatically; +old frozen plans are not changed. + +The extended `create_dungeons` fixture checks Cave stacking/cap and the +remaining lake/Flat/Void restrictions. The random-structure matrix now requires +Cave Dungeon coverage. `dungeons_probe` accepts an optional 1–4 chunk-side +bound for manual tests; it is not run automatically, avoiding a large sync +chunk-generation scan. Actual dungeon placement is handed to the client check. + +The .92 build and disposable server checks passed: Cave and surface authored +fixtures, 32-page stacking/overflow, retained lake and Flat/Void rejection, +and random-structure coverage including a Nether Cave Dungeon selection. +Dungeon counts survived unload/reload and full restart; frozen random plans +survived reseed. Placement JSON matched the 1.21.1 file byte-for-byte. The +server was stopped and its temporary world deleted. No commit, push or +publication was performed. No dungeon chunk scan was run automatically. + +## Biome-specific Nether and End materials + +`1.0.1.93-beta.1` ports root `AgeDimensionService.withBiomeMaterialRules`. +The backend was using `caves.materialRule()` unchanged, which selects +`minecraft:overworld_caves` and can apply Overworld grass to Nether biomes. +The new noise-settings wrapper sequences the five vanilla End biomes' registered +End rule, then the five vanilla Nether biomes' registered Nether rule, then +the original terrain rule. This fixes Cave Nether coverings and the equivalent +End/Nether mismatch in authored Standard, Amplified and Island noise Ages. +It uses the active rule registry rather than copying vanilla material tables. + +All non-material settings are preserved. Dry Cave's AIR default fluid, empty +aquifer config and minimum-relative sea level remain intact. Native Nether +surface rules can still place their own lava patches; this does not restore +flooded Cave aquifers. Flat/Void layer materials and custom feature materials +are not rewritten. Old saved Ages pick up the rule when reloaded, but only +newly generated chunks change; no generated blocks are retroactively edited. +No profile field or network protocol change is required. + +The creation fixture verifies unchanged geometry/router/default blocks and +fluids, aquifers and sea level for four noise presets, and round-trips the +composed material rule with registry-aware codecs. `material_probe` limits +generation to one terrain-stage chunk and reports actual block counts. + +Build, registry-aware rule codec tests and disposable server terrain checks +passed. All five Nether Cave fixtures generated their native material families +without grass/dirt: Nylium, Soul Sand/Soil, Basalt/Blackstone or Netherrack. +Standard The End and Island End Highlands generated End Stone; Lush Cave's +original Overworld materials remained present. Reload retained the rules, and +fresh chunks after full restart again used Nylium/End Stone. No full feature +or entity chunk scans were run. The server was stopped and temporary world +deleted. Nothing was committed, pushed or published. + ## Verification of this first slice The 1.21.1 `compileJava` and `build` tasks passed offline after the backend diff --git a/ports/mc263/README.md b/ports/mc263/README.md index 26a02ab..cad50db 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -5,8 +5,12 @@ with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported biome-distribution pages, selected material/vegetation/structure features, and -independent Age weather and lighting. Celestial systems, instability, crystal -portals, and much of the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not +independent Age weather and lighting, multiple authored or seeded celestial +bodies and eclipses, biome/material completion, and random features/structures. +Instability, crystal portals, discovery/stations, writing recovery and optional +integration parity remain unfinished. The latest published beta is +[1.0.1.93-beta.1](RELEASE_NOTES_1.0.1.93-beta.1.md). +Use a test world and keep a backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves. The entries below record how the port was built, including earlier alpha-only @@ -947,6 +951,346 @@ reload, reseed, restart and unchanged sampled lighting/time. Actual rendered size remains a manual check. The test server stopped and its world was deleted. No commit/push/publication is performed until explicitly requested. +The local `1.0.1.79-beta.1` slice adds shared color groups before **Normal +Time**, tinting both its sun and moon as in 1.21.1. Use Number + Red/Green/ +Blue/White/Black pages before that controller; Length/Phase/Angle/Size can +appear between the colors and Normal Time without losing the pending group. +Normal Time consumes the group, so it does not tint a later Sky or Ambient +Light Color target. Trailing colors still target the sky. Mixing, seeded +unnumbered amounts and completed-group clamping use the existing color rules. + +The tint changes native sun/moon RGB only, retaining sprite transparency, +weather fading, moon phases and existing size/orbit/time settings. It does +not recolor stars, sunrise glow or change gameplay illumination. Older saved +color profiles keep untinted bodies; new profiles save and sync their tint. +Catalog protocol is now 7: use matching client/server builds. Standalone +Sun/Moon controllers and independent per-body colors remain pending. + +Test **Link Panel → Zero Length → Zenith Phase → Number 24 → Red → Normal +Time**, optionally with No Weather and Normal Lighting, for a red sun. Use +Nadir Phase to view the same tint on the moon. Mix several colors, add a +separate color group before Sky, and confirm that the Overworld stays normal +after travel. Check glow transparency and persistence after reopening the +world. The op fixture `create_celestial_color + <0..24>` provides +independently colored sky/ambient plus a frozen body for diagnostics. +Client rendering remains a manual check; nothing is committed or published +without an explicit request. + +The local `1.0.1.80-beta.1` slice supports one standalone **Sun** or **Moon** +controller in place of Normal Time. Both accept the existing Color, Length, +Phase, Direction, Angle and Size modifiers (including Number overrides). +Sun omits the paired moon; Moon omits the paired sun and sunrise glow. +Size applies to the selected standalone body, while Normal Time still sizes +only its sun. A missing Sky selects Overworld; explicit Sky remains decisive, +and non-Overworld body rendering is still pending. + +Standalone bodies use the root-style linear orbit rather than the native +paired cycle's eased orbit. Zenith puts either body overhead, Nadir puts it +below the world, and Rising/Setting place it at the horizon. Zero Length +freezes that position. Gameplay skylight combines altitude with precipitation +and the root 0.18 moonlight multiplier (overhead sun = 15, overhead moon = 6, +below-horizon body = 4 under clear weather). Bright/Dark rules remain separate. +Native background, stars and moon-phase schedules remain for this slice; +multiple independent bodies, eclipse handling, and full seeded celestial +completion are not yet supported. Catalog protocol is now 8. + +Test **Link Panel → Zero Length → Zenith Phase → Large Celestial → Sun**, +then replace Sun with Moon in a new book. Each should show only that body +overhead. Try Number + Color and Size, a moving/reversed cycle, and Nadir; +confirm the paired Normal Time and Overworld are unaffected after travel. +Restart and revisit. The diagnostic `create_body +` creates a red, large, east-bearing frozen body. +More than one celestial controller is still rejected, not silently discarded. +Nothing is committed or published without an explicit request. + +The local `1.0.1.81-beta.1` slice accepts multiple celestial controllers. +Each Sun/Moon consumes its own preceding Color, Length, Phase, Direction, +Angle and Size group; modifiers reset after that controller. Normal Time +contributes a sun/moon pair with opposite phases and its usual sun-only Size. +The 1.21.1 limit of **ten suns and ten moons** applies, counting pairs toward +both limits. Unsupported duplicate modifiers within one group and dangling +modifiers are still rejected; the full recovery/instability grammar is pending. + +Multiple bodies retain independent saved orbits/colors and draw using the +native body resources, with suns before moons. Each has its own linear orbit; +the first controller supplies the shared day timeline and native background/ +star/moon-phase schedules. Skylight combines positive-altitude suns (capped at +1), or sums weaker moonlight when no sun contributes, matching the root +aggregation rule. Explicit non-Overworld backgrounds still hide bodies. +Catalog protocol is now 9. Older single-body/paired profiles remain unchanged. + +**Eclipse effects are not in this slice:** sky darkening, disc occlusion and +corona effects will be ported next. Visual overlap alone is not an eclipse. + +Test two groups in one book: **Zero Length → Zenith Phase → Large Celestial +→ Number 24 → Red → Sun**, then **Zero Length → Rising Phase → Small Celestial +→ Number 24 → Blue → Moon** (with Link Panel first). Each should retain its +own size, color and position. Try a moving/reversed third body, a Normal Time +pair alongside a standalone body, restart, and travel back to an ordinary +Age/Overworld. `/rivencraft_port age create_celestials ` creates a +diagnostic three-body Age. Rendering remains a manual client check. Nothing +is committed or published without an explicit request. + +The local `1.0.1.82-beta.1` slice adds eclipse effects to saved multi-body +profiles. Contact is based on the central quarter-size discs, not their +halos. Opposite-kind overlap progressively dims a moon and whitens a sun's +corona. A visible eclipsed sun darkens the sky by up to 65% and reduces its +sunlight contribution by up to 85%, using the 1.21.1 formulas. Other suns can +still supply light; same-kind overlaps do not produce eclipses. Below-horizon +overlap does not darken the visible sky. + +Each body's native transparent glow is followed by an ordinary-alpha central +disc, so an eclipsing moon obscures the sun instead of showing it through the +glow. During an eclipse the moon core uses the solid full-moon sprite, matching +root's occluding disc; native phases are retained outside eclipse contact. +Weather fading remains. Meshes are cached, rebuilt after atlas changes and +closed with the sky renderer. No new image assets are needed. Existing Ages +with multiple bodies get these derived effects without rebinding their books. + +Test **Link Panel → Zero Length → Zenith Phase → Sun → Zero Length → Zenith +Phase → Large Celestial → Moon** (No Weather and Normal Lighting recommended). +This holds an eclipse overhead. Change the Moon to Half Length in a new book +to watch it pass across the sun. Verify opaque center/transparent glow, sky +and ground darkening, restart and travel back to an ordinary dimension. +The op fixture `/rivencraft_port age create_eclipse +` creates controlled comparisons. +The multi-body fixture is now named `create_celestials`, avoiding a collision +with the existing terrain-generation `create_multi` command. Catalog schema +remains protocol 9. Explicit non-Overworld body rendering and broader seeded +completion are still pending. Nothing is committed or published without an +explicit request. + +The local `1.0.1.83-beta.1` correction makes standalone and multi-body authored +moons use the **full-moon sprite in both glow and opaque-center passes**, as +the 1.21.1 custom renderer does. Native dark lunar-phase pixels could become +black patches once the new ordinary-alpha center pass made them opaque. +The native Normal Time pair keeps its calendar phases. Eclipse geometry, +darkening and corona formulas are unchanged; genuinely eclipsed moons still +turn dark and obscure their overlapping sun. + +Read-only inspection of the reported `celest` save found all four body colors +intact (`-1`, untinted). Its first moon and second sun share period, direction, +phase, origin and bearing, creating a permanent eclipse after eclipse support +was added. This is separate from the phase-sprite rendering correction. No +saved orbits/colors are rewritten, and existing Ages need not be recreated. +Please revisit `celest`, compare a nonaligned authored moon, check an eclipse +and resource reload, then return to Overworld/Normal Time to confirm native +phases are unaffected. This rendering correction still needs a client check. +Nothing is committed or published without an explicit request. + +The local `1.0.1.84-beta.1` slice renders authored **Normal Time, Sun, Moon +and multiple bodies over all four Sky styles**, including explicit Nether, +End and animated Static. Backgrounds draw first; the existing authored body +pass follows with its colors, sizes, orbits, opaque centers and eclipse effects. +Nether retains its fog backdrop, End retains its native sky texture, and +Static keeps animating. These are presentation changes only: no Nether/End +terrain, ceiling, coordinate-scale or dragon behavior is introduced. + +Eclipses shade End/Static background tint and the Nether-style fog backdrop; +gameplay light still uses the saved body profile. Native Overworld rendering +and no-celestial Ages are unchanged. No new assets, saved fields or catalog +protocol changes are needed. Already-created Ages with explicit backgrounds +and authored celestials gain the rendering on update without rebinding. + +Test the same frozen overhead Sun/Moon book with **Nether Sky**, **End Sky** +and **Static Sky**, then test a moving body and eclipse in each. Check body +colors/size, no duplicate bodies, unchanged background style, Static animation, +F3+T, restart and travel to Overworld. The existing diagnostic +`age create_eclipse [overworld|nether|end|static]` now +accepts an optional explicit Sky for comparisons. Client rendering remains a +manual check. Nothing is committed or published without an explicit request. + +The local `1.0.1.85-beta.1` slice randomizes a **bare Sun or Moon page** when +a new Age is created. Each such controller gets its own deterministic color, +size, bearing, phase, direction and length. Defaults use the root random-Age +palette (mixing 1–3 colors), size 0.75×–1.75×, bearing 0–359°, full-cycle phase, +either direction, and length 0.2–2 days with Legacy's 1-in-7 stopped-cycle +chance. Sun/Moon counts still come from your pages; nothing adds extra bodies. + +This applies only when that controller has **no written celestial modifiers**. +If its group includes Color, Size, Angle, Phase, Direction or Length, the +existing explicitly authored behavior is retained, including defaults for +its other fields. Sky/ambient colors consumed by their own target and unrelated +terrain/weather/lighting pages do not make a body authored or reshuffle its +random settings. Normal Time is unchanged. Groups use Age seed plus controller +ordinal; repeated bare pages get independent settings. Choices are saved, +not rerolled on GUI opening, reload/restart or terrain reseeding. + +Test a **new** book with **Link Panel → Sun → Moon → Sun**, optionally adding +No Weather and Normal Lighting for visibility. Expect varied colors/sizes/ +orbits; some bodies can be below the horizon or stopped. Test an explicitly +modified body in the same book, then restart and revisit. Existing Ages keep +their original saved settings and are not randomized retroactively. The op +fixture `age create_random_celestials ` tests independent seeded +defaults and bounds. Nothing is committed or published without explicit request. + +The local `1.0.1.86-beta.1` slice completes the celestial branch for **new +books with no celestial controller pages**, including Link-Panel-only books. +The Age seed chooses **0–3 suns and 0–3 moons**, following the root random +count draws. Each body receives the independent saved settings introduced +in .85. An explicit Sun, Moon or Normal Time suppresses this extra-body +completion entirely; its written controllers remain authoritative. + +A valid zero-sun/zero-moon result draws neither body nor sunrise glow, rather +than accidentally falling back to a vanilla pair. This does not force Dark +Lighting: background/star schedules and independent lighting retain their +normal behavior. Random background selection remains independent of body +counts, and all supported Sky styles can render the chosen bodies. Default +sky colors retain their current behavior; this slice completes bodies only. + +Choices persist through restart and terrain reseeding. Existing Ages are not +rerolled. Catalog protocol is now **10**, requiring matching client/server +builds. Test several new Link-Panel-only books, plus books that contain only +terrain/weather/lighting/Sky pages. Expect varied counts, including sun-only, +moon-only and empty skies; some bodies may be below the horizon. Compare an +explicit one-Sun book and a Normal Time book, then restart and revisit. +`age create_completed_sky [overworld|nether|end|static]` provides +clear-weather Flat fixtures and reports a sample zero-body seed. The existing +`age create_random` now also uses the completed celestial plan. Nothing is +committed or published without explicit request. + +The local `1.0.1.87-beta.1` slice gives **new Link-Panel-only random Ages** +a saved random sky/fog tint, matching 1.21.1's seeded palette and rounded +mix of 1–3 colors. This completes the sky-color draw already reserved by the +random celestial count selection. Body settings/counts are unchanged. + +Books containing symbol pages keep their authored/default color rules; a +black authored sky remains black. Ambient lighting is not randomized by this +change. Existing Ages retain their saved colors. No new saved fields or +catalog protocol change is needed. Create several new Link-Panel-only books, +check sky/fog colors and restart persistence, then compare an explicitly +colored Sky book and an uncolored terrain-only book. Nothing is committed or +published without explicit request. + +The local `1.0.1.88-beta.1` slice seed-completes **explicit biome distribution +pages with missing biome pages**. Single Biome alone selects one eligible +biome. Tiled/Grid/region controllers alone choose at least two distinct +biomes, using 1.21.1's seeded continuation rule and the available pool as the +maximum. With one authored biome they fill just the missing second biome; +with two distinct authored biomes they add nothing. Repeated biome pages keep +their weight but do not satisfy the distinct-biome minimum by themselves. + +Cave completion draws only recognized Cave/Nether biomes. Void supplies The +Void only when no biome is authored; a multi-biome Void book needs sufficient +explicit biome pages rather than acquiring an unwanted Void biome. Native +still rejects biome pages. No-controller biome behavior is unchanged, and +the broader recovery/instability grammar is not part of this slice. + +Test a new Link Panel + Tiny Biome Regions book, Single Biome alone, and Tiny +with one/two specific biome pages. Also try Cave + Tiny and restart/revisit. +The operator fixture is `age create_completed_biomes +`; `age biome_pool ` inspects the generator without loading +chunks. Existing Age pools do not change. Nothing is committed or published +without explicit request. + +The local `1.0.1.89-beta.1` slice supports **unpaired Block/Fluid pages**. +When no feature immediately follows a material, the Age seed and original +page position choose a compatible implied feature, following 1.21.1's model. +Explicit material → feature pairs remain unchanged. Consecutive material +pages each receive their own selection; the resulting choices may repeat. +Only the saved feature plans gain these implied choices, not extra physical +pages in the book. + +Solid candidates are Rocks, Floating Islands, Tendrils, Spheres, Spikes and +Obelisks, restricted by terrain. Cave permits Tendrils/Spheres; Void also +permits Floating Islands. Flat excludes Rocks. Fluid candidates are the three +lake types on supported surface noise terrain. An unpaired fluid on Flat, +Void or Cave is still refused because no compatible lake implementation is +available there. Explicit incompatible pairings and existing 32-feature +limits are unchanged; the full recovery/instability grammar remains pending. + +Test Link Panel + Glowstone, consecutive Glowstone/Stone pages, and an unpaired +Water page on Standard terrain. Compare an explicit Glowstone → Tendrils +pair, try a solid on Cave/Void, and restart/revisit. Existing Ages retain +their saved features. Operator diagnostics: `age create_implied_material + ` and `age feature_plan ` (no chunk +generation). Nothing is committed or published without explicit request. + +The local `1.0.1.90-beta.1` slice adds **random material features to new +Link-Panel-only Ages**. The Age seed rolls 0–2 distinct feature types using +1.21.1's Legacy rank weighting, then chooses a registered compatible solid +or source fluid for each. Candidates are Rocks, Floating Islands, Tendrils, +Spheres, Spikes, Obelisks and the three lake types, filtered by terrain. + +The same creation pass rolls rare Skylands (1-in-8 on noise terrain), Crystal +Formations (1-in-10 on exposed surface terrain), and Star Fissures using the +root small-feature grammar. These are additional to the 0–2 material features. +Random structure selection and page-eligibility config remain future port +work. Authored books do not gain unrequested random features, and existing +Age plans are not rerolled. Choices persist through restart and reseed. + +Test several new Link-Panel-only books for varied material features, including +Ages with none; compare an explicitly authored book and revisit after restart. +For controlled checks, `age create_random_features ` +creates a feature-only fixture and `age feature_plan ` reports its +saved generator choices without generating chunks. `age create_random` uses +the same production selector. Nothing is committed or published without +explicit request. + +The local `1.0.1.91-beta.1` slice ports **random structure selection** from +1.21.1. New Link-Panel-only Ages roll 0–2 distinct structure families in +addition to .90's material features. Legacy ranks prefer Villages, Strongholds +and Mineshafts, followed by Dungeons/Fortresses; newer structures are rarer. +The 26.3 Abandoned Camps page joins that rare pool. Candidates must support +the terrain and at least one biome in the actual selected biome pool. + +A saved structure-control flag distinguishes an intentionally empty selection +from older Ages' native behavior. Unselected page-controlled structure sets +are disabled even when no structures were chosen; other native structure +families remain governed by vanilla biome rules. Authored pages retain their +biome override behavior, and old saved Ages are not changed. Cave Dungeons +remain excluded by the port's existing terrain restriction; this is a known +remaining difference from 1.21.1, not a new root behavior. + +Test several new random Ages, including a restart/revisit, and compare an +authored Village book. For focused checks, `age create_random_structures + ` creates a controlled fixture; +`age feature_plan ` reports the frozen selection, including Dungeons. +No catalog protocol change is needed. Nothing is committed or published +without explicit request. + +The local `1.0.1.92-beta.1` slice enables **Dungeons on Cave terrain**, including +Nether cave biomes, matching 1.21.1's noise-terrain support. The already-ported +placement JSON is byte-identical to root: eight vanilla monster-room attempts +per page, uniformly from one block above the minimum to one below the maximum +build height. Vanilla still requires a suitable enclosed chamber; an attempt +is not a guaranteed dungeon. Stacking up to 32 pages is unchanged. + +Only the combined Cave dungeon/lake rejection was split: lakes remain rejected +there, and Flat/Void still reject Dungeons. New random Cave Ages can now select +Dungeons through .91's normal eligibility/rank rules. Existing saved plans +are unchanged. `age create_dungeons cave` creates an eight-page +fixture. `age dungeons_probe [chunks]` optionally limits the search to +1–4 chunks per side; omitting it retains the previous 4×4 search. That probe +generates chunks and is reserved for manual placement checks. + +Test Cave + Dungeons, optionally stacking pages for visibility; use spectator +mode to find monster rooms and compare a Nether-biome Cave Age. This slice's +server validation checks authoring and persistence without generating a large +region. No saved-field/protocol change is needed. Nothing is committed or +published without explicit request. + +The local `1.0.1.93-beta.1` slice fixes **Nether and End biome materials on +noise terrain**, including Nether biomes in Cave Ages. The port had retained +the vanilla terrain's Overworld material rules, which could coat Nether +biomes in grass. It now ports 1.21.1's biome rule layer: the five vanilla End +biomes use the registered End rule, the five vanilla Nether biomes use the +registered Nether rule, and all other biomes retain the terrain's original +rules. Nether coverings include Nylium, Soul Sand/Soil, Basalt and Blackstone +where their native rules select them; End areas use End Stone. + +Noise geometry, biome selection, lighting, default fluids, sea level and dry +Cave aquifer settings remain unchanged. This is not a Flat/Void layer rewrite. +The correction applies after reload to **new chunks only** in existing Ages; +already generated grass is not replaced. Test a new Nether-biome Cave Age +and End-biome Standard/Island Age, or explore new chunks after restarting. + +Operator fixtures: `age create_biome_surface ` +and `age material_probe [chunk_x chunk_z]`. The latter examines one +terrain-stage chunk (default 0,0) without feature, lighting or entity generation. Nothing is committed +or published without explicit request. + From the repository root: ```sh diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.93-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.93-beta.1.md new file mode 100644 index 0000000..d10eb80 --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.93-beta.1.md @@ -0,0 +1,83 @@ +# Rivencraft 26.3 beta 1.0.1.93-beta.1 + +Collects the player-validated port slices .79–.93 since 1.0.1.78-beta.1. +This beta is not yet feature-complete with Minecraft 1.21.1. + +## Celestial writing and rendering + +- Ported standalone Sun and Moon pages and multiple independently authored + bodies, up to ten of each. Each controller consumes its own ordered Color, + Length, Phase, Direction, Angle and Size modifiers; Number pages fine-tune + supported values. Normal Time retains its paired sun/moon behavior. +- Color groups before Normal Time tint both bodies; independent Sun/Moon + groups can use different colors. Authored moons use full-moon sprites as in + 1.21.1; native Normal Time retains calendar moon phases. +- Authored bodies render over Overworld, Nether, End and animated Static + backgrounds. Opaque centers retain transparent glows. Eclipse alignment + affects body color, background shading and gameplay sky light. +- Bare Sun/Moon pages receive independent seeded colors, sizes, bearings, + phases, directions and lengths, including stopped cycles. Explicit modifier + groups retain their authored behavior. +- Books without celestial controllers seed-complete 0–3 suns and 0–3 moons. + Empty skies do not accidentally draw a vanilla pair. Explicit controllers + suppress extra-body completion. Link-Panel-only random Ages also receive + seeded sky/fog colors using the 1.21.1 palette. +- All resolved settings are saved, not rerolled by viewing a book, restarting, + or reseeding future terrain. Existing Age profiles remain unchanged. + +## Biome and feature completion + +- Explicit biome-distribution pages can now seed-complete missing biome + pools. Single selects one; other nonnative controllers require two distinct + biomes. Fully omitted pools use the seeded continuation rule, bounded by the + available biome pool. Authored pools fill only the missing minimum. +- Supplied biome order and duplicate weights are retained. Two distinct + supplied biomes do not acquire an unwanted third biome. Cave pools use + recognized Cave/Nether biomes; Void does not inject The Void alongside + supplied biome pages. +- Unpaired Block/Fluid pages receive seeded terrain-compatible implied + features. Consecutive materials resolve independently; explicit pairs remain + authoritative. Unsupported pairings and the current feature caps remain. +- Link-Panel-only Ages roll 0–2 distinct material feature types with Legacy + rank weighting and compatible registered solids/source fluids. Additional + rare rolls cover Skylands, Crystal Formations and Star Fissures. +- Random Ages also roll 0–2 distinct structure families, filtered by terrain, + registry availability and the chosen biome pool. Newer structures, including + 26.3 Abandoned Camps, are rare candidates. An intentionally empty selection + disables page-controlled structure sets rather than enabling all of them. +- Cave terrain now supports authored and randomly selected Dungeons, matching + 1.21.1. Dungeon placement retains eight monster-room attempts per page and + modern build-height anchors. Flat/Void Dungeons and Cave lakes remain + unsupported. + +## Nether and End material correction + +- Ported 1.21.1's biome-specific material-rule layer for noise terrain. Nether + biomes in Cave Ages now use their native Netherrack, Nylium, Soul Sand/Soil, + Basalt and Blackstone rules instead of Overworld grass. End biomes use End + Stone. Other biomes retain the original terrain rules. +- Geometry, biome selection, dry Cave aquifers and other environment settings + are preserved. Flat/Void layers and authored feature materials are unchanged. +- Existing Ages pick up the correction after reload, but only new chunks + change. Already generated grass is not replaced. + +## Compatibility and validation + +- Requires Minecraft **26.3**, NeoForge **26.3.0.99**, and **Java 25**. Dynamic + Dimensions and Bad Packets are not required. Minecraft 1.21.1 is unchanged. +- Catalog protocol is **10**: use matching client/server builds. +- Player in-game checks passed for all included slices. Builds and targeted + disposable-server checks covered writing, seeded completion, codecs/catalog + round trips, terrain materials, reload, reseed and restart. Temporary servers + and test worlds were removed. This is not a full automated GameTest run. +- Full writing recovery, discovery/stations, instability, portals and optional + integration parity remain unfinished. Random-page eligibility config also + remains pending; valuable/hazardous random materials currently lack the + finished version's instability scoring. Creative availability does not imply + full page support. Number-controlled Static resolution (#109) and time-page + naming (#110) remain separate work. +- Beta save compatibility is not guaranteed. Back up worlds and keep the two + Minecraft version tracks and saves separate. + +Report issues and help improve the wiki at +[BlackSheep Git](https://git.paddedcell.org/dnorberg/rivencraft). diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 63c42fb..6dc6d3e 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.78-beta.1 +mod_version=1.0.1.93-beta.1 diff --git a/ports/mc263/port_en_us.json b/ports/mc263/port_en_us.json index cf9c570..e02d1d5 100644 --- a/ports/mc263/port_en_us.json +++ b/ports/mc263/port_en_us.json @@ -9,33 +9,35 @@ "item.rivencraft.page.symbol.implemented": "Implemented in 26.3; book and terrain requirements still apply.", "symbol.rivencraft.weather.normal.description": "Use an independent normal weather cycle. Cave terrain keeps weather off.", "symbol.rivencraft.sky.overworld.description": "Use the native Overworld sky. Terrain and lighting remain independently authored.", - "symbol.rivencraft.sky.nether.description": "Use a Nether-style sky and reddish fog, without Nether terrain or gameplay rules.", - "symbol.rivencraft.sky.end.description": "Use the native End sky backdrop, without End terrain or a dragon fight.", - "symbol.rivencraft.sky.static.description": "Animated static surrounds the Age. Preceding color pages tint it; otherwise it is gray.", + "symbol.rivencraft.sky.nether.description": "Use a Nether-style reddish fog backdrop. Authored celestials render over it; terrain and gameplay rules remain independent.", + "symbol.rivencraft.sky.end.description": "Use the native End sky backdrop with authored celestials over it, without End terrain or a dragon fight.", + "symbol.rivencraft.sky.static.description": "Animated static surrounds the Age, behind authored celestials. Preceding colors tint it; otherwise it is gray.", "symbol.rivencraft.lighting.ambient_color": "Ambient Light Color", "symbol.rivencraft.lighting.ambient_color.description": "Apply preceding shared colors to ambient light. Use Bright separately for full effective ambient lighting.", "symbol.rivencraft.number.description": "Before Color: value × 10 per channel. Before Length: value × 0.08 days. Before Phase/Angle: value × 15 degrees. Before Size: 0.25× to 3× (0-24).", - "symbol.rivencraft.size.small.description": "Before Normal Time: render the sun at 0.5× size. Its paired moon stays normal-sized, matching 1.21.1. A preceding Number overrides the size.", - "symbol.rivencraft.size.normal.description": "Before Normal Time: render the sun at 1× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.", - "symbol.rivencraft.size.large.description": "Before Normal Time: render the sun at 2× size. A preceding Number selects 0.25× through 3× (0-24). Its paired moon is unchanged.", - "symbol.rivencraft.angle.north.description": "Before Normal Time: set the native celestial orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.", - "symbol.rivencraft.angle.east.description": "Before Normal Time: set the native celestial orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.", - "symbol.rivencraft.angle.south.description": "Before Normal Time: set the native celestial orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.", - "symbol.rivencraft.angle.west.description": "Before Normal Time: set the native celestial orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.", - "symbol.rivencraft.phase.rising.description": "Before Normal Time: start this Age's native cycle at sunrise. A preceding Number overrides Phase in 15-degree steps from midnight.", - "symbol.rivencraft.phase.zenith.description": "Before Normal Time: start this Age's native cycle at noon. With Zero Length it remains noon.", - "symbol.rivencraft.phase.setting.description": "Before Normal Time: start this Age's native cycle at sunset. A preceding Number overrides Phase in 15-degree steps from midnight.", - "symbol.rivencraft.phase.nadir.description": "Before Normal Time: start this Age's native cycle at midnight. With Zero Length it remains midnight.", - "symbol.rivencraft.direction.clockwise.description": "Before Normal Time: advance this Age's native day cycle in its usual direction.", - "symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time: run this Age's native day cycle in reverse. Does not change the Overworld clock.", - "symbol.rivencraft.celestial.normal_time.description": "Use the native sun/moon cycle with Length, Phase, Direction, Angle, and sun Size modifiers. Without a Length, use one normal day.", - "symbol.rivencraft.period.zero.description": "Before Normal Time: freeze the Age's day timelines at the written Phase, or a saved seeded time if Phase is omitted. A preceding Number overrides the Length.", - "symbol.rivencraft.period.half.description": "Before Normal Time: a half-length day (twice as fast). A preceding Number sets Length to value × 0.08 days.", - "symbol.rivencraft.period.full.description": "Before Normal Time: a normal-length day. A preceding Number sets Length to value × 0.08 days.", - "symbol.rivencraft.period.double.description": "Before Normal Time: a double-length day (half as fast). A preceding Number sets Length to value × 0.08 days.", - "symbol.rivencraft.color.red.description": "Add red to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", - "symbol.rivencraft.color.green.description": "Add green to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", - "symbol.rivencraft.color.blue.description": "Add blue to the next sky or Ambient Light Color target; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", + "symbol.rivencraft.size.small.description": "Before Sun/Moon: 0.5× body size. Before Normal Time: size the sun only. A preceding Number overrides the size.", + "symbol.rivencraft.size.normal.description": "Before Sun/Moon: 1× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).", + "symbol.rivencraft.size.large.description": "Before Sun/Moon: 2× body size. Before Normal Time: size the sun only. A preceding Number selects 0.25× through 3× (0-24).", + "symbol.rivencraft.angle.north.description": "Before Normal Time, Sun or Moon: set orbit bearing to 0 degrees. A preceding Number overrides Angle in 15-degree steps.", + "symbol.rivencraft.angle.east.description": "Before Normal Time, Sun or Moon: set orbit bearing to 90 degrees. A preceding Number overrides Angle in 15-degree steps.", + "symbol.rivencraft.angle.south.description": "Before Normal Time, Sun or Moon: set orbit bearing to 180 degrees. A preceding Number overrides Angle in 15-degree steps.", + "symbol.rivencraft.angle.west.description": "Before Normal Time, Sun or Moon: set orbit bearing to 270 degrees. A preceding Number overrides Angle in 15-degree steps.", + "symbol.rivencraft.phase.rising.description": "Before a celestial controller: start the sun or standalone moon at rising. A preceding Number overrides Phase in 15-degree steps from Nadir.", + "symbol.rivencraft.phase.zenith.description": "Before a celestial controller: start the sun or standalone moon overhead. Zero Length holds this position.", + "symbol.rivencraft.phase.setting.description": "Before a celestial controller: start the sun or standalone moon at setting. A preceding Number overrides Phase in 15-degree steps from Nadir.", + "symbol.rivencraft.phase.nadir.description": "Before a celestial controller: start the sun or standalone moon below the world. Zero Length holds this position.", + "symbol.rivencraft.direction.clockwise.description": "Before Normal Time, Sun or Moon: advance this Age's celestial cycle in its usual direction.", + "symbol.rivencraft.direction.counterclockwise.description": "Before Normal Time, Sun or Moon: reverse this Age's celestial cycle. Does not change the Overworld clock.", + "symbol.rivencraft.celestial.normal_time.description": "Add a sun/moon pair. Preceding modifiers apply to this pair; Size affects its sun only. Colors tint both. Limit ten suns and ten moons per Age.", + "symbol.rivencraft.celestial.sun.description": "Add a sun. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Moon alignment causes eclipses; limit ten suns.", + "symbol.rivencraft.celestial.moon.description": "Add a moon with weaker light. A bare page gets saved seeded color, size and orbit controls. Written modifiers preserve authored behavior and reset per controller. Sun alignment causes eclipses; limit ten moons.", + "symbol.rivencraft.period.zero.description": "Before Normal Time, Sun or Moon: freeze at the written Phase, or a saved seeded time if omitted. A preceding Number overrides the Length.", + "symbol.rivencraft.period.half.description": "Before Normal Time, Sun or Moon: a half-length cycle (twice as fast). A preceding Number sets Length to value × 0.08 days.", + "symbol.rivencraft.period.full.description": "Before Normal Time, Sun or Moon: a normal-length cycle. A preceding Number sets Length to value × 0.08 days.", + "symbol.rivencraft.period.double.description": "Before Normal Time, Sun or Moon: a double-length cycle (half as fast). A preceding Number sets Length to value × 0.08 days.", + "symbol.rivencraft.color.red.description": "Add red to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", + "symbol.rivencraft.color.green.description": "Add green to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", + "symbol.rivencraft.color.blue.description": "Add blue to the next Sky, Ambient Light Color or celestial controller; trailing colors tint the sky. Precede with a Number, or use a seeded amount.", "symbol.rivencraft.color.white.description": "Add equally to all RGB channels. Precede with a Number, or use a seeded amount.", "symbol.rivencraft.color.black.description": "Subtract equally from all RGB channels. Precede with a Number, or use a seeded amount.", "symbol.rivencraft.lighting.normal.description": "Use normal skylight and ambient brightness. Cave terrain remains without skylight.", @@ -82,8 +84,10 @@ "symbol.rivencraft.feature.red_shrubs.description": "Add red shrub patches to surface terrain with suitable ground.", "symbol.rivencraft.feature.abandoned_camps": "Abandoned Camps", "symbol.rivencraft.feature.abandoned_camps.description": "Allow abandoned camps on surface noise terrain, including outside their usual biomes.", - "symbol.rivencraft.block.generated.description": "Place immediately before a compatible feature; unpaired materials are not yet supported.", + "symbol.rivencraft.block.generated.description": "Modify the next compatible feature. Without a following feature, choose a compatible feature from the Age seed.", "symbol.rivencraft.biome.native.description": "Use the terrain's native biome source; do not include Biome pages.", + "symbol.rivencraft.feature.dungeons.description": "Add monster-room dungeon attempts throughout surface noise or Cave terrain, including Nether cave biomes. Not Flat or Void.", + "symbol.rivencraft.biome.single.description": "Use one biome across the Age. If omitted, choose an eligible biome from the Age seed.", "symbol.rivencraft.terrain.flat.description": "A level, biome-decorated landscape with its foundation at Y=0.", "symbol.rivencraft.feature.floating_islands.description": "Add high floating islands alongside noise, Flat, or Void terrain; not Cave terrain.", "symbol.rivencraft.feature.caves.description": "Add extra cave carving to noise terrain, including Cave; not Flat or Void.", diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java index 012c3a5..4e87551 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/AgeCatalogSync.java @@ -21,7 +21,7 @@ public final class AgeCatalogSync { } public static void registerPayloads(RegisterPayloadHandlersEvent event) { - var registrar = event.registrar("6"); + var registrar = event.registrar("10"); registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC); registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC, (payload, context) -> { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java index 8447e55..0eaf46f 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java @@ -14,22 +14,27 @@ import org.joml.Vector3f; import org.joml.Vector3fc; /** Frozen shared-color writing, adapted from 1.21.1 AgeSkyProfile. -1 means native. */ -public record PortAgeColors(int sky, int ambient) { +public record PortAgeColors(int sky, int ambient, int celestial) { public static final PortAgeColors NATIVE = new PortAgeColors(-1, -1); public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.intRange(-1, 0xffffff).fieldOf("sky").forGetter(PortAgeColors::sky), - Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient) + Codec.intRange(-1, 0xffffff).fieldOf("ambient").forGetter(PortAgeColors::ambient), + Codec.intRange(-1, 0xffffff).optionalFieldOf("celestial", -1).forGetter(PortAgeColors::celestial) ).apply(instance, PortAgeColors::new)); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( - ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient, PortAgeColors::new); + ByteBufCodecs.INT, PortAgeColors::sky, ByteBufCodecs.INT, PortAgeColors::ambient, + ByteBufCodecs.INT, PortAgeColors::celestial, PortAgeColors::new); public static final Identifier AMBIENT = Identifier.fromNamespaceAndPath("rivencraft", "lighting/ambient_color"); private static volatile Map clientColors = Map.of(); public PortAgeColors { - if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff) + if (sky < -1 || sky > 0xffffff || ambient < -1 || ambient > 0xffffff + || celestial < -1 || celestial > 0xffffff) throw new IllegalArgumentException("Invalid Age color"); } + public PortAgeColors(int sky, int ambient) { this(sky, ambient, -1); } + public static void updateClient(Map colors) { clientColors = Map.copyOf(colors); } public static PortAgeColors forLevel(Level level) { @@ -65,7 +70,7 @@ public record PortAgeColors(int sky, int ambient) { public static PortAgeColors compile(List pages, long seed) { if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier"); - int red = 0, green = 0, blue = 0, sky = -1, ambient = -1; + int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1; boolean written = false; for (int i = 0; i < pages.size(); i++) { Identifier page = pages.get(i); @@ -81,17 +86,43 @@ public record PortAgeColors(int sky, int ambient) { case "color/white" -> { red += amount; green += amount; blue += amount; } case "color/black" -> { red -= amount; green -= amount; blue -= amount; } } - } else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT)) { + } else if (PortSkyStyle.forPage(page) != null || page.equals(AMBIENT) + || PortDayCycle.isController(page)) { + if (PortDayCycle.isController(page)) celestial = -1; if (written) { int rgb = clamp(red) << 16 | clamp(green) << 8 | clamp(blue); - if (page.equals(AMBIENT)) ambient = rgb; else sky = rgb; + if (page.equals(AMBIENT)) ambient = rgb; + else if (PortDayCycle.isController(page)) celestial = rgb; + else sky = rgb; } red = green = blue = 0; written = false; } } if (written) sky = clamp(red) << 16 | clamp(green) << 8 | clamp(blue); - return new PortAgeColors(sky, ambient); + return new PortAgeColors(sky, ambient, celestial); + } + + /** Only pageless random Ages receive a random sky tint, as in 1.21.1. */ + public static PortAgeColors resolve(List pages, long seed) { + if (!pages.isEmpty()) return compile(pages, seed); + var random = new Random(seed ^ 0x534B5950524F464CL); + random.nextInt(5); // Root sky-style draw precedes the sky-color draw. + return new PortAgeColors(randomColor(random, true), -1); + } + + /** Shared palette; retain .85's truncation for already-defined body defaults. */ + static int randomColor(Random random, boolean rounded) { + int[] palette = {0xB74A50, 0xD9834B, 0xDBC574, 0x5AAB80, 0x6FA8DF, 0x9870B9, 0xD5DBE1, 0x252531}; + int count = 1 + random.nextInt(3), red = 0, green = 0, blue = 0; + for (int i = 0; i < count; i++) { + int color = palette[random.nextInt(palette.length)]; + red += color >> 16 & 255; + green += color >> 8 & 255; + blue += color & 255; + } + int offset = rounded ? count / 2 : 0; + return (red + offset) / count << 16 | (green + offset) / count << 8 | (blue + offset) / count; } private static int clamp(int value) { return Math.max(0, Math.min(255, value)); } 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 0321b3d..8112af6 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 @@ -8,6 +8,7 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.LinkBookTarget; import net.minecraft.commands.Commands; import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.arguments.IdentifierArgument; import net.minecraft.core.registries.Registries; import net.minecraft.core.component.DataComponents; import net.minecraft.network.chat.Component; @@ -68,6 +69,85 @@ public final class PortAgeCommands { return 1; }))) .then(Commands.literal("age") + .then(Commands.literal("create_biome_surface") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .then(Commands.argument("biome", IdentifierArgument.id()) + .executes(PortAgeCommands::createBiomeSurface)))))) + .then(Commands.literal("material_probe") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(context -> probeBiomeMaterials(context, 0, 0)) + .then(Commands.argument("chunk_x", IntegerArgumentType.integer()) + .then(Commands.argument("chunk_z", IntegerArgumentType.integer()) + .executes(context -> probeBiomeMaterials(context, + IntegerArgumentType.getInteger(context, "chunk_x"), + IntegerArgumentType.getInteger(context, "chunk_z"))))))) + .then(Commands.literal("create_random_structures") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .then(Commands.argument("biome", IdentifierArgument.id()) + .executes(PortAgeCommands::createRandomStructures)))))) + .then(Commands.literal("create_random_features") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .executes(PortAgeCommands::createRandomFeatures))))) + .then(Commands.literal("create_implied_material") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .then(Commands.argument("material", IdentifierArgument.id()) + .executes(PortAgeCommands::createImpliedMaterial)))))) + .then(Commands.literal("feature_plan") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeFeaturePlan))) + .then(Commands.literal("create_completed_biomes") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("terrain", StringArgumentType.word()) + .then(Commands.argument("distribution", StringArgumentType.word()) + .executes(PortAgeCommands::createCompletedBiomes)))))) + .then(Commands.literal("biome_pool") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(PortAgeCommands::probeBiomePool))) + .then(Commands.literal("create_completed_sky") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(context -> createCompletedSky(context, null)) + .then(Commands.argument("sky", StringArgumentType.word()) + .executes(context -> createCompletedSky(context, + StringArgumentType.getString(context, "sky"))))))) + .then(Commands.literal("create_random_celestials") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(PortAgeCommands::createRandomCelestialsAge)))) + .then(Commands.literal("create_eclipse") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("mode", StringArgumentType.word()) + .executes(PortAgeCommands::createEclipseAge) + .then(Commands.argument("sky", StringArgumentType.word()) + .executes(context -> createEclipseAge(context, + StringArgumentType.getString(context, "sky")))))))) + .then(Commands.literal("create_celestials") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(PortAgeCommands::createMultiBodyAge)))) + .then(Commands.literal("create_body") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("body", StringArgumentType.word()) + .then(Commands.argument("phase", StringArgumentType.word()) + .executes(PortAgeCommands::createBodyAge)))))) + .then(Commands.literal("create_celestial_color") + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .then(Commands.argument("phase", StringArgumentType.word()) + .then(Commands.argument("color", StringArgumentType.word()) + .then(Commands.argument("number", IntegerArgumentType.integer(0, 24)) + .executes(PortAgeCommands::createCelestialColorAge))))))) .then(Commands.literal("create_size") .then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("seed", LongArgumentType.longArg()) @@ -209,8 +289,13 @@ public final class PortAgeCommands { PortRandomAgeSelection.Selection selected = PortRandomAgeSelection.select( context.getSource().getServer(), seed); var environment = PortEnvironmentSelection.resolve(java.util.List.of(), seed, selected.terrain()); + var cycle = PortDayCycle.resolve(java.util.List.of(), seed, + PortDayCycle.nativeTicks(context.getSource().getServer().overworld())); + var features = PortRandomFeatureSelection.select(context.getSource().getServer(), + seed, selected.terrain(), selected.biomes()); int created = createAge(context, selected.terrain(), selected.biomes(), - null, null, 0, null, null, environment); + features.rocks(), null, features.dungeons(), features.lakes(), features.terrain(), environment, cycle, + PortAgeColors.resolve(java.util.List.of(), seed)); if (created != 0) context.getSource().sendSuccess(() -> Component.literal( "Random Age plan: terrain=" + selected.terrain() + " biomes=" + selected.biomes() @@ -244,7 +329,9 @@ public final class PortAgeCommands { .then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("seed", LongArgumentType.longArg()) .executes(context -> createAge(context, PortAgeTerrain.STANDARD, - null, null, null, testDungeons()))))) + null, null, null, testDungeons())) + .then(Commands.argument("terrain", StringArgumentType.word()) + .executes(PortAgeCommands::createDungeonTerrain))))) .then(Commands.literal("create_lakes") .then(Commands.argument("name", StringArgumentType.word()) .then(Commands.argument("seed", LongArgumentType.longArg()) @@ -378,7 +465,10 @@ public final class PortAgeCommands { .executes(PortAgeCommands::probeVegetation))) .then(Commands.literal("dungeons_probe") .then(Commands.argument("name", StringArgumentType.word()) - .executes(PortAgeCommands::probeDungeons))) + .executes(context -> probeDungeons(context, 4)) + .then(Commands.argument("chunks", IntegerArgumentType.integer(1, 4)) + .executes(context -> probeDungeons(context, + IntegerArgumentType.getInteger(context, "chunks")))))) .then(Commands.literal("lakes_probe") .then(Commands.argument("name", StringArgumentType.word()) .executes(PortAgeCommands::probeLakes))) @@ -586,12 +676,659 @@ public final class PortAgeCommands { var pos = new net.minecraft.core.BlockPos(0, 80, 0); var sun = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SUN_ANGLE, pos); var light = level.environmentAttributes().getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_LIGHT_LEVEL); + var moon = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.MOON_ANGLE, pos); + if (cycle.customLight()) { + float expectedAngle = cycle.bodyAngle(source.totalTicks()); + float weather = (1F - level.getRainLevel(1F) * 5F / 16F) + * (1F - level.getThunderLevel(1F) * 5F / 16F); + float expectedLight = 15F - (int) ((1F - cycle.bodyLight(source.totalTicks()) * weather) * 11F); + if (cycle.singleBody() && (Math.abs(sun - expectedAngle) > 0.01F || Math.abs(moon - expectedAngle) > 0.01F) + || Math.abs(light - expectedLight) > 0.01F) + throw new IllegalStateException("Single-body attribute mismatch"); + } context.getSource().sendSuccess(() -> Component.literal("Cycle probe " + ageId(context) + " saved=" + cycle + " nativeTicks=" + source.totalTicks() + " ageTicks=" + sample.totalTicks() - + " sunAngle=" + sun + " skyLight=" + light), false); + + " sunAngle=" + sun + " moonAngle=" + moon + " skyLight=" + light), false); return 1; } + private static int createBiomeSurface(CommandContext context) { + try { + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + var biome = IdentifierArgument.getId(context, "biome"); + var plan = new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, java.util.List.of(biome)); + var server = context.getSource().getServer(); + var registryOps = net.minecraft.resources.RegistryOps.create(com.mojang.serialization.JsonOps.INSTANCE, server.registryAccess()); + for (var key : java.util.List.of(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.OVERWORLD, + net.minecraft.world.level.levelgen.NoiseGeneratorSettings.AMPLIFIED, + net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES, + net.minecraft.world.level.levelgen.NoiseGeneratorSettings.FLOATING_ISLANDS)) { + var base = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS).getOrThrow(key).value(); + var wrapped = PortBiomeMaterialRules.apply(server.registryAccess(), base).value(); + if (!wrapped.noiseSettings().equals(base.noiseSettings()) || wrapped.noiseRouter() != base.noiseRouter() + || wrapped.defaultBlock() != base.defaultBlock() || wrapped.defaultFluid() != base.defaultFluid() + || wrapped.seaLevel() != base.seaLevel() || !wrapped.aquifers().equals(base.aquifers())) + throw new IllegalStateException("Biome material rules changed terrain/aquifer settings"); + var codec = net.minecraft.world.level.levelgen.material.rule.MaterialRule.HOLDER_CODEC; + var encoded = codec.encodeStart(registryOps, wrapped.materialRule()).getOrThrow(); + var decoded = codec.parse(registryOps, encoded).getOrThrow(); + if (!encoded.equals(codec.encodeStart(registryOps, decoded).getOrThrow())) + throw new IllegalStateException("Biome material rule codec round trip failed"); + } + return createAge(context, terrain, plan); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + /** One terrain-stage chunk only: no feature/lighting/entity generation. */ + private static int probeBiomeMaterials(CommandContext context, int chunkX, int chunkZ) { + var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context))); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded")); + return 0; + } + var chunk = level.getChunk(chunkX, chunkZ, net.minecraft.world.level.chunk.status.ChunkStatus.TERRAIN); + var counts = new TreeMap(); + var pos = new net.minecraft.core.BlockPos.MutableBlockPos(); + for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) + for (int y = chunk.getMinY(); y < chunk.getMaxY(); y++) { + var state = chunk.getBlockState(pos.set(chunk.getPos().getMinBlockX() + x, y, chunk.getPos().getMinBlockZ() + z)); + if (!state.isAir()) counts.merge(net.minecraft.core.registries.BuiltInRegistries.BLOCK + .getKey(state.getBlock()).toString(), 1, Integer::sum); + } + context.getSource().sendSuccess(() -> Component.literal("Terrain materials " + ageId(context) + + " chunk=" + chunkX + "," + chunkZ + " " + counts), false); + return 1; + } + + private static int createRandomStructures(CommandContext context) { + try { + var server = context.getSource().getServer(); + verifyRandomStructures(server); + long seed = LongArgumentType.getLong(context, "seed"); + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + Identifier biome = IdentifierArgument.getId(context, "biome"); + var plan = biome.equals(Identifier.withDefaultNamespace("native")) ? null : new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, + java.util.List.of(biome)); + var selected = PortRandomFeatureSelection.select(server, seed, terrain, plan); + int result = createAge(context, terrain, plan, selected.rocks(), null, selected.dungeons(), selected.lakes(), selected.terrain()); + return result == 0 ? 0 : probeFeaturePlan(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static void verifyRandomStructures(net.minecraft.server.MinecraftServer server) { + boolean empty = false, one = false, two = false, dungeons = false, caveDungeons = false, modern = false; + for (var terrain : PortAgeTerrain.values()) for (long seed = 0; seed < 64; seed++) { + var selected = PortRandomStructureSelection.select(server, seed, terrain, null); + int count = selected.structures().size() + selected.dungeons(); + if (count > 2 || selected.dungeons() > 1 + || new java.util.HashSet<>(selected.structures()).size() != selected.structures().size() + || !selected.equals(PortRandomStructureSelection.select(server, seed, terrain, null)) + || terrain == PortAgeTerrain.VOID && count != 0) + throw new IllegalStateException("Random structure count/terrain/determinism failed"); + empty |= count == 0; + one |= count == 1; + two |= count == 2; + dungeons |= selected.dungeons() != 0; + caveDungeons |= terrain == PortAgeTerrain.CAVE && selected.dungeons() != 0; + modern |= selected.structures().stream().anyMatch(page -> page.getPath().equals("feature/trial_chambers")); + for (var feature : selected.structures()) + if (!PortStructurePages.supportsTerrain(feature, terrain)) + throw new IllegalStateException("Random structure violated terrain eligibility"); + for (String biome : java.util.List.of("plains", "the_end", "basalt_deltas")) { + var biomeId = Identifier.withDefaultNamespace(biome); + var fixed = new PortBiomePlan(PortBiomePlan.Distribution.SINGLE, java.util.List.of(biomeId)); + var chosen = PortRandomStructureSelection.select(server, seed, terrain, fixed); + for (var feature : chosen.structures()) + if (!PortStructurePages.randomEligible(feature, terrain, java.util.Set.of(biomeId), server.registryAccess())) + throw new IllegalStateException("Random structure violated biome eligibility"); + } + } + if (!empty || !one || !two || !dungeons || !caveDungeons || !modern) + throw new IllegalStateException("Random structure fixture coverage failed"); + var sets = server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET); + var source = server.overworld().getChunkSource().getGenerator().getBiomeSource(); + var villages = ResourceKey.create(Registries.STRUCTURE_SET, Identifier.withDefaultNamespace("villages")); + var page = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/villages"); + if (PortStructurePages.filtered(sets, java.util.List.of(), source).get(villages).isPresent() + || PortStructurePages.filtered(sets, java.util.List.of(page), source).get(villages).isEmpty() + || PortStructurePages.randomEligible(page, PortAgeTerrain.STANDARD, + java.util.Set.of(Identifier.withDefaultNamespace("the_end")), server.registryAccess())) + throw new IllegalStateException("Random empty-selection/biome gate failed"); + var merged = PortRandomFeatureSelection.select(server, 91, PortAgeTerrain.STANDARD, null); + var base = PortRandomFeatureSelection.select(91, PortAgeTerrain.STANDARD); + var encoded = PortTerrainFeaturePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, merged.terrain()).getOrThrow(); + if (!merged.terrain().structureControl() + || !java.util.Objects.equals(merged.rocks(), base.rocks()) || !java.util.Objects.equals(merged.lakes(), base.lakes()) + || !merged.terrain().equals(PortTerrainFeaturePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Random structure merge/persistence failed"); + var older = PortTerrainFeaturePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, + com.google.gson.JsonParser.parseString("{\"choices\":[],\"skylands\":true}")).getOrThrow(); + if (older.structureControl()) throw new IllegalStateException("Old Age acquired structure control"); + } + + private static int createRandomFeatures(CommandContext context) { + try { + verifyRandomFeatures(); + long seed = LongArgumentType.getLong(context, "seed"); + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + var features = PortRandomFeatureSelection.select(seed, terrain); + int result = createAge(context, terrain, null, features.rocks(), null, 0, features.lakes(), features.terrain()); + return result == 0 ? 0 : probeFeaturePlan(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static void verifyRandomFeatures() { + var seen = new java.util.HashSet(); + boolean empty = false, single = false, doubleFeature = false, skylands = false, crystals = false, fissures = false; + for (var terrain : PortAgeTerrain.values()) for (long seed = 0; seed < 64; seed++) { + var selected = PortRandomFeatureSelection.select(seed, terrain); + if (!selected.equals(PortRandomFeatureSelection.select(seed, terrain))) + throw new IllegalStateException("Random feature completion is not deterministic"); + var materialPages = new java.util.ArrayList(); + if (selected.rocks() != null) { + materialPages.add(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/rocks")); + var encoded = PortRockPlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, selected.rocks()).getOrThrow(); + if (!selected.rocks().equals(PortRockPlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Random Rocks codec mismatch"); + } + if (selected.lakes() != null) { + materialPages.addAll(selected.lakes().choices().stream().map(PortLakePlan.Choice::page).toList()); + var encoded = PortLakePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, selected.lakes()).getOrThrow(); + if (!selected.lakes().equals(PortLakePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Random lake codec mismatch"); + } + if (selected.terrain() != null) { + var plan = selected.terrain(); + materialPages.addAll(plan.choices().stream().map(PortTerrainFeaturePlan.Choice::page) + .filter(page -> !page.equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS)).toList()); + skylands |= plan.skylands(); + crystals |= plan.choices().stream().anyMatch(choice -> choice.page().equals(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS)); + fissures |= plan.starFissures(); + var encoded = PortTerrainFeaturePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, plan).getOrThrow(); + if (!plan.equals(PortTerrainFeaturePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Random terrain feature codec mismatch"); + } + int count = materialPages.size(); + int expected = new java.util.Random(seed ^ 0x4D4154455249414CL).nextInt(3); + if (count != expected || new java.util.HashSet<>(materialPages).size() != count) + throw new IllegalStateException("Random material feature count/uniqueness failed"); + var terrainPage = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + terrain.name().toLowerCase(Locale.ROOT)); + for (Identifier feature : materialPages) + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(terrainPage, feature))) + throw new IllegalStateException("Random feature is incompatible with its terrain"); + seen.addAll(materialPages); + empty |= count == 0; + single |= count == 1; + doubleFeature |= count == 2; + } + if (seen.size() != 9 || !empty || !single || !doubleFeature || !skylands || !crystals || !fissures) + throw new IllegalStateException("Random feature seed coverage failed"); + } + + private static int createImpliedMaterial(CommandContext context) { + try { + verifyMaterialCompletion(); + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"); + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + var material = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId( + IdentifierArgument.getId(context, "material")); + var pages = java.util.List.of(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, + "terrain/" + terrain.name().toLowerCase(Locale.ROOT)), material); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server, seed); + int result = createAge(context, terrain, biomes, + net.dnorberg.rivencraft.writing.PortAuthoredWriting.rockPlan(pages, seed), null, 0, + net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(pages, seed), + net.dnorberg.rivencraft.writing.PortAuthoredWriting.terrainFeaturePlan(pages, seed)); + return result == 0 ? 0 : probeFeaturePlan(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static void verifyMaterialCompletion() { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + var solid = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(Identifier.withDefaultNamespace("glowstone")); + var fluid = net.dnorberg.rivencraft.writing.PortRegistryPages.blockPageId(Identifier.withDefaultNamespace("water")); + var seen = new java.util.HashSet(); + for (var terrain : PortAgeTerrain.values()) for (long seed = 0; seed < 64; seed++) { + var terrainPage = symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)); + var pages = java.util.List.of(terrainPage, solid, solid); + var completed = net.dnorberg.rivencraft.writing.PortMaterialCompletion.complete(pages, seed); + if (completed.size() != 5 || !completed.get(1).equals(solid) || !completed.get(3).equals(solid) + || !net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(completed) + || !completed.equals(net.dnorberg.rivencraft.writing.PortMaterialCompletion.complete(pages, seed)) + || !completed.equals(net.dnorberg.rivencraft.writing.PortMaterialCompletion.complete(completed, seed))) + throw new IllegalStateException("Implied material completion lost order, compatibility or determinism"); + seen.add(completed.get(2)); + var explicit = java.util.List.of(terrainPage, solid, PortTerrainFeaturePlan.TENDRILS); + if (!explicit.equals(net.dnorberg.rivencraft.writing.PortMaterialCompletion.complete(explicit, seed))) + throw new IllegalStateException("Explicit material target was replaced"); + boolean wetTerrain = terrain == PortAgeTerrain.STANDARD || terrain == PortAgeTerrain.AMPLIFIED || terrain == PortAgeTerrain.ISLAND; + var wet = java.util.List.of(terrainPage, fluid); + if (net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(wet) != wetTerrain) + throw new IllegalStateException("Implied fluid terrain validation failed"); + if (wetTerrain) { + var lakes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.lakePlan(wet, seed); + if (lakes == null || lakes.choices().size() != 1 + || !lakes.choices().getFirst().fluid().equals(Identifier.withDefaultNamespace("water"))) + throw new IllegalStateException("Implied lake discarded its authored fluid"); + } + } + if (seen.size() < 6 || net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports( + java.util.List.of(fluid, PortTerrainFeaturePlan.TENDRILS))) + throw new IllegalStateException("Implied feature coverage/explicit incompatibility check failed"); + } + + private static int probeFeaturePlan(CommandContext context) { + var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context))); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded")); + return 0; + } + if (!(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)) { + context.getSource().sendSuccess(() -> Component.literal("Feature plan " + ageId(context) + ": none"), false); + return 1; + } + context.getSource().sendSuccess(() -> Component.literal("Feature plan " + ageId(context) + + " rocks=" + features.rocks() + " lakes=" + features.lakes() + " dungeons=" + features.dungeons() + + " terrain=" + features.terrainFeatures()), false); + return 1; + } + + private static int createCompletedBiomes(CommandContext context) { + try { + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"); + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + var distribution = PortBiomePlan.Distribution.valueOf( + StringArgumentType.getString(context, "distribution").toUpperCase(Locale.ROOT)); + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + var pages = java.util.List.of(symbol.apply("terrain/" + terrain.name().toLowerCase(Locale.ROOT)), + symbol.apply("biome/" + distribution.name().toLowerCase(Locale.ROOT))); + var plan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server, seed); + // Controller-only and partial pools complete; already-sufficient pools never gain extras. + var plains = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("plains")); + var forest = net.dnorberg.rivencraft.writing.PortRegistryPages.biomePageId(Identifier.withDefaultNamespace("forest")); + for (var mode : PortBiomePlan.Distribution.values()) { + var controller = symbol.apply("biome/" + mode.name().toLowerCase(Locale.ROOT)); + for (long s = 0; s < 32; s++) { + var selected = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(controller), server, s); + if (!selected.equals(net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(controller), server, s)) + || selected.distribution() != mode) + throw new IllegalStateException("Biome completion is not deterministic"); + } + if (mode == PortBiomePlan.Distribution.NATIVE || mode == PortBiomePlan.Distribution.SINGLE) continue; + var partial = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(controller, plains), server, seed); + var full = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(controller, plains, forest), server, seed); + var repeated = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(java.util.List.of(controller, plains, plains), server, seed); + if (partial.biomes().size() != 2 || !partial.biomes().getFirst().equals(Identifier.withDefaultNamespace("plains")) + || !full.biomes().equals(java.util.List.of(Identifier.withDefaultNamespace("plains"), Identifier.withDefaultNamespace("forest"))) + || repeated.biomes().size() != 3 || new java.util.HashSet<>(repeated.biomes()).size() != 2) + throw new IllegalStateException("Authored pool completion changed weights or added unnecessary biomes"); + } + var encoded = PortBiomePlan.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, plan).getOrThrow(); + if (!plan.equals(PortBiomePlan.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Completed biome plan codec mismatch"); + int created = createAge(context, terrain, plan); + if (created != 0) context.getSource().sendSuccess(() -> Component.literal("Completed biome plan: " + plan), false); + return created == 0 ? 0 : probeBiomePool(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int probeBiomePool(CommandContext context) { + var level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, ageId(context))); + if (level == null) { + context.getSource().sendFailure(Component.literal("Age not loaded")); + return 0; + } + var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream() + .flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).sorted().toList(); + context.getSource().sendSuccess(() -> Component.literal("Biome pool " + ageId(context) + ": " + biomes), false); + return 1; + } + + private static int createCompletedSky(CommandContext context, String sky) { + try { + verifyRandomSkyColors(); + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + var pages = new java.util.ArrayList(java.util.stream.Stream.of("terrain/flat", "lighting/normal", "weather/off").map(symbol).toList()); + if (sky != null) { + PortSkyStyle.valueOf(sky.toUpperCase(Locale.ROOT)); + pages.add(symbol.apply("sky/" + sky.toLowerCase(Locale.ROOT))); + } + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"), origin = PortDayCycle.nativeTicks(server.overworld()); + var cycle = PortDayCycle.resolve(pages, seed, origin); + int zeroSeed = -1; + boolean sunOnly = false, moonOnly = false, mixed = false; + for (int s = 0; s < 256; s++) { + var selected = PortDayCycle.resolve(java.util.List.of(), s, origin); + long suns = selected.bodies().stream().filter(body -> body.kind() == PortDayCycle.SUN).count(); + long moons = selected.bodies().size() - suns; + if (suns > 3 || moons > 3 || !selected.authored() + || !selected.equals(PortDayCycle.resolve(pages, s, origin))) + throw new IllegalStateException("Missing-celestial completion bounds/determinism failed"); + if (suns == 0 && moons == 0) { + zeroSeed = zeroSeed < 0 ? s : zeroSeed; + if (selected.bodyMode() != PortDayCycle.NONE || selected.customLight()) + throw new IllegalStateException("Empty sky incorrectly acquires a hidden native pair"); + } + sunOnly |= suns > 0 && moons == 0; + moonOnly |= moons > 0 && suns == 0; + mixed |= suns > 0 && moons > 0; + } + for (String controller : java.util.List.of("celestial/sun", "celestial/moon", "celestial/normal_time")) { + var explicit = java.util.List.of(symbol.apply(controller)); + if (!PortDayCycle.resolve(explicit, seed, origin).equals(PortDayCycle.compile(explicit, seed, origin))) + throw new IllegalStateException("Explicit controller gained additional bodies"); + } + var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, + com.google.gson.JsonParser.parseString("{\"authored\":false,\"period\":1,\"stopped_time\":0}")).getOrThrow(); + if (zeroSeed < 0 || !sunOnly || !moonOnly || !mixed || !older.equals(PortDayCycle.NATIVE)) + throw new IllegalStateException("Completion coverage/old-profile default failed"); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), + PortAgeColors.compile(pages, seed), cycle); + final int sample = zeroSeed; + context.getSource().sendSuccess(() -> Component.literal("Completed sky; sample zero-body seed=" + sample), false); + return probeDayCycle(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int createRandomCelestialsAge(CommandContext context) { + try { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + var pages = java.util.stream.Stream.of("celestial/sun", "celestial/moon", "celestial/sun", + "weather/off", "lighting/normal", "terrain/flat").map(symbol).toList(); + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"), origin = PortDayCycle.nativeTicks(server.overworld()); + var cycle = PortDayCycle.compile(pages, seed, origin); + var decorated = new java.util.ArrayList(java.util.stream.Stream.of("weather/off", "sky/overworld", + "terrain/flat", "number/10", "color/red", "lighting/ambient_color").map(symbol).toList()); + decorated.addAll(pages); + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages) + || cycle.bodies().size() != 3 || cycle.bodies().get(0).equals(cycle.bodies().get(2)) + || !cycle.equals(PortDayCycle.compile(pages, seed, origin)) + || !cycle.bodies().equals(PortDayCycle.compile(decorated, seed, origin).bodies())) + throw new IllegalStateException("Random body determinism/independence failed"); + var oneSun = PortDayCycle.compile(java.util.List.of(symbol.apply("celestial/sun")), seed, origin); + var oneMoon = PortDayCycle.compile(java.util.List.of(symbol.apply("celestial/moon")), seed, origin); + if (oneSun.bodies().size() != 1 || oneMoon.bodies().size() != 1 + || !oneSun.bodies().getFirst().equals(cycle.bodies().getFirst()) + || oneMoon.bodies().getFirst().kind() != PortDayCycle.MOON) + throw new IllegalStateException("Standalone bare body compilation failed"); + int stopped = 0, moving = 0, forward = 0, reverse = 0; + for (int s = 0; s < 128; s++) { + var random = PortDayCycle.compile(pages, s, origin); + for (var body : random.bodies()) { + if (body.period() == 0) stopped++; else moving++; + if (body.clockwise()) forward++; else reverse++; + if (body.period() != 0 && (body.period() < 0.2 || body.period() > 2) + || body.size() < 0.75 || body.size() >= 1.75 || body.bearing() < 0 + || body.bearing() >= 360 || body.phaseTick() < 0 || body.phaseTick() >= 24000 + || body.color() < 0 || body.color() > 0xffffff) + throw new IllegalStateException("Random body bounds failed"); + } + } + if (stopped == 0 || moving == 0 || forward == 0 || reverse == 0) + throw new IllegalStateException("Random body pool coverage failed"); + var explicit = PortDayCycle.compile(java.util.List.of(symbol.apply("size/small"), symbol.apply("celestial/sun")), seed, origin); + var normal = PortDayCycle.compile(java.util.List.of(PortDayCycle.NORMAL_TIME), seed, origin); + if (!explicit.bodies().isEmpty() || explicit.sunSize() != 0.5F || explicit.period() != 1 || explicit.bearing() != -1 + || !normal.bodies().isEmpty() || normal.period() != 1 || normal.sunSize() != 1) + throw new IllegalStateException("Explicit/Normal Time default changed"); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), + PortAgeColors.compile(pages, seed), cycle); + return probeDayCycle(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int createEclipseAge(CommandContext context) { + return createEclipseAge(context, null); + } + + private static int createEclipseAge(CommandContext context, String sky) { + try { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + String mode = StringArgumentType.getString(context, "mode"); + if (!java.util.Set.of("aligned", "partial", "clear", "below", "moving").contains(mode)) + throw new IllegalArgumentException("Use aligned, partial, clear, below or moving"); + long seed = LongArgumentType.getLong(context, "seed"); + var server = context.getSource().getServer(); + long origin = PortDayCycle.nativeTicks(server.overworld()); + var pages = new java.util.ArrayList(); + pages.add(symbol.apply("period/zero")); + if (mode.equals("partial")) pages.add(symbol.apply("number/13")); + pages.add(symbol.apply(mode.equals("below") ? "phase/nadir" : "phase/zenith")); + if (mode.equals("partial")) { pages.add(symbol.apply("number/24")); pages.add(symbol.apply("size/large")); } + pages.addAll(java.util.stream.Stream.of("number/24", "color/red", "celestial/sun", + mode.equals("moving") ? "period/half" : "period/zero", + mode.equals("below") || mode.equals("clear") ? "phase/nadir" : "phase/zenith").map(symbol).toList()); + if (mode.equals("partial")) pages.add(symbol.apply("number/24")); + pages.addAll(java.util.stream.Stream.of("size/large", "celestial/moon", "weather/off", "lighting/normal", "terrain/flat").map(symbol).toList()); + if (sky != null) { + PortSkyStyle.valueOf(sky.toUpperCase(Locale.ROOT)); + pages.add(symbol.apply("sky/" + sky.toLowerCase(Locale.ROOT))); + } + var cycle = PortDayCycle.compile(pages, seed, origin); + var bodies = cycle.bodies(); + float eclipse = PortCelestialBody.eclipseAt(bodies, origin); + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages) + || (mode.equals("aligned") || mode.equals("moving")) && (Math.abs(eclipse - 1) > 0.0001F + || Math.abs(cycle.bodyLight(origin) - 0.15F) > 0.0001F) + || mode.equals("partial") && !(eclipse > 0 && eclipse < 1) + || (mode.equals("clear") || mode.equals("below")) && eclipse != 0) + throw new IllegalStateException("Eclipse fixture failed: " + mode); + var sun = bodies.get(0); + var moon = bodies.get(1); + if (sun.eclipse(java.util.List.of(sun, sun), origin) != 0 + || sun.eclipseColor(1) != 0xfccccc || moon.eclipseColor(1) != 0 + || mode.equals("moving") && (PortCelestialBody.eclipseAt(bodies, origin + 100) >= eclipse + || PortCelestialBody.eclipseAt(bodies, origin + 800) != 0)) + throw new IllegalStateException("Eclipse color/movement invariant failed"); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), + PortAgeColors.compile(pages, seed), cycle); + context.getSource().sendSuccess(() -> Component.literal("Eclipse fixture " + mode + " overlap=" + eclipse + + " bodyLight=" + cycle.bodyLight(origin) + " skyShade=" + (1F - 0.65F * eclipse)), false); + return probeDayCycle(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int createMultiBodyAge(CommandContext context) { + try { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + var pages = java.util.stream.Stream.of("period/zero", "phase/zenith", "size/large", "number/24", "color/red", "celestial/sun", + "period/zero", "phase/nadir", "size/small", "number/24", "color/blue", "celestial/moon", + "period/half", "phase/rising", "angle/east", "direction/counterclockwise", "number/12", "color/green", "celestial/sun", + "weather/off", "lighting/normal", "terrain/flat").map(symbol).toList(); + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"), origin = PortDayCycle.nativeTicks(server.overworld()); + var cycle = PortDayCycle.compile(pages, seed, origin); + var bodies = cycle.bodies(); + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages) || bodies.size() != 3 + || bodies.get(0).kind() != PortDayCycle.SUN || bodies.get(0).color() != 0xf00000 || bodies.get(0).size() != 2F + || bodies.get(1).kind() != PortDayCycle.MOON || bodies.get(1).color() != 0x0000f0 || bodies.get(1).size() != 0.5F + || bodies.get(2).color() != 0x007800 || bodies.get(2).size() != 1F || bodies.get(2).bearing() != 90 + || Math.abs(bodies.get(2).angle(origin + 100) + 93F) > 0.001F + || cycle.bodyLight(origin) != 1F) + throw new IllegalStateException("Independent body fixture failed"); + var limits = new java.util.ArrayList(); + for (int i = 0; i < 10; i++) limits.add(PortDayCycle.NORMAL_TIME); + var atLimit = PortDayCycle.compile(limits, seed, origin); + if (!PortDayCycle.validWriting(limits) || atLimit.bodies().size() != 20) + throw new IllegalStateException("Body limit rejected valid writing"); + limits.add(symbol.apply("celestial/sun")); + if (PortDayCycle.validWriting(limits)) throw new IllegalStateException("Body limit not enforced"); + var reset = PortDayCycle.compile(java.util.stream.Stream.of("number/24", "color/red", "size/large", "celestial/sun", + "size/normal", "celestial/moon", "celestial/normal_time").map(symbol).toList(), seed, origin).bodies(); + if (reset.size() != 4 || reset.get(1).color() != -1 || reset.get(1).size() != 1F + || reset.get(2).color() != -1 || reset.get(3).size() != 1F + || Math.abs(Math.abs(reset.get(2).angle(origin) - reset.get(3).angle(origin)) - 180F) > 0.001F) + throw new IllegalStateException("Body modifier reset/paired expansion failed"); + var moonlight = PortDayCycle.compile(java.util.stream.Stream.of("period/zero", "phase/zenith", "celestial/moon", + "period/zero", "phase/zenith", "celestial/moon").map(symbol).toList(), seed, origin); + var sunlight = PortDayCycle.compile(java.util.stream.Stream.of("period/zero", "phase/zenith", "celestial/sun", + "period/zero", "phase/zenith", "celestial/sun").map(symbol).toList(), seed, origin); + if (Math.abs(moonlight.bodyLight(origin) - 0.36F) > 0.0001F || sunlight.bodyLight(origin) != 1F) + throw new IllegalStateException("Multi-body light aggregation failed"); + var encoded = PortDayCycle.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, cycle).getOrThrow(); + if (!cycle.equals(PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Body profile codec round trip failed"); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), + PortAgeColors.compile(pages, seed), cycle); + return probeDayCycle(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int createBodyAge(CommandContext context) { + try { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + String body = StringArgumentType.getString(context, "body"); + Identifier controller = symbol.apply("celestial/" + body); + Identifier phase = symbol.apply("phase/" + StringArgumentType.getString(context, "phase")); + if ((!body.equals("sun") && !body.equals("moon")) || !PortDayCycle.isPhase(phase)) + throw new IllegalArgumentException("Use sun/moon and rising/zenith/setting/nadir"); + var pages = java.util.stream.Stream.of("period/zero", phase.getPath(), "angle/east", + "size/large", "number/24", "color/red", controller.getPath(), "weather/off", + "lighting/normal", "terrain/flat").map(symbol).toList(); + var server = context.getSource().getServer(); + long seed = LongArgumentType.getLong(context, "seed"), origin = PortDayCycle.nativeTicks(server.overworld()); + var cycle = PortDayCycle.compile(pages, seed, origin); + var colors = PortAgeColors.compile(pages, seed); + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages) + || !cycle.singleBody() || colors.celestial() != 0xf00000 || colors.sky() != -1 + || cycle.sunSize() != 2 || cycle.bearing() != 90) + throw new IllegalStateException("Single-body fixture failed"); + for (String kind : java.util.List.of("sun", "moon")) { + var target = symbol.apply("celestial/" + kind); + int mode = kind.equals("sun") ? PortDayCycle.SUN : PortDayCycle.MOON; + for (var entry : java.util.Map.of("rising", 0, "zenith", 6000, "setting", 12000, "nadir", 18000).entrySet()) { + var selected = PortDayCycle.compile(java.util.List.of(symbol.apply("period/zero"), + symbol.apply("phase/" + entry.getKey()), target), seed, origin); + float expectedLight = entry.getKey().equals("zenith") ? (mode == PortDayCycle.MOON ? 0.18F : 1F) : 0F; + if (selected.bodyMode() != mode || selected.ticks(origin) != entry.getValue() + || Math.abs(selected.bodyLight(origin) - expectedLight) > 0.0001F) + throw new IllegalStateException("Single-body phase/light invariant failed"); + } + var moving = PortDayCycle.compile(java.util.stream.Stream.of("period/half", "phase/zenith", + "direction/counterclockwise", "number/12", "size/large", target.getPath()).map(symbol).toList(), seed, origin); + if (moving.sunSize() != 1.625F || moving.ticks(origin + 100) != 5800 + || Math.abs(moving.bodyAngle(origin + 100) + 3F) > 0.0001F + || !PortDayCycle.validWriting(java.util.List.of(target, PortDayCycle.NORMAL_TIME)) + || !PortDayCycle.validWriting(java.util.List.of(target, target)) + || PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large")))) + throw new IllegalStateException("Single-body modifier/order invariant failed"); + } + var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, + com.google.gson.JsonParser.parseString("{\"authored\":true,\"period\":1,\"stopped_time\":0,\"sun_size\":2}")).getOrThrow(); + if (older.bodyMode() != PortDayCycle.PAIRED || older.singleBody()) + throw new IllegalStateException("Old paired body default failed"); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + if (environment.sky() != PortSkyStyle.OVERWORLD) + throw new IllegalStateException("Single body selected an incompatible sky"); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), colors, cycle); + return probeColors(context) == 1 ? probeDayCycle(context) : 0; + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int createCelestialColorAge(CommandContext context) { + try { + java.util.function.Function symbol = path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + Identifier phase = symbol.apply("phase/" + StringArgumentType.getString(context, "phase")); + Identifier color = symbol.apply("color/" + StringArgumentType.getString(context, "color")); + if (!PortDayCycle.isPhase(phase) || !PortAgeColors.isColor(color)) + throw new IllegalArgumentException("Use rising/zenith/setting/nadir and red/green/blue/white/black"); + var pages = java.util.List.of(symbol.apply("number/10"), symbol.apply("color/blue"), + symbol.apply("sky/overworld"), symbol.apply("number/20"), symbol.apply("color/white"), + PortAgeColors.AMBIENT, symbol.apply("period/zero"), phase, + symbol.apply("number/" + IntegerArgumentType.getInteger(context, "number")), color, + PortDayCycle.NORMAL_TIME, symbol.apply("weather/off"), symbol.apply("lighting/normal"), + symbol.apply("terrain/flat")); + long seed = LongArgumentType.getLong(context, "seed"); + var colors = PortAgeColors.compile(pages, seed); + int amount = IntegerArgumentType.getInteger(context, "number") * 10; + int expected = switch (color.getPath()) { + case "color/red" -> amount << 16; + case "color/green" -> amount << 8; + case "color/blue" -> amount; + case "color/white" -> amount * 0x010101; + default -> 0; + }; + if (!net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(pages) + || !colors.equals(new PortAgeColors(0x000064, 0xc8c8c8, expected))) + throw new IllegalStateException("Celestial color fixture failed"); + // Color groups persist across intervening Length/Phase/Size modifiers, then + // are consumed by Normal Time rather than leaking into a following target. + var mixed = PortAgeColors.compile(java.util.stream.Stream.of("number/24", "color/red", + "number/24", "color/red", "number/24", "color/black", "size/large", + "celestial/normal_time", "sky/overworld", "lighting/ambient_color") + .map(symbol).toList(), seed); + var trailing = PortAgeColors.compile(java.util.stream.Stream.of("number/10", "color/red", + "celestial/normal_time", "number/5", "color/green").map(symbol).toList(), seed); + var unnumbered = java.util.List.of(symbol.apply("color/blue"), PortDayCycle.NORMAL_TIME); + var older = PortAgeColors.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, + com.google.gson.JsonParser.parseString("{\"sky\":100,\"ambient\":-1}")).getOrThrow(); + if (!mixed.equals(new PortAgeColors(-1, -1, 0xf00000)) + || !trailing.equals(new PortAgeColors(0x003200, -1, 0x640000)) + || !older.equals(new PortAgeColors(100, -1)) + || !PortAgeColors.compile(unnumbered, seed).equals(PortAgeColors.compile(unnumbered, seed)) + || PortAgeColors.compile(unnumbered, seed).sky() != -1) + throw new IllegalStateException("Celestial color grouping/default invariant failed"); + var server = context.getSource().getServer(); + var cycle = PortDayCycle.compile(pages, seed, PortDayCycle.nativeTicks(server.overworld())); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); + var environment = PortEnvironmentSelection.resolve(pages, seed, PortAgeTerrain.FLAT); + RuntimeAgeBackend.create(server, ageId(context), seed, biomes, PortAgeTerrain.FLAT, + null, null, 0, null, null, environment.weather(), environment.lighting(), environment.sky(), colors, cycle); + return probeColors(context) == 1 ? probeDayCycle(context) : 0; + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + private static int createColorAge(CommandContext context) { try { String sky = StringArgumentType.getString(context, "sky").toLowerCase(Locale.ROOT); @@ -641,7 +1378,8 @@ public final class PortAgeCommands { var sky = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos); var fog = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.FOG_COLOR, pos); var ambient = level.environmentAttributes().getValue(net.minecraft.world.attribute.EnvironmentAttributes.AMBIENT_LIGHT_COLOR, pos); - if (colors.sky() >= 0 && (!sky.equals(PortAgeColors.vector(colors.sky())) || !fog.equals(sky)) + var expectedFog = colors.sky() < 0 ? fog : new org.joml.Vector3f(PortAgeColors.vector(colors.sky())).mul(PortSkyStyle.fogShade(level)); + if (colors.sky() >= 0 && (!sky.equals(PortAgeColors.vector(colors.sky())) || !fog.equals(expectedFog)) || colors.ambient() >= 0 && !ambient.equals(PortAgeColors.vector(colors.ambient()))) { context.getSource().sendFailure(Component.literal("Color attribute mismatch")); return 0; @@ -651,6 +1389,29 @@ public final class PortAgeCommands { return 1; } + private static void verifyRandomSkyColors() { + long[] seeds = {0, 1, 2, 14, 86, 87}; + int[] expected = {0x8abac0, 0x8cb39c, 0x6e3841, 0xcda38c, 0xb7a6cd, 0x406859}; + for (int i = 0; i < seeds.length; i++) { + var colors = PortAgeColors.resolve(java.util.List.of(), seeds[i]); + var encoded = PortAgeColors.CODEC.encodeStart(com.mojang.serialization.JsonOps.INSTANCE, colors).getOrThrow(); + if (colors.sky() != expected[i] || colors.ambient() != -1 || colors.celestial() != -1 + || !colors.equals(PortAgeColors.resolve(java.util.List.of(), seeds[i])) + || !colors.equals(PortAgeColors.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, encoded).getOrThrow())) + throw new IllegalStateException("Random sky root-vector/persistence mismatch"); + } + for (var paths : java.util.List.of(java.util.List.of("sky/end"), java.util.List.of("celestial/sun"), + java.util.List.of("terrain/flat"), java.util.List.of("number/12", "color/red", "sky/overworld"), + java.util.List.of("number/0", "color/black"))) { + var pages = paths.stream().map(path -> Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path)).toList(); + if (!PortAgeColors.resolve(pages, 87).equals(PortAgeColors.compile(pages, 87))) + throw new IllegalStateException("Authored sky colors were randomized"); + } + var older = PortAgeColors.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, + com.google.gson.JsonParser.parseString("{\"sky\":-1,\"ambient\":-1}")).getOrThrow(); + if (!older.equals(PortAgeColors.NATIVE)) throw new IllegalStateException("Old sky-color defaults changed"); + } + private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) { var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(), RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)); @@ -925,7 +1686,41 @@ public final class PortAgeCommands { java.util.Collections.nCopies(8, page)); } - private static int probeDungeons(CommandContext context) { + private static int createDungeonTerrain(CommandContext context) { + try { + var terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(context, "terrain").toUpperCase(Locale.ROOT)); + var terrainPage = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, + "terrain/" + terrain.name().toLowerCase(Locale.ROOT)); + var dungeon = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/dungeons"); + var cave = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/cave"); + var pages = new java.util.ArrayList(java.util.Collections.nCopies(8, dungeon)); + pages.addFirst(terrainPage); + var maximum = new java.util.ArrayList(java.util.Collections.nCopies(32, dungeon)); + maximum.addFirst(cave); + if (net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount(maximum) != 32) + throw new IllegalStateException("Cave Dungeons stacking failed"); + maximum.add(dungeon); + if (net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(maximum) + || net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(cave, + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/surface_lakes")))) + throw new IllegalStateException("Dungeon cap/lake restriction changed"); + for (String unsupported : java.util.List.of("flat", "void")) + if (net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(java.util.List.of(dungeon, + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "terrain/" + unsupported)))) + throw new IllegalStateException("Dungeons unexpectedly accepted unsupported terrain"); + var server = context.getSource().getServer(); + int count = net.dnorberg.rivencraft.writing.PortAuthoredWriting.dungeonCount(pages); + var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server, + LongArgumentType.getLong(context, "seed")); + int created = createAge(context, terrain, biomes, null, null, count); + return created == 0 ? 0 : probeFeaturePlan(context); + } catch (IllegalArgumentException | IllegalStateException exception) { + context.getSource().sendFailure(Component.literal(exception.getMessage())); + return 0; + } + } + + private static int probeDungeons(CommandContext context, int chunks) { Identifier id = ageId(context); ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); if (level == null || !(level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features)) { @@ -934,7 +1729,7 @@ public final class PortAgeCommands { } int count = features.dungeons(); int spawners = 0; - for (int chunkX = 0; chunkX < 4; chunkX++) for (int chunkZ = 0; chunkZ < 4; chunkZ++) { + for (int chunkX = 0; chunkX < chunks; chunkX++) for (int chunkZ = 0; chunkZ < chunks; chunkZ++) { var chunk = level.getChunk(chunkX, chunkZ); for (var pos : chunk.getBlockEntitiesPos()) { if (chunk.getBlockState(pos).is(Blocks.SPAWNER)) spawners++; @@ -942,7 +1737,7 @@ public final class PortAgeCommands { } int placed = spawners; context.getSource().sendSuccess(() -> Component.literal( - "Dungeons probe " + id + " pages=" + count + " spawners=" + placed), false); + "Dungeons probe " + id + " pages=" + count + " chunks=" + chunks + "x" + chunks + " spawners=" + placed), false); return placed; } @@ -1092,6 +1887,24 @@ public final class PortAgeCommands { PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, PortEnvironmentSelection environment) { + return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures, + environment, PortDayCycle.NATIVE); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, + int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, + PortEnvironmentSelection environment, PortDayCycle cycle) { + return createAge(context, terrain, biomePlan, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures, + environment, cycle, PortAgeColors.NATIVE); + } + + private static int createAge(CommandContext context, + PortAgeTerrain terrain, PortBiomePlan biomePlan, + PortRockPlan rockPlan, PortVegetationPlan vegetationPlan, + int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, + PortEnvironmentSelection environment, PortDayCycle cycle, PortAgeColors colors) { String name = StringArgumentType.getString(context, "name"); Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); long seed = LongArgumentType.getLong(context, "seed"); @@ -1099,7 +1912,7 @@ public final class PortAgeCommands { try { level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, terrain, rockPlan, vegetationPlan, dungeons, lakes, terrainFeatures, - environment.weather(), environment.lighting(), environment.sky()); + environment.weather(), environment.lighting(), environment.sky(), colors, cycle); } 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/PortBiomeMaterialRules.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java new file mode 100644 index 0000000..4f4eeb1 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBiomeMaterialRules.java @@ -0,0 +1,31 @@ +package net.dnorberg.rivencraft.age; + +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.Registries; +import net.minecraft.data.worldgen.material.EndMaterialRules; +import net.minecraft.data.worldgen.material.NetherMaterialRules; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.material.MaterialRules; + +/** Root AgeDimensionService.withBiomeMaterialRules adapted to 26.3's rule registry. */ +final class PortBiomeMaterialRules { + private PortBiomeMaterialRules() {} + + static Holder apply(HolderLookup.Provider registries, NoiseGeneratorSettings base) { + var biomes = registries.lookupOrThrow(Registries.BIOME); + var rules = registries.lookupOrThrow(Registries.MATERIAL_RULE); + var materials = MaterialRules.sequence( + MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.THE_END, Biomes.END_HIGHLANDS, + Biomes.END_MIDLANDS, Biomes.SMALL_END_ISLANDS, Biomes.END_BARRENS), + MaterialRules.getRule(rules, EndMaterialRules.END)), + MaterialRules.ifTrue(MaterialRules.isBiome(biomes, Biomes.NETHER_WASTES, Biomes.SOUL_SAND_VALLEY, + Biomes.CRIMSON_FOREST, Biomes.WARPED_FOREST, Biomes.BASALT_DELTAS), + MaterialRules.getRule(rules, NetherMaterialRules.NETHER)), + base.materialRule().value()); + return Holder.direct(new NoiseGeneratorSettings(base.noiseSettings(), base.defaultBlock(), base.defaultFluid(), + base.noiseRouter(), Holder.direct(materials), base.spawnTarget(), base.seaLevel(), base.disableMobGeneration(), + base.aquifers(), base.useLegacyRandomSource(), base.debugFunctions())); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCelestialBody.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCelestialBody.java new file mode 100644 index 0000000..956d14c --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortCelestialBody.java @@ -0,0 +1,96 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +/** One frozen authored orbit, using the same angular motion as 1.21.1 Body. */ +public record PortCelestialBody(int kind, double period, int stoppedTime, long originTick, + int phaseTick, boolean clockwise, int bearing, float size, int color) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.intRange(1, 2).fieldOf("kind").forGetter(PortCelestialBody::kind), + Codec.doubleRange(0, 2).fieldOf("period").forGetter(PortCelestialBody::period), + Codec.intRange(0, 23999).fieldOf("stopped_time").forGetter(PortCelestialBody::stoppedTime), + Codec.LONG.fieldOf("origin_tick").forGetter(PortCelestialBody::originTick), + Codec.intRange(0, 23999).fieldOf("phase_tick").forGetter(PortCelestialBody::phaseTick), + Codec.BOOL.fieldOf("clockwise").forGetter(PortCelestialBody::clockwise), + Codec.intRange(-1, 359).fieldOf("bearing").forGetter(PortCelestialBody::bearing), + Codec.floatRange(0.25F, 3F).fieldOf("size").forGetter(PortCelestialBody::size), + Codec.intRange(-1, 0xffffff).fieldOf("color").forGetter(PortCelestialBody::color) + ).apply(instance, PortCelestialBody::new)); + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_INT, PortCelestialBody::kind, ByteBufCodecs.DOUBLE, PortCelestialBody::period, + ByteBufCodecs.VAR_INT, PortCelestialBody::stoppedTime, ByteBufCodecs.VAR_LONG, PortCelestialBody::originTick, + ByteBufCodecs.VAR_INT, PortCelestialBody::phaseTick, ByteBufCodecs.BOOL, PortCelestialBody::clockwise, + ByteBufCodecs.INT, PortCelestialBody::bearing, ByteBufCodecs.FLOAT, PortCelestialBody::size, + ByteBufCodecs.INT, PortCelestialBody::color, PortCelestialBody::new); + + public PortCelestialBody { + if (kind != PortDayCycle.SUN && kind != PortDayCycle.MOON || !Double.isFinite(period) + || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000 + || phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing > 359 + || !Float.isFinite(size) || size < 0.25F || size > 3 || color < -1 || color > 0xffffff) + throw new IllegalArgumentException("Invalid celestial body"); + } + + public static PortCelestialBody of(PortDayCycle cycle, int kind, int color, boolean pairedMoon) { + int offset = pairedMoon ? 12000 : 0; + return new PortCelestialBody(kind, cycle.period(), (cycle.stoppedTime() + offset) % 24000, + cycle.originTick(), (cycle.phaseTick() + offset) % 24000, cycle.clockwise(), + cycle.bearing(), pairedMoon ? 1F : cycle.sunSize(), color); + } + + public float angle(double nativeTicks) { + double time = period == 0 ? stoppedTime + : (clockwise ? 1 : -1) * (nativeTicks - originTick) / period + phaseTick; + return (float) (((time - 6000D) % 24000D) * (360D / 24000D)); + } + + public float light(long nativeTicks) { + return Math.max(0F, (float) Math.cos(Math.toRadians(angle(nativeTicks)))) + * (kind == PortDayCycle.MOON ? 0.18F : 1F); + } + + public net.minecraft.world.phys.Vec3 direction(double ticks) { + double orbit = Math.toRadians(angle(ticks)), axis = Math.toRadians(bearing < 0 ? -90 : bearing); + double lateral = Math.sin(orbit); + return new net.minecraft.world.phys.Vec3(Math.sin(axis) * lateral, Math.cos(orbit), Math.cos(axis) * lateral); + } + + /** Root AgeSkyProfile.eclipseFor: contact uses the central quarter-size disc, not its halo. */ + public float eclipse(java.util.List bodies, double ticks) { + float strongest = 0; + var direction = direction(ticks); + double radius = Math.atan((kind == PortDayCycle.SUN ? 30D : 20D) * size * 0.25D / 100D); + for (var other : bodies) { + if (other.kind == kind) continue; + double separation = Math.acos(Math.max(-1D, Math.min(1D, direction.dot(other.direction(ticks))))); + double contact = radius + Math.atan((other.kind == PortDayCycle.SUN ? 30D : 20D) * other.size * 0.25D / 100D); + strongest = Math.max(strongest, (float) Math.max(0D, 1D - separation / contact)); + } + return strongest; + } + + public static float eclipseAt(java.util.List bodies, double ticks) { + float strongest = 0; + for (var body : bodies) if (body.kind == PortDayCycle.SUN && body.direction(ticks).y > 0D) + strongest = Math.max(strongest, body.eclipse(bodies, ticks)); + return strongest; + } + + /** Shared client/server-checkable RGB rule; alpha is handled by the renderer. */ + public int eclipseColor(float eclipse) { + int rgb = color < 0 ? 0xffffff : color; + float corona = kind == PortDayCycle.SUN ? (float) Math.pow(eclipse, 8D) * 0.8F : 0F; + int red = channel(rgb >> 16 & 255, eclipse, corona); + int green = channel(rgb >> 8 & 255, eclipse, corona); + int blue = channel(rgb & 255, eclipse, corona); + return red << 16 | green << 8 | blue; + } + + private int channel(int value, float eclipse, float corona) { + return Math.round(kind == PortDayCycle.MOON ? value * (1F - eclipse) : value + (255 - value) * corona); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java index 2379727..1b468b8 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java @@ -19,7 +19,9 @@ import net.minecraft.world.level.Level; /** Authored native celestial controls. Never mutates the shared clock. */ public record PortDayCycle(boolean authored, double period, int stoppedTime, - long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) { + long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode, + List bodies) { + public static final int PAIRED = 0, SUN = 1, MOON = 2, NONE = 3; public static final PortDayCycle NATIVE = new PortDayCycle(false, 1, 0); public static final Identifier NORMAL_TIME = Identifier.fromNamespaceAndPath("rivencraft", "celestial/normal_time"); public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( @@ -30,20 +32,40 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, Codec.intRange(0, 23999).optionalFieldOf("phase_tick", 0).forGetter(PortDayCycle::phaseTick), Codec.BOOL.optionalFieldOf("clockwise", true).forGetter(PortDayCycle::clockwise), Codec.intRange(-1, 359).optionalFieldOf("bearing", -1).forGetter(PortDayCycle::bearing), - Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize) + Codec.floatRange(0.25F, 3F).optionalFieldOf("sun_size", 1F).forGetter(PortDayCycle::sunSize), + Codec.intRange(0, 3).optionalFieldOf("body_mode", PAIRED).forGetter(PortDayCycle::bodyMode), + PortCelestialBody.CODEC.listOf().optionalFieldOf("bodies", List.of()).forGetter(PortDayCycle::bodies) ).apply(instance, PortDayCycle::new)); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.BOOL, PortDayCycle::authored, ByteBufCodecs.DOUBLE, PortDayCycle::period, ByteBufCodecs.VAR_INT, PortDayCycle::stoppedTime, ByteBufCodecs.VAR_LONG, PortDayCycle::originTick, ByteBufCodecs.VAR_INT, PortDayCycle::phaseTick, ByteBufCodecs.BOOL, PortDayCycle::clockwise, - ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize, PortDayCycle::new); + ByteBufCodecs.INT, PortDayCycle::bearing, ByteBufCodecs.FLOAT, PortDayCycle::sunSize, + ByteBufCodecs.VAR_INT, PortDayCycle::bodyMode, + PortCelestialBody.STREAM_CODEC.apply(ByteBufCodecs.list()), PortDayCycle::bodies, PortDayCycle::new); private static volatile Map clientCycles = Map.of(); public PortDayCycle { + bodies = List.copyOf(bodies); if (!Double.isFinite(period) || period < 0 || period > 2 || stoppedTime < 0 || stoppedTime >= 24000 || phaseTick < 0 || phaseTick >= 24000 || bearing < -1 || bearing >= 360 - || !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F) + || !Float.isFinite(sunSize) || sunSize < 0.25F || sunSize > 3F + || bodyMode < PAIRED || bodyMode > NONE + || bodyMode == NONE && (!authored || !bodies.isEmpty())) throw new IllegalArgumentException("Invalid day cycle"); + if (bodies.stream().filter(body -> body.kind() == SUN).count() > 10 + || bodies.stream().filter(body -> body.kind() == MOON).count() > 10) + throw new IllegalArgumentException("An Age may have at most ten suns and ten moons"); + } + + public PortDayCycle(boolean authored, double period, int stoppedTime, + long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize, int bodyMode) { + this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, bodyMode, List.of()); + } + + public PortDayCycle(boolean authored, double period, int stoppedTime, + long originTick, int phaseTick, boolean clockwise, int bearing, float sunSize) { + this(authored, period, stoppedTime, originTick, phaseTick, clockwise, bearing, sunSize, PAIRED); } public PortDayCycle(boolean authored, double period, int stoppedTime) { @@ -87,7 +109,14 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, } public static boolean supports(Identifier page) { - return page.equals(NORMAL_TIME) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page); + return isController(page) || isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page); + } + + public static boolean isController(Identifier page) { + return page.getNamespace().equals("rivencraft") && switch (page.getPath()) { + case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true; + default -> false; + }; } public static boolean isSize(Identifier page) { @@ -98,25 +127,74 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, } public static boolean validWriting(List pages) { - int target = pages.indexOf(NORMAL_TIME), periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0; - if (target != pages.lastIndexOf(NORMAL_TIME)) return false; - for (int i = 0; i < pages.size(); i++) { - if (isPeriod(pages.get(i)) && (++periods > 1 || i > target)) return false; - if (isPhase(pages.get(i)) && (++phases > 1 || i > target)) return false; - if (isDirection(pages.get(i)) && (++directions > 1 || i > target)) return false; - if (isAngle(pages.get(i)) && (++angles > 1 || i > target)) return false; - if (isSize(pages.get(i)) && (++sizes > 1 || i > target)) return false; + int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0; + for (Identifier page : pages) { + if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1 + || isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1 + || isSize(page) && ++sizes > 1) return false; + if (isController(page)) { + if (!page.getPath().equals("celestial/moon")) suns++; + if (!page.getPath().equals("celestial/sun")) moons++; + if (suns > 10 || moons > 10) return false; + periods = phases = directions = angles = sizes = 0; + } } - return true; + return periods + phases + directions + angles + sizes == 0; } public static PortDayCycle compile(List pages, long seed) { return compile(pages, seed, 0); } + /** Creation-only completion. Exact diagnostic fixtures can still use compile. */ + public static PortDayCycle resolve(List pages, long seed, long nativeTicks) { + if (!validWriting(pages) || !PortAgeColors.validNumbers(pages)) + throw new IllegalArgumentException("Invalid celestial modifier order"); + if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks); + // Match root AgeSkyProfile.random's count draw positions: sky style, + // mixed sky color, then independent 0..3 sun and moon counts. + var random = new Random(seed ^ 0x534B5950524F464CL); + random.nextInt(5); + randomColor(random); + int suns = random.nextInt(4), moons = random.nextInt(4); + var bodies = new java.util.ArrayList(); + for (int i = 0; i < suns + moons; i++) + bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies); + if (bodies.isEmpty()) return new PortDayCycle(true, 1, 0, 0, 0, true, -1, 1F, NONE); + var first = bodies.getFirst(); + return new PortDayCycle(true, first.period(), first.stoppedTime(), first.originTick(), first.phaseTick(), + first.clockwise(), first.bearing(), first.size(), first.kind(), bodies); + } + public static PortDayCycle compile(List pages, long seed, long nativeTicks) { - if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one Length, Phase, Direction, Angle, and Size before Normal Time"); - if (!pages.contains(NORMAL_TIME)) return NATIVE; + if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons"); + if (pages.stream().filter(PortDayCycle::isController).count() <= 1) + return compileOne(pages, seed, nativeTicks, 0); + var bodies = new java.util.ArrayList(); + PortDayCycle first = null; + int start = 0, ordinal = 0; + for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) { + var cycle = compileOne(pages.subList(start, i + 1), seed, nativeTicks, ordinal++); + if (first == null) first = cycle; + // Prefix retains original page indices for seeded unnumbered colors. + int color = PortAgeColors.compile(pages.subList(0, i + 1), seed).celestial(); + if (!cycle.bodies.isEmpty()) bodies.addAll(cycle.bodies); + else { + if (cycle.bodyMode != MOON) bodies.add(PortCelestialBody.of(cycle, SUN, color, false)); + if (cycle.bodyMode != SUN) bodies.add(PortCelestialBody.of(cycle, MOON, color, cycle.bodyMode == PAIRED)); + } + start = i + 1; + } + return new PortDayCycle(first.authored, first.period, first.stoppedTime, first.originTick, + first.phaseTick, first.clockwise, first.bearing, first.sunSize, first.bodyMode, bodies); + } + + private static PortDayCycle compileOne(List pages, long seed, long nativeTicks, int ordinal) { + var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null); + if (controller == null) return NATIVE; + int mode = controller.getPath().equals("celestial/sun") ? SUN + : controller.getPath().equals("celestial/moon") ? MOON : PAIRED; + if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode); double period = 1; Integer phase = null; boolean clockwise = true; @@ -160,7 +238,59 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, int stopped = period == 0 ? phase != null ? phase : new Random(seed ^ 0x4441594359434C45L).nextInt(24000) : 0; return new PortDayCycle(true, period, stopped, phase != null ? nativeTicks : 0, - phase == null ? 0 : phase, clockwise, bearing, size); + phase == null ? 0 : phase, clockwise, bearing, size, mode); + } + + private static boolean hasBodyModifiers(List pages) { + boolean color = false, controls = false; + for (var page : pages) { + if (isController(page)) return controls || color; + if (PortAgeColors.isColor(page)) color = true; + else if (PortSkyStyle.forPage(page) != null || page.equals(PortAgeColors.AMBIENT)) color = false; + if (isPeriod(page) || isPhase(page) || isDirection(page) || isAngle(page) || isSize(page)) controls = true; + } + return false; + } + + private static PortDayCycle randomUnmodified(long seed, int ordinal, long nativeTicks, int mode) { + // Independent per-controller stream: unrelated terrain/sky/weather pages + // cannot reshuffle the bodies. Use root random-Age bounds, not native defaults. + var random = new Random(seed ^ 0x43454C4553544941L ^ (ordinal + 1L) * 0x9E3779B97F4A7C15L); + double period = random.nextInt(7) == 0 ? 0D : 0.2D + random.nextDouble() * 1.8D; + int phase = random.nextInt(24000), bearing = random.nextInt(360); + float size = 0.75F + random.nextFloat(); + boolean clockwise = random.nextBoolean(); + int color = randomColor(random); + var cycle = new PortDayCycle(true, period, period == 0 ? phase : 0, nativeTicks, + phase, clockwise, bearing, size, mode); + return new PortDayCycle(true, period, cycle.stoppedTime, nativeTicks, phase, clockwise, bearing, size, + mode, List.of(PortCelestialBody.of(cycle, mode, color, false))); + } + + private static int randomColor(Random random) { + return PortAgeColors.randomColor(random, false); + } + + public boolean singleBody() { return authored && (bodyMode == SUN || bodyMode == MOON); } + public boolean customLight() { return singleBody() || !bodies.isEmpty(); } + + /** Linear authored orbit, as in 1.21.1 Body; zero degrees is Zenith. */ + public float bodyAngle(long nativeTicks) { + return (float) (((time(nativeTicks) - 6000D) % 24000D) * (360D / 24000D)); + } + + public float bodyLight(long nativeTicks) { + if (bodyMode == NONE) return 0; + if (!bodies.isEmpty()) { + float sun = 0, moon = 0; + for (var body : bodies) { + if (body.kind() == SUN) sun += body.light(nativeTicks) * (1F - 0.85F * body.eclipse(bodies, nativeTicks)); + else moon += body.light(nativeTicks); + } + return Math.min(1F, sun > 0 ? sun : moon); + } + float altitude = Math.max(0F, (float) Math.cos(Math.toRadians(bodyAngle(nativeTicks)))); + return altitude * (bodyMode == MOON ? 0.18F : 1F); } public long ticks(long nativeTicks) { 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 index 7d01aa1..a2f4e8b 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java @@ -113,7 +113,7 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { } @Override public ChunkGeneratorStructureState createState(HolderLookup sets, RandomState random, long seed) { - if (hasStructurePages()) { + if (controlsStructures()) { var lookup = PortStructurePages.filtered(sets, terrainFeatures.structures(), getBiomeSource()); var planning = PortStructurePages.planningBiomes(getBiomeSource(), sets, terrainFeatures.structures()); if (base instanceof PortFlatChunkGenerator || base instanceof net.minecraft.world.level.levelgen.FlatLevelSource) @@ -124,13 +124,13 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { return base.createState(sets, random, seed); } - private boolean hasStructurePages() { - return terrainFeatures != null && !terrainFeatures.structures().isEmpty(); + private boolean controlsStructures() { + return terrainFeatures != null && (terrainFeatures.structureControl() || !terrainFeatures.structures().isEmpty()); } @Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state, StructureManager structures, ChunkAccess chunk, StructureTemplateManager templates, ResourceKey dimension) { - if (hasStructurePages()) super.createStructures(registries, state, structures, chunk, templates, dimension); + if (controlsStructures()) super.createStructures(registries, state, structures, chunk, templates, dimension); else base.createStructures(registries, state, structures, chunk, templates, dimension); } @Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java index 246e763..531202d 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomAgeSelection.java @@ -54,7 +54,8 @@ public final class PortRandomAgeSelection { return new Selection(terrain, new PortBiomePlan(distribution, shuffled.subList(0, count))); } - private static List eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) { + public static List eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) { + if (terrain == PortAgeTerrain.VOID) return List.of(Identifier.withDefaultNamespace("the_void")); if (terrain == PortAgeTerrain.CAVE) { List caves = server.registryAccess().lookupOrThrow(Registries.BIOME).listElements() .filter(PortRegistryPages::isCaveBiome) diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java new file mode 100644 index 0000000..e718c60 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomFeatureSelection.java @@ -0,0 +1,85 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import net.dnorberg.rivencraft.writing.PortAuthoredWriting; +import net.dnorberg.rivencraft.writing.PortRegistryPages; +import net.minecraft.resources.Identifier; + +/** Frozen page-free material features and rare formations from 1.21.1. */ +public final class PortRandomFeatureSelection { + private PortRandomFeatureSelection() {} + public record Selection(PortRockPlan rocks, PortLakePlan lakes, PortTerrainFeaturePlan terrain, int dungeons) {} + + public static Selection select(net.minecraft.server.MinecraftServer server, long seed, + PortAgeTerrain terrain, PortBiomePlan biomes) { + var base = select(seed, terrain); + var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes); + var plan = base.terrain(); + plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true) + : new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(), + structures.structures(), plan.carvers(), true); + return new Selection(base.rocks(), base.lakes(), plan, structures.dungeons()); + } + + public static Selection select(long seed, PortAgeTerrain terrain) { + var random = new Random(seed ^ 0x4D4154455249414CL); + int count = random.nextInt(3); + var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT)); + var candidates = new ArrayList>(); + for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks", + "surface_lakes", "deep_lakes", "lava_lakes")) { + var page = id("feature/" + name); + if (PortAuthoredWriting.supports(List.of(terrainPage, page))) + candidates.add(new PortRankedChoice.Option<>(page, rank(name))); + } + var pages = new ArrayList(); + pages.add(terrainPage); + List solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds(); + for (int selected = 0; selected < count && !candidates.isEmpty();) { + Identifier feature = PortRankedChoice.choose(random, candidates); + candidates.removeIf(option -> option.value().equals(feature)); + var materials = PortLakePlan.isLake(feature) ? fluids : solids; + if (materials.isEmpty()) continue; + pages.add(PortRegistryPages.blockPageId(materials.get(random.nextInt(materials.size())))); + pages.add(feature); + selected++; + } + if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID + && Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0) + pages.add(PortTerrainFeaturePlan.SKYLANDS); + if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0) + pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS); + if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES); + return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed), + PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0); + } + + private static int rank(String name) { + return switch (name) { + case "surface_lakes", "deep_lakes" -> 1; + case "lava_lakes" -> 2; + case "spheres", "spikes", "obelisks" -> 3; + default -> 4; + }; + } + + private static boolean randomStarFissure(long seed) { + var random = new Random(mix(seed ^ 0x535441524752414DL)); + for (int step = 0; step < 27; step++) { + int branch = random.nextInt(5); + if (branch == 0) return false; + if (random.nextInt(9) == 0) return true; + if (branch == 1) return false; + } + return false; + } + + private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); } + 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/PortRandomStructureSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java new file mode 100644 index 0000000..9247126 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRandomStructureSelection.java @@ -0,0 +1,69 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.Set; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.dimension.LevelStem; + +/** Creation-only structure completion, adapted from 1.21.1 AgeStructureSelection. */ +public final class PortRandomStructureSelection { + private static final Identifier DUNGEONS = page("dungeons"); + private PortRandomStructureSelection() {} + public record Selection(int dungeons, List structures) { + public Selection { structures = List.copyOf(structures); } + } + + public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) { + Set biomes = actualBiomes(server, terrain, plan); + var eligible = new ArrayList>(); + for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages", + "trial_chambers", "bastions", "buried_treasure", "shipwrecks", "ocean_ruins", "pillager_outposts", + "ruined_portals", "nether_fossils", "ancient_cities", "trail_ruins", "abandoned_camps")) { + Identifier feature = page(name); + boolean allowed = feature.equals(DUNGEONS) + ? net.dnorberg.rivencraft.writing.PortAuthoredWriting.supports(List.of( + Identifier.fromNamespaceAndPath("rivencraft", "terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT)), feature)) + && server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE) + .get(ResourceKey.create(Registries.PLACED_FEATURE, + Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent() + : PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess()); + if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name))); + } + var random = new Random(seed ^ 0x5354525543545552L); + int count = random.nextInt(3), dungeons = 0; + var structures = new ArrayList(); + for (int selected = 0; selected < count && !eligible.isEmpty(); selected++) { + var feature = PortRankedChoice.choose(random, eligible); + if (feature.equals(DUNGEONS)) dungeons++; + else structures.add(feature); + eligible.removeIf(option -> option.value().equals(feature)); + } + return new Selection(dungeons, structures); + } + + private static Set actualBiomes(MinecraftServer server, PortAgeTerrain terrain, PortBiomePlan plan) { + if (plan != null && plan.distribution() != PortBiomePlan.Distribution.NATIVE) + return Set.copyOf(plan.biomes()); + if (terrain == PortAgeTerrain.FLAT) return Set.of(Identifier.withDefaultNamespace("plains")); + if (terrain == PortAgeTerrain.VOID) return Set.of(Identifier.withDefaultNamespace("the_void")); + if (terrain == PortAgeTerrain.CAVE) return Set.of(Identifier.withDefaultNamespace("lush_caves")); + return server.registryAccess().lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD) + .value().generator().getBiomeSource().possibleBiomes().stream() + .flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + + private static int rank(String name) { + return switch (name) { + case "villages", "strongholds", "mineshafts" -> 1; + case "dungeons", "nether_fortresses" -> 2; + default -> 3; + }; + } + private static Identifier page(String name) { return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRankedChoice.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRankedChoice.java new file mode 100644 index 0000000..29c77cc --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortRankedChoice.java @@ -0,0 +1,36 @@ +package net.dnorberg.rivencraft.age; + +import java.util.HashMap; +import java.util.List; +import java.util.Random; + +/** The 1.21.1 LegacyRankChoice rule, retained for seeded grammar completion. */ +final class PortRankedChoice { + private PortRankedChoice() {} + record Option(T value, int rank) {} + + static T choose(Random random, List> options) { + if (options.isEmpty()) throw new IllegalArgumentException("No ranked choices"); + var counts = new HashMap(); + int highest = 0; + for (var option : options) { + counts.merge(option.rank(), 1, Integer::sum); + highest = Math.max(highest, option.rank()); + } + var weights = new HashMap(); + int weight = 1, previousTotal = 0; + for (int rank = highest; rank >= 0; rank--) { + int count = counts.getOrDefault(rank, 0); + if (weight != 1 && count > 0) weight = Math.max(weight, previousTotal / count + 1); + weights.put(rank, weight); + previousTotal = count * weight; + weight++; + } + int ticket = random.nextInt(options.stream().mapToInt(option -> weights.get(option.rank())).sum()); + for (var option : options) { + ticket -= weights.get(option.rank()); + if (ticket < 0) return option.value(); + } + throw new IllegalStateException("Ranked choice ticket was not consumed"); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java index 38f0492..01ebcc7 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortSkyStyle.java @@ -31,11 +31,20 @@ public enum PortSkyStyle { ? OVERWORLD : random(seed)); } + /** Nether-style Ages use their fog clear color as the visible backdrop. */ + public static float fogShade(net.minecraft.world.level.Level level) { + boolean nether = level.dimensionTypeRegistration().unwrapKey().map(key -> + key.identifier().getNamespace().equals("rivencraft") + && key.identifier().getPath().endsWith("_sky_nether")).orElse(false); + var cycle = PortDayCycle.forLevel(level); + return nether && !cycle.bodies().isEmpty() + ? 1F - 0.65F * PortCelestialBody.eclipseAt(cycle.bodies(), PortDayCycle.nativeTicks(level)) : 1F; + } + private static boolean isCelestial(Identifier page) { if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return false; return switch (page.getPath()) { - // Only Overworld currently draws bodies in this port. Recognize the - // Sun/Moon intentions too, without marking those pages implemented. + // Preserve the established compatible-default rule for written controllers. case "celestial/normal_time", "celestial/sun", "celestial/moon" -> true; default -> false; }; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStructurePages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStructurePages.java index 12a5e2a..18334ec 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStructurePages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStructurePages.java @@ -88,6 +88,20 @@ public final class PortStructurePages { .orElseThrow(() -> new IllegalArgumentException("Missing structure set for " + page))); } + /** Random choices need at least one normally eligible biome; authored pages may override it. */ + public static boolean randomEligible(Identifier page, PortAgeTerrain terrain, Set biomes, + HolderLookup.Provider registries) { + if (!supportsTerrain(page, terrain)) return false; + var set = registries.lookupOrThrow(Registries.STRUCTURE_SET) + .get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page))); + if (set.isEmpty()) return false; + var compatible = set.get().value().structures().stream() + .flatMap(entry -> entry.structure().value().biomes().stream()) + .flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()) + .collect(java.util.stream.Collectors.toSet()); + return compatible.isEmpty() || biomes.stream().anyMatch(compatible::contains); + } + private static Map enabled(List pages) { Map result = new HashMap<>(); for (Identifier page : pages) result.merge(SETS.get(page), 1, Integer::sum); 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 index 410fb13..018c502 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortTerrainFeaturePlan.java @@ -9,7 +9,8 @@ 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, List structures, List carvers) { + boolean starFissures, List structures, List carvers, + boolean structureControl) { 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"); @@ -30,7 +31,8 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int .forGetter(PortTerrainFeaturePlan::denseOres), Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures), Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures), - Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers) + Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers), + Codec.BOOL.optionalFieldOf("structure_control", false).forGetter(PortTerrainFeaturePlan::structureControl) ).apply(instance, PortTerrainFeaturePlan::new)); public record Choice(Identifier page, Identifier material) { @@ -55,7 +57,7 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int PortCarverPages.validateCounts(carvers); if (denseOres < 0 || denseOres > MAX_PER_FEATURE) throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences"); - if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty()) + if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() && !structureControl) throw new IllegalArgumentException("Terrain feature plan must not be empty"); var counts = new HashMap(); for (Choice choice : choices) { @@ -64,6 +66,11 @@ public record PortTerrainFeaturePlan(List choices, boolean skylands, int } } + public PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, + boolean starFissures, List structures, List carvers) { + this(choices, skylands, denseOres, starFissures, structures, carvers, false); + } + public PortTerrainFeaturePlan(List choices, boolean skylands, int denseOres, boolean starFissures, List structures) { this(choices, skylands, denseOres, starFissures, structures, List.of()); 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 ef07ab3..02f02f2 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 @@ -465,7 +465,7 @@ public final class RuntimeAgeBackend { case CAVE -> dryCaveSettings(server); case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource"); }; - generator = new NoiseBasedChunkGenerator(source, settings); + generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value())); } if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null) generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortCelestialDiscs.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortCelestialDiscs.java new file mode 100644 index 0000000..6f22737 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortCelestialDiscs.java @@ -0,0 +1,85 @@ +package net.dnorberg.rivencraft.client; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.commands.RenderPass; +import com.mojang.renderpearl.api.pipeline.BlendFunction; +import com.mojang.renderpearl.api.pipeline.ColorTargetState; +import com.mojang.renderpearl.api.pipeline.PrimitiveTopology; +import com.mojang.renderpearl.api.pipeline.RenderPipeline; +import java.util.IdentityHashMap; +import java.util.Map; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.MoonPhase; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.RegisterRenderPipelinesEvent; +import org.joml.Matrix4f; +import org.joml.Vector4f; + +/** Legacy's opaque central disc, separate from the native transparent glow. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT) +public final class PortCelestialDiscs implements AutoCloseable { + private static final Identifier SUN = Identifier.withDefaultNamespace("sun"); + private static final RenderPipeline CORE = RenderPipelines.CELESTIAL.toBuilder() + .withLocation(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "pipeline/celestial_core")) + .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)).build(); + private final Map cores = new IdentityHashMap<>(); + private TextureAtlasSprite atlasGeneration; + + @SubscribeEvent public static void register(RegisterRenderPipelinesEvent event) { event.registerPipeline(CORE); } + + public void draw(RenderPass pass, TextureAtlas atlas, boolean moon, MoonPhase phase, + Matrix4f transform, int color, float alpha) { + if (alpha <= 0) return; + var generation = atlas.getSprite(SUN); + if (generation != atlasGeneration) { + close(); + atlasGeneration = generation; + } + var sprite = moon ? atlas.getSprite(Identifier.withDefaultNamespace("moon/" + phase.getSerializedName())) : generation; + var buffer = cores.computeIfAbsent(sprite, ignored -> build(sprite, moon)); + var indices = RenderSystem.getSequentialBuffer(PrimitiveTopology.QUADS); + var indexBuffer = indices.getBuffer(6); + var rgb = net.dnorberg.rivencraft.age.PortAgeColors.vector(color); + var uniforms = RenderSystem.getDynamicUniforms().writeTransform(transform, new Vector4f(rgb, alpha)); + pass.setPipeline(RenderSystem.getCompiledPipeline(CORE)); + RenderSystem.bindDefaultUniforms(pass); + pass.setUniform("DynamicTransforms", uniforms); + pass.setUniform("Sampler0", atlas.getTextureView(), atlas.getSampler()); + pass.setVertexBuffer(0, buffer.slice()); + pass.setIndexBuffer(indexBuffer, indices.type()); + pass.drawIndexed(6, 1, 0, 0, 0); + } + + private static GpuBuffer build(TextureAtlasSprite sprite, boolean moon) { + var format = DefaultVertexFormat.POSITION_TEX; + try (var bytes = ByteBufferBuilder.exactlySized(4 * format.getVertexSize())) { + var builder = new BufferBuilder(bytes, PrimitiveTopology.QUADS, format); + // Same 8/32 central area as root's ordinary-alpha second pass. + float u0 = sprite.getU(0.375F), u1 = sprite.getU(0.625F); + float v0 = sprite.getV(0.375F), v1 = sprite.getV(0.625F); + builder.addVertex(-0.25F, 0, -0.25F).setUv(moon ? u1 : u0, moon ? v1 : v0); + builder.addVertex(0.25F, 0, -0.25F).setUv(moon ? u0 : u1, moon ? v1 : v0); + builder.addVertex(0.25F, 0, 0.25F).setUv(moon ? u0 : u1, moon ? v0 : v1); + builder.addVertex(-0.25F, 0, 0.25F).setUv(moon ? u1 : u0, moon ? v0 : v1); + try (var mesh = builder.buildOrThrow()) { + return RenderSystem.getDevice().createBuffer(() -> "Rivencraft celestial core", 40, mesh.vertexBuffer()); + } + } + } + + @Override public void close() { + cores.values().forEach(GpuBuffer::close); + cores.clear(); + atlasGeneration = null; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StaticSkyState.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StaticSkyState.java index a4c1116..79e73e7 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StaticSkyState.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/StaticSkyState.java @@ -2,6 +2,11 @@ package net.dnorberg.rivencraft.client; /** Carries extracted sky presentation values without live-world reads during drawing. */ public interface StaticSkyState { + int rivencraft$bodyOverlay(); + void rivencraft$bodyOverlay(int mode); + record Body(int kind, float angle, int bearing, float size, int color, float eclipse) {} + java.util.List rivencraft$bodies(); + void rivencraft$bodies(java.util.List bodies); long rivencraft$staticFrame(); void rivencraft$staticFrame(long frame); int rivencraft$skyTint(); @@ -10,4 +15,8 @@ public interface StaticSkyState { void rivencraft$orbitBearing(int bearing); float rivencraft$sunSize(); void rivencraft$sunSize(float size); + int rivencraft$celestialTint(); + void rivencraft$celestialTint(int color); + int rivencraft$bodyMode(); + void rivencraft$bodyMode(int mode); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeColorAttributesMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeColorAttributesMixin.java index a845287..84db6ac 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeColorAttributesMixin.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeColorAttributesMixin.java @@ -22,7 +22,9 @@ public class AgeColorAttributesMixin { }); builder.addTimeBasedLayer(EnvironmentAttributes.FOG_COLOR, (base, tick) -> { int rgb = PortAgeColors.forLevel(level).sky(); - return rgb < 0 ? base : PortAgeColors.vector(rgb); + var color = rgb < 0 ? base : PortAgeColors.vector(rgb); + float shade = net.dnorberg.rivencraft.age.PortSkyStyle.fogShade(level); + return shade == 1F ? color : new org.joml.Vector3f(color).mul(shade); }); builder.addTimeBasedLayer(EnvironmentAttributes.AMBIENT_LIGHT_COLOR, (base, tick) -> { int rgb = PortAgeColors.forLevel(level).ambient(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeDayTimelineMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeDayTimelineMixin.java index 43b6081..1fe4ab3 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeDayTimelineMixin.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/AgeDayTimelineMixin.java @@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.mixin; import net.dnorberg.rivencraft.age.PortDayCycle; import net.minecraft.world.attribute.EnvironmentAttributeSystem; +import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.attribute.WeatherAttributes; import net.minecraft.world.level.Level; import org.spongepowered.asm.mixin.Mixin; @@ -11,6 +12,30 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @Mixin(EnvironmentAttributeSystem.Builder.class) public class AgeDayTimelineMixin { + @Inject(method = "addDefaultLayers", at = @At("RETURN")) + private void rivencraft$singleBody(Level level, CallbackInfoReturnable cir) { + var id = level.dimension().identifier(); + if (!id.getNamespace().equals("rivencraft") || !id.getPath().startsWith("age/")) return; + var builder = cir.getReturnValue(); + builder.addTimeBasedLayer(EnvironmentAttributes.SUN_ANGLE, (base, tick) -> { + var cycle = PortDayCycle.forLevel(level); + return cycle.singleBody() ? cycle.bodyAngle(PortDayCycle.nativeTicks(level)) : base; + }); + builder.addTimeBasedLayer(EnvironmentAttributes.MOON_ANGLE, (base, tick) -> { + var cycle = PortDayCycle.forLevel(level); + return cycle.singleBody() ? cycle.bodyAngle(PortDayCycle.nativeTicks(level)) : base; + }); + builder.addTimeBasedLayer(EnvironmentAttributes.SKY_LIGHT_LEVEL, (base, tick) -> { + var cycle = PortDayCycle.forLevel(level); + if (!cycle.customLight()) return base; + // Match root AgeSkyProfile.lightAt and LevelSkyBrightnessMixin's + // single skylight channel. Bright/Dark and no-skylight geometry remain separate. + float weather = (1F - level.getRainLevel(1F) * 5F / 16F) + * (1F - level.getThunderLevel(1F) * 5F / 16F); + return 15F - (int) ((1F - cycle.bodyLight(PortDayCycle.nativeTicks(level)) * weather) * 11F); + }); + } + @Inject(method = "addDynamicLayers", at = @At("HEAD"), cancellable = true) private void rivencraft$dayCycle(Level level, CallbackInfoReturnable cir) { var id = level.dimension().identifier(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyRendererMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyRendererMixin.java index b8710bd..db505a0 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyRendererMixin.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyRendererMixin.java @@ -26,6 +26,24 @@ public class StaticSkyRendererMixin { @Unique private int rivencraft$endTint = -1; @Unique private int rivencraft$orbitBearing = -1; @Unique private float rivencraft$sunSize = 1F; + @Unique private int rivencraft$celestialTint = -1; + @Unique private int rivencraft$bodyMode; + @Unique private java.util.List rivencraft$bodies = java.util.List.of(); + @Unique private net.dnorberg.rivencraft.client.PortCelestialDiscs rivencraft$discs; + @Shadow @Final private net.minecraft.client.renderer.texture.TextureAtlas celestialsAtlas; + @Shadow private org.joml.Matrix4f applyCelestialBodyTransform(com.mojang.blaze3d.vertex.PoseStack pose, + float height, float size) { throw new AssertionError(); } + @Shadow private void renderSun(com.mojang.renderpearl.api.commands.RenderPass pass, float alpha, + com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); } + @Shadow private void renderMoon(com.mojang.renderpearl.api.commands.RenderPass pass, + net.minecraft.world.level.MoonPhase phase, float alpha, + com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); } + @Shadow private void renderStars(com.mojang.renderpearl.api.commands.RenderPass pass, float brightness, + com.mojang.blaze3d.vertex.PoseStack pose) { throw new AssertionError(); } + @Shadow private void renderSunMoonAndStars(com.mojang.renderpearl.api.commands.RenderPass pass, + com.mojang.blaze3d.vertex.PoseStack pose, float sunAngle, + float moonAngle, float starAngle, net.minecraft.world.level.MoonPhase phase, + float alpha, float stars) { throw new AssertionError(); } @Inject(method = "extractRenderState", at = @At("TAIL")) private void rivencraft$extract(ClientLevel level, float partialTicks, Camera camera, @@ -36,11 +54,66 @@ public class StaticSkyRendererMixin { ((StaticSkyState) state).rivencraft$staticFrame(enabled ? level.getGameTime() / 2 : Long.MIN_VALUE); ((StaticSkyState) state).rivencraft$skyTint(net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).sky()); var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forLevel(level); + // 0=native Overworld/no authored overlay, 1=fog-only backdrop, + // 2=native End backdrop, 3=custom Static backdrop. + int overlay = !cycle.authored() || cycle.bodyMode() == net.dnorberg.rivencraft.age.PortDayCycle.NONE + || state.skybox == DimensionType.Skybox.OVERWORLD ? 0 + : enabled ? 3 : state.skybox == DimensionType.Skybox.NONE ? 1 : 2; + ((StaticSkyState) state).rivencraft$bodyOverlay(overlay); ((StaticSkyState) state).rivencraft$orbitBearing(cycle.authored() ? cycle.bearing() : -1); ((StaticSkyState) state).rivencraft$sunSize(cycle.authored() ? cycle.sunSize() : 1F); + ((StaticSkyState) state).rivencraft$celestialTint( + net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).celestial()); + ((StaticSkyState) state).rivencraft$bodyMode(cycle.authored() ? cycle.bodyMode() : 0); + // Root's authored Moon always samples the full-moon sprite. Preserve + // native calendar phases only for the native Normal Time pair. + if (cycle.singleBody() && cycle.bodyMode() == net.dnorberg.rivencraft.age.PortDayCycle.MOON) + state.moonPhase = net.minecraft.world.level.MoonPhase.FULL_MOON; + var authoredBodies = cycle.bodies(); + if (authoredBodies.isEmpty() && overlay != 0) { + var bodies = new java.util.ArrayList(); + int tint = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(level).celestial(); + if (cycle.bodyMode() != net.dnorberg.rivencraft.age.PortDayCycle.MOON) + bodies.add(net.dnorberg.rivencraft.age.PortCelestialBody.of(cycle, 1, tint, false)); + if (cycle.bodyMode() != net.dnorberg.rivencraft.age.PortDayCycle.SUN) + bodies.add(net.dnorberg.rivencraft.age.PortCelestialBody.of(cycle, 2, tint, cycle.bodyMode() == 0)); + authoredBodies = java.util.List.copyOf(bodies); + } + if (authoredBodies.isEmpty()) ((StaticSkyState) state).rivencraft$bodies(java.util.List.of()); + else { + var clock = level.clockManager().getInstance(level.registryAccess().lookupOrThrow( + net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(net.minecraft.world.clock.WorldClocks.OVERWORLD)); + double ticks = clock.totalTicks() + clock.partialTick(); + var sources = authoredBodies; + var bodies = sources.stream().map(body -> { + float eclipse = body.eclipse(sources, ticks); + return new StaticSkyState.Body(body.kind(), body.angle(ticks), body.bearing(), body.size(), + body.eclipseColor(eclipse), eclipse); + }).toList(); + ((StaticSkyState) state).rivencraft$bodies(bodies); + float eclipse = 0; + for (var body : bodies) if (body.kind() == 1 && Math.cos(Math.toRadians(body.angle())) > 0) + eclipse = Math.max(eclipse, body.eclipse()); + if (state.skybox == DimensionType.Skybox.OVERWORLD) { + state.skyColor = new org.joml.Vector3f(state.skyColor).mul(1F - 0.65F * eclipse); + } else if (eclipse > 0 && overlay >= 2) { + int tint = ((StaticSkyState) state).rivencraft$skyTint(); + if (tint < 0) tint = enabled ? 0x929292 : 0xffffff; + ((StaticSkyState) state).rivencraft$skyTint(rivencraft$shade(tint, 1F - 0.65F * eclipse)); + } + } // Only the extracted presentation uses END to schedule the sky pass. The actual // dimension has NONE, so it never acquires End flashes or End gameplay behavior. - if (enabled) state.skybox = DimensionType.Skybox.END; + if (enabled || overlay == 1) { + state.skybox = DimensionType.Skybox.END; + state.endFlashIntensity = 0; + } + if (overlay != 0) state.rainBrightness = 1F - level.getRainLevel(partialTicks); + } + + @Unique private static int rivencraft$shade(int rgb, float shade) { + return Math.round((rgb >> 16 & 255) * shade) << 16 + | Math.round((rgb >> 8 & 255) * shade) << 8 | Math.round((rgb & 255) * shade); } @Inject(method = "render", at = @At("HEAD"), cancellable = true) @@ -49,9 +122,82 @@ public class StaticSkyRendererMixin { rivencraft$endTint = ((StaticSkyState) state).rivencraft$skyTint(); rivencraft$orbitBearing = ((StaticSkyState) state).rivencraft$orbitBearing(); rivencraft$sunSize = ((StaticSkyState) state).rivencraft$sunSize(); + rivencraft$celestialTint = ((StaticSkyState) state).rivencraft$celestialTint(); + rivencraft$bodyMode = ((StaticSkyState) state).rivencraft$bodyMode(); + rivencraft$bodies = ((StaticSkyState) state).rivencraft$bodies(); + if (((StaticSkyState) state).rivencraft$bodyOverlay() == 1) { + rivencraft$overlay(fog, state); + ci.cancel(); + return; + } if (frame == Long.MIN_VALUE) return; if (rivencraft$staticSky == null) rivencraft$staticSky = new PortStaticSky(); rivencraft$staticSky.render(renderTarget, fog, frame, rivencraft$endTint); + if (((StaticSkyState) state).rivencraft$bodyOverlay() == 3) rivencraft$overlay(fog, state); + ci.cancel(); + } + + @Inject(method = "render", at = @At("RETURN")) + private void rivencraft$afterBackground(GpuBufferSlice fog, SkyRenderState state, CallbackInfo ci) { + // RETURN includes the native End branch's early return, after its pass closes. + if (((StaticSkyState) state).rivencraft$bodyOverlay() == 2) rivencraft$overlay(fog, state); + } + + @Unique private void rivencraft$overlay(GpuBufferSlice fog, SkyRenderState state) { + if (rivencraft$bodies.isEmpty()) return; + com.mojang.blaze3d.systems.RenderSystem.setShaderFog(fog); + try (var pass = com.mojang.blaze3d.systems.RenderSystem.getDevice().createCommandEncoder().createRenderPass( + () -> "Rivencraft celestial overlay", renderTarget.getColorTextureView(), java.util.Optional.empty(), + renderTarget.getDepthTextureView(), java.util.OptionalDouble.empty())) { + renderSunMoonAndStars(pass, new com.mojang.blaze3d.vertex.PoseStack(), 0, 0, 0, + net.minecraft.world.level.MoonPhase.FULL_MOON, state.rainBrightness, 0); + } + } + + @Inject(method = "renderSunMoonAndStars", at = @At("HEAD"), cancellable = true) + private void rivencraft$drawBodies(com.mojang.renderpearl.api.commands.RenderPass pass, + com.mojang.blaze3d.vertex.PoseStack pose, float sunAngle, + float moonAngle, float starAngle, net.minecraft.world.level.MoonPhase phase, + float alpha, float stars, CallbackInfo ci) { + if (rivencraft$bodies.isEmpty()) return; + int savedMode = rivencraft$bodyMode, savedTint = rivencraft$celestialTint; + float savedSize = rivencraft$sunSize; + try { + // Stars belong behind opaque discs, not on top of a dark eclipsing moon. + if (stars > 0) { + pose.pushPose(); + try { + pose.rotateDegrees(com.mojang.math.Axis.YP, -90); + pose.rotate(com.mojang.math.Axis.XP, starAngle); + renderStars(pass, stars, pose); + } finally { pose.popPose(); } + } + // Same ordering as 1.21.1: all suns, then all moons; each opaque + // central disc is drawn after its transparent glow. + for (int kind = 1; kind <= 2; kind++) for (var body : rivencraft$bodies) { + if (body.kind() != kind) continue; + rivencraft$bodyMode = kind; + rivencraft$celestialTint = body.color(); + rivencraft$sunSize = body.size(); + pose.pushPose(); + try { + pose.rotateDegrees(com.mojang.math.Axis.YP, body.bearing() < 0 ? -90 : body.bearing()); + pose.rotateDegrees(com.mojang.math.Axis.XP, body.angle()); + float glowAlpha = alpha * (kind == 1 ? 0.84F : 0.88F + 0.12F * body.eclipse()); + var bodyPhase = net.minecraft.world.level.MoonPhase.FULL_MOON; + if (kind == 1) renderSun(pass, glowAlpha, pose); else renderMoon(pass, bodyPhase, glowAlpha, pose); + if (rivencraft$discs == null) rivencraft$discs = new net.dnorberg.rivencraft.client.PortCelestialDiscs(); + // Both passes must use the same sprite. Native dark phase pixels + // become black patches under ordinary alpha, unlike the old glow-only pass. + var transform = applyCelestialBodyTransform(pose, 100F, (kind == 1 ? 30F : 20F) * body.size()); + rivencraft$discs.draw(pass, celestialsAtlas, kind == 2, bodyPhase, transform, body.color(), alpha); + } finally { pose.popPose(); } + } + } finally { + rivencraft$bodyMode = savedMode; + rivencraft$celestialTint = savedTint; + rivencraft$sunSize = savedSize; + } ci.cancel(); } @@ -68,10 +214,48 @@ public class StaticSkyRendererMixin { return nativeSize * rivencraft$sunSize; } - @Inject(method = "renderSunriseAndSunset", at = @At("HEAD")) + @ModifyArg(method = "renderMoon", at = @At(value = "INVOKE", target = + "Lnet/minecraft/client/renderer/SkyRenderer;applyCelestialBodyTransform(Lcom/mojang/blaze3d/vertex/PoseStack;FF)Lorg/joml/Matrix4f;"), index = 2) + private float rivencraft$scaleMoon(float nativeSize) { + return rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON + ? nativeSize * rivencraft$sunSize : nativeSize; + } + + @Inject(method = "renderSun", at = @At("HEAD"), cancellable = true) + private void rivencraft$hideSun(com.mojang.renderpearl.api.commands.RenderPass pass, float alpha, + com.mojang.blaze3d.vertex.PoseStack pose, CallbackInfo ci) { + if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON + || rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) ci.cancel(); + } + + @Inject(method = "renderMoon", at = @At("HEAD"), cancellable = true) + private void rivencraft$hideMoon(com.mojang.renderpearl.api.commands.RenderPass pass, + net.minecraft.world.level.MoonPhase phase, float alpha, + com.mojang.blaze3d.vertex.PoseStack pose, CallbackInfo ci) { + if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.SUN + || rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) ci.cancel(); + } + + @ModifyArg(method = {"renderSun", "renderMoon"}, at = @At(value = "INVOKE", target = + "Lnet/minecraft/client/renderer/DynamicGpuData;writeTransform(Lorg/joml/Matrix4f;Lorg/joml/Vector4f;)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;"), index = 1) + private org.joml.Vector4f rivencraft$tintCelestial(org.joml.Vector4f nativeColor) { + // Normal Time consumes one shared color group for its sun and moon in 1.21.1. + // Keep native alpha (including weather fading) and the sprite's transparent halo. + if (rivencraft$celestialTint < 0) return nativeColor; + var tint = net.dnorberg.rivencraft.age.PortAgeColors.vector(rivencraft$celestialTint); + return new org.joml.Vector4f(nativeColor.x * tint.x(), nativeColor.y * tint.y(), + nativeColor.z * tint.z(), nativeColor.w); + } + + @Inject(method = "renderSunriseAndSunset", at = @At("HEAD"), cancellable = true) private void rivencraft$rotateHorizon(com.mojang.renderpearl.api.commands.RenderPass pass, com.mojang.blaze3d.vertex.PoseStack pose, float angle, org.joml.Vector4fc color, CallbackInfo ci) { + if (rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.MOON + || rivencraft$bodyMode == net.dnorberg.rivencraft.age.PortDayCycle.NONE) { + ci.cancel(); + return; + } if (rivencraft$orbitBearing < 0) return; pose.pushPose(); pose.rotateDegrees(com.mojang.math.Axis.YP, rivencraft$orbitBearing + 90); @@ -81,7 +265,9 @@ public class StaticSkyRendererMixin { private void rivencraft$restoreHorizon(com.mojang.renderpearl.api.commands.RenderPass pass, com.mojang.blaze3d.vertex.PoseStack pose, float angle, org.joml.Vector4fc color, CallbackInfo ci) { - if (rivencraft$orbitBearing >= 0) pose.popPose(); + if (rivencraft$bodyMode != net.dnorberg.rivencraft.age.PortDayCycle.MOON + && rivencraft$bodyMode != net.dnorberg.rivencraft.age.PortDayCycle.NONE + && rivencraft$orbitBearing >= 0) pose.popPose(); } @Redirect(method = "renderEndSky", at = @At(value = "INVOKE", target = @@ -94,6 +280,10 @@ public class StaticSkyRendererMixin { @Inject(method = "close", at = @At("HEAD")) private void rivencraft$close(CallbackInfo ci) { + if (rivencraft$discs != null) { + rivencraft$discs.close(); + rivencraft$discs = null; + } if (rivencraft$staticSky != null) { rivencraft$staticSky.close(); rivencraft$staticSky = null; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyStateMixin.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyStateMixin.java index 0bec028..202b714 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyStateMixin.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/mixin/StaticSkyStateMixin.java @@ -14,6 +14,14 @@ public class StaticSkyStateMixin implements StaticSkyState { @Unique private int rivencraft$tint = -1; @Unique private int rivencraft$bearing = -1; @Unique private float rivencraft$sunSize = 1F; + @Unique private int rivencraft$celestialTint = -1; + @Unique private int rivencraft$bodyMode; + @Unique private int rivencraft$bodyOverlay; + public int rivencraft$bodyOverlay() { return rivencraft$bodyOverlay; } + public void rivencraft$bodyOverlay(int mode) { rivencraft$bodyOverlay = mode; } + @Unique private java.util.List rivencraft$bodies = java.util.List.of(); + public java.util.List rivencraft$bodies() { return rivencraft$bodies; } + public void rivencraft$bodies(java.util.List bodies) { rivencraft$bodies = java.util.List.copyOf(bodies); } public long rivencraft$staticFrame() { return rivencraft$frame; } public void rivencraft$staticFrame(long frame) { rivencraft$frame = frame; } public int rivencraft$skyTint() { return rivencraft$tint; } @@ -22,6 +30,10 @@ public class StaticSkyStateMixin implements StaticSkyState { public void rivencraft$orbitBearing(int bearing) { rivencraft$bearing = bearing; } public float rivencraft$sunSize() { return rivencraft$sunSize; } public void rivencraft$sunSize(float size) { rivencraft$sunSize = size; } + public int rivencraft$celestialTint() { return rivencraft$celestialTint; } + public void rivencraft$celestialTint(int color) { rivencraft$celestialTint = color; } + public int rivencraft$bodyMode() { return rivencraft$bodyMode; } + public void rivencraft$bodyMode(int mode) { rivencraft$bodyMode = mode; } @Inject(method = "reset", at = @At("TAIL")) private void rivencraft$reset(CallbackInfo ci) { @@ -29,5 +41,9 @@ public class StaticSkyStateMixin implements StaticSkyState { rivencraft$tint = -1; rivencraft$bearing = -1; rivencraft$sunSize = 1F; + rivencraft$celestialTint = -1; + rivencraft$bodyMode = 0; + rivencraft$bodyOverlay = 0; + rivencraft$bodies = java.util.List.of(); } } 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 0cdd178..3a1962a 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 @@ -108,9 +108,10 @@ public final class PortBookNetworking { public static Identifier createAge(ServerPlayer player, String title, List pages) { if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null; + long seed = player.getRandom().nextLong(); net.dnorberg.rivencraft.age.PortBiomePlan biomePlan; try { - biomePlan = PortAuthoredWriting.biomePlan(pages, player.registryAccess()); + biomePlan = PortAuthoredWriting.biomePlan(pages, player.level().getServer(), seed); } catch (IllegalArgumentException exception) { player.sendOverlayMessage(Component.literal("Age writing unavailable: " + exception.getMessage())); return null; @@ -127,7 +128,6 @@ public final class PortBookNetworking { id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); } try { - long seed = player.getRandom().nextLong(); PortAgeTerrain terrain = PortAuthoredWriting.terrain(pages); if (pages.isEmpty()) { PortRandomAgeSelection.Selection random = PortRandomAgeSelection.select(server, seed); @@ -135,15 +135,17 @@ public final class PortBookNetworking { biomePlan = random.biomes(); } var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain); + var randomFeatures = pages.isEmpty() + ? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null; RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain, - PortAuthoredWriting.rockPlan(pages, seed), + randomFeatures == null ? PortAuthoredWriting.rockPlan(pages, seed) : randomFeatures.rocks(), PortAuthoredWriting.vegetationPlan(pages), - PortAuthoredWriting.dungeonCount(pages), - PortAuthoredWriting.lakePlan(pages, seed), - PortAuthoredWriting.terrainFeaturePlan(pages, seed), + randomFeatures == null ? PortAuthoredWriting.dungeonCount(pages) : randomFeatures.dungeons(), + randomFeatures == null ? PortAuthoredWriting.lakePlan(pages, seed) : randomFeatures.lakes(), + randomFeatures == null ? PortAuthoredWriting.terrainFeaturePlan(pages, seed) : randomFeatures.terrain(), environment.weather(), environment.lighting(), environment.sky(), - net.dnorberg.rivencraft.age.PortAgeColors.compile(pages, seed), - net.dnorberg.rivencraft.age.PortDayCycle.compile(pages, seed, + net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed), + net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed, net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()))); } catch (IllegalArgumentException | IllegalStateException exception) { player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage())); 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 ff6d4f3..ca0987b 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 @@ -72,6 +72,12 @@ public final class PortAuthoredWriting { /** A page never disappears into an Age unless this port can apply its effect. */ public static boolean supports(List pages) { + try { + return supportsExplicit(PortMaterialCompletion.complete(pages, 0)); + } catch (IllegalArgumentException exception) { return false; } + } + + private static boolean supportsExplicit(List pages) { if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false; if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false; boolean terrain = false; @@ -97,7 +103,7 @@ public final class PortAuthoredWriting { || pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false; if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) { - // Shared color groups can precede a sky/ambient target or tint the sky at the end. + // Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky. } else if (isTerrain(page) && !terrain) { terrain = true; } else if (PortSkyStyle.forPage(page) != null && sky++ == 0) { @@ -107,7 +113,7 @@ public final class PortAuthoredWriting { } else if (PortWeatherMode.forPage(page) != null && weather++ == 0) { // A single independent weather policy for this Age. } else if (controller(page) != null && controllers++ == 0) { - // An explicit controller needs an authored pool. + // Missing biome slots are seed-completed when the Age is created. } else if (isBlockPage(page) && pendingMaterial == null && PortSymbolPages.find(page) != null && (PortSymbolPages.find(page).category().equals("rivencraft:block") @@ -152,7 +158,8 @@ public final class PortAuthoredWriting { } if (pendingMaterial != null || (rocks > 0 || pages.stream().anyMatch(PortVegetationPlan::requiresSurface)) && (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; - if ((dungeons > 0 || lakes > 0) && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN) + if (dungeons > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; + if (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 (!carvers.isEmpty() && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false; @@ -169,8 +176,8 @@ public final class PortAuthoredWriting { .filter(value -> value != null).findFirst().orElseThrow(); boolean valid = switch (distribution) { case NATIVE -> biomes == 0; - case SINGLE -> biomes == 1; - default -> biomes >= 2; + case SINGLE -> biomes <= 1; + default -> true; }; return valid; } @@ -214,13 +221,14 @@ public final class PortAuthoredWriting { } public static PortRockPlan rockPlan(List pages, long seed) { - if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + pages = completedMaterials(pages, seed); 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"); + pages = completedMaterials(pages, seed); + final List completed = pages; Map> materials = new HashMap<>(); Map occurrences = new HashMap<>(); List choices = new ArrayList<>(); @@ -230,7 +238,7 @@ public final class PortAuthoredWriting { choices.add(new PortTerrainFeaturePlan.Choice(page, PortTerrainFeaturePlan.CRYSTAL_MATERIAL)); continue; } - List selected = materials.computeIfAbsent(page, feature -> solidMaterials(pages, seed, feature)); + List selected = materials.computeIfAbsent(page, feature -> solidMaterials(completed, seed, feature)); int occurrence = occurrences.merge(page, 1, Integer::sum) - 1; choices.add(new PortTerrainFeaturePlan.Choice(page, selected.get(occurrence))); } @@ -262,7 +270,7 @@ public final class PortAuthoredWriting { } public static PortLakePlan lakePlan(List pages, long seed) { - if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); + pages = completedMaterials(pages, seed); if (pages.stream().noneMatch(PortLakePlan::isLake)) return null; List fluidPool = PortRegistryPages.fluidMaterialIds(); if (fluidPool.isEmpty()) throw new IllegalStateException("No source-fluid materials are registered"); @@ -288,6 +296,12 @@ public final class PortAuthoredWriting { return new PortLakePlan(choices); } + private static List completedMaterials(List pages, long seed) { + var completed = PortMaterialCompletion.complete(pages, seed); + if (!supportsExplicit(completed)) throw new IllegalArgumentException("Unsupported authored Age pages"); + return completed; + } + public static PortVegetationPlan vegetationPlan(List pages) { if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages"); List vegetation = pages.stream().filter(PortVegetationPlan::isVegetation).toList(); @@ -307,6 +321,17 @@ public final class PortAuthoredWriting { /** Resolves all authored biomes from the active registry before creating a level. */ public static PortBiomePlan biomePlan(List pages, HolderLookup.Provider registries) { + return biomePlan(pages, registries, 0, null); + } + + /** Creation-only completion; the saved plan never rerolls on load or reseed. */ + public static PortBiomePlan biomePlan(List pages, net.minecraft.server.MinecraftServer server, long seed) { + return biomePlan(pages, server.registryAccess(), seed, + net.dnorberg.rivencraft.age.PortRandomAgeSelection.eligibleBiomes(server, terrain(pages))); + } + + private static PortBiomePlan biomePlan(List pages, HolderLookup.Provider registries, + long seed, List available) { if (!supports(pages)) throw new IllegalArgumentException( "Unsupported terrain or biome-page combination in this port slice"); List biomes = new ArrayList<>(); @@ -341,6 +366,25 @@ public final class PortAuthoredWriting { 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 (distribution != null && available != null) { + int minimum = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2; + int target = minimum; + if (biomes.isEmpty() && distribution != PortBiomePlan.Distribution.SINGLE) { + var random = new Random(seed ^ 0x42494F4D45434F55L); + while (target < available.size() && random.nextInt(3) != 0) target++; + } + var distinct = new java.util.HashSet<>(biomes); + // Void may retain authored weather/season biomes, but must not gain + // an unrequested The Void biome just to fill a multi-biome controller. + var filler = new ArrayList<>(pages.contains(VOID_TERRAIN) && !biomes.isEmpty() ? List.of() : available); + Collections.shuffle(filler, new Random(seed ^ 0x42494F4D45504F4FL)); + for (Identifier biome : filler) { + if (distinct.size() >= target) break; + if (distinct.add(biome)) biomes.add(biome); + } + if (distinct.size() < minimum) throw new IllegalArgumentException( + distribution + " requires " + minimum + " distinct biomes; supply more biome pages for this terrain"); + } if (biomes.isEmpty()) return null; if (distribution == null) distribution = biomes.size() == 1 ? PortBiomePlan.Distribution.SINGLE : biomes.size() == 2 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java new file mode 100644 index 0000000..a5a717a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortMaterialCompletion.java @@ -0,0 +1,68 @@ +package net.dnorberg.rivencraft.writing; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.age.PortCarverPages; +import net.dnorberg.rivencraft.age.PortLakePlan; +import net.dnorberg.rivencraft.age.PortStructurePages; +import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan; +import net.dnorberg.rivencraft.age.PortVegetationPlan; +import net.minecraft.resources.Identifier; + +/** Creation-time implied features, adapted from 1.21.1 AgeFeatureSelection. */ +public final class PortMaterialCompletion { + private PortMaterialCompletion() {} + + public static List complete(List pages, long seed) { + var result = new ArrayList(); + for (int index = 0; index < pages.size(); index++) { + Identifier page = pages.get(index); + result.add(page); + var entry = PortSymbolPages.find(page); + if (entry == null || !(entry.category().equals("rivencraft:block") + || entry.category().equals("rivencraft:fluid"))) continue; + if (index + 1 < pages.size() && isFeature(pages.get(index + 1))) continue; + Identifier material = PortRegistryPages.blockId(page); + var candidates = candidates(pages, entry.category().equals("rivencraft:fluid")); + if (candidates.isEmpty()) throw new IllegalArgumentException( + "No compatible feature for material " + material + " on this terrain"); + long mixed = mix(seed ^ (long) material.hashCode() * 0x9E3779B97F4A7C15L ^ index); + result.add(candidates.get(Math.floorMod(mixed, candidates.size()))); + } + return List.copyOf(result); + } + + private static List candidates(List pages, boolean fluid) { + boolean cave = pages.contains(id("terrain/cave")); + boolean empty = pages.contains(id("terrain/void")); + boolean flat = pages.contains(id("terrain/flat")); + if (fluid) return cave || empty || flat ? List.of() + : List.of(id("feature/surface_lakes"), id("feature/deep_lakes"), id("feature/lava_lakes")); + var choices = new ArrayList(); + if (!cave && !empty && !flat) choices.add(id("feature/rocks")); + if (!cave) choices.add(PortTerrainFeaturePlan.FLOATING_ISLANDS); + choices.add(PortTerrainFeaturePlan.TENDRILS); + choices.add(PortTerrainFeaturePlan.SPHERES); + if (!cave && !empty) { + choices.add(PortTerrainFeaturePlan.SPIKES); + choices.add(PortTerrainFeaturePlan.OBELISKS); + } + return choices; + } + + private static boolean isFeature(Identifier page) { + return PortTerrainFeaturePlan.isSupported(page) || PortLakePlan.isLake(page) + || PortVegetationPlan.isVegetation(page) || PortStructurePages.supports(page) + || PortCarverPages.supports(page) || page.equals(id("feature/rocks")) + || page.equals(id("feature/dungeons")) || page.equals(PortTerrainFeaturePlan.SKYLANDS) + || page.equals(PortTerrainFeaturePlan.DENSE_ORES) || page.equals(PortTerrainFeaturePlan.STAR_FISSURES); + } + + private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +}