From 94a914b1b31675bdf8ae5c8ee06eae6e57d918e9 Mon Sep 17 00:00:00 2001 From: xefensor Date: Sun, 23 Aug 2026 12:18:02 +0200 Subject: [PATCH] Overhaul Nether atmosphere and portal corruption --- AGENTS.md | 7 +- CHANGELOG.md | 8 + docs/compatibility.md | 18 +- docs/internal/design_implementation_status.md | 7 +- docs/internal/retold_design_risks.md | 3 +- docs/internal/retold_mob_ai_system.md | 6 +- docs/internal/retold_mod_system.md | 71 +- docs/internal/retold_roadmap.md | 8 +- docs/internal/testing_strategy.md | 49 + .../api/world/RetoldWorldMutationType.java | 1 + .../api/world/RetoldWorldProtection.java | 17 + .../retold/gametest/RetoldGameTests.java | 2 + .../retold/mixin/NetherFogColorMixin.java | 33 + .../mixin/NetherFossilDriedGhastMixin.java | 24 + .../mixin/NetherFossilPieceInvoker.java | 19 + .../retold/mixin/NetherLightmapMixin.java | 33 + .../mixin/NetherPortalDrainTravelMixin.java | 34 + .../retold/module/RetoldWorldgenModule.java | 2 + .../xefensor/retold/registry/RetoldTags.java | 30 + .../RetoldNetherMobSpawnGameTests.java | 210 ++++ .../portal/RetoldNetherPortalDrainEvents.java | 1012 +++++++++++++++++ .../RetoldNetherPortalDrainGameTests.java | 733 ++++++++++++ .../RetoldNetherPortalDrainTransforms.java | 291 +++++ .../portal/RetoldNetherPortalDrainTravel.java | 53 + .../add_sparse_crimson_forest.json | 10 + .../add_sparse_warped_forest.json | 11 + .../sparsify_crimson_forest.json | 10 + .../sparsify_warped_forest.json | 11 + .../block/nether_portal_drain/immune.json | 14 + .../tags/block/nether_portal_drain/melts.json | 8 + .../nether_portal_drain/outer_ground.json | 12 + .../block/nether_portal_drain/stable.json | 72 ++ .../nether_portal_drain/to_blackstone.json | 104 ++ .../nether_portal_drain/to_crimson_wood.json | 14 + .../nether_portal_drain/to_dead_bush.json | 13 + .../block/nether_portal_drain/to_gravel.json | 5 + .../nether_portal_drain/to_netherrack.json | 6 + .../block/nether_portal_drain/vegetation.json | 35 + .../placed_feature/sparse_crimson_fungi.json | 12 + .../placed_feature/sparse_crimson_scrub.json | 12 + .../placed_feature/sparse_nether_sprouts.json | 12 + .../placed_feature/sparse_twisting_vines.json | 27 + .../placed_feature/sparse_warped_fungi.json | 12 + .../placed_feature/sparse_warped_scrub.json | 12 + .../placed_feature/sparse_weeping_vines.json | 27 + src/main/resources/retold.mixins.json | 5 + 46 files changed, 3100 insertions(+), 15 deletions(-) create mode 100644 src/main/java/cz/xefensor/retold/mixin/NetherFogColorMixin.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/NetherFossilDriedGhastMixin.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/NetherFossilPieceInvoker.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/NetherLightmapMixin.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/NetherPortalDrainTravelMixin.java create mode 100644 src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainEvents.java create mode 100644 src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainGameTests.java create mode 100644 src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTransforms.java create mode 100644 src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTravel.java create mode 100644 src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_crimson_forest.json create mode 100644 src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_warped_forest.json create mode 100644 src/main/resources/data/retold/neoforge/biome_modifier/sparsify_crimson_forest.json create mode 100644 src/main/resources/data/retold/neoforge/biome_modifier/sparsify_warped_forest.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/immune.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/melts.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/outer_ground.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/stable.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/to_blackstone.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/to_crimson_wood.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/to_dead_bush.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/to_gravel.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/to_netherrack.json create mode 100644 src/main/resources/data/retold/tags/block/nether_portal_drain/vegetation.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_fungi.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_scrub.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_nether_sprouts.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_twisting_vines.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_fungi.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_scrub.json create mode 100644 src/main/resources/data/retold/worldgen/placed_feature/sparse_weeping_vines.json diff --git a/AGENTS.md b/AGENTS.md index e55cafac..df28d110 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,12 @@ Other useful commands: | Worldgen or structures | Test multiple seeds and chunk borders in fresh worlds; check existing-world behavior when applicable | | Dimension, portal, or progression | Test both directions, repeated travel, death/reconnect, fresh and existing worlds, and relevant stages | | Networking | Test a dedicated server with a separate client; include multiple players when state can diverge | -| Visual or asset change | Provide screenshots or video and verify attribution/license records | +| Visual or asset change | Run applicable non-visual checks, then leave subjective in-game visual testing and approval to the developer; verify attribution/license records for asset changes | + +AI agents must not operate the game client or desktop UI to perform subjective visual tests. Start +the client only when needed for a technical startup or rendering-hook check, stop it afterward, and +state exactly what was not visually verified. The developer owns screenshots, video, comparison +across visual settings, and final visual approval. If a relevant check cannot be run, state exactly what was not verified and why. Never claim an in-game, dedicated-server, multiplayer, or existing-world result that was not actually tested. diff --git a/CHANGELOG.md b/CHANGELOG.md index e40d4c7a..c437c2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Each release should be readable in two passes: ### Player-Facing +- Crimson and Warped Forests now generate as open fungal deserts. Their colored nylium, resources, mobs, and atmosphere remain, but giant fungi, roots, sprouts, and vines are much more widely scattered. +- Nether fossils no longer generate Dried Ghasts. Dried Ghasts remain craftable, and existing or player-placed blocks can still hydrate into Ghastlings and grow into Happy Ghasts. +- Every Nether biome now uses the Nether Wastes' classic dark-red fog instead of changing fog color at biome borders. The Nether also restores a darker Minecraft 1.0-style ambient-light floor, making unlit terrain and distant silhouettes moodier while lava, fire, and other light sources remain effective. +- Active Nether portals in the Overworld now permanently drain their surroundings through two continuous outward fronts instead of changing scattered random positions. The leading death front removes large vegetation, turns supported small plants into Dead Bushes, kills coral, melts snow and ice, and turns living ground into Coarse Dirt. A nested Nether-corruption front follows at half its radius: dead ground and Clay become Netherrack; Sand becomes Gravel; Overworld stone and Sandstone become shape-aware Blackstone; bricks and tiles become polished Blackstone bricks; Overworld wood becomes matching Crimson wood; and water evaporates without deleting a waterlogged block. Netherrack and all other native Nether materials are stable, Gravel remains Gravel, and unmatched crafted or decorative blocks—including Concrete, Terracotta, Glass, Wool, rails, bookshelves, pumpkins, Hay, and mushroom blocks—remain unchanged. Lava source blocks anywhere inside the full affected sphere linearly resist both fronts: each source removes one portal block's share of the normal speed, and matching or exceeding the portal's interior block count stops the spread completely. Flowing lava does not count and lava is never consumed. A standard 2×3 portal reaches 16 and eight blocks; every additional interior portal block adds one block to the death radius, with the corruption radius remaining half and the death radius capped at 48. Terrain therefore dies before nearby Nether terrain replaces it, and larger portals have a proportionally larger environmental cost. Soul Sand, Soul Soil, fire, magma, and lava are never created. The effect grows slowly in the background and receives a pulse after every successful traveler, including mobs and items, while portals, Obsidian, ores, valuable storage blocks, containers, and machines remain protected. - Wildfire patrol groups now use obstacle-aware airborne routes. The leader checks for clear vertical space, climbs before crossing walls or escaping depressions, and its Blaze escorts route toward their moving line positions instead of continuing to push into terrain. - Wildfire escort Blazes now follow their leader into combat instead of being left where the patrol ended. They inherit the Wildfire's valid target, climb blocked terrain, and regroup into a compact staggered screen around the leader while retaining their vanilla fireball cycle; lava recovery still releases the group. - Wounded Wildfires now recognize only lava columns at least three blocks deep as recovery shelters. They ignore shallow pools, route to the surface of qualifying deep lava, and must remain fully submerged in that deep column to heal health or rebuild shields. @@ -52,6 +56,10 @@ Each release should be readable in two passes: ### Technical +- Data-driven biome modifiers replace only the Crimson and Warped Forest vegetation placements in newly generated chunks. Giant-fungus attempts fall from eight per cavern layer to one, forest scrub from six/five to one, vines from ten to two, and Warped Forest sprouts from four to one; terrain, surface blocks, ores, springs, structures, spawn lists, and existing chunks are unchanged. +- A narrow Nether-fossil placement hook suppresses only the post-template Dried Ghast generation attempt. It does not alter the Dried Ghast recipe, block behavior, existing chunks, or Happy Ghast entity type. +- Client-only atmosphere hooks substitute the Nether Wastes `#330808` base fog color and an approximately 10% neutral `#1A1A1A` ambient-light floor throughout the vanilla Nether dimension. Fog changes happen before vanilla darkness, vision, sky blending, and fluid-specific fog; lightmap changes retain block-light levels, the brightness option, and vision effects. Biome and dimension definitions remain untouched, so datapack-added Nether biomes inherit the presentation without losing their other settings or changing server-side light rules. +- `RetoldNetherPortalDrainEvents` indexes active portal fields only from loaded Overworld chunks, rotates bounded background and traveler-pulse work fairly, and never loads terrain to find or transform a target. Each portal has separate deterministic nearest-to-farthest death and corruption cursors; corruption may advance only where its squared radius is at most one quarter of the death front's. The outer radius is `min(48, 16 + width × height − 6)` and the inner radius is half. Loaded lava sources across that outer sphere are refreshed through a fair scan capped at two chunks every five ticks. A fractional work-credit limiter applies the exact factor `max(0, (portal area − lava sources) / portal area)` to background and pulse work and waits for the initial snapshot before spreading. A compact sorted primitive offset index supports every radius through 48 without retaining hundreds of thousands of `BlockPos` objects. Explicit semantic tags now define stable Nether materials, melting blocks, small plants, vegetation, living ground, Sand, Clay, stone/masonry, wood, and immunity; there are no generic axe/pickaxe/shovel or full-collision fallbacks, so unclassified blocks are unchanged. All transformations use vanilla blocks, and each individual edit passes through the `NETHER_PORTAL_DRAIN` world-protection category. A narrow portal-transition hook queues the pulse only after successful travel and caps repeated traffic at 96 pending changes per portal. - Wildfire formation movement now preempts vanilla Blaze wandering, selects bounded collision-free climb corridors, and follows budgeted three-dimensional flight paths refreshed every ten ticks. The focused formation GameTest includes a three-block wall and verifies leader ascent/crossing plus escort ascent before combat releases the owner. - Added flagless `WILDFIRE_ESCORT_COMBAT` movement for encounter Blazes. It inherits the leader's target through faction-assist ownership, follows budgeted flying routes into a compact combat screen, explicitly climbs an immediately blocked forward corridor, and coexists with vanilla Blaze firing. A focused three-to-five-escort test verifies that the whole group crosses a wall and reacquires the shared target. - Wildfire recovery now uses a cached `DEEP_LAVA_SOURCE` search that accepts only the exposed surface of a lava column with at least two more lava blocks below it. Focused tests cover shallow-pool rejection, selection of deeper lava over a closer shallow pool, the complete submerged recovery cycle, and the unchanged 50-Wildfire 20-TPS budget (4.876 ms/tick peak). diff --git a/docs/compatibility.md b/docs/compatibility.md index 9bf47a6a..8d23d922 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -66,12 +66,24 @@ The following block and item tags are supported extension points: | `retold:bat_foods` | Dropped food accepted by hungry Bats. | | `retold:feline_scavenge_foods` | Extra scavenged food accepted by Cats and Ocelots in addition to meat and fish. | | `retold:torch_igniters` | Items players may use to relight Retold's extinguished torches. | +| `retold:nether_portal_drain/immune` | Blocks that Overworld Nether-portal drain must never replace. Block entities are always immune independently of this tag. | +| `retold:nether_portal_drain/stable` | Native Nether materials and other final results that neither drain front may replace. Water can still evaporate from a waterlogged stable block in the inner zone. | +| `retold:nether_portal_drain/outer_ground` | Living ground that becomes Coarse Dirt in the outer zone and Netherrack in the inner zone. | +| `retold:nether_portal_drain/vegetation` | Vegetation that permanently withers to air in either drain zone. | +| `retold:nether_portal_drain/to_dead_bush` | Small plants that become a Dead Bush when it can survive at that position, otherwise air. | +| `retold:nether_portal_drain/melts` | Snow and frozen blocks that melt to air when reached by the death front. | +| `retold:nether_portal_drain/to_gravel` | Sand-like inner-zone blocks that become Gravel. | +| `retold:nether_portal_drain/to_netherrack` | Soil-like inner-zone blocks that become Netherrack. | +| `retold:nether_portal_drain/to_blackstone` | Stone and masonry inner-zone blocks that become shape-aware Blackstone or polished Blackstone forms. | +| `retold:nether_portal_drain/to_crimson_wood` | Wooden inner-zone blocks that become a compatible Crimson wood form when one exists. | Only add content whose behavior matches the tag's complete meaning. For example, a block added to `retold:panda_bamboo_blocks` must be safe to remove when consumed; a decorative prismarine-like block should not enter `retold:ocean_monument_protected_blocks` unless it should participate in monument mining pressure. An item added to `retold:campfire_consumable_igniters` must be safe to consume one at a time; durability-based tools belong in their own interaction path instead. +Portal drain intentionally has no generic mining-tool or full-block fallback. A modded block remains +unchanged unless its author or a datapack explicitly assigns it to one of the semantic drain tags. The three renewable habitat-forage tags do not remove their blocks. They remain usable when `mobGriefing=false`, use Retold's normal bounded forage search, and observe the existing 600-tick @@ -206,8 +218,8 @@ bounds, mutation category, optional responsible entity, and optional subject ide block actions use one-block bounds; portals and whole-chunk operations expose their full possible area so an adapter can reject overlaps rather than checking only the center. Current categories distinguish mob breaking and placement, other entity breaking, Aender portal creation, -delayed-structure retrogen, Aender chunk regeneration, and generic Retold world changes. Retold -currently routes the following owned operations through this layer: +delayed-structure retrogen, Aender chunk regeneration, Overworld Nether-portal drain, and generic +Retold world changes. Retold currently routes the following owned operations through this layer: - destructive animal forage, Panda bamboo consumption, weak-barrier breaking, Spider lair webs, and Villager torch maintenance @@ -215,6 +227,8 @@ currently routes the following owned operations through this layer: - Aender stale-chunk blanking and regeneration - generated Aender counterpart portals and player-built portal activation - delayed structure retrogen +- permanent Overworld Nether-portal drain, categorized as `NETHER_PORTAL_DRAIN` and checked once for + every individual block immediately before it changes The normal NeoForge entity-griefing hook and `mobGriefing` rule still apply before Retold's position-aware mob checks. Protection rules supplement those standard checks; they do not replace diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index 1cb238db..765f2246 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -303,14 +303,15 @@ Largest missing or partial design areas: | Nether Remnants/pigmen/blazes as fortress faction | Partial / implemented AI | Nether Remnants faction/territory covers piglin, brute, and blaze. GameTests verify member/config mapping, the Nether-only rule, both bastion and fortress tags, and the shared warning/retaliation lifecycle. Generated bastion and fortress behavior still needs in-game verification. | | Nether dragon | Deferred/TBD | Possibly planned for the ending, but not final. | | Nether dragon restores balance in Stage 3 | Partial | Stage 3 undead/piglin swap implies restored balance; no dragon. | -| Nether darker like 1.0 | Not implemented | -| Nether portal spread | Not implemented | Still planned as portal energy draining/spreading into surroundings. | +| Nether darker like 1.0 | Implemented / needs visual verification | Client-only atmosphere hooks unify every biome on the Nether Wastes `#330808` fog and replace the modern `#302821` ambient-light color with an approximately 10% neutral `#1A1A1A` floor matching the classic brightness curve. Block-light values, brightness and vision options, fluid fog, mob spawning, and server lighting rules remain unchanged. Final darkness and readability need developer visual approval across Nether biomes and brightness settings. | +| Dried Ghasts excluded from natural Nether generation | Implemented / needs worldgen verification | `NetherFossilDriedGhastMixin` cancels only the post-template Dried Ghast placement attempt in Nether fossils. The recipe, block, existing placements, hydration, Ghastlings, Happy Ghasts, Creative, and commands remain functional. Fresh Nether fossils still need natural multi-seed verification. | +| Nether portal spread | Implemented / needs natural and multiplayer verification | Loaded active Nether portals permanently drain the Overworld through two concurrent deterministic nearest-to-farthest fronts, independent of world stage. Background work plus every successful traveler—including mobs and items—advance a leading death cursor that removes large vegetation, turns supported small plants to Dead Bushes, kills coral, melts frozen blocks, and turns living ground to Coarse Dirt. A separate Nether-corruption cursor continually follows, but can reach at most half the death radius. Dead ground and Clay become Netherrack, Sand becomes Gravel, Overworld stone and Sandstone become shape-aware Blackstone, masonry becomes polished Blackstone bricks, Overworld wood becomes Crimson forms, and water evaporates without deleting waterlogged host blocks. Native Nether results and Gravel are stable; unmatched crafted and decorative blocks remain unchanged because no generic mining-tool/full-block fallback exists. Loaded lava sources throughout the full outer sphere linearly reduce both background and traveler-pulse work by `max(0, (portal blocks - lava sources) / portal blocks)`; flowing lava is ignored, matching or exceeding the portal area stops both fronts, and the lava is not consumed. A standard six-block 2×3 portal keeps the 16/8 radii. Each additional interior portal block adds one outer-radius block; the outer radius caps at 48 and the inner radius is half, rounded down. Both fronts cycle so later construction remains eligible, and living ground encountered late still cannot skip Coarse Dirt. Soul Sand, Soul Soil, fire, magma, and lava are never generated. Block entities, portals, Obsidian/Crying Obsidian, ores, valuable storage blocks, and a datapack immunity tag are protected. Loaded-chunk indexing, fair queues, work caps, a compact primitive offset index, a bounded two-chunk-per-five-ticks lava scan, and `NETHER_PORTAL_DRAIN` protection checks prevent forced loading and permit claim integrations. Six focused GameTests cover area scaling/the cap, the nested front invariant, two-step mappings/outward ordering/protections, the explicit stable/change/unchanged palette, linear lava resistance, and the bounded successful-travel pulse; natural pacing at standard and large radii, lava response latency and performance, unusual portals and terrain, chunk borders, save/reload, existing worlds, multiplayer, and dedicated servers remain unverified. | | Living piglins attack for shiny material | Partial / unclear | Piglin AI/faction exists; specific shiny-material rule not confirmed. | | Pigmen can be hired/fight with player | Not implemented | Still planned for Stage 3 piglin/pigman behavior. | | Wither different by dimension | Not implemented | | Fight music during wither | Not implemented | | Wither as first boss / Nether access gating | Not implemented | -| Basalt/soul valley kept, Nether forests changed/removed | Not implemented / unclear | +| Basalt/soul valley kept, Nether forests changed/removed | Implemented / needs multi-seed visual verification | Basalt Deltas and Soul Sand Valleys remain unchanged. Data-driven biome modifiers turn Crimson and Warped Forests into sparse fungal deserts without removing either biome: giant-fungus attempts fall from eight per cavern layer to one, ordinary forest scrub from six/five to one, vines from ten to two, and Warped Forest sprouts from four to one. Nylium surfaces, terrain, resources, structures, atmosphere, mobs, and existing chunks remain unchanged. The exact registry contract is GameTest-covered; natural density, sightlines, and transitions need developer approval in fresh chunks across multiple seeds. | ## End And Aender Content Decisions diff --git a/docs/internal/retold_design_risks.md b/docs/internal/retold_design_risks.md index 5414ebfd..f0318bd3 100644 --- a/docs/internal/retold_design_risks.md +++ b/docs/internal/retold_design_risks.md @@ -14,6 +14,7 @@ | End | End City/outer End removal needs fresh-world and existing-world verification. | Data/mixins exist, but generated-world behavior should be tested. | | Elytra | Elytra should remain an item but not be survival-obtainable. | Depends on End Cities not generating and no alternate survival source being added unintentionally. | | Worldgen | Trail Ruins and fossils are confirmed to stay in normal world generation. | Trial Chamber and Ancient City generation are disabled through empty biome tags, and the default Overworld climate preset omits Deep Dark. Their content remains registered. Do not add removal hooks for Trail Ruins or fossils. | +| Nether forests | Sparse vegetation could make Crimson and Warped Forests too empty, reduce renewable wood discoverability, or expose awkward vanilla terrain transitions. | Data-driven modifiers keep both biome identities, nylium surfaces, mobs, structures, ores, springs, small fungi, and rare giant fungi while reducing only placement frequency in newly generated chunks. The exact registry composition is GameTest-covered. Visually verify density, navigation, resource discoverability, biome borders, and cavern-layer behavior across multiple fresh seeds before tuning the confirmed counts. | | Worldgen | Strongholds should be limited to 3. | Not confirmed implemented. | | Mobs | Survival-only mob removals can drift when Minecraft adds or changes acquisition paths. | Sniffer survival acquisition is removed by overriding warm-ocean-ruin archaeology without its egg. Endermite survival spawning is blocked through the normal spawn-position event and the separate direct Ender Pearl creation hook. GameTests cover both policies while command/Creative entities remain functional, and the developer confirmed the Endermite behavior in-game on 2026-08-02. Recompare the archaeology override and audit all Sniffer/Endermite acquisition paths whenever Minecraft changes them. | | Mob AI contract | The confirmed design is substantially broader than the current 83-profile implementation. | Implement in dependency order and keep each family marked partial until its target, hunger, home, stage, territory, movement, and performance behavior is tested. Do not infer completion from the presence of a profile. Ground active-food search, Bat 3D search/detours, exact-species Cod school routing, Villager communal-store routing, Parrot flying forage, Undead-mount combat, cross-family Undead target parity, and the unmanaged Wildfire path have isolated coverage. A synthetic isolated test covers 256 always-ticking managed animals for 200 server ticks and guards AI work budgets/cache use. The latest complete baseline covers 75 tests/375 phases below 50 ms/tick; focused later-profile runs now extend registration through 83 tests/415 phases, but that complete matrix has not been rerun. Natural mixed-hostile populations, multiple players, long sessions, dedicated-server profiler evidence, existing-world behavior, and exhaustive automated per-species path coverage remain unverified. | @@ -49,7 +50,7 @@ | Items | The implemented Flint-through-Diamond spine changes first-spawn assumptions, leaf/bush loot, chest and Villager alternatives, Mending acquisition, Copper distribution, Campfire ignition, recipes, station use, mining speed, Flint/Steel Spears, ten Steel equipment items, animal-armor enchanting, and the effective maximum durability of existing Diamond equipment. Its provisional balance, fuel economy, compatibility, and reused visuals need validation. Later equipment and combat reworks remain planned. | Focused GameTests cover the material ladder through Steel, six-attempt ordinary/large Copper placement across seeded chunk-border origins, scaled leaf/Dead Bush/living-bush Stick rolls and harvest exclusions, Flint/Steel Spear recipes/tags, bonus and Village chest ceilings, mastery/price trade tiers, enchanted trade rarity, Wandering Trader pricing, Mending source tags, Campfire/Brick Furnace boundaries, all twelve enchantable Diamond items including a curse-only durability unlock, and animal-armor enchant effects. Curses intentionally count as enchantments, but revisit that choice if curse acquisition makes full Diamond durability too cheap. Chest/trade/enchantment overrides are global server data and therefore consistent across players, but existing saved Villager offers are deliberately not rerolled and datapacks may replace the same tables/tags. Naturally verify new and existing worlds, Villager leveling/discounts/restocking, multiplayer/dedicated servers, every chest type, fishing and structure Mending absence, bush farming, Spear combat/repair/enchanting, reused visuals, fresh-seed Copper pacing, Campfire visuals/automation, Steel fuel economy, and dynamic Diamond durability. Existing Mending items remain functional, and commands/Creative retain access. Aenderite still provides only an ore/raw/ingot foundation. | | Environment | Death drops should despawn much later than vanilla, not never. | No implementation confirmed. | | Beds | Beds should not skip night; valid daytime bed rest is allowed when night skipping is disabled. | Healing behavior not confirmed implemented. | -| Nether | Nether portal spread is planned as energy drain. | Needs bounded implementation design. | +| Nether portal drain | Permanent corruption around an Overworld portal can surprise builders, damage classified blocks, compete across many loaded portals, or cross a claim boundary. Area scaling allows a sufficiently large portal to reach a 48-block death radius and 24-block corruption radius, greatly increasing candidate count, lava-scan area, and overlap potential. Concurrent fronts can also become visually uneven when the death wave has few eligible blocks but the corruption wave crosses dense stone, while chunk/server reload can temporarily revisit already transformed inner positions. | The implemented stage-independent owner uses separate deterministic nearest-to-farthest death and corruption cursors, slow background work, bounded successful-traveler pulses, loaded-chunk indexing, fair queues, and no forced loading. The standard 2×3 portal remains 16/8; each additional interior block adds one outer-radius block before the 48 cap, and corruption remains at half radius. A compact primitive index avoids a retained `BlockPos` object per candidate. Corruption can advance only when its squared distance is at most one quarter of the inspected death front. Each cursor skips up to 48 ineligible or already transformed positions per work unit and cycles after both fronts reach their edges so later player construction remains eligible. It protects all block entities plus tagged portals, ores, storage blocks, Obsidian, Crying Obsidian, and Retold Aender infrastructure. Explicit semantic palette tags replace the former generic mining/full-block fallbacks: native Nether results are stable and unclassified vanilla or modded blocks remain unchanged unless a datapack opts them into a transformation. A separate fair queue scans no more than two loaded chunks every five ticks for lava sources inside each outer sphere. Its completed snapshot applies exact fractional work credit, pauses initial spread until known, ignores flowing lava, and stops both fronts when sources match or exceed portal blocks without consuming lava. Every individual edit asks the `NETHER_PORTAL_DRAIN` protection category. Focused tests cover area calculations/cap/index extent, the nested-radius invariant, Coarse-Dirt intermediate state, death-wave stone exclusion, explicit stable/change/unchanged mappings, coral death, frozen melting, water and waterlogged evaporation, lava-source boundaries and linear rates, protected valuables, denial, and pulse caps. Naturally verify performance and pacing at several portal areas including the cap, lava placement/removal response time, dense lava fields, many simultaneous portals, two-front readability, dense builds, adjacent/overlapping portals, chunk borders/unloads, save/reload, existing worlds, representative modded blocks/datapacks, real claim adapters, multiplayer, and dedicated servers before tuning cadence or scaling. | ## Watchpoints diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index 468120a6..c09c09dd 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -573,8 +573,10 @@ an implementation claim. The completion matrix below and functional through commands/creative; existing entities and their Retold AI are retained. Wardens/Ancient Cities/Deep Dark and Trial Chambers are removed from survival. Shulkers and End Cities are outside the active mob design because End Cities do not exist in Retold survival. -- Happy Ghasts, ghastlings, and dried ghast corpses do not exist. Only the ordinary hostile Ghast - remains. The Creaking/Pale Garden can stay vanilla for now. +- Dried Ghasts do not generate with Nether fossils. Their crafting recipe, existing and + player-placed blocks, hydration into Ghastlings, and growth into Happy Ghasts remain available; + only the natural Nether acquisition route is removed. The ordinary hostile Ghast remains + unchanged. The Creaking/Pale Garden can stay vanilla for now. - Planned additions retain these identities: Killer Bunny as a rare natural hostile creature; Iceologer as an Illager guarding an isolated igloo with a warning; the original one-block, stackable Mojang Tuff Golem display-statue concept; and the music-disc monster. Fire, Earth, diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md index 432e7fe2..27c1a7fe 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -33,6 +33,8 @@ The core ideas are: - mobs use Retold faction/territory/behavior systems - torch weather and extinguished torch blocks add environmental pressure - client visuals are synchronized for stage, sky, teaching UI, and chronolith beams +- the vanilla Nether uses the Nether Wastes' classic dark-red fog and a Minecraft 1.0-style dark ambient-light floor across all biomes +- Crimson and Warped Forest vegetation is sparsified into open fungal-desert scenery without replacing the biomes, their nylium surfaces, or their gameplay registries ## Design Direction Source @@ -102,6 +104,68 @@ The main event registration is intentionally explicit. When adding a new system, | `worldgen/air` | Air Temple structure, wind zone, Breeze spawning, and Gale Core encounter | | `worldgen/delayed` | stage-delayed structure generation and mob suppression | +`NetherFogColorMixin` is a client-only atmospheric base-color hook. In the vanilla Nether dimension, +it substitutes the Nether Wastes `#330808` fog color before vanilla sky blending, darkness, and +vision adjustments. It does not replace biome definitions and does not affect water, lava, or +powder-snow fog, allowing vanilla and datapack-added Nether biomes to retain their other effects. +`NetherLightmapMixin` complements it by replacing the modern `#302821` Nether ambient-light color +with the approximately 10% neutral `#1A1A1A` floor used by the classic lighting curve. It leaves +block-light values, the brightness option, vision effects, mob spawning, and server lighting rules +unchanged. + +The Crimson and Warped Forest desert treatment is owned by data-driven NeoForge biome modifiers. +They remove only the vanilla dense vegetation placements and add Retold-owned placed features that +reuse the same vanilla configured features at lower frequency. Giant fungi use one attempt per +cavern layer instead of eight; mixed roots and small-fungus scrub uses one instead of six in Crimson +and five in Warped; vines use two attempts instead of ten; and Warped sprouts use one instead of +four. Surface rules, terrain density, ores, springs, structures, biome effects, and spawn lists are +not replaced. The change therefore affects newly generated chunks, while existing vegetation stays +where it was generated. Datapacks can replace or tune the Retold placed features and biome +modifiers without a Java hook. + +Overworld Nether-portal drain is owned by `RetoldNetherPortalDrainEvents`, +`RetoldNetherPortalDrainTransforms`, and the narrow `NetherPortalDrainTravelMixin`. Loaded-chunk and +portal-spawn events index active portal rectangles without forcing terrain to load. Each portal has +a pair of runtime cursors over one immutable candidate list sorted by squared distance. Fair bounded +background work and successful-travel pulses prefer alternating `DRAIN` and `CORRUPT` work. The +death cursor advances toward the portal-specific outer edge and can only remove vegetation or turn +living ground into Coarse Dirt. The outer radius is `min(48, 16 + width * height - 6)`, preserving +16 blocks for the standard six-block interior while counting every additional portal block. The +corruption radius is half, rounded down. Its cursor follows continuously through that inner prefix, but its +next squared distance must be no more than one quarter of the inspected death-front distance. It can +therefore never exceed half the death radius. Corruption applies Nether material mappings or +evaporates water. If newly loaded or placed living ground missed the earlier death cursor, the +corruption resolver makes it Coarse Dirt and defers its Netherrack conversion to a later cycle rather +than bypassing the intermediate state. Both cursors reset only after reaching their respective edges +so later construction remains eligible. +Each portal also maintains a runtime snapshot of loaded lava source blocks throughout its full outer +sphere. One fair queue scans at most two intersecting chunks every five ticks globally and never +loads a chunk. Until the initial snapshot completes, that portal does not advance. Thereafter a +fractional work-credit limiter applies +`max(0, (portal block count - lava source count) / portal block count)` equally to background and +traveler-pulse attempts. Flowing lava is excluded, matching or exceeding the portal block count +halts both fronts, and later snapshots allow the spread to slow, stop, or resume without consuming +the lava. +The shared ordered offsets through radius 48 are stored as packed primitive longs and decoded only +for a bounded probe, avoiding a permanent object allocation for every possible position. +Completed block transformations themselves persist normally, while a reloaded portal cheaply skips +already transformed candidates. The successful +Overworld/Nether transition adds capped work after the original post-teleport callback, so players, +mobs, and items share the same rule. A standard 2×3 portal has a 16-block death sphere and an +eight-block inner corruption zone; additional interior portal blocks expand the outer radius one for +one through 48, and the corruption radius remains half. Explicit semantic block tags select stable +Nether results, disappearing vegetation, small plants, melting blocks, outer ground, Gravel, +Netherrack, Blackstone, Crimson wood, and immunity mappings. There are no generic mining-tool or +full-collision fallbacks, so unmatched blocks remain unchanged. Compatible stairs, slabs, walls, +doors, fences, logs, and similar blocks retain shared state. Living coral becomes its exact dead +variant. Inner water evaporates, including by clearing `WATERLOGGED` without deleting the host +block. No Soul Sand, Soul Soil, fire, magma, or lava is generated. Block entities and +tagged portals, valuables, ores, storage blocks, Obsidian, Crying Obsidian, and Aender infrastructure +are immune. Every actual edit uses the `NETHER_PORTAL_DRAIN` world-protection category immediately +before changing the block. The system is stage-independent and keeps no persistence of its own +because completed transformations are ordinary saved block state; the current scan phase and cursor +are intentionally reconstructed when a portal is indexed again. + The mob AI package is split by behavior ownership: | Behavior package | Purpose | @@ -785,9 +849,10 @@ The context exposes the server level, representative position, immutable inclusi mutation type, optional responsible entity, and optional subject id. Single-block actions use exact one-block bounds; portal and chunk operations expose their complete possible area. Current routed owners are destructive forage and bamboo, weak barriers, Spider webs, Villager torch maintenance, -Gale Core breaking, Aender stale-chunk replacement, Aender portal creation, and delayed -structure retrogen. `RetoldMobGriefing` continues to invoke NeoForge's normal entity-griefing hook -first, then the position-aware Retold rule immediately before destructive or placement actions. +Gale Core breaking, Aender stale-chunk replacement, Aender portal creation, delayed structure +retrogen, and Overworld Nether-portal drain. `RetoldMobGriefing` continues to invoke NeoForge's +normal entity-griefing hook first, then the position-aware Retold rule immediately before destructive +or placement actions. Do not call a claim mod from gameplay classes. Add an optional adapter that translates a `RetoldWorldMutationContext` into that mod's permission query. No adapter may be required for Retold diff --git a/docs/internal/retold_roadmap.md b/docs/internal/retold_roadmap.md index c5bc0f3c..1de95c6f 100644 --- a/docs/internal/retold_roadmap.md +++ b/docs/internal/retold_roadmap.md @@ -26,6 +26,8 @@ Retold is still built around: wider awareness, convergence, imperfect cross-family support, and a modest tagged natural-spawn weight increase under vanilla caps rather than direct stat buffs - survival worldgen/spawn removal for some modern content instead of necessarily deleting all code support +- Crimson and Warped Forests remaining distinct biomes but generating as open fungal deserts, with + colored nylium and rare fungal landmarks instead of dense trees and undergrowth - beds not skipping night - rain extinguishing normal torches @@ -63,7 +65,6 @@ These are still planned but need feature-specific design before implementation: - broader enchanting acquisition changes beyond the implemented removal of Mending from new random loot and Librarian trades - sword/shield combat rework - Stage 3 piglin/pigman hiring or follower behavior -- Nether portal spread as portal energy draining surroundings - longer death-drop despawn timer than vanilla - bed healing that consumes hunger - water torches, glowstone torches, rainbows, pet doors, and glow improvements @@ -259,9 +260,10 @@ This is also a maintenance rule. Native Retold systems move forward with Retold' - If villages maintain roads in the future, connect that to actual village activity rather than generating decorative roads with no simulated cause. - Exact design, block transitions, rates, and whether this becomes a confirmed Retold feature remain undecided until a focused design pass. -- [ ] Keep Nether portal environmental corruption/spread native to Retold. +- [x] Keep Nether portal environmental corruption/spread native to Retold. - It is part of Retold's energy/dimension lore rather than a generic visual effect. - - Let Retold control affected blocks, spread rate, portal-use influence, world-stage interaction, reversibility, and performance limits. + - Implemented as permanent, stage-independent Overworld drain around loaded active portals: a deterministic nearest-to-farthest death front advances toward 16 blocks for a standard 2×3 portal while a material-aware Nether-corruption front follows continuously at no more than half its radius. Each extra interior portal block adds one outer-radius block, capped at 48; the inner radius remains half. Slow background progress and a capped pulse after every successful traveler advance the two fronts. Loaded lava sources throughout the full outer sphere linearly reduce both forms of work, reaching zero when their count matches or exceeds the portal's interior area; flowing lava does not count. + - Retold controls affected and immune blocks through semantic tags, uses only vanilla Coarse Dirt/Netherrack/Blackstone/Crimson targets, never creates Soul Sand, Soul Soil, fire, magma, or lava, and routes every edit through world protection without force-loading chunks. - [ ] Prefer native Retold logic for environmental changes that interact with world state or society. - candidate examples: limited block aging/weathering, vegetation reclaiming abandoned areas, village maintenance/repair, persistent animal traces, dens/nests, and meaningful snow accumulation diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md index 5e1199a8..40bb8904 100644 --- a/docs/internal/testing_strategy.md +++ b/docs/internal/testing_strategy.md @@ -37,6 +37,11 @@ Record the exact commands and results. | Resources, recipes, loot, tags, or profiles | Focused loader/registration test and the affected behavior test when one exists; `./gradlew build` once before handoff | Unrelated gameplay suites | | Worldgen, dimension, networking, persistence, or visuals | Focused automated coverage plus only the relevant manual environments described in `AGENTS.md` | Unrelated mob and TPS suites | +For visual changes, agents run applicable build and technical startup checks but leave subjective +in-game inspection to the developer. Do not drive the game or desktop UI to produce screenshots or +claim visual approval. Record the exact technical checks performed and hand off the remaining +visual scenarios to the developer. + `./gradlew build` runs compilation, JUnit tests, PMD, and assembly; it does not run NeoForge GameTests. It is the normal final code-quality check, not something to repeat after every edit. @@ -428,6 +433,50 @@ modified biome registry and guards the exact biome, smallest-positive weight, so absence from ordinary Nether Wastes. This data-only spawn-list change does not justify a per-mob TPS run; naturally verify spawn frequency and placement in fresh Soul Sand Valley terrain. +For Dried Ghast natural availability, run the exact +`retold:nether_fossils_omit_dried_ghasts` selector. It invokes the vanilla Nether-fossil placement +path at a deterministic successful candidate, verifies that no Dried Ghast is generated, and +confirms that the block itself remains placeable. This narrow worldgen hook does not justify mob or +TPS selectors; naturally verify fresh Nether fossils across multiple seeds. + +For sparse Nether forests, run the exact +`retold:nether_forests_use_sparse_desert_vegetation` selector. It reads the modified biome registry, +verifies that both forests replaced every targeted dense vanilla placement with the Retold sparse +variants, and guards representative retained lava-spring and quartz features. This data-only +vegetation-density change does not justify mob or TPS selectors. The developer should visually +verify Crimson and Warped Forest density, sightlines, transitions, and resource availability in +fresh chunks across multiple seeds; existing generated vegetation should remain unchanged. + +For Overworld Nether-portal drain mappings or protection, run the exact +`retold:nether_portal_drain_maps_materials_and_protects_valuable_blocks` selector. It covers the +required living-ground-to-Coarse-Dirt intermediate state, proves the death phase cannot directly +corrupt stone, proves even living ground first encountered by the corruption resolver cannot skip +Coarse Dirt, then covers final soil/stone/masonry/wood state-preserving mappings, permanent water +evaporation, the valuable/container/portal-frame boundary, and a denying world-protection rule. For +palette membership changes, also run +`retold:nether_portal_drain_uses_explicit_palette`. It guards stable Netherrack, Blackstone, Warped +wood, Soul Soil, and Gravel; representative unchanged crafted/decorative blocks; Sand, Clay, +Sandstone, and brick conversions; frozen-block melting; small-plant and cactus death; exact coral +death; and the different outer/inner handling of waterlogged blocks. For +lava-resistance changes, run +`retold:nether_portal_lava_sources_linearly_resist_spread`. It proves that only source blocks inside +the full affected sphere count—excluding flowing lava and sources outside the radius—and guards +full, five-sixths, half, one-sixth, stopped, and over-saturated linear work rates. For +successful-travel triggering or work caps, run the separate exact +`retold:nether_portal_travel_pulses_are_bounded` selector. When candidate selection or the radial +front changes, run `retold:nether_portal_drain_advances_outward`, which requires every ordered +candidate distance to be monotonically nondecreasing and verifies that the corruption radius can +never exceed half the death radius, including both final 8/16-block shells. Do not substitute a +client wait for the bounded automated checks. When portal-size scaling, portal rectangle detection, +or the maximum offset index changes, run `retold:nether_portal_drain_scales_with_portal_area`; it +guards the standard 2×3 radius, per-block area growth, representative width/height composition, the +48-block cap, and complete maximum-radius index coverage. Naturally verify the +intended 30–45-minute visible outward maturation around outdoor and underground portals, unusual +frame sizes, adjacent portals, chunk borders and unloads, +save/reload, existing worlds, entities/items traveling both directions, real claim adapters, +multiplayer, and dedicated-server behavior. Subjective appearance and pacing approval belongs to +the developer. + For Strider lava sustenance specifically, the natural-food case must also assert that relief does not consume the lava; pair it with `retold:hunger_survival_strider` and `retold:mob_tps_strider`. diff --git a/src/main/java/cz/xefensor/retold/api/world/RetoldWorldMutationType.java b/src/main/java/cz/xefensor/retold/api/world/RetoldWorldMutationType.java index 73e101f5..f7bfd56b 100644 --- a/src/main/java/cz/xefensor/retold/api/world/RetoldWorldMutationType.java +++ b/src/main/java/cz/xefensor/retold/api/world/RetoldWorldMutationType.java @@ -6,6 +6,7 @@ public enum RetoldWorldMutationType { MOB_PLACE, ENTITY_BREAK, PORTAL_CREATE, + NETHER_PORTAL_DRAIN, STRUCTURE_RETROGEN, AENDER_CHUNK_REGENERATE, WORLD_MODIFY diff --git a/src/main/java/cz/xefensor/retold/api/world/RetoldWorldProtection.java b/src/main/java/cz/xefensor/retold/api/world/RetoldWorldProtection.java index dbc24f05..b26edd4b 100644 --- a/src/main/java/cz/xefensor/retold/api/world/RetoldWorldProtection.java +++ b/src/main/java/cz/xefensor/retold/api/world/RetoldWorldProtection.java @@ -140,6 +140,23 @@ public static boolean canCreatePortal( )); } + public static boolean canDrainAroundNetherPortal( + ServerLevel level, + BlockPos pos + ) { + return canModify(new RetoldWorldMutationContext( + level, + pos, + RetoldWorldMutationBounds.single(pos), + RetoldWorldMutationType.NETHER_PORTAL_DRAIN, + null, + Identifier.fromNamespaceAndPath( + Retold.MODID, + "nether_portal_drain" + ) + )); + } + public static boolean canRetrogenStructure( ServerLevel level, ChunkPos chunkPos, diff --git a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java index 5be4ec4f..3f335614 100644 --- a/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java +++ b/src/main/java/cz/xefensor/retold/gametest/RetoldGameTests.java @@ -89,6 +89,7 @@ import cz.xefensor.retold.worldgen.air.RetoldAirTempleDiscoveryGameTests; import cz.xefensor.retold.worldgen.air.RetoldGaleCoreGameTests; import cz.xefensor.retold.worldgen.fire.RetoldWildfireGameTests; +import cz.xefensor.retold.worldgen.portal.RetoldNetherPortalDrainGameTests; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.Holder; @@ -311,6 +312,7 @@ public static void register(RegisterGameTestsEvent event) { RetoldWorldProtectionGameTests.register(event, environment); RetoldMobAvailabilityGameTests.register(event, environment); RetoldNetherMobSpawnGameTests.register(event); + RetoldNetherPortalDrainGameTests.register(event, environment); RetoldAiPerformanceGameTests.register(event); RetoldPerMobTpsGameTests.register(event); RetoldAiSightCacheGameTests.register(event, environment); diff --git a/src/main/java/cz/xefensor/retold/mixin/NetherFogColorMixin.java b/src/main/java/cz/xefensor/retold/mixin/NetherFogColorMixin.java new file mode 100644 index 00000000..e7e20a23 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/NetherFogColorMixin.java @@ -0,0 +1,33 @@ +package cz.xefensor.retold.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.fog.environment.AtmosphericFogEnvironment; +import net.minecraft.world.level.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(AtmosphericFogEnvironment.class) +public abstract class NetherFogColorMixin { + private static final int NETHER_WASTES_FOG_COLOR = 0x330808; + + @ModifyExpressionValue( + method = "getBaseColor", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/attribute/EnvironmentAttributeProbe;getValue(Lnet/minecraft/world/attribute/EnvironmentAttribute;F)Ljava/lang/Object;", + ordinal = 0 + ) + ) + private Object retold$useClassicNetherFogColor( + Object original, + @Local(argsOnly = true) ClientLevel level + ) { + if (level.dimension() == Level.NETHER) { + return NETHER_WASTES_FOG_COLOR; + } + + return original; + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/NetherFossilDriedGhastMixin.java b/src/main/java/cz/xefensor/retold/mixin/NetherFossilDriedGhastMixin.java new file mode 100644 index 00000000..fa07a753 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/NetherFossilDriedGhastMixin.java @@ -0,0 +1,24 @@ +package cz.xefensor.retold.mixin; + +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.structures.NetherFossilPieces; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(NetherFossilPieces.NetherFossilPiece.class) +public abstract class NetherFossilDriedGhastMixin { + @Inject(method = "placeDriedGhast", at = @At("HEAD"), cancellable = true) + private void retold$omitNaturalDriedGhast( + WorldGenLevel level, + RandomSource random, + BoundingBox fossilBoundingBox, + BoundingBox chunkBoundingBox, + CallbackInfo ci + ) { + ci.cancel(); + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/NetherFossilPieceInvoker.java b/src/main/java/cz/xefensor/retold/mixin/NetherFossilPieceInvoker.java new file mode 100644 index 00000000..6ddee2d4 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/NetherFossilPieceInvoker.java @@ -0,0 +1,19 @@ +package cz.xefensor.retold.mixin; + +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.structures.NetherFossilPieces; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +@Mixin(NetherFossilPieces.NetherFossilPiece.class) +public interface NetherFossilPieceInvoker { + @Invoker("placeDriedGhast") + void retold$invokePlaceDriedGhast( + WorldGenLevel level, + RandomSource random, + BoundingBox fossilBoundingBox, + BoundingBox chunkBoundingBox + ); +} diff --git a/src/main/java/cz/xefensor/retold/mixin/NetherLightmapMixin.java b/src/main/java/cz/xefensor/retold/mixin/NetherLightmapMixin.java new file mode 100644 index 00000000..749c5c7d --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/NetherLightmapMixin.java @@ -0,0 +1,33 @@ +package cz.xefensor.retold.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LightmapRenderStateExtractor; +import net.minecraft.world.level.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +@Mixin(LightmapRenderStateExtractor.class) +public abstract class NetherLightmapMixin { + private static final int CLASSIC_NETHER_AMBIENT_COLOR = 0x1A1A1A; + + @ModifyExpressionValue( + method = "extract", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/world/attribute/EnvironmentAttributeProbe;getValue(Lnet/minecraft/world/attribute/EnvironmentAttribute;F)Ljava/lang/Object;", + ordinal = 3 + ) + ) + private Object retold$useClassicNetherAmbientLight( + Object original, + @Local ClientLevel level + ) { + if (level.dimension() == Level.NETHER) { + return CLASSIC_NETHER_AMBIENT_COLOR; + } + + return original; + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/NetherPortalDrainTravelMixin.java b/src/main/java/cz/xefensor/retold/mixin/NetherPortalDrainTravelMixin.java new file mode 100644 index 00000000..6d8174e8 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/NetherPortalDrainTravelMixin.java @@ -0,0 +1,34 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.worldgen.portal.RetoldNetherPortalDrainTravel; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.NetherPortalBlock; +import net.minecraft.world.level.portal.TeleportTransition; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(NetherPortalBlock.class) +public abstract class NetherPortalDrainTravelMixin { + @Inject(method = "getPortalDestination", at = @At("RETURN"), cancellable = true) + private void retold$attachSuccessfulPortalDrainPulse( + ServerLevel currentLevel, + Entity entity, + BlockPos portalEntryPos, + CallbackInfoReturnable cir + ) { + TeleportTransition transition = cir.getReturnValue(); + if (transition != null) { + cir.setReturnValue( + RetoldNetherPortalDrainTravel.attachSuccessfulTravelPulse( + currentLevel, + portalEntryPos, + transition + ) + ); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java index 987d5460..5fb2947b 100644 --- a/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java +++ b/src/main/java/cz/xefensor/retold/module/RetoldWorldgenModule.java @@ -7,6 +7,7 @@ import cz.xefensor.retold.worldgen.air.wind.AirTempleWindEvents; import cz.xefensor.retold.worldgen.fire.RetoldRemnantFireDamage; import cz.xefensor.retold.worldgen.fire.WildfireSpawnEvents; +import cz.xefensor.retold.worldgen.portal.RetoldNetherPortalDrainEvents; import cz.xefensor.retold.worldgen.delayed.RetoldAttachments; import cz.xefensor.retold.worldgen.delayed.RetoldChunkEditEvents; import cz.xefensor.retold.worldgen.delayed.RetoldClientChunkTracker; @@ -31,6 +32,7 @@ public static void registerGameBus(IEventBus gameEventBus) { gameEventBus.register(GaleCoreAttackEvents.class); gameEventBus.register(RetoldRemnantFireDamage.class); gameEventBus.register(WildfireSpawnEvents.class); + gameEventBus.register(RetoldNetherPortalDrainEvents.class); gameEventBus.register(RetoldChunkEditEvents.class); gameEventBus.register(RetoldDelayedStructureRetrogen.class); gameEventBus.register(RetoldDelayedStructureMobBlocker.class); diff --git a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java index 77ef1f34..bd50d327 100644 --- a/src/main/java/cz/xefensor/retold/registry/RetoldTags.java +++ b/src/main/java/cz/xefensor/retold/registry/RetoldTags.java @@ -178,6 +178,36 @@ public final class RetoldTags { public static final TagKey OCEAN_MONUMENT_PROTECTED_BLOCKS = blockTag( "ocean_monument_protected_blocks" ); + public static final TagKey NETHER_PORTAL_DRAIN_IMMUNE = blockTag( + "nether_portal_drain/immune" + ); + public static final TagKey NETHER_PORTAL_DRAIN_STABLE = blockTag( + "nether_portal_drain/stable" + ); + public static final TagKey NETHER_PORTAL_DRAIN_OUTER_GROUND = blockTag( + "nether_portal_drain/outer_ground" + ); + public static final TagKey NETHER_PORTAL_DRAIN_VEGETATION = blockTag( + "nether_portal_drain/vegetation" + ); + public static final TagKey NETHER_PORTAL_DRAIN_TO_DEAD_BUSH = blockTag( + "nether_portal_drain/to_dead_bush" + ); + public static final TagKey NETHER_PORTAL_DRAIN_MELTS = blockTag( + "nether_portal_drain/melts" + ); + public static final TagKey NETHER_PORTAL_DRAIN_TO_GRAVEL = blockTag( + "nether_portal_drain/to_gravel" + ); + public static final TagKey NETHER_PORTAL_DRAIN_TO_NETHERRACK = blockTag( + "nether_portal_drain/to_netherrack" + ); + public static final TagKey NETHER_PORTAL_DRAIN_TO_BLACKSTONE = blockTag( + "nether_portal_drain/to_blackstone" + ); + public static final TagKey NETHER_PORTAL_DRAIN_TO_CRIMSON_WOOD = blockTag( + "nether_portal_drain/to_crimson_wood" + ); public static final TagKey> AUTOMATIC_BREEDERS = TagKey.create( Registries.ENTITY_TYPE, diff --git a/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java index 03cd1e04..ee4809ce 100644 --- a/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java +++ b/src/main/java/cz/xefensor/retold/worldgen/RetoldNetherMobSpawnGameTests.java @@ -1,6 +1,8 @@ package cz.xefensor.retold.worldgen; import cz.xefensor.retold.Retold; +import cz.xefensor.retold.mixin.NetherFossilPieceInvoker; +import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.registries.Registries; import net.minecraft.gametest.framework.BuiltinTestFunctions; @@ -9,18 +11,30 @@ import net.minecraft.gametest.framework.TestData; import net.minecraft.gametest.framework.TestEnvironmentDefinition; import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; import net.minecraft.world.entity.EntityTypes; import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biomes; import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.structures.NetherFossilPieces; import net.neoforged.neoforge.event.RegisterGameTestsEvent; +import java.util.Collections; +import java.util.List; +import java.util.Set; import java.util.function.Consumer; +import java.util.stream.Collectors; public final class RetoldNetherMobSpawnGameTests { private static final Identifier EMPTY_STRUCTURE = Identifier.withDefaultNamespace("empty"); + private static final Identifier NETHER_FOSSIL_TEMPLATE = + Identifier.withDefaultNamespace("nether_fossils/fossil_1"); private RetoldNetherMobSpawnGameTests() { } @@ -37,6 +51,198 @@ public static void register(RegisterGameTestsEvent event) { "wither_skeletons_spawn_rarely_in_soul_sand_valleys", RetoldNetherMobSpawnGameTests::witherSkeletonsSpawnRarelyInSoulSandValleys ); + registerTest( + event, + environment, + "nether_fossils_omit_dried_ghasts", + RetoldNetherMobSpawnGameTests::netherFossilsOmitDriedGhasts + ); + registerTest( + event, + environment, + "nether_forests_use_sparse_desert_vegetation", + RetoldNetherMobSpawnGameTests::netherForestsUseSparseDesertVegetation + ); + } + + private static void netherForestsUseSparseDesertVegetation(GameTestHelper helper) { + var biomes = helper.getLevel() + .registryAccess() + .lookupOrThrow(Registries.BIOME); + Set crimsonFeatures = featureIds( + biomes.getValueOrThrow(Biomes.CRIMSON_FOREST) + ); + Set warpedFeatures = featureIds( + biomes.getValueOrThrow(Biomes.WARPED_FOREST) + ); + + assertFeaturesAbsent( + helper, + crimsonFeatures, + "Crimson Forest", + List.of( + vanillaId("crimson_fungi"), + vanillaId("crimson_forest_vegetation"), + vanillaId("weeping_vines") + ) + ); + assertFeaturesPresent( + helper, + crimsonFeatures, + "Crimson Forest", + List.of( + id("sparse_crimson_fungi"), + id("sparse_crimson_scrub"), + id("sparse_weeping_vines"), + vanillaId("ore_quartz_nether"), + vanillaId("spring_lava") + ) + ); + + assertFeaturesAbsent( + helper, + warpedFeatures, + "Warped Forest", + List.of( + vanillaId("warped_fungi"), + vanillaId("warped_forest_vegetation"), + vanillaId("nether_sprouts"), + vanillaId("twisting_vines") + ) + ); + assertFeaturesPresent( + helper, + warpedFeatures, + "Warped Forest", + List.of( + id("sparse_warped_fungi"), + id("sparse_warped_scrub"), + id("sparse_nether_sprouts"), + id("sparse_twisting_vines"), + vanillaId("ore_quartz_nether"), + vanillaId("spring_lava") + ) + ); + + Set sparseForestFeatures = Set.of( + id("sparse_crimson_fungi"), + id("sparse_crimson_scrub"), + id("sparse_weeping_vines"), + id("sparse_warped_fungi"), + id("sparse_warped_scrub"), + id("sparse_nether_sprouts"), + id("sparse_twisting_vines") + ); + for (Biome unchangedBiome : List.of( + biomes.getValueOrThrow(Biomes.NETHER_WASTES), + biomes.getValueOrThrow(Biomes.SOUL_SAND_VALLEY), + biomes.getValueOrThrow(Biomes.BASALT_DELTAS) + )) { + helper.assertTrue( + Collections.disjoint(featureIds(unchangedBiome), sparseForestFeatures), + "Sparse forest vegetation must not leak into other Nether biomes" + ); + } + helper.succeed(); + } + + private static Set featureIds(Biome biome) { + return biome.getGenerationSettings() + .features() + .stream() + .flatMap(holderSet -> holderSet.stream()) + .flatMap(feature -> feature.unwrapKey().stream()) + .map(key -> key.identifier()) + .collect(Collectors.toSet()); + } + + private static void assertFeaturesPresent( + GameTestHelper helper, + Set actual, + String biomeName, + List expected + ) { + for (Identifier feature : expected) { + helper.assertTrue( + actual.contains(feature), + biomeName + " must retain or add " + feature + ); + } + } + + private static void assertFeaturesAbsent( + GameTestHelper helper, + Set actual, + String biomeName, + List removed + ) { + for (Identifier feature : removed) { + helper.assertTrue( + !actual.contains(feature), + biomeName + " must replace dense vegetation feature " + feature + ); + } + } + + private static void netherFossilsOmitDriedGhasts(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos candidate = findDriedGhastCandidate(helper, level); + BoundingBox candidateBox = new BoundingBox( + candidate.getX(), + candidate.getY(), + candidate.getZ(), + candidate.getX(), + candidate.getY(), + candidate.getZ() + ); + NetherFossilPieces.NetherFossilPiece piece = + new NetherFossilPieces.NetherFossilPiece( + level.getStructureManager(), + NETHER_FOSSIL_TEMPLATE, + candidate, + Rotation.NONE + ); + + level.setBlock(candidate, Blocks.AIR.defaultBlockState(), 3); + ((NetherFossilPieceInvoker) piece).retold$invokePlaceDriedGhast( + level, + RandomSource.create(0L), + candidateBox, + candidateBox + ); + + helper.assertTrue( + level.getBlockState(candidate).isAir(), + "Nether fossil generation must not place a Dried Ghast" + ); + + BlockPos retainedBlock = candidate.above(); + level.setBlock(retainedBlock, Blocks.DRIED_GHAST.defaultBlockState(), 3); + helper.assertTrue( + level.getBlockState(retainedBlock).is(Blocks.DRIED_GHAST), + "Dried Ghasts must remain available outside natural fossil generation" + ); + helper.succeed(); + } + + private static BlockPos findDriedGhastCandidate( + GameTestHelper helper, + ServerLevel level + ) { + for (int offset = 0; offset < 64; offset++) { + BlockPos candidate = helper.absolutePos(new BlockPos(1, 2 + offset, 1)); + RandomSource positionalRandom = RandomSource + .createThreadLocalInstance(level.getSeed()) + .forkPositional() + .at(candidate); + + if (positionalRandom.nextFloat() < 0.5F) { + return candidate; + } + } + + helper.fail("Could not find a deterministic Dried Ghast placement candidate"); + return BlockPos.ZERO; } private static void witherSkeletonsSpawnRarelyInSoulSandValleys( @@ -101,6 +307,10 @@ private static Identifier id(String path) { return Identifier.fromNamespaceAndPath(Retold.MODID, path); } + private static Identifier vanillaId(String path) { + return Identifier.withDefaultNamespace(path); + } + private static final class InlineGameTest extends FunctionGameTestInstance { private final Consumer test; diff --git a/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainEvents.java b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainEvents.java new file mode 100644 index 00000000..da98b7f0 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainEvents.java @@ -0,0 +1,1012 @@ +package cz.xefensor.retold.worldgen.portal; + +import cz.xefensor.retold.api.world.RetoldWorldProtection; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.util.BlockUtil; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.NetherPortalBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.level.BlockEvent; +import net.neoforged.neoforge.event.level.ChunkEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; +import net.neoforged.neoforge.event.tick.LevelTickEvent; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** Bounded loaded-chunk owner for permanent Overworld Nether-portal drain. */ +public final class RetoldNetherPortalDrainEvents { + static final int OUTER_RADIUS = 16; + static final int INNER_RADIUS = 8; + static final int MAX_OUTER_RADIUS = 48; + static final int PULSE_CHANGES_PER_TRAVEL = 12; + static final int MAX_QUEUED_PULSE_CHANGES = 96; + + private static final int BASE_PORTAL_AREA = 6; + private static final int BACKGROUND_INTERVAL_TICKS = 40; + private static final int MAX_CHUNK_INDEXES_PER_TICK = 2; + private static final int MAX_BACKGROUND_PORTALS_PER_INTERVAL = 16; + private static final int MAX_PULSE_CHANGES_PER_TICK = 8; + private static final int MAX_PULSE_CHANGES_PER_PORTAL_TICK = 2; + private static final int LAVA_SCAN_INTERVAL_TICKS = 5; + private static final int MAX_LAVA_CHUNKS_PER_SCAN_TICK = 2; + private static final int MAX_ORDERED_PROBES_PER_CHANGE = 48; + private static final int MAX_PORTAL_SIZE = 21; + private static final int OFFSET_COORDINATE_BITS = 7; + private static final int OFFSET_COORDINATE_MASK = + (1 << OFFSET_COORDINATE_BITS) - 1; + private static final int OFFSET_COORDINATE_KEY_BITS = + OFFSET_COORDINATE_BITS * 3; + private static final long[] ORDERED_DRAIN_OFFSETS = + createOrderedDrainOffsets(); + + private static final Map, PortalTracker> TRACKERS = + new HashMap<>(); + + private RetoldNetherPortalDrainEvents() { + } + + @SubscribeEvent + public static void onPortalSpawn(BlockEvent.PortalSpawnEvent event) { + if (event.getLevel() instanceof ServerLevel level + && level.dimension() == Level.OVERWORLD) { + tracker(level).pendingChunks.add(chunkKey(event.getPos())); + } + } + + @SubscribeEvent + public static void onChunkLoad(ChunkEvent.Load event) { + if (event.getLevel() instanceof ServerLevel level + && level.dimension() == Level.OVERWORLD) { + tracker(level).pendingChunks.add(event.getChunk().getPos().pack()); + } + } + + @SubscribeEvent + public static void onChunkUnload(ChunkEvent.Unload event) { + if (!(event.getLevel() instanceof ServerLevel level) + || level.dimension() != Level.OVERWORLD) { + return; + } + + PortalTracker tracker = TRACKERS.get(level.dimension()); + if (tracker == null) { + return; + } + + long unloadedChunk = event.getChunk().getPos().pack(); + tracker.pendingChunks.remove(unloadedChunk); + + Iterator> iterator = + tracker.portals.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (ChunkPos.containing(entry.getValue().minCorner()).pack() + == unloadedChunk) { + iterator.remove(); + tracker.pulseWork.remove(entry.getKey()); + tracker.queuedPulses.remove(entry.getKey()); + tracker.progress.remove(entry.getKey()); + tracker.lavaResistance.remove(entry.getKey()); + tracker.queuedLavaScans.remove(entry.getKey()); + tracker.lavaScanQueue.removeIf( + candidate -> candidate.equals(entry.getKey()) + ); + } + } + } + + @SubscribeEvent + public static void onLevelTick(LevelTickEvent.Post event) { + if (!(event.getLevel() instanceof ServerLevel level) + || level.dimension() != Level.OVERWORLD) { + return; + } + + PortalTracker tracker = TRACKERS.get(level.dimension()); + if (tracker == null) { + return; + } + + indexPendingChunks(level, tracker); + if (level.getGameTime() % LAVA_SCAN_INTERVAL_TICKS == 0L) { + processLavaResistanceScans(level, tracker); + } + processPulseWork(level, tracker); + + if (level.getGameTime() % BACKGROUND_INTERVAL_TICKS == 0L) { + processBackgroundDrain(level, tracker); + } + } + + @SubscribeEvent + public static void onServerStopped(ServerStoppedEvent event) { + TRACKERS.clear(); + } + + public static void queueTravelPulse(ServerLevel level, BlockPos portalPos) { + if (level.dimension() != Level.OVERWORLD) { + return; + } + + PortalRecord record = registerPortalNear(level, portalPos); + if (record == null) { + tracker(level).pendingChunks.add(chunkKey(portalPos)); + return; + } + + PortalTracker tracker = tracker(level); + long key = record.minCorner().asLong(); + int pending = tracker.pulseWork.getOrDefault(key, 0); + tracker.pulseWork.put( + key, + Math.min( + MAX_QUEUED_PULSE_CHANGES, + pending + PULSE_CHANGES_PER_TRAVEL + ) + ); + + if (tracker.queuedPulses.add(key)) { + tracker.pulseQueue.addLast(key); + } + } + + static boolean drainAt( + ServerLevel level, + BlockPos portalCenter, + BlockPos target + ) { + return transformAt( + level, + portalCenter, + target, + DrainPhase.DRAIN, + OUTER_RADIUS + ); + } + + static boolean corruptAt( + ServerLevel level, + BlockPos portalCenter, + BlockPos target + ) { + return transformAt( + level, + portalCenter, + target, + DrainPhase.CORRUPT, + INNER_RADIUS + ); + } + + private static boolean transformAt( + ServerLevel level, + BlockPos portalCenter, + BlockPos target, + DrainPhase phase, + int radius + ) { + int dx = target.getX() - portalCenter.getX(); + int dy = target.getY() - portalCenter.getY(); + int dz = target.getZ() - portalCenter.getZ(); + int distanceSquared = dx * dx + dy * dy + dz * dz; + if (distanceSquared == 0 + || distanceSquared > radius * radius + || level.getChunkSource().getChunkNow( + target.getX() >> 4, + target.getZ() >> 4 + ) == null) { + return false; + } + + BlockState source = level.getBlockState(target); + BlockState transformed = phase == DrainPhase.DRAIN + ? RetoldNetherPortalDrainTransforms.resolveDrain( + level, + target, + source + ) + : RetoldNetherPortalDrainTransforms.resolveCorruption( + level, + target, + source + ); + + if (transformed == null + || transformed == source + || !RetoldWorldProtection.canDrainAroundNetherPortal( + level, + target + )) { + return false; + } + + return level.setBlock(target, transformed, Block.UPDATE_ALL); + } + + static int pendingPulseWorkForTest(ServerLevel level, BlockPos portalPos) { + PortalRecord record = resolvePortal(level, portalPos); + if (record == null) { + return 0; + } + + PortalTracker tracker = TRACKERS.get(level.dimension()); + return tracker == null + ? 0 + : tracker.pulseWork.getOrDefault(record.minCorner().asLong(), 0); + } + + static void clearForTests() { + TRACKERS.clear(); + } + + private static void indexPendingChunks( + ServerLevel level, + PortalTracker tracker + ) { + Iterator iterator = tracker.pendingChunks.iterator(); + int indexed = 0; + + while (iterator.hasNext() && indexed < MAX_CHUNK_INDEXES_PER_TICK) { + long packedChunk = iterator.next(); + iterator.remove(); + ChunkPos chunkPos = ChunkPos.unpack(packedChunk); + LevelChunk chunk = level.getChunkSource().getChunkNow( + chunkPos.x(), + chunkPos.z() + ); + + if (chunk == null) { + continue; + } + + Set coveredPortalBlocks = new HashSet<>(); + chunk.findBlocks( + state -> state.is(Blocks.NETHER_PORTAL), + (pos, state) -> { + if (coveredPortalBlocks.contains(pos.asLong())) { + return; + } + + PortalRecord record = registerPortalAt(level, pos); + if (record != null) { + record.addPortalBlocksTo(coveredPortalBlocks); + } + } + ); + indexed++; + } + } + + private static void processBackgroundDrain( + ServerLevel level, + PortalTracker tracker + ) { + int visits = Math.min( + MAX_BACKGROUND_PORTALS_PER_INTERVAL, + tracker.backgroundQueue.size() + ); + + for (int visit = 0; visit < visits; visit++) { + Long key = tracker.backgroundQueue.removeFirst(); + PortalRecord record = tracker.portals.get(key); + + if (record == null || !record.isActive(level)) { + removePortal(tracker, key); + continue; + } + + tryNextOutwardTransformation(level, tracker, key, record); + tracker.backgroundQueue.addLast(key); + } + } + + private static void processLavaResistanceScans( + ServerLevel level, + PortalTracker tracker + ) { + int visits = Math.min( + MAX_LAVA_CHUNKS_PER_SCAN_TICK, + tracker.lavaScanQueue.size() + ); + + for (int visit = 0; visit < visits; visit++) { + Long key = tracker.lavaScanQueue.removeFirst(); + tracker.queuedLavaScans.remove(key); + PortalRecord record = tracker.portals.get(key); + PortalResistance resistance = tracker.lavaResistance.get(key); + + if (record == null + || resistance == null + || !record.isActive(level)) { + removePortal(tracker, key); + continue; + } + + resistance.scanNextChunk(level); + queueLavaScan(tracker, key); + } + } + + private static void processPulseWork( + ServerLevel level, + PortalTracker tracker + ) { + int remainingBudget = MAX_PULSE_CHANGES_PER_TICK; + int visits = tracker.pulseQueue.size(); + + while (remainingBudget > 0 + && visits-- > 0 + && !tracker.pulseQueue.isEmpty()) { + Long key = tracker.pulseQueue.removeFirst(); + tracker.queuedPulses.remove(key); + PortalRecord record = tracker.portals.get(key); + int work = tracker.pulseWork.getOrDefault(key, 0); + + if (record == null || work <= 0 || !record.isActive(level)) { + removePortal(tracker, key); + continue; + } + + PortalResistance resistance = tracker.lavaResistance.get(key); + if (resistance == null || !resistance.hasSnapshot()) { + tracker.pulseQueue.addLast(key); + tracker.queuedPulses.add(key); + continue; + } + + int attempts = Math.min( + Math.min(work, MAX_PULSE_CHANGES_PER_PORTAL_TICK), + remainingBudget + ); + for (int attempt = 0; attempt < attempts; attempt++) { + tryNextOutwardTransformation(level, tracker, key, record); + } + + remainingBudget -= attempts; + work -= attempts; + if (work > 0) { + tracker.pulseWork.put(key, work); + tracker.pulseQueue.addLast(key); + tracker.queuedPulses.add(key); + } else { + tracker.pulseWork.remove(key); + } + } + } + + private static boolean tryNextOutwardTransformation( + ServerLevel level, + PortalTracker tracker, + long portalKey, + PortalRecord portal + ) { + PortalResistance resistance = tracker.lavaResistance.get(portalKey); + if (resistance == null + || !resistance.allowsSpread(portal.area())) { + return false; + } + + BlockPos center = portal.center(); + PortalProgress progress = tracker.progress.computeIfAbsent( + portalKey, + ignored -> new PortalProgress(portal) + ); + progress.resetIfComplete(); + DrainPhase phase = progress.nextPhase(); + + for (int probe = 0; probe < MAX_ORDERED_PROBES_PER_CHANGE; probe++) { + if (!progress.hasCandidate(phase)) { + break; + } + + BlockPos target = targetAt( + center, + progress.nextOffsetIndex(phase) + ); + if (target.getY() < level.getMinY() + || target.getY() >= level.getMaxY()) { + continue; + } + + boolean transformed = phase == DrainPhase.DRAIN + ? transformAt( + level, + center, + target, + phase, + portal.outerRadius() + ) + : transformAt( + level, + center, + target, + phase, + portal.innerRadius() + ); + if (transformed) { + progress.completeTurn(phase); + return true; + } + } + + progress.completeTurn(phase); + return false; + } + + static int offsetCountForRadiusForTest(int radius) { + return countOffsetsWithin(radius); + } + + static int offsetDistanceSquaredForTest(int index) { + return distanceSquaredAt(index); + } + + static int outerRadiusForPortalForTest(int width, int height) { + return outerRadiusForPortal(width, height); + } + + static boolean corruptionFitsBehindDeathFrontForTest( + int deathDistanceSquared, + int corruptionDistanceSquared + ) { + return corruptionFitsBehindDeathFront( + deathDistanceSquared, + corruptionDistanceSquared + ); + } + + static int lavaSourcesInAffectedAreaForTest( + ServerLevel level, + BlockPos center, + int radius + ) { + int minChunkX = (center.getX() - radius) >> 4; + int maxChunkX = (center.getX() + radius) >> 4; + int minChunkZ = (center.getZ() - radius) >> 4; + int maxChunkZ = (center.getZ() + radius) >> 4; + int count = 0; + + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + count += countLavaSourcesInChunk( + level, + center, + radius, + chunkX, + chunkZ + ); + } + } + + return count; + } + + static int allowedSpreadAttemptsForTest( + int portalArea, + int lavaSources, + int attempts + ) { + LinearSpreadLimiter limiter = new LinearSpreadLimiter(); + int allowed = 0; + + for (int attempt = 0; attempt < attempts; attempt++) { + if (limiter.allows(portalArea, lavaSources)) { + allowed++; + } + } + + return allowed; + } + + private static long[] createOrderedDrainOffsets() { + int maximumDistanceSquared = MAX_OUTER_RADIUS * MAX_OUTER_RADIUS; + int[] distanceCounts = new int[maximumDistanceSquared + 1]; + + for (int y = -MAX_OUTER_RADIUS; y <= MAX_OUTER_RADIUS; y++) { + for (int z = -MAX_OUTER_RADIUS; z <= MAX_OUTER_RADIUS; z++) { + for (int x = -MAX_OUTER_RADIUS; x <= MAX_OUTER_RADIUS; x++) { + int distanceSquared = x * x + y * y + z * z; + if (distanceSquared > 0 + && distanceSquared <= maximumDistanceSquared) { + distanceCounts[distanceSquared]++; + } + } + } + } + + int totalOffsets = 0; + int[] nextIndex = new int[distanceCounts.length]; + for (int distanceSquared = 1; + distanceSquared < distanceCounts.length; + distanceSquared++) { + nextIndex[distanceSquared] = totalOffsets; + totalOffsets += distanceCounts[distanceSquared]; + } + + long[] ordered = new long[totalOffsets]; + for (int y = -MAX_OUTER_RADIUS; y <= MAX_OUTER_RADIUS; y++) { + for (int z = -MAX_OUTER_RADIUS; z <= MAX_OUTER_RADIUS; z++) { + for (int x = -MAX_OUTER_RADIUS; x <= MAX_OUTER_RADIUS; x++) { + int distanceSquared = x * x + y * y + z * z; + if (distanceSquared > 0 + && distanceSquared <= maximumDistanceSquared) { + ordered[nextIndex[distanceSquared]++] = packOffset( + x, + y, + z, + distanceSquared + ); + } + } + } + } + + return ordered; + } + + private static long packOffset( + int x, + int y, + int z, + int distanceSquared + ) { + long coordinateKey = (long) (y + MAX_OUTER_RADIUS) + << OFFSET_COORDINATE_BITS * 2 + | (long) (z + MAX_OUTER_RADIUS) + << OFFSET_COORDINATE_BITS + | x + MAX_OUTER_RADIUS; + return (long) distanceSquared << OFFSET_COORDINATE_KEY_BITS + | coordinateKey; + } + + private static BlockPos targetAt(BlockPos center, int index) { + long packed = ORDERED_DRAIN_OFFSETS[index]; + int x = (int) (packed & OFFSET_COORDINATE_MASK) + - MAX_OUTER_RADIUS; + int z = (int) (packed >> OFFSET_COORDINATE_BITS + & OFFSET_COORDINATE_MASK) - MAX_OUTER_RADIUS; + int y = (int) (packed >> OFFSET_COORDINATE_BITS * 2 + & OFFSET_COORDINATE_MASK) - MAX_OUTER_RADIUS; + return center.offset(x, y, z); + } + + private static int distanceSquaredAt(int index) { + return (int) (ORDERED_DRAIN_OFFSETS[index] + >> OFFSET_COORDINATE_KEY_BITS); + } + + private static int countOffsetsWithin(int radius) { + int low = 0; + int high = ORDERED_DRAIN_OFFSETS.length; + int maximumDistanceSquared = radius * radius; + + while (low < high) { + int middle = (low + high) >>> 1; + if (distanceSquaredAt(middle) <= maximumDistanceSquared) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; + } + + private static int outerRadiusForPortal(int width, int height) { + int area = width * height; + return Math.min( + MAX_OUTER_RADIUS, + OUTER_RADIUS + Math.max(0, area - BASE_PORTAL_AREA) + ); + } + + private static boolean corruptionFitsBehindDeathFront( + int deathDistanceSquared, + int corruptionDistanceSquared + ) { + return corruptionDistanceSquared * 4 <= deathDistanceSquared; + } + + private static int countLavaSourcesInChunk( + ServerLevel level, + BlockPos center, + int radius, + int chunkX, + int chunkZ + ) { + LevelChunk chunk = level.getChunkSource().getChunkNow(chunkX, chunkZ); + if (chunk == null) { + return 0; + } + + int[] count = {0}; + int radiusSquared = radius * radius; + chunk.findBlocks( + state -> state.getFluidState().is(FluidTags.LAVA) + && state.getFluidState().isSource(), + (pos, state) -> { + int dx = pos.getX() - center.getX(); + int dy = pos.getY() - center.getY(); + int dz = pos.getZ() - center.getZ(); + if (dx * dx + dy * dy + dz * dz <= radiusSquared) { + count[0]++; + } + } + ); + return count[0]; + } + + private static @Nullable PortalRecord registerPortalNear( + ServerLevel level, + BlockPos pos + ) { + PortalRecord direct = registerPortalAt(level, pos); + if (direct != null) { + return direct; + } + + for (BlockPos candidate : BlockPos.withinManhattan(pos, 2, 2, 2)) { + if (level.getBlockState(candidate).is(Blocks.NETHER_PORTAL)) { + return registerPortalAt(level, candidate); + } + } + + return null; + } + + private static @Nullable PortalRecord registerPortalAt( + ServerLevel level, + BlockPos portalPos + ) { + PortalRecord record = resolvePortal(level, portalPos); + if (record == null) { + return null; + } + + PortalTracker tracker = tracker(level); + long key = record.minCorner().asLong(); + PortalRecord existing = tracker.portals.get(key); + if (existing == null) { + tracker.portals.put(key, record); + tracker.backgroundQueue.addLast(key); + tracker.progress.put(key, new PortalProgress(record)); + tracker.lavaResistance.put(key, new PortalResistance(record)); + queueLavaScan(tracker, key); + } else if (!existing.equals(record)) { + tracker.portals.put(key, record); + tracker.progress.put(key, new PortalProgress(record)); + tracker.lavaResistance.put(key, new PortalResistance(record)); + queueLavaScan(tracker, key); + } + + return tracker.portals.get(key); + } + + private static @Nullable PortalRecord resolvePortal( + ServerLevel level, + BlockPos portalPos + ) { + BlockState portalState = level.getBlockState(portalPos); + if (!portalState.is(Blocks.NETHER_PORTAL)) { + return null; + } + + Direction.Axis axis = portalState.getValue(NetherPortalBlock.AXIS); + if (!portalSearchAreaLoaded(level, portalPos, axis)) { + return null; + } + + BlockUtil.FoundRectangle rectangle = BlockUtil.getLargestRectangleAround( + portalPos, + axis, + MAX_PORTAL_SIZE, + Direction.Axis.Y, + MAX_PORTAL_SIZE, + candidate -> level.getBlockState(candidate) + .getOptionalValue(NetherPortalBlock.AXIS) + .filter(candidateAxis -> candidateAxis == axis) + .isPresent() + && level.getBlockState(candidate).is(Blocks.NETHER_PORTAL) + ); + Direction widthDirection = axis == Direction.Axis.X + ? Direction.EAST + : Direction.SOUTH; + BlockPos center = rectangle.minCorner + .relative(widthDirection, (rectangle.axis1Size - 1) / 2) + .above((rectangle.axis2Size - 1) / 2); + + int width = rectangle.axis1Size; + int height = rectangle.axis2Size; + int outerRadius = outerRadiusForPortal(width, height); + return new PortalRecord( + rectangle.minCorner.immutable(), + center.immutable(), + axis, + width, + height, + outerRadius, + outerRadius / 2 + ); + } + + private static boolean portalSearchAreaLoaded( + ServerLevel level, + BlockPos pos, + Direction.Axis axis + ) { + int minChunk = axis == Direction.Axis.X + ? (pos.getX() - MAX_PORTAL_SIZE) >> 4 + : (pos.getZ() - MAX_PORTAL_SIZE) >> 4; + int maxChunk = axis == Direction.Axis.X + ? (pos.getX() + MAX_PORTAL_SIZE) >> 4 + : (pos.getZ() + MAX_PORTAL_SIZE) >> 4; + int fixedChunk = axis == Direction.Axis.X + ? pos.getZ() >> 4 + : pos.getX() >> 4; + + for (int varyingChunk = minChunk; + varyingChunk <= maxChunk; + varyingChunk++) { + int chunkX = axis == Direction.Axis.X + ? varyingChunk + : fixedChunk; + int chunkZ = axis == Direction.Axis.X + ? fixedChunk + : varyingChunk; + if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) { + return false; + } + } + + return true; + } + + private static void removePortal(PortalTracker tracker, long key) { + tracker.portals.remove(key); + tracker.pulseWork.remove(key); + tracker.queuedPulses.remove(key); + tracker.progress.remove(key); + tracker.lavaResistance.remove(key); + tracker.queuedLavaScans.remove(key); + tracker.lavaScanQueue.removeIf(candidate -> candidate == key); + } + + private static void queueLavaScan(PortalTracker tracker, long key) { + if (tracker.queuedLavaScans.add(key)) { + tracker.lavaScanQueue.addLast(key); + } + } + + private static PortalTracker tracker(ServerLevel level) { + return TRACKERS.computeIfAbsent( + level.dimension(), + ignored -> new PortalTracker() + ); + } + + private static long chunkKey(BlockPos pos) { + return ChunkPos.pack(pos.getX() >> 4, pos.getZ() >> 4); + } + + private static final class PortalTracker { + private final Map portals = new HashMap<>(); + private final ArrayDeque backgroundQueue = new ArrayDeque<>(); + private final Map pulseWork = new HashMap<>(); + private final ArrayDeque pulseQueue = new ArrayDeque<>(); + private final Set queuedPulses = new HashSet<>(); + private final Set pendingChunks = new LinkedHashSet<>(); + private final Map progress = new HashMap<>(); + private final Map lavaResistance = + new HashMap<>(); + private final ArrayDeque lavaScanQueue = new ArrayDeque<>(); + private final Set queuedLavaScans = new HashSet<>(); + } + + private enum DrainPhase { + DRAIN, + CORRUPT + } + + private static final class PortalProgress { + private final int drainLimit; + private final int corruptionLimit; + private int drainCursor; + private int corruptionCursor; + private boolean preferCorruption; + + private PortalProgress(PortalRecord portal) { + drainLimit = countOffsetsWithin(portal.outerRadius()); + corruptionLimit = countOffsetsWithin(portal.innerRadius()); + } + + private void resetIfComplete() { + if (drainCursor < drainLimit + || corruptionCursor < corruptionLimit) { + return; + } + + drainCursor = 0; + corruptionCursor = 0; + preferCorruption = false; + } + + private DrainPhase nextPhase() { + if (drainCursor >= drainLimit) { + return DrainPhase.CORRUPT; + } + if (preferCorruption && hasCandidate(DrainPhase.CORRUPT)) { + return DrainPhase.CORRUPT; + } + return DrainPhase.DRAIN; + } + + private boolean hasCandidate(DrainPhase phase) { + if (phase == DrainPhase.DRAIN) { + return drainCursor < drainLimit; + } + if (corruptionCursor >= corruptionLimit + || drainCursor == 0) { + return false; + } + + int deathFrontDistanceSquared = distanceSquaredAt( + drainCursor - 1 + ); + int corruptionDistanceSquared = distanceSquaredAt( + corruptionCursor + ); + return corruptionFitsBehindDeathFront( + deathFrontDistanceSquared, + corruptionDistanceSquared + ); + } + + private int nextOffsetIndex(DrainPhase phase) { + if (phase == DrainPhase.DRAIN) { + return drainCursor++; + } + return corruptionCursor++; + } + + private void completeTurn(DrainPhase phase) { + preferCorruption = phase == DrainPhase.DRAIN; + } + } + + private static final class PortalResistance { + private final BlockPos center; + private final int radius; + private final int minChunkX; + private final int maxChunkX; + private final int minChunkZ; + private final int maxChunkZ; + private final LinearSpreadLimiter limiter = new LinearSpreadLimiter(); + private int nextChunkX; + private int nextChunkZ; + private int scanCount; + private int lavaSources; + private boolean hasSnapshot; + + private PortalResistance(PortalRecord portal) { + center = portal.center(); + radius = portal.outerRadius(); + minChunkX = (center.getX() - radius) >> 4; + maxChunkX = (center.getX() + radius) >> 4; + minChunkZ = (center.getZ() - radius) >> 4; + maxChunkZ = (center.getZ() + radius) >> 4; + nextChunkX = minChunkX; + nextChunkZ = minChunkZ; + } + + private void scanNextChunk(ServerLevel level) { + scanCount += countLavaSourcesInChunk( + level, + center, + radius, + nextChunkX, + nextChunkZ + ); + + nextChunkX++; + if (nextChunkX <= maxChunkX) { + return; + } + + nextChunkX = minChunkX; + nextChunkZ++; + if (nextChunkZ <= maxChunkZ) { + return; + } + + lavaSources = scanCount; + hasSnapshot = true; + scanCount = 0; + nextChunkZ = minChunkZ; + } + + private boolean allowsSpread(int portalArea) { + return hasSnapshot && limiter.allows(portalArea, lavaSources); + } + + private boolean hasSnapshot() { + return hasSnapshot; + } + } + + private static final class LinearSpreadLimiter { + private int credit; + + private boolean allows(int portalArea, int lavaSources) { + int activeShare = portalArea - Math.min(lavaSources, portalArea); + if (activeShare <= 0) { + credit = 0; + return false; + } + + credit += activeShare; + if (credit < portalArea) { + return false; + } + + credit -= portalArea; + return true; + } + } + + private record PortalRecord( + BlockPos minCorner, + BlockPos center, + Direction.Axis axis, + int width, + int height, + int outerRadius, + int innerRadius + ) { + private int area() { + return width * height; + } + + private boolean isActive(ServerLevel level) { + return level.getChunkSource().getChunkNow( + center.getX() >> 4, + center.getZ() >> 4 + ) != null + && level.getBlockState(center).is(Blocks.NETHER_PORTAL) + && level.getBlockState(center).getValue( + NetherPortalBlock.AXIS + ) == axis; + } + + private void addPortalBlocksTo(Set positions) { + Direction widthDirection = axis == Direction.Axis.X + ? Direction.EAST + : Direction.SOUTH; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + positions.add( + minCorner.relative(widthDirection, x) + .above(y) + .asLong() + ); + } + } + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainGameTests.java b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainGameTests.java new file mode 100644 index 00000000..6b078467 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainGameTests.java @@ -0,0 +1,733 @@ +package cz.xefensor.retold.worldgen.portal; + +import cz.xefensor.retold.Retold; +import cz.xefensor.retold.api.world.RetoldWorldMutationType; +import cz.xefensor.retold.api.world.RetoldWorldProtection; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.BuiltinTestFunctions; +import net.minecraft.gametest.framework.FunctionGameTestInstance; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.gametest.framework.TestData; +import net.minecraft.gametest.framework.TestEnvironmentDefinition; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LiquidBlock; +import net.minecraft.world.level.block.NetherPortalBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.Half; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.event.RegisterGameTestsEvent; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +public final class RetoldNetherPortalDrainGameTests { + private static final Identifier EMPTY_STRUCTURE = + Identifier.withDefaultNamespace("empty"); + private static final Identifier TEST_PROTECTION = + Identifier.fromNamespaceAndPath( + Retold.MODID, + "test_nether_portal_drain_protection" + ); + + private RetoldNetherPortalDrainGameTests() { + } + + public static void register( + RegisterGameTestsEvent event, + Holder> environment + ) { + TestData>> testData = + new TestData<>(environment, EMPTY_STRUCTURE, 80, 0, true); + + registerTest( + event, + testData, + "nether_portal_drain_maps_materials_and_protects_valuable_blocks", + RetoldNetherPortalDrainGameTests::mapsMaterialsAndProtectsValuables + ); + registerTest( + event, + testData, + "nether_portal_drain_uses_explicit_palette", + RetoldNetherPortalDrainGameTests::usesExplicitPalette + ); + registerTest( + event, + testData, + "nether_portal_lava_sources_linearly_resist_spread", + RetoldNetherPortalDrainGameTests::lavaSourcesLinearlyResistSpread + ); + registerTest( + event, + testData, + "nether_portal_travel_pulses_are_bounded", + RetoldNetherPortalDrainGameTests::travelPulsesAreBounded + ); + registerTest( + event, + testData, + "nether_portal_drain_advances_outward", + RetoldNetherPortalDrainGameTests::drainAdvancesOutward + ); + registerTest( + event, + testData, + "nether_portal_drain_scales_with_portal_area", + RetoldNetherPortalDrainGameTests::drainScalesWithPortalArea + ); + } + + private static void mapsMaterialsAndProtectsValuables( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos center = helper.absolutePos(new BlockPos(8, 4, 8)); + BlockPos outerGround = center.offset(12, 0, 0); + BlockPos outerLeaves = center.offset(-12, 0, 0); + BlockPos outerStone = center.offset(0, 0, 12); + BlockPos innerSoil = center.east(); + BlockPos innerStone = center.east(2); + BlockPos innerStairs = center.east(3); + BlockPos innerLog = center.east(4); + BlockPos innerWater = center.east(5); + BlockPos missedInnerSoil = center.south(2); + BlockPos protectedOre = center.west(); + BlockPos protectedStorage = center.west(2); + BlockPos protectedContainer = center.west(3); + BlockPos protectedObsidian = center.west(4); + BlockPos denied = center.north(2); + + BlockState stoneBrickStairs = Blocks.STONE_BRICK_STAIRS + .defaultBlockState() + .setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST) + .setValue(BlockStateProperties.HALF, Half.TOP); + BlockState horizontalLog = Blocks.OAK_LOG.defaultBlockState() + .setValue(BlockStateProperties.AXIS, Direction.Axis.X); + + level.setBlock(outerGround, Blocks.GRASS_BLOCK.defaultBlockState(), 3); + level.setBlock(outerLeaves, Blocks.OAK_LEAVES.defaultBlockState(), 3); + level.setBlock(outerStone, Blocks.STONE.defaultBlockState(), 3); + level.setBlock(innerSoil, Blocks.DIRT.defaultBlockState(), 3); + level.setBlock(innerStone, Blocks.STONE.defaultBlockState(), 3); + level.setBlock(innerStairs, stoneBrickStairs, 3); + level.setBlock(innerLog, horizontalLog, 3); + level.setBlock(innerWater, Blocks.WATER.defaultBlockState(), 3); + level.setBlock(missedInnerSoil, Blocks.GRASS_BLOCK.defaultBlockState(), 3); + level.setBlock(protectedOre, Blocks.DIAMOND_ORE.defaultBlockState(), 3); + level.setBlock(protectedStorage, Blocks.DIAMOND_BLOCK.defaultBlockState(), 3); + level.setBlock(protectedContainer, Blocks.CHEST.defaultBlockState(), 3); + level.setBlock(protectedObsidian, Blocks.OBSIDIAN.defaultBlockState(), 3); + level.setBlock(denied, Blocks.DIRT.defaultBlockState(), 3); + + drain(helper, level, center, outerGround); + drain(helper, level, center, outerLeaves); + helper.assertFalse( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + outerStone + ), + "Outer drain must leave non-living stone unchanged" + ); + drain(helper, level, center, innerSoil); + helper.assertTrue( + level.getBlockState(innerSoil).is(Blocks.COARSE_DIRT), + "Inner living ground must die before Nether corruption" + ); + helper.assertFalse( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + innerStone + ), + "The death wave must not directly corrupt stone" + ); + corrupt(helper, level, center, innerSoil); + corrupt(helper, level, center, innerStone); + corrupt(helper, level, center, innerStairs); + corrupt(helper, level, center, innerLog); + corrupt(helper, level, center, innerWater); + corrupt(helper, level, center, missedInnerSoil); + helper.assertTrue( + level.getBlockState(missedInnerSoil).is(Blocks.COARSE_DIRT), + "Corruption must not bypass death for newly loaded living ground" + ); + corrupt(helper, level, center, missedInnerSoil); + + helper.assertTrue( + level.getBlockState(outerGround).is(Blocks.COARSE_DIRT), + "Outer living ground must drain into coarse dirt" + ); + helper.assertTrue( + level.getBlockState(outerLeaves).isAir(), + "Outer vegetation must wither away" + ); + helper.assertTrue( + level.getBlockState(innerSoil).is(Blocks.NETHERRACK), + "Inner soil must corrupt into netherrack" + ); + helper.assertTrue( + level.getBlockState(innerStone).is(Blocks.BLACKSTONE), + "Inner stone must corrupt into blackstone" + ); + + BlockState transformedStairs = level.getBlockState(innerStairs); + helper.assertTrue( + transformedStairs.is(Blocks.POLISHED_BLACKSTONE_BRICK_STAIRS) + && transformedStairs.getValue( + BlockStateProperties.HORIZONTAL_FACING + ) == Direction.EAST + && transformedStairs.getValue( + BlockStateProperties.HALF + ) == Half.TOP, + "Masonry stairs must retain their compatible shape state" + ); + + BlockState transformedLog = level.getBlockState(innerLog); + helper.assertTrue( + transformedLog.is(Blocks.CRIMSON_STEM) + && transformedLog.getValue(BlockStateProperties.AXIS) + == Direction.Axis.X, + "Wood must become matching crimson wood without losing its axis" + ); + helper.assertTrue( + level.getBlockState(innerWater).isAir(), + "Inner-zone water must evaporate permanently" + ); + helper.assertTrue( + level.getBlockState(missedInnerSoil).is(Blocks.NETHERRACK), + "Dead ground may become netherrack on a later corruption visit" + ); + + for (BlockPos protectedPos : List.of( + protectedOre, + protectedStorage, + protectedContainer, + protectedObsidian + )) { + helper.assertFalse( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + protectedPos + ), + "Protected block must reject the death wave at " + + protectedPos + ); + helper.assertFalse( + RetoldNetherPortalDrainEvents.corruptAt( + level, + center, + protectedPos + ), + "Protected block must reject Nether corruption at " + + protectedPos + ); + } + + RetoldWorldProtection.Registration registration = + RetoldWorldProtection.register( + TEST_PROTECTION, + context -> context.type() + != RetoldWorldMutationType.NETHER_PORTAL_DRAIN + || !context.pos().equals(denied) + ); + try { + helper.assertFalse( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + denied + ), + "A protection rule must be able to deny portal drain" + ); + helper.assertTrue( + level.getBlockState(denied).is(Blocks.DIRT), + "Denied portal drain must leave the original block intact" + ); + } finally { + registration.close(); + } + + helper.succeed(); + } + + private static void lavaSourcesLinearlyResistSpread( + GameTestHelper helper + ) { + ServerLevel level = helper.getLevel(); + BlockPos center = helper.absolutePos(new BlockPos(8, 4, 8)); + int radius = RetoldNetherPortalDrainEvents.OUTER_RADIUS; + + level.setBlock( + center.offset(radius, 0, 0), + Blocks.LAVA.defaultBlockState(), + 3 + ); + level.setBlock( + center.east(), + Blocks.LAVA.defaultBlockState().setValue(LiquidBlock.LEVEL, 1), + 3 + ); + level.setBlock( + center.offset(radius + 1, 0, 0), + Blocks.LAVA.defaultBlockState(), + 3 + ); + level.setBlock(center.south(), Blocks.WATER.defaultBlockState(), 3); + + helper.assertValueEqual( + RetoldNetherPortalDrainEvents + .lavaSourcesInAffectedAreaForTest( + level, + center, + radius + ), + 1, + "Only lava sources inside the full affected sphere must count" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 0, + 12 + ), + 12, + "A portal without lava must retain full spread speed" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 1, + 12 + ), + 10, + "One source around a six-block portal must leave five-sixths speed" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 3, + 12 + ), + 6, + "Three sources around a six-block portal must halve spread speed" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 5, + 12 + ), + 2, + "Five sources must leave one-sixth spread speed" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 6, + 12 + ), + 0, + "Matching the portal area in lava sources must stop spread" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.allowedSpreadAttemptsForTest( + 6, + 7, + 12 + ), + 0, + "Extra lava sources must keep spread stopped" + ); + + helper.succeed(); + } + + private static void usesExplicitPalette(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + BlockPos center = helper.absolutePos(new BlockPos(8, 4, 8)); + BlockPos target = center.east(); + + for (BlockState stable : List.of( + Blocks.NETHERRACK.defaultBlockState(), + Blocks.BLACKSTONE.defaultBlockState(), + Blocks.WARPED_PLANKS.defaultBlockState(), + Blocks.SOUL_SOIL.defaultBlockState(), + Blocks.GRAVEL.defaultBlockState() + )) { + level.setBlock(target, stable, 3); + helper.assertFalse( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + target + ), + stable.getBlock() + " must also resist the death front" + ); + helper.assertFalse( + RetoldNetherPortalDrainEvents.corruptAt( + level, + center, + target + ), + stable.getBlock() + " must be a stable portal-drain result" + ); + helper.assertTrue( + level.getBlockState(target).is(stable.getBlock()), + stable.getBlock() + " must remain unchanged" + ); + } + + for (BlockState unchanged : List.of( + Blocks.CONCRETE.white().defaultBlockState(), + Blocks.CONCRETE_POWDER.white().defaultBlockState(), + Blocks.TERRACOTTA.defaultBlockState(), + Blocks.GLASS.defaultBlockState(), + Blocks.WOOL.white().defaultBlockState(), + Blocks.RAIL.defaultBlockState(), + Blocks.HAY_BLOCK.defaultBlockState(), + Blocks.PUMPKIN.defaultBlockState(), + Blocks.RED_MUSHROOM_BLOCK.defaultBlockState(), + Blocks.BOOKSHELF.defaultBlockState() + )) { + level.setBlock(target, unchanged, 3); + helper.assertFalse( + RetoldNetherPortalDrainEvents.corruptAt( + level, + center, + target + ), + unchanged.getBlock() + + " has no agreed Nether equivalent and must be ignored" + ); + helper.assertTrue( + level.getBlockState(target).is(unchanged.getBlock()), + unchanged.getBlock() + " must remain unchanged" + ); + } + + assertCorruptsTo(helper, level, center, target, Blocks.SAND, Blocks.GRAVEL); + assertCorruptsTo(helper, level, center, target, Blocks.CLAY, Blocks.NETHERRACK); + assertCorruptsTo( + helper, + level, + center, + target, + Blocks.SANDSTONE_STAIRS, + Blocks.BLACKSTONE_STAIRS + ); + assertCorruptsTo( + helper, + level, + center, + target, + Blocks.BRICKS, + Blocks.POLISHED_BLACKSTONE_BRICKS + ); + + level.setBlock(target, Blocks.ICE.defaultBlockState(), 3); + drain(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).isAir(), + "Ice must melt during the death front" + ); + + level.setBlock(target.below(), Blocks.COARSE_DIRT.defaultBlockState(), 3); + level.setBlock(target, Blocks.DANDELION.defaultBlockState(), 3); + drain(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).is(Blocks.DEAD_BUSH), + "A supported small plant must become a dead bush" + ); + + level.setBlock(target, Blocks.CACTUS.defaultBlockState(), 3); + drain(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).isAir(), + "Cactus must disappear during the death front" + ); + + level.setBlock(target.below(), Blocks.STONE.defaultBlockState(), 3); + BlockState wetCoral = Blocks.TUBE_CORAL_FAN.defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true); + level.setBlock(target, wetCoral, 3); + drain(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).is(Blocks.DEAD_TUBE_CORAL_FAN) + && level.getBlockState(target).getValue( + BlockStateProperties.WATERLOGGED + ), + "The death front must kill coral without evaporating its water" + ); + + level.setBlock(target, wetCoral, 3); + corrupt(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).is(Blocks.DEAD_TUBE_CORAL_FAN) + && !level.getBlockState(target).getValue( + BlockStateProperties.WATERLOGGED + ), + "The corruption front must kill coral and evaporate water" + ); + + BlockState waterloggedStairs = Blocks.STONE_BRICK_STAIRS + .defaultBlockState() + .setValue(BlockStateProperties.WATERLOGGED, true); + level.setBlock(target, waterloggedStairs, 3); + corrupt(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).is( + Blocks.POLISHED_BLACKSTONE_BRICK_STAIRS + ) && !level.getBlockState(target).getValue( + BlockStateProperties.WATERLOGGED + ), + "Waterlogged masonry must transform while only its water evaporates" + ); + + helper.succeed(); + } + + private static void assertCorruptsTo( + GameTestHelper helper, + ServerLevel level, + BlockPos center, + BlockPos target, + net.minecraft.world.level.block.Block source, + net.minecraft.world.level.block.Block expected + ) { + level.setBlock(target, source.defaultBlockState(), 3); + corrupt(helper, level, center, target); + helper.assertTrue( + level.getBlockState(target).is(expected), + source + " must corrupt into " + expected + ); + } + + private static void travelPulsesAreBounded(GameTestHelper helper) { + RetoldNetherPortalDrainEvents.clearForTests(); + ServerLevel overworld = helper.getLevel(); + ServerLevel nether = Objects.requireNonNull( + overworld.getServer().getLevel(Level.NETHER), + "GameTest server must provide the Nether" + ); + BlockPos portal = helper.absolutePos(new BlockPos(4, 3, 4)); + + for (int y = 0; y < 3; y++) { + for (int x = 0; x < 2; x++) { + overworld.setBlock( + portal.offset(x, y, 0), + Blocks.NETHER_PORTAL.defaultBlockState().setValue( + NetherPortalBlock.AXIS, + Direction.Axis.X + ), + 3 + ); + } + } + + ArmorStand traveler = helper.spawn(EntityTypes.ARMOR_STAND, 4, 3, 4); + TeleportTransition baseTransition = new TeleportTransition( + nether, + Vec3.atBottomCenterOf(portal), + Vec3.ZERO, + 0.0F, + 0.0F, + TeleportTransition.DO_NOTHING + ); + TeleportTransition wrapped = + RetoldNetherPortalDrainTravel.attachSuccessfulTravelPulse( + overworld, + portal, + baseTransition + ); + wrapped.postTeleportTransition().onTransition(traveler); + + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.pendingPulseWorkForTest( + overworld, + portal + ), + RetoldNetherPortalDrainEvents.PULSE_CHANGES_PER_TRAVEL, + "Every successful traveler must add one bounded drain pulse" + ); + + for (int travel = 0; travel < 20; travel++) { + wrapped.postTeleportTransition().onTransition(traveler); + } + + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.pendingPulseWorkForTest( + overworld, + portal + ), + RetoldNetherPortalDrainEvents.MAX_QUEUED_PULSE_CHANGES, + "Rapid portal traffic must coalesce at the per-portal work cap" + ); + RetoldNetherPortalDrainEvents.clearForTests(); + helper.succeed(); + } + + private static void drainAdvancesOutward(GameTestHelper helper) { + int offsetCount = RetoldNetherPortalDrainEvents + .offsetCountForRadiusForTest( + RetoldNetherPortalDrainEvents.OUTER_RADIUS + ); + helper.assertTrue(offsetCount > 0, "Drain order must not be empty"); + + int previousDistanceSquared = 0; + for (int index = 0; index < offsetCount; index++) { + int distanceSquared = RetoldNetherPortalDrainEvents + .offsetDistanceSquaredForTest(index); + helper.assertTrue( + distanceSquared >= previousDistanceSquared, + "Portal drain candidates must never move back toward the portal" + ); + previousDistanceSquared = distanceSquared; + } + + helper.assertValueEqual( + RetoldNetherPortalDrainEvents + .offsetDistanceSquaredForTest(0), + 1, + "Portal drain must begin immediately beside the portal" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents + .offsetDistanceSquaredForTest(offsetCount - 1), + RetoldNetherPortalDrainEvents.OUTER_RADIUS + * RetoldNetherPortalDrainEvents.OUTER_RADIUS, + "Portal drain must finish at the configured outer radius" + ); + helper.assertFalse( + RetoldNetherPortalDrainEvents + .corruptionFitsBehindDeathFrontForTest(1, 1), + "Nether corruption must not begin alongside the death front" + ); + helper.assertTrue( + RetoldNetherPortalDrainEvents + .corruptionFitsBehindDeathFrontForTest(4, 1), + "Corruption may begin once the death radius is twice as large" + ); + helper.assertFalse( + RetoldNetherPortalDrainEvents + .corruptionFitsBehindDeathFrontForTest(255, 64), + "The final corruption shell must wait for the final death shell" + ); + helper.assertTrue( + RetoldNetherPortalDrainEvents + .corruptionFitsBehindDeathFrontForTest(256, 64), + "The final 8-block corruption shell must fit inside the 16-block death front" + ); + helper.succeed(); + } + + private static void drainScalesWithPortalArea(GameTestHelper helper) { + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.outerRadiusForPortalForTest(2, 3), + 16, + "A standard six-block portal must keep the original radius" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.outerRadiusForPortalForTest(3, 3), + 19, + "Every portal block beyond the standard area must add one radius block" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.outerRadiusForPortalForTest(4, 5), + 30, + "Both portal width and height must contribute through total area" + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.outerRadiusForPortalForTest(7, 7), + RetoldNetherPortalDrainEvents.MAX_OUTER_RADIUS, + "Large portals must stop at the 48-block cap" + ); + + int maximumOffsetCount = RetoldNetherPortalDrainEvents + .offsetCountForRadiusForTest( + RetoldNetherPortalDrainEvents.MAX_OUTER_RADIUS + ); + helper.assertValueEqual( + RetoldNetherPortalDrainEvents.offsetDistanceSquaredForTest( + maximumOffsetCount - 1 + ), + RetoldNetherPortalDrainEvents.MAX_OUTER_RADIUS + * RetoldNetherPortalDrainEvents.MAX_OUTER_RADIUS, + "The bounded offset index must cover the complete maximum radius" + ); + helper.succeed(); + } + + private static void drain( + GameTestHelper helper, + ServerLevel level, + BlockPos center, + BlockPos target + ) { + helper.assertTrue( + RetoldNetherPortalDrainEvents.drainAt( + level, + center, + target + ), + "Expected portal death-wave transformation at " + target + ); + } + + private static void corrupt( + GameTestHelper helper, + ServerLevel level, + BlockPos center, + BlockPos target + ) { + helper.assertTrue( + RetoldNetherPortalDrainEvents.corruptAt( + level, + center, + target + ), + "Expected portal Nether-corruption transformation at " + target + ); + } + + private static void registerTest( + RegisterGameTestsEvent event, + TestData>> testData, + String path, + Consumer test + ) { + event.registerTest( + Identifier.fromNamespaceAndPath(Retold.MODID, path), + new InlineGameTest(testData, test) + ); + } + + private static final class InlineGameTest + extends FunctionGameTestInstance { + private final Consumer test; + + private InlineGameTest( + TestData>> testData, + Consumer test + ) { + super(BuiltinTestFunctions.ALWAYS_PASS, testData); + this.test = test; + } + + @Override + public void run(GameTestHelper helper) { + test.accept(helper); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTransforms.java b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTransforms.java new file mode 100644 index 00000000..065ec43f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTransforms.java @@ -0,0 +1,291 @@ +package cz.xefensor.retold.worldgen.portal; + +import cz.xefensor.retold.registry.RetoldTags; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ButtonBlock; +import net.minecraft.world.level.block.DoorBlock; +import net.minecraft.world.level.block.DoublePlantBlock; +import net.minecraft.world.level.block.FenceBlock; +import net.minecraft.world.level.block.FenceGateBlock; +import net.minecraft.world.level.block.PressurePlateBlock; +import net.minecraft.world.level.block.RotatedPillarBlock; +import net.minecraft.world.level.block.SlabBlock; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.TrapDoorBlock; +import net.minecraft.world.level.block.WallBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.Property; +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** Material-aware vanilla palette used by permanent Overworld portal drain. */ +public final class RetoldNetherPortalDrainTransforms { + private static final Map CORAL_DEATH_TARGETS = Map.ofEntries( + Map.entry(Blocks.TUBE_CORAL_BLOCK, Blocks.DEAD_TUBE_CORAL_BLOCK), + Map.entry(Blocks.BRAIN_CORAL_BLOCK, Blocks.DEAD_BRAIN_CORAL_BLOCK), + Map.entry(Blocks.BUBBLE_CORAL_BLOCK, Blocks.DEAD_BUBBLE_CORAL_BLOCK), + Map.entry(Blocks.FIRE_CORAL_BLOCK, Blocks.DEAD_FIRE_CORAL_BLOCK), + Map.entry(Blocks.HORN_CORAL_BLOCK, Blocks.DEAD_HORN_CORAL_BLOCK), + Map.entry(Blocks.TUBE_CORAL, Blocks.DEAD_TUBE_CORAL), + Map.entry(Blocks.BRAIN_CORAL, Blocks.DEAD_BRAIN_CORAL), + Map.entry(Blocks.BUBBLE_CORAL, Blocks.DEAD_BUBBLE_CORAL), + Map.entry(Blocks.FIRE_CORAL, Blocks.DEAD_FIRE_CORAL), + Map.entry(Blocks.HORN_CORAL, Blocks.DEAD_HORN_CORAL), + Map.entry(Blocks.TUBE_CORAL_FAN, Blocks.DEAD_TUBE_CORAL_FAN), + Map.entry(Blocks.BRAIN_CORAL_FAN, Blocks.DEAD_BRAIN_CORAL_FAN), + Map.entry(Blocks.BUBBLE_CORAL_FAN, Blocks.DEAD_BUBBLE_CORAL_FAN), + Map.entry(Blocks.FIRE_CORAL_FAN, Blocks.DEAD_FIRE_CORAL_FAN), + Map.entry(Blocks.HORN_CORAL_FAN, Blocks.DEAD_HORN_CORAL_FAN), + Map.entry( + Blocks.TUBE_CORAL_WALL_FAN, + Blocks.DEAD_TUBE_CORAL_WALL_FAN + ), + Map.entry( + Blocks.BRAIN_CORAL_WALL_FAN, + Blocks.DEAD_BRAIN_CORAL_WALL_FAN + ), + Map.entry( + Blocks.BUBBLE_CORAL_WALL_FAN, + Blocks.DEAD_BUBBLE_CORAL_WALL_FAN + ), + Map.entry( + Blocks.FIRE_CORAL_WALL_FAN, + Blocks.DEAD_FIRE_CORAL_WALL_FAN + ), + Map.entry( + Blocks.HORN_CORAL_WALL_FAN, + Blocks.DEAD_HORN_CORAL_WALL_FAN + ) + ); + + private RetoldNetherPortalDrainTransforms() { + } + + public static @Nullable BlockState resolveDrain( + ServerLevel level, + BlockPos pos, + BlockState source + ) { + if (source.isAir() + || source.is(RetoldTags.NETHER_PORTAL_DRAIN_IMMUNE) + || source.hasBlockEntity()) { + return null; + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_STABLE)) { + return null; + } + + BlockState death = resolveDeath(level, pos, source); + if (death != null) { + return death; + } + + return null; + } + + private static @Nullable BlockState resolveDeath( + ServerLevel level, + BlockPos pos, + BlockState source + ) { + Block deadCoral = CORAL_DEATH_TARGETS.get(source.getBlock()); + if (deadCoral != null) { + return copySharedProperties(source, deadCoral); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_MELTS) + || source.is(RetoldTags.NETHER_PORTAL_DRAIN_VEGETATION)) { + return Blocks.AIR.defaultBlockState(); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_TO_DEAD_BUSH)) { + if (source.getBlock() instanceof DoublePlantBlock) { + return Blocks.AIR.defaultBlockState(); + } + + BlockState deadBush = Blocks.DEAD_BUSH.defaultBlockState(); + return deadBush.canSurvive(level, pos) + ? deadBush + : Blocks.AIR.defaultBlockState(); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_OUTER_GROUND) + && !source.is(Blocks.COARSE_DIRT)) { + return Blocks.COARSE_DIRT.defaultBlockState(); + } + + return null; + } + + public static @Nullable BlockState resolveCorruption( + ServerLevel level, + BlockPos pos, + BlockState source + ) { + if (source.isAir() + || source.is(RetoldTags.NETHER_PORTAL_DRAIN_IMMUNE) + || source.hasBlockEntity()) { + return null; + } + + boolean evaporatedWater = false; + if (source.getFluidState().is(FluidTags.WATER)) { + if (!source.hasProperty(BlockStateProperties.WATERLOGGED)) { + return Blocks.AIR.defaultBlockState(); + } + + source = source.setValue(BlockStateProperties.WATERLOGGED, false); + evaporatedWater = true; + } + + if (!source.getFluidState().isEmpty()) { + return null; + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_STABLE)) { + return evaporatedWater ? source : null; + } + + BlockState death = resolveDeath(level, pos, source); + if (death != null) { + return death; + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_TO_CRIMSON_WOOD)) { + return copySharedProperties(source, crimsonWoodTarget(source)); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_TO_BLACKSTONE)) { + return copySharedProperties(source, blackstoneTarget(source)); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_TO_GRAVEL)) { + return Blocks.GRAVEL.defaultBlockState(); + } + + if (source.is(RetoldTags.NETHER_PORTAL_DRAIN_TO_NETHERRACK)) { + return Blocks.NETHERRACK.defaultBlockState(); + } + + return evaporatedWater ? source : null; + } + + private static Block crimsonWoodTarget(BlockState source) { + Block block = source.getBlock(); + + if (block instanceof RotatedPillarBlock) { + String path = BuiltInRegistries.BLOCK.getKey(block).getPath(); + boolean stripped = path.startsWith("stripped_"); + boolean barkOnEverySide = path.contains("wood") + || path.contains("hyphae"); + + if (barkOnEverySide) { + return stripped + ? Blocks.STRIPPED_CRIMSON_HYPHAE + : Blocks.CRIMSON_HYPHAE; + } + + return stripped + ? Blocks.STRIPPED_CRIMSON_STEM + : Blocks.CRIMSON_STEM; + } + + if (block instanceof StairBlock) { + return Blocks.CRIMSON_STAIRS; + } + if (block instanceof SlabBlock) { + return Blocks.CRIMSON_SLAB; + } + if (block instanceof FenceBlock) { + return Blocks.CRIMSON_FENCE; + } + if (block instanceof FenceGateBlock) { + return Blocks.CRIMSON_FENCE_GATE; + } + if (block instanceof DoorBlock) { + return Blocks.CRIMSON_DOOR; + } + if (block instanceof TrapDoorBlock) { + return Blocks.CRIMSON_TRAPDOOR; + } + if (block instanceof ButtonBlock) { + return Blocks.CRIMSON_BUTTON; + } + if (block instanceof PressurePlateBlock) { + return Blocks.CRIMSON_PRESSURE_PLATE; + } + + return Blocks.CRIMSON_PLANKS; + } + + private static Block blackstoneTarget(BlockState source) { + Block block = source.getBlock(); + String path = BuiltInRegistries.BLOCK.getKey(block).getPath(); + boolean sandstone = path.contains("sandstone"); + boolean brickLike = path.contains("brick") || path.contains("tile"); + + if (block instanceof StairBlock) { + return brickLike + ? Blocks.POLISHED_BLACKSTONE_BRICK_STAIRS + : Blocks.BLACKSTONE_STAIRS; + } + if (block instanceof SlabBlock) { + return brickLike + ? Blocks.POLISHED_BLACKSTONE_BRICK_SLAB + : Blocks.BLACKSTONE_SLAB; + } + if (block instanceof WallBlock) { + return brickLike + ? Blocks.POLISHED_BLACKSTONE_BRICK_WALL + : Blocks.BLACKSTONE_WALL; + } + if (block instanceof ButtonBlock) { + return Blocks.POLISHED_BLACKSTONE_BUTTON; + } + if (block instanceof PressurePlateBlock) { + return Blocks.POLISHED_BLACKSTONE_PRESSURE_PLATE; + } + if (path.contains("cracked")) { + return Blocks.CRACKED_POLISHED_BLACKSTONE_BRICKS; + } + if (path.contains("chiseled") && !sandstone) { + return Blocks.CHISELED_POLISHED_BLACKSTONE; + } + if (brickLike) { + return Blocks.POLISHED_BLACKSTONE_BRICKS; + } + + return Blocks.BLACKSTONE; + } + + private static BlockState copySharedProperties( + BlockState source, + Block targetBlock + ) { + BlockState target = targetBlock.defaultBlockState(); + + for (Property property : source.getProperties()) { + if (target.hasProperty(property)) { + target = copyProperty(source, target, property); + } + } + + return target; + } + + private static > BlockState copyProperty( + BlockState source, + BlockState target, + Property property + ) { + return target.setValue(property, source.getValue(property)); + } +} diff --git a/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTravel.java b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTravel.java new file mode 100644 index 00000000..778f1ebf --- /dev/null +++ b/src/main/java/cz/xefensor/retold/worldgen/portal/RetoldNetherPortalDrainTravel.java @@ -0,0 +1,53 @@ +package cz.xefensor.retold.worldgen.portal; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.portal.TeleportTransition; + +/** Adds portal-drain work only after a vanilla Nether transition succeeds. */ +public final class RetoldNetherPortalDrainTravel { + private RetoldNetherPortalDrainTravel() { + } + + public static TeleportTransition attachSuccessfulTravelPulse( + ServerLevel sourceLevel, + BlockPos sourcePortalPos, + TeleportTransition transition + ) { + boolean leavesOverworld = sourceLevel.dimension() == Level.OVERWORLD + && transition.newLevel().dimension() == Level.NETHER; + boolean entersOverworld = sourceLevel.dimension() == Level.NETHER + && transition.newLevel().dimension() == Level.OVERWORLD; + + if (!leavesOverworld && !entersOverworld) { + return transition; + } + + TeleportTransition.PostTeleportTransition drainPulse = entity -> { + if (leavesOverworld) { + RetoldNetherPortalDrainEvents.queueTravelPulse( + sourceLevel, + sourcePortalPos + ); + } else if (entity.level() instanceof ServerLevel destinationLevel) { + RetoldNetherPortalDrainEvents.queueTravelPulse( + destinationLevel, + entity.blockPosition() + ); + } + }; + + return new TeleportTransition( + transition.newLevel(), + transition.position(), + transition.deltaMovement(), + transition.yRot(), + transition.xRot(), + transition.missingRespawnBlock(), + transition.asPassenger(), + transition.relatives(), + transition.postTeleportTransition().then(drainPulse) + ); + } +} diff --git a/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_crimson_forest.json b/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_crimson_forest.json new file mode 100644 index 00000000..0b27f436 --- /dev/null +++ b/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_crimson_forest.json @@ -0,0 +1,10 @@ +{ + "type": "neoforge:add_features", + "biomes": "minecraft:crimson_forest", + "features": [ + "retold:sparse_crimson_fungi", + "retold:sparse_crimson_scrub", + "retold:sparse_weeping_vines" + ], + "step": "vegetal_decoration" +} diff --git a/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_warped_forest.json b/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_warped_forest.json new file mode 100644 index 00000000..c8a6de2d --- /dev/null +++ b/src/main/resources/data/retold/neoforge/biome_modifier/add_sparse_warped_forest.json @@ -0,0 +1,11 @@ +{ + "type": "neoforge:add_features", + "biomes": "minecraft:warped_forest", + "features": [ + "retold:sparse_warped_fungi", + "retold:sparse_warped_scrub", + "retold:sparse_nether_sprouts", + "retold:sparse_twisting_vines" + ], + "step": "vegetal_decoration" +} diff --git a/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_crimson_forest.json b/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_crimson_forest.json new file mode 100644 index 00000000..bd14b774 --- /dev/null +++ b/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_crimson_forest.json @@ -0,0 +1,10 @@ +{ + "type": "neoforge:remove_features", + "biomes": "minecraft:crimson_forest", + "features": [ + "minecraft:crimson_fungi", + "minecraft:crimson_forest_vegetation", + "minecraft:weeping_vines" + ], + "steps": "vegetal_decoration" +} diff --git a/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_warped_forest.json b/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_warped_forest.json new file mode 100644 index 00000000..e235aa2b --- /dev/null +++ b/src/main/resources/data/retold/neoforge/biome_modifier/sparsify_warped_forest.json @@ -0,0 +1,11 @@ +{ + "type": "neoforge:remove_features", + "biomes": "minecraft:warped_forest", + "features": [ + "minecraft:warped_fungi", + "minecraft:warped_forest_vegetation", + "minecraft:nether_sprouts", + "minecraft:twisting_vines" + ], + "steps": "vegetal_decoration" +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/immune.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/immune.json new file mode 100644 index 00000000..ba9cfb5c --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/immune.json @@ -0,0 +1,14 @@ +{ + "values": [ + "#minecraft:dragon_immune", + "#minecraft:portals", + "#c:ores", + "#c:storage_blocks", + "minecraft:ancient_debris", + "retold:aenderite_ore", + "retold:aender_stabilizer", + "retold:aender_chronolith", + "retold:dev_aender_portal_frame", + "retold:aender_portal" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/melts.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/melts.json new file mode 100644 index 00000000..7d9aca40 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/melts.json @@ -0,0 +1,8 @@ +{ + "values": [ + "#minecraft:ice", + "minecraft:snow", + "minecraft:snow_block", + "minecraft:powder_snow" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/outer_ground.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/outer_ground.json new file mode 100644 index 00000000..0527b762 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/outer_ground.json @@ -0,0 +1,12 @@ +{ + "values": [ + "#minecraft:dirt", + "#minecraft:grass_blocks", + "#minecraft:mud", + "#minecraft:moss_blocks", + "minecraft:podzol", + "minecraft:mycelium", + "minecraft:farmland", + "minecraft:dirt_path" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/stable.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/stable.json new file mode 100644 index 00000000..12e0ab40 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/stable.json @@ -0,0 +1,72 @@ +{ + "values": [ + "#minecraft:base_stone_nether", + "#minecraft:nylium", + "#minecraft:crimson_stems", + "#minecraft:warped_stems", + "minecraft:gravel", + "minecraft:soul_sand", + "minecraft:soul_soil", + "minecraft:magma_block", + "minecraft:polished_basalt", + "minecraft:smooth_basalt", + "minecraft:gilded_blackstone", + "minecraft:polished_blackstone", + "minecraft:polished_blackstone_stairs", + "minecraft:polished_blackstone_slab", + "minecraft:polished_blackstone_wall", + "minecraft:polished_blackstone_button", + "minecraft:polished_blackstone_pressure_plate", + "minecraft:polished_blackstone_bricks", + "minecraft:cracked_polished_blackstone_bricks", + "minecraft:chiseled_polished_blackstone", + "minecraft:polished_blackstone_brick_stairs", + "minecraft:polished_blackstone_brick_slab", + "minecraft:polished_blackstone_brick_wall", + "minecraft:blackstone_stairs", + "minecraft:blackstone_slab", + "minecraft:blackstone_wall", + "minecraft:nether_bricks", + "minecraft:cracked_nether_bricks", + "minecraft:chiseled_nether_bricks", + "minecraft:nether_brick_stairs", + "minecraft:nether_brick_slab", + "minecraft:nether_brick_wall", + "minecraft:nether_brick_fence", + "minecraft:red_nether_bricks", + "minecraft:red_nether_brick_stairs", + "minecraft:red_nether_brick_slab", + "minecraft:red_nether_brick_wall", + "minecraft:nether_wart_block", + "minecraft:warped_wart_block", + "minecraft:shroomlight", + "minecraft:glowstone", + "minecraft:crimson_fungus", + "minecraft:warped_fungus", + "minecraft:crimson_roots", + "minecraft:warped_roots", + "minecraft:nether_sprouts", + "minecraft:weeping_vines", + "minecraft:weeping_vines_plant", + "minecraft:twisting_vines", + "minecraft:twisting_vines_plant", + "minecraft:crimson_planks", + "minecraft:crimson_stairs", + "minecraft:crimson_slab", + "minecraft:crimson_fence", + "minecraft:crimson_fence_gate", + "minecraft:crimson_door", + "minecraft:crimson_trapdoor", + "minecraft:crimson_button", + "minecraft:crimson_pressure_plate", + "minecraft:warped_planks", + "minecraft:warped_stairs", + "minecraft:warped_slab", + "minecraft:warped_fence", + "minecraft:warped_fence_gate", + "minecraft:warped_door", + "minecraft:warped_trapdoor", + "minecraft:warped_button", + "minecraft:warped_pressure_plate" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/to_blackstone.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_blackstone.json new file mode 100644 index 00000000..013bf5c4 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_blackstone.json @@ -0,0 +1,104 @@ +{ + "values": [ + "#minecraft:base_stone_overworld", + "#minecraft:stone_bricks", + "minecraft:cobblestone", + "minecraft:mossy_cobblestone", + "minecraft:cobblestone_stairs", + "minecraft:cobblestone_slab", + "minecraft:cobblestone_wall", + "minecraft:mossy_cobblestone_stairs", + "minecraft:mossy_cobblestone_slab", + "minecraft:mossy_cobblestone_wall", + "minecraft:stone_stairs", + "minecraft:stone_slab", + "minecraft:smooth_stone", + "minecraft:smooth_stone_slab", + "minecraft:granite_stairs", + "minecraft:granite_slab", + "minecraft:granite_wall", + "minecraft:polished_granite", + "minecraft:polished_granite_stairs", + "minecraft:polished_granite_slab", + "minecraft:diorite_stairs", + "minecraft:diorite_slab", + "minecraft:diorite_wall", + "minecraft:polished_diorite", + "minecraft:polished_diorite_stairs", + "minecraft:polished_diorite_slab", + "minecraft:andesite_stairs", + "minecraft:andesite_slab", + "minecraft:andesite_wall", + "minecraft:polished_andesite", + "minecraft:polished_andesite_stairs", + "minecraft:polished_andesite_slab", + "minecraft:cobbled_deepslate", + "minecraft:cobbled_deepslate_stairs", + "minecraft:cobbled_deepslate_slab", + "minecraft:cobbled_deepslate_wall", + "minecraft:polished_deepslate", + "minecraft:polished_deepslate_stairs", + "minecraft:polished_deepslate_slab", + "minecraft:polished_deepslate_wall", + "minecraft:chiseled_deepslate", + "minecraft:tuff_stairs", + "minecraft:tuff_slab", + "minecraft:tuff_wall", + "minecraft:polished_tuff", + "minecraft:polished_tuff_stairs", + "minecraft:polished_tuff_slab", + "minecraft:polished_tuff_wall", + "minecraft:chiseled_tuff", + "minecraft:calcite", + "minecraft:dripstone_block", + "minecraft:sandstone", + "minecraft:chiseled_sandstone", + "minecraft:cut_sandstone", + "minecraft:smooth_sandstone", + "minecraft:sandstone_stairs", + "minecraft:sandstone_slab", + "minecraft:sandstone_wall", + "minecraft:smooth_sandstone_stairs", + "minecraft:smooth_sandstone_slab", + "minecraft:cut_sandstone_slab", + "minecraft:red_sandstone", + "minecraft:chiseled_red_sandstone", + "minecraft:cut_red_sandstone", + "minecraft:smooth_red_sandstone", + "minecraft:red_sandstone_stairs", + "minecraft:red_sandstone_slab", + "minecraft:red_sandstone_wall", + "minecraft:smooth_red_sandstone_stairs", + "minecraft:smooth_red_sandstone_slab", + "minecraft:cut_red_sandstone_slab", + "minecraft:bricks", + "minecraft:brick_stairs", + "minecraft:brick_slab", + "minecraft:brick_wall", + "minecraft:mud_bricks", + "minecraft:mud_brick_stairs", + "minecraft:mud_brick_slab", + "minecraft:mud_brick_wall", + "minecraft:deepslate_bricks", + "minecraft:cracked_deepslate_bricks", + "minecraft:deepslate_brick_stairs", + "minecraft:deepslate_brick_slab", + "minecraft:deepslate_brick_wall", + "minecraft:deepslate_tiles", + "minecraft:cracked_deepslate_tiles", + "minecraft:deepslate_tile_stairs", + "minecraft:deepslate_tile_slab", + "minecraft:deepslate_tile_wall", + "minecraft:stone_brick_stairs", + "minecraft:stone_brick_slab", + "minecraft:stone_brick_wall", + "minecraft:mossy_stone_brick_stairs", + "minecraft:mossy_stone_brick_slab", + "minecraft:mossy_stone_brick_wall", + "minecraft:tuff_bricks", + "minecraft:tuff_brick_stairs", + "minecraft:tuff_brick_slab", + "minecraft:tuff_brick_wall", + "minecraft:chiseled_tuff_bricks" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/to_crimson_wood.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_crimson_wood.json new file mode 100644 index 00000000..3efa3a08 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_crimson_wood.json @@ -0,0 +1,14 @@ +{ + "values": [ + "#minecraft:logs", + "#minecraft:planks", + "#minecraft:wooden_stairs", + "#minecraft:wooden_slabs", + "#minecraft:wooden_fences", + "#minecraft:fence_gates", + "#minecraft:wooden_doors", + "#minecraft:wooden_trapdoors", + "#minecraft:wooden_buttons", + "#minecraft:wooden_pressure_plates" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/to_dead_bush.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_dead_bush.json new file mode 100644 index 00000000..35106e42 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_dead_bush.json @@ -0,0 +1,13 @@ +{ + "values": [ + "#minecraft:small_flowers", + "#minecraft:saplings", + "minecraft:short_grass", + "minecraft:short_dry_grass", + "minecraft:fern", + "minecraft:bush", + "minecraft:sweet_berry_bush", + "minecraft:pink_petals", + "minecraft:wildflowers" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/to_gravel.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_gravel.json new file mode 100644 index 00000000..ed00b961 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_gravel.json @@ -0,0 +1,5 @@ +{ + "values": [ + "#minecraft:sand" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/to_netherrack.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_netherrack.json new file mode 100644 index 00000000..1caeba80 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/to_netherrack.json @@ -0,0 +1,6 @@ +{ + "values": [ + "#retold:nether_portal_drain/outer_ground", + "minecraft:clay" + ] +} diff --git a/src/main/resources/data/retold/tags/block/nether_portal_drain/vegetation.json b/src/main/resources/data/retold/tags/block/nether_portal_drain/vegetation.json new file mode 100644 index 00000000..abffc525 --- /dev/null +++ b/src/main/resources/data/retold/tags/block/nether_portal_drain/vegetation.json @@ -0,0 +1,35 @@ +{ + "values": [ + "#minecraft:leaves", + "#minecraft:crops", + "minecraft:tall_grass", + "minecraft:tall_dry_grass", + "minecraft:large_fern", + "minecraft:vine", + "minecraft:glow_lichen", + "minecraft:leaf_litter", + "minecraft:hanging_roots", + "minecraft:sugar_cane", + "minecraft:cactus", + "minecraft:cactus_flower", + "minecraft:bamboo", + "minecraft:bamboo_sapling", + "minecraft:cocoa", + "minecraft:lily_pad", + "minecraft:seagrass", + "minecraft:tall_seagrass", + "minecraft:kelp", + "minecraft:kelp_plant", + "minecraft:small_dripleaf", + "minecraft:big_dripleaf", + "minecraft:big_dripleaf_stem", + "minecraft:cave_vines", + "minecraft:cave_vines_plant", + "minecraft:spore_blossom", + "minecraft:sunflower", + "minecraft:lilac", + "minecraft:peony", + "minecraft:rose_bush", + "minecraft:pitcher_plant" + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_fungi.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_fungi.json new file mode 100644 index 00000000..acb42d7d --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_fungi.json @@ -0,0 +1,12 @@ +{ + "feature": "minecraft:crimson_fungus", + "placement": [ + { + "type": "minecraft:count_on_every_layer", + "count": 1 + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_scrub.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_scrub.json new file mode 100644 index 00000000..dcca2041 --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_crimson_scrub.json @@ -0,0 +1,12 @@ +{ + "feature": "minecraft:crimson_forest_vegetation", + "placement": [ + { + "type": "minecraft:count_on_every_layer", + "count": 1 + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_nether_sprouts.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_nether_sprouts.json new file mode 100644 index 00000000..40c4ecd1 --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_nether_sprouts.json @@ -0,0 +1,12 @@ +{ + "feature": "minecraft:nether_sprouts", + "placement": [ + { + "type": "minecraft:count_on_every_layer", + "count": 1 + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_twisting_vines.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_twisting_vines.json new file mode 100644 index 00000000..7ca7f935 --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_twisting_vines.json @@ -0,0 +1,27 @@ +{ + "feature": "minecraft:twisting_vines", + "placement": [ + { + "type": "minecraft:count", + "count": 2 + }, + { + "type": "minecraft:in_square" + }, + { + "type": "minecraft:height_range", + "height": { + "type": "minecraft:uniform", + "max_inclusive": { + "below_top": 0 + }, + "min_inclusive": { + "above_bottom": 0 + } + } + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_fungi.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_fungi.json new file mode 100644 index 00000000..caf3c0a5 --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_fungi.json @@ -0,0 +1,12 @@ +{ + "feature": "minecraft:warped_fungus", + "placement": [ + { + "type": "minecraft:count_on_every_layer", + "count": 1 + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_scrub.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_scrub.json new file mode 100644 index 00000000..f3b9c5eb --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_warped_scrub.json @@ -0,0 +1,12 @@ +{ + "feature": "minecraft:warped_forest_vegetation", + "placement": [ + { + "type": "minecraft:count_on_every_layer", + "count": 1 + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/data/retold/worldgen/placed_feature/sparse_weeping_vines.json b/src/main/resources/data/retold/worldgen/placed_feature/sparse_weeping_vines.json new file mode 100644 index 00000000..25abd461 --- /dev/null +++ b/src/main/resources/data/retold/worldgen/placed_feature/sparse_weeping_vines.json @@ -0,0 +1,27 @@ +{ + "feature": "minecraft:weeping_vines", + "placement": [ + { + "type": "minecraft:count", + "count": 2 + }, + { + "type": "minecraft:in_square" + }, + { + "type": "minecraft:height_range", + "height": { + "type": "minecraft:uniform", + "max_inclusive": { + "below_top": 0 + }, + "min_inclusive": { + "above_bottom": 0 + } + } + }, + { + "type": "minecraft:biome" + } + ] +} diff --git a/src/main/resources/retold.mixins.json b/src/main/resources/retold.mixins.json index f21c6f37..917fb557 100644 --- a/src/main/resources/retold.mixins.json +++ b/src/main/resources/retold.mixins.json @@ -11,6 +11,9 @@ "AdvancementVisibilityEvaluatorMixin", "NoVillageNearWorldSpawnMixin", "OverworldBiomeBuilderMixin", + "NetherFossilDriedGhastMixin", + "NetherFossilPieceInvoker", + "NetherPortalDrainTravelMixin", "EndDragonFightMixin", "EndGatewayGenerationMixin", "MerchantMenuAccessor", @@ -63,6 +66,8 @@ "EndermanParticleMixin", "LivingEntityTeleportParticleMixin", "MerchantScreenMixin", + "NetherFogColorMixin", + "NetherLightmapMixin", "EnchantmentScreenMixin", "PortalParticleMixin" ],