From 32c52e635ff03a738df8d8643c1cae0479fb1afd Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Tue, 10 Nov 2026 17:40:52 -0500 Subject: [PATCH] Port live instability, writing recovery and Link Modifier controls to 26.3 --- docs/MINECRAFT_26_3_PORT.md | 262 ++++++++++++ ports/mc263/README.md | 395 +++++++++++++++++- ports/mc263/RELEASE_NOTES_1.0.1.129-beta.1.md | 68 +++ ports/mc263/build.gradle | 6 + ports/mc263/gradle.properties | 2 +- .../net/dnorberg/rivencraft/Rivencraft.java | 12 + .../rivencraft/age/PortAgeColors.java | 3 +- .../rivencraft/age/PortAgeCommands.java | 6 +- .../rivencraft/age/PortAgeTravel.java | 24 +- .../rivencraft/age/PortBlockInstability.java | 314 ++++++++++++++ .../age/PortBlockProfileSavedData.java | 63 +++ .../dnorberg/rivencraft/age/PortDayCycle.java | 35 +- .../age/PortEnvironmentalProviders.java | 178 ++++++++ .../rivencraft/age/PortHazardProviders.java | 356 ++++++++++++++++ .../rivencraft/age/PortHazardSavedData.java | 34 ++ .../age/PortInstabilityCommands.java | 53 ++- .../rivencraft/age/PortInstabilityConfig.java | 80 ++++ .../rivencraft/age/PortInstabilityData.java | 7 + .../age/PortInstabilityRuntime.java | 43 +- .../rivencraft/age/PortMeteorProvider.java | 133 ++++++ .../rivencraft/block/LinkModifierBlock.java | 51 +++ .../entity/BookReceptacleBlockEntity.java | 3 +- .../block/entity/LinkModifierBlockEntity.java | 69 +++ .../rivencraft/client/LinkModifierScreen.java | 120 ++++++ .../client/PortBookBinderClientEvents.java | 1 + .../rivencraft/item/DescriptiveBookItem.java | 1 + .../rivencraft/item/LinkProperties.java | 21 +- .../rivencraft/item/LinkingBookItem.java | 22 +- .../dnorberg/rivencraft/item/PageItem.java | 10 +- .../rivencraft/item/PortLinkDestinations.java | 30 ++ .../rivencraft/menu/LinkModifierMenu.java | 94 +++++ .../network/PortBookNetworking.java | 13 +- .../network/SetLinkModifierTitlePayload.java | 18 + .../rivencraft/portal/PortalBookTravel.java | 8 +- .../rivencraft/registry/ModCreativeTabs.java | 1 + .../writing/PortAuthoredTerrainSelection.java | 14 + .../writing/PortAuthoredWriting.java | 24 +- .../rivencraft/writing/PortRegistryPages.java | 2 +- .../rivencraft/writing/PortSymbolPages.java | 12 +- .../writing/PortWritingRecovery.java | 99 +++++ .../rivencraft/items/link_modifier.json | 1 + 41 files changed, 2611 insertions(+), 77 deletions(-) create mode 100644 ports/mc263/RELEASE_NOTES_1.0.1.129-beta.1.md create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockInstability.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockProfileSavedData.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentalProviders.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardProviders.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardSavedData.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortMeteorProvider.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/LinkModifierBlock.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/LinkModifierBlockEntity.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortLinkDestinations.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierTitlePayload.java create mode 100644 ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortWritingRecovery.java create mode 100644 ports/mc263/src/main/resources/assets/rivencraft/items/link_modifier.json diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 7bf82a4..935d13f 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -2326,3 +2326,265 @@ The user passed .120 and authorized commit, push and publication of the accumula implemented libraries, fallback crystal portals and partial instability/decay from remaining gameplay and optional-integration parity. Tracking issues #98/#102/#104 remain open; #114 still requires the matching 1.21.1 fix. + + +## .121 lightning/explosion providers + +The player passed .120. Adapted the 1.21.1 hazard implementation, independent hazard +scheduler lane, loaded-only ring candidates, Legacy aggregated rates, bounded explosion +calculator, entity/Creative policy, protected-block/spawn checks and event guards. +Modern differences: EntityTypes registry constants, explicit entity spawn reason, +server damage API, gamerule registry, spatial fire-spread permission, BreakBlockEvent, +and explosion damage exposure argument. Since Level.explode now returns void, the +calculator retains its approved positions for bounded post-blast fire and records +whether an uncancelled explosion actually began. Lightning dispatch also reports +whether its entity was accepted. Existing optional mods are not required. + +Per-Age modern SavedData persists attempt count and separate hazard cooldowns (with +the meteor field retained for the next provider port). Default config matches 1.21.1. +Added OP `/rivencraft_port instability hazard lightning|explosions` to skip probability +without bypassing cooldowns, loaded terrain, Age/config/provider policy or spawn +exclusion. Console sources may supply dimension/position explicitly. Neither the +command nor provider registration changes saved instability scores/decks. + +Version 1.0.1.121-beta.1 builds and starts a dedicated server. The user offered manual +damage/block-destruction testing, so the temporary server was stopped before running +those fixtures and the world removed. Effects, visual/audio behavior, damage/block +caps and policy/restart checks are handed over; they are not reported as tested. +No new permanent GameTests, commit, push or publication. Meteor, burning, erosion, +crumble, full grammar recovery and live material profiling remain pending. + + +## .122 meteor provider + +The player passed .121. Extracted the meteor-specific portion of the existing 1.21.1 +AgeEnvironmentalProviders into a focused modern provider. The effect remains the +30-tick flame/smoke flight and roar followed by a strength-3 bounded impact, without +inventing a new rendered entity. Natural chance, randomized chunk/central coordinates, +48-block descent height, headroom/spawn checks, 200-tick cooldown, one active flight +per Age and 16 active Ages retain the root values. + +The selected card uses an independent scheduler pass, sharing the existing hazard +candidate ring and saved attempt/cooldown data. The impact reuses the .121 blast +guards with meteor-specific config (24 blocks, eight entities, 12 damage by default). +Event handlers cancel active flights on disabled policy, absent players, unavailable +target chunk, Age unload or shutdown; session-only descent state is not replayed. +Modern changes use loaded ChunkPos accessors, max-Y API and codec SavedData already +ported for hazards. No optional mod dependency or new asset was introduced. + +Added OP `/rivencraft_port instability meteor`, bypassing chance only. Status reports +meteor config distinctly from potion policy. The original roar is verified in the +built JAR. Version 1.0.1.122-beta.1 builds; effects, damage/protection, cooldown and +cancellation/restart behavior are delegated to the player's manual testing as offered. +No server/test world was launched, no permanent test added, and no commit/publication. +Burning, erosion/crumble, full grammar recovery and live material profiling remain. + + +## .123 burning and erosion + +The player passed .122. Extracted/adapted the 1.21.1 burning/erosion handlers into a +focused provider class, retaining bounded candidate scans, seeded attempt sequence, +independent scheduler budgets and protection/event logic. Burning keeps the active +Legacy card, sky exposure, Survival/Adventure targeting, four targets per pass and +4..12-second duration. The manual pulse targets the player and uses modern water/rain +checking rather than the removed isInWaterRainOrBubble API. + +Erosion stays absent from the Legacy deck, with the prior requested testing default +of enabled at >=10,000 current instability. It converts at most one eligible sampled +block to an adjacent above/horizontal source fluid. Current score resolves creation +contributions or an explicit session override; live profiling is not yet claimed. +The pulse requires a real aimed block within six blocks and the same threshold and +mutation/protection policy. Modern BreakBlockEvent and snapshot placement rollback +are reused. Provider status distinguishes these controls from potion policy. + +Added OP `/rivencraft_port instability environment burning|erosion`. No scores/plans +are changed by provider registration or test pulses. Config names/defaults match the +1.21.1 scorching/erosion sections. Version 1.0.1.123-beta.1 builds; visual/damage/fluid, +exemption, policy and natural scheduling checks are handed to the player. No temporary +server/world or permanent fixtures were created; no commit/push/publication. + + +## .124 live material profiling + +The player passed .123. Crumble has no active 1.21.1 handler, so it remains explicitly +unavailable. Ported AgeBlockInstability and modern per-dimension profile SavedData, +using the same 401/500 readiness thresholds, 1,024 sample cap, watched/native/modded +weights, 5% Overworld allowance, 100,000-point cap and difficulty scaling. Registry +material eligibility reuses the Page pool rules; common ore/storage tags use modern +BlockItems tag accessors. Installed content remains optional; exact weight overrides +retain the existing root syntax and validation. + +Deferred chunk-load sampling processes four entries/tick with a bounded queue. Spawn +completion requests one chunk per second across occupied profiling Ages and their +baseline, with at most one request in flight. The off-thread getChunkFuture path was +verified in the local 26.3 source to avoid main-thread managedBlock. Completion checks +server stop/level identity. Existing sampled chunks are not recounted. Effective +provider plans now combine live points and frozen writing, cache stable totals/seeds, +and retain the original saved plan. Old neutral Ages stay excluded. OP status shows +progress/totals and session override reset restores the live effective plan. + +Version 1.0.1.124-beta.1 builds and starts a dedicated server. Real chunk-load sampling +persisted 25 snapshots and counted a fixture Diamond Block. A bounded restart test +used synthetic sample records, plus resumed actual loads: 423 Age / 522 baseline +samples yielded 2,987 live points (independently calculated), a frozen writing score +of zero and live selected providers. Override reset restored that plan. No hundreds- +of-chunks generation test was run; occupied-Age background generation and generated +material/modded/config checks are delegated to the player. Server stopped and world +removed. No permanent fixtures, commit, push or publication. + + +## .125 ordered controller recovery + +The player passed .124. Added semantic-role metadata to the port symbol index and +a focused recovery pass for singular-controller duplicates/conflicts, category +order, Native-with-biomes and Single-with-extra-biomes. Last singular choice wins; +Single retains the first biome; Native drops authored biome choices. Duplicate or +conflict diagnostics contribute 500 points and order diagnostics 100, following +the corresponding 1.21.1 grammar rules. Costs retain original page indices. + +Binder support and server terrain/environment selection share recovered choices. +Original book/library pages, color boundaries and celestial-chain indices remain +intact; raw number/celestial validation and all unimplemented-page guards still +apply. Missing biome registry entries are checked even if recovery would discard +the page. Ambient Color has its own stage and is not a singular lighting choice. +Existing Ages are unchanged. This is the first recovery slice, not completion of +celestial/dangling/material grammar recovery or season integration. + +Version 1.0.1.125-beta.1 builds; 13 temporary focused checks pass, covering recovery, +costs, preserved chains and rejection boundaries. No server/test world or permanent +fixtures. In-game last-choice behavior, original-page retention and OP diagnostics +are delegated to the player. No commit, push or publication. + + +## .126 ordered celestial and number recovery + +The player passed .125. Ported the 1.21.1 grammar treatment of pending celestial +modifiers and numeric adjacency. Repeated Length/Phase/Angle/Size/Direction pages +before a celestial use the final value of that kind without a duplicate penalty. +Only the final unconsumed modifier of each kind adds 100 dangling-modifier points. +A number without an immediately following supported Color/Length/Phase/Angle/Size +page adds 100 points (misplaced, or dangling at the end). Consecutive numbers leave +only the last available for the next target. Colors still mix in written order. + +These defects no longer block binding. Numbers and celestial compilation retain +the original page sequence and seeded color indices; controller recovery never +moves a number next to a new target. A trailing modifier cannot rewrite an earlier +body, including the optimized single-body compilation path. Missing celestial +branches retain their existing seeded completion; dangling controls do not apply +to those generated bodies. All original book/library pages remain. Unsupported +pages and the ten-sun/ten-moon limit still block binding; existing Ages are unchanged. + +Version 1.0.1.126-beta.1 builds. Seventeen temporary focused checks passed, covering +last-value selection, dangling costs/indices, numeric adjacency, per-body isolation, +seeded color stability, valid numbered writing, body limits, unsupported pages and +cycle codec round-trip. No server/world or permanent test fixture was created. +Manual validation: bind a book with Half Length, Double Length, Sun and verify the +last length; bind Sun followed by Large Size and confirm it does not resize that +sun. Test a trailing Number page and inspect the added writing contribution with +`/rivencraft_port instability status`. Original pages should remain visible. +No commit, push or publication. + + +## .127 Link Modifier station: title and core link properties + +The player passed .126. Started the missing 1.21.1 Link Modifier station port, +reusing its block model, textures, horizontal facing variants and Legacy 176x166 +single-book GUI. The item is available in the main creative tab or via +`/give @s rivencraft:link_modifier`; it has no crafting recipe. As in 1.21.1, +creative acquisition does not impose a new permission restriction on ordinary +book edits once the station has been placed. + +One persistent slot accepts Linking and Descriptive Books, including unbound +books, with normal and shift-click transfers. The server menu handles renaming +and Disarm, Maintain Momentum and Intra Linking toggles. Descriptive Books cannot +toggle Intra Linking, which only governs same-dimension Linking Book travel. +Existing target, author, symbols and Age identity are retained. Renaming changes +the item's custom title, not the Age's saved folder/registry identity. Removing +the title restores the item's normal name. Text editing consumes inventory/hotbar +shortcuts while focused; Escape closes the screen. Programmatic text refreshes do +not send rename packets. Menu identity, owner, distance and live station checks +protect edits; unknown button IDs and non-book inputs are rejected. + +The book is saved with modern ValueInput/ValueOutput container serialization. +Station removal uses block-entity preRemoveSideEffects to release the stored book +once, including non-player removal. No tick handler or renderer is needed. + +This is a station slice, not full Link Modifier parity. Seed editing, Age retirement, +Relative Link, Intra-Linking Only and platform overrides remain unported and are +not presented as working controls. Existing core flag behavior is reused unchanged. +Manual validation: place the station in multiple orientations, insert/shift-click +both book types, rename using text containing E, toggle each available property, +remove the book and use it to check travel effects. Confirm Descriptive Book Intra +Linking is unavailable, restart with a book left in the station, and break a loaded +station to recover one book. Build and dedicated persistence results are recorded +below. No commit, push or publication. + +Version 1.0.1.127-beta.1 builds. Packaged model, GUI, texture, item definition, +loot table and localization checks passed; no station recipe is included. A +fresh dedicated server placed the station and serialized a named Linking Book +with all three core flags. Restart retained the same book/name/flags and station +removal completed normally. The first harness run expected the wrong wording +for vanilla force-load cleanup; the corrected harness completed successfully. +Both temporary worlds were deleted and servers stopped. No permanent tests were +added. UI interaction, transfer counts and modifier travel effects await the +player's in-game check. + + +## .128 six Link Modifier controls + +The player reported that .127 exposed only three of 1.21.1's six controls. Added +the remaining Intra-Linking Only, Relative Link and Generate Platform controls in +the existing two-column, three-row Legacy layout, with matching bit values and +server-owned menu actions. Enabling Intra-Linking Only enables Intra Linking too; +disabling Intra Linking clears Intra-Linking Only. Both intra controls are disabled +for Descriptive Books. Unknown actions and out-of-range menu edits remain rejected. + +Linking Book Intra-Linking Only blocks cross-dimension travel. Relative Link uses +the traveller's block offset from the source world's spawn, applied to destination +spawn, preserving destination yaw/pitch. Normal links retain their precise bound +coordinates. Player and non-player portal travel share the new flags and relative +coordinate helper. Descriptive relative arrivals still require proper footing. + +Generate Platform is off by default for Linking Books. When enabled, a successful +link fills one missing floor block with cloud platform only if the block two above +the arrival is air, matching the 1.21.1 helper. Existing floors are retained. +Descriptive Books instead default to enabled and use Disable Platform internally; +turning it off prevents fallback platform creation and refuses travel if no footed +landing is available. Receptacle tint preparation respects that setting too. +New flags serialize in the shared component and appear in book/panel tooltips. +Existing three-flag books remain valid. Seed editing and Age retirement remain +separate, unfinished station work. + +Version 1.0.1.128-beta.1 builds. A temporary dedicated-server fixture passed 19 +checks: menu actions/dependencies, descriptive intra rejection, numeric flag codec, +relative offsets, platform-off/default behavior, platform placement/preservation, +invalid input and remote edit protection. It did not perform real client travel. +The test server stopped and its world/fixture sources were removed. The production +JAR is rebuilt without that fixture. Manual checks: all six buttons and tooltips, +same/cross-dimension Intra-Linking Only, Relative Link offsets, and platform on/off +for both book types (including a new Void Age without footing). No commit, push +or publication. + + +## .129 Link Modifier mouse input repair + +The player reported that none of the six controls responded. Confirmed the +26.3 input constants in the local Minecraft artifact: left/middle/right are +1/2/3, while the custom Link Modifier handler checked the old left-button value +0. Its server actions therefore passed the prior direct-menu fixture while +real mouse clicks never sent an action. + +Replaced the manually drawn/clicked areas with six registered Minecraft Button +widgets at the same Legacy positions. Their callbacks send the existing validated +menu action IDs, and their custom rendering retains the square controls. Native +input provides correct mouse mapping, focus, click sound and keyboard activation. +All six tooltips expose their On/Off state; unavailable Descriptive Book intra +controls remain disabled. Tooltip objects update only when state changes so their +hover delay is not reset every tick. Book mutations/travel code is unchanged. + +Version 1.0.1.129-beta.1 builds. The native widget's left-button mapping and callback +dispatch were verified in the local Minecraft bytecode. No client click test or +new server run was performed; the previous 19 checks covered server actions, not +client input. Please click each option with an inserted Linking Book and verify +its selected shading and tooltip/book properties change, then repeat supported +options with a Descriptive Book. No commit, push or publication. diff --git a/ports/mc263/README.md b/ports/mc263/README.md index b741a56..0cfbb06 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -9,9 +9,11 @@ biome-distribution pages, selected material/vegetation/structure features, and independent Age weather and lighting, multiple authored or seeded celestial bodies and eclipses, biome/material completion, and random features/structures. Crystal portals and libraries are ported. Instability scoring/provider selection, -status effects and colored decay are partially ported; remaining hazards, live material profiling, -remaining discovery/stations, writing recovery and optional integrations remain unfinished. The latest published beta is -[1.0.1.120-beta.1](RELEASE_NOTES_1.0.1.120-beta.1.md). +status effects, colored decay, lightning, explosions, meteors, burning and erosion, +ordered writing recovery, live material profiling and all six Link Modifier controls are now ported. +Link Modifier seed editing/retirement, remaining discovery/configuration and optional +integrations remain unfinished. The latest published beta is +[1.0.1.129-beta.1](RELEASE_NOTES_1.0.1.129-beta.1.md). Use a test world and keep a backup; do not substitute this JAR for the 1.21.1 mod or import 1.21.1 saves. @@ -2142,3 +2144,390 @@ No permanent test fixtures, commit, push or publication. The player passed every slice, including .120 colored decay, and authorized commit, push and publication as 1.0.1.120-beta.1. Both Minecraft targets build successfully. See the consolidated release notes for the published scope and remaining parity gaps. + + +## Beta .121 — lightning and explosions + +The player passed .120. This slice ports the 1.21.1 lightning/explosion providers, +their separate scheduling lane, persisted per-Age attempt sequence/cooldowns, +loaded-chunk candidate selection and existing damage/protection settings. + +Each selected hazard receives a roll on each admitted Age scheduler pass, independent +of rotating potion/decay providers. Legacy per-chunk/per-tick odds are aggregated as +`1 - (1 - 1/denominator)^(loadedChunks * interval * effectiveCopies)`. Denominators +remain 100,000 for clear-weather lightning, 5,000 during rain plus thunder, and 1,000 +for explosions. Candidate chunks are already-loaded rings around Age players, capped +at 81 by default; no chunks are generated for hazards. Lightning uses the motion-blocking +surface; natural explosions choose a random Y across the dimension's modern height. + +Defaults match 1.21.1: hazards/block damage/player damage/fire enabled, 20-tick minimum +gap per hazard type, strength-3 blasts, at most 16 distinct destructible blocks and +eight damaged entities, and at most 12 damage per entity (lightning itself caps at 5). +Creative/Spectator are excluded. Spawn columns within eight blocks horizontally are +excluded as targets; protected infrastructure, block entities, TNT, portals and +unbreakable blocks resist blasts. Break and placement cancellation checks remain. +World mutation and mobGriefing gate block damage; entity damage and visual/audio +feedback can remain active when block mutation is disabled. + +The modern API no longer returns an explosion/block list. The damage calculator +retains its bounded approved positions for post-blast ignition, only where the block +is now air and fire placement is allowed. Cancelled explosion startup does not count +as dispatch or generate follow-up fire. NeoForge's renamed BreakBlockEvent and 26.3's +`canSpreadFireAround` replace the old break event and doFireTick query respectively. +Vanilla explosion networking and lightning entities provide existing visuals/sounds. +The `instability_hazards` config section retains the 1.21.1 setting names/defaults. + +Operator checks inside an Age: + +- `/rivencraft_port instability hazard lightning` +- `/rivencraft_port instability hazard explosions` + +These skip random probability only, retaining loaded-chunk, cooldown, Age/config/provider +and protected-spawn checks. They target a nearby loaded chunk center, using its surface +for lightning and the command's Y for explosions, rather than the player's exact feet. +Move away from the arrival area and allow at least one second between repeats of the +same hazard. The command reports no dispatch when blocked/cancelled. It also works with +an explicitly positioned console source. Generic `pulse lightning`/`pulse explosions` +retain their natural probability roll; `test_score`/`status` exercise normal selection. +The saved score/plan and catalog protocol are unchanged. + +Build and dedicated-server startup passed. The user offered to validate damage and +block destruction manually, so the server was stopped before effect/terrain fixtures +were run and its temporary world was removed. Manual checks include lightning/thunder, +explosion visuals/sound, damage caps and Creative immunity, block/protected-block limits, +spawn exclusion, world-mutation/mobGriefing/fire settings, cooldown/restart persistence, +and natural rates in occupied Ages. No permanent tests, commit, push or publication. + + +## Beta .122 — meteor flight and impacts + +The player passed .121 lightning/explosion testing. Meteors now reuse the 1.21.1 +particle descent, roar sound, selected Legacy provider card, independent scheduling +lane and bounded blast helper. They are not a new falling-block/entity model: a +flame/smoke trail descends for 30 ticks (1.5 seconds) from up to 48 blocks above a +captured surface target, followed by the impact. The existing meteor roar is bundled. + +Natural scheduling aggregates the Legacy 1-in-50,000 per-chunk/per-tick odds across +loaded chunks, scheduler interval and effective card copies, as in 1.21.1. Targets +are randomized among already-loaded nearby candidate chunks; X/Z vary within the +safe central area of each chunk. Up to 16 candidate chunks are considered per +schedule attempt. Target selection rejects protected spawn columns and surfaces +without headroom. No terrain is loaded or generated by the provider. + +The shared hazard SavedData preserves the meteor cooldown (200 game ticks) and random +attempt sequence. At most one meteor can descend per Age and 16 Ages can have active +flights. A pending descent is session-only: server stop, Age unload, no players, +unloaded target chunk, or disabled Age/global/provider/meteor policy cancels it. +Interrupted flights never replay after restart; the saved cooldown remains. The +flight is removed before impact dispatch so a failed impact cannot repeat. + +The independent `[instability_meteor]` settings match 1.21.1: enabled/block damage/ +player damage/fire default true, maximum 24 impact blocks, eight damaged entities, +and 12 damage per entity, strength 3. Impact reuses protected block/BE/infrastructure, +claim events, Creative/Spectator immunity, world-mutation/mobGriefing and modern fire +permission checks. Disabling block mutation still permits descent, sound and eligible +entity damage. Generic instability status reports meteor availability separately +from potion policy; saved scores/decks and catalog protocol are unchanged. + +Test inside an Age, away from its arrival area: + +`/rivencraft_port instability meteor` + +This skips the natural probability roll, retaining cooldown, loaded target, active +flight and all policy checks. It uses randomized nearby impact locations, unlike +the deliberately fixed chunk-center lightning/explosion test commands. Wait at +least ten seconds before repeating. `/rivencraft_port instability pulse meteors` +keeps the natural chance roll; test-score/status tools exercise saved deck selection. + +The build passed and JAR sound/class packaging was verified. Following the player's +manual validation preference, particle/audio presentation, impacts/damage/protection, +repeat cooldown, disabling/leaving during flight and restart cancellation are handed +over for in-game testing. No server or new world was launched for this slice. No new +permanent tests, commit, push or publication. + + +## Beta .123 — burning and erosion + +The player passed .122 meteor testing. This slice adapts the remaining burning and +erosion portions of 1.21.1 AgeEnvironmentalProviders, using the existing hazard +candidate selection, saved attempt sequence, protection helpers and independent +provider scheduling. No new textures, entities, sounds or optional mods are required. + +Burning remains an active Legacy deck card. Sky-exposed eligible living entities +outside protected spawn can ignite; Survival/Adventure players are included by +default and Creative/Spectator are excluded. Natural processing inspects at most +eight loaded chunks/eight eligible candidates per chunk, ignites at most one per +inspected chunk and four entities per pass by default. The old 1-in-10 per-tick gate +is aggregated over the scheduler interval. Fire lasts four seconds per effective +provider level, capped at twelve. Vanilla water/rain extinguishing and fire immunity +remain in force. Disabling the provider stops new ignitions; existing fire follows +vanilla behavior rather than the potion lease cleanup mechanism. + +Erosion remains outside Legacy's saved card deck. As requested for testing in 1.21.1, +its config defaults enabled and natural attempts run in admitted occupied Ages at a +current score of at least 10,000. Current score uses the frozen creation total or an +explicit session test-score override; live material profiling remains pending. +At most eight random positions are sampled in already-loaded candidate chunks per +pass, with at most one conversion. An eligible block next to a source fluid above or +on a horizontal side is replaced with that fluid's source block; fluid below does +not grow upward. Normal fluid ticks can subsequently spread it. + +Erosion requires world_mutation and mobGriefing, and reuses decay's protection against +spawn/infrastructure, block entities, fluids, critical/unbreakable blocks and protected +tags. Modern break/place claim events and rollback remain. Its `instability_erosion` +enabled toggle does not add a new saved card or alter the Age score. Burning uses +`instability_scorching`: enabled/affect_players default true and max_targets_per_pass +is 4 (range 0–16), matching 1.21.1. + +Operator manual checks inside an Age: + +- On dry ground under open sky, outside arrival protection and in Survival, run + `/rivencraft_port instability environment burning`. It targets the player directly + for four seconds, skips the chance roll, and explicitly reports water/rain that + would immediately extinguish the fire. +- Place ordinary Stone beside or below source water/lava, outside protected arrival. + Use `/rivencraft_port instability test_score 10000`, look at the Stone within six + blocks, and run `/rivencraft_port instability environment erosion`. The aimed + block should become that adjacent source fluid. Existing policies still apply. + End with `/rivencraft_port instability clear_test_score` to restore the saved plan. +- Automatic burning needs a selected burning card; natural erosion uses the enabled + test policy/threshold. `status` exposes both config/threshold information when + inspecting the respective provider, without adding scores to ordinary books. + +Build passed. The player is performing visual/effect testing, so no server/world or +new permanent fixtures were created. Manual validation covers burning/fire damage, +Creative/shelter/spawn exclusion, wet extinguishing feedback, source-fluid conversion, +protected blocks, mobGriefing/world-mutation/config switches and natural scheduling. +No commit, push or publication. Crumble, full writing recovery and live abundance +profiling remain major instability work. + + +## Beta .124 — live material-abundance instability + +The player passed .123. This slice ports 1.21.1's Legacy-style generated-block +profiling and live effective provider selection. Crumble remains a catalog entry +without a registered handler in 1.21.1; no new Crumble behavior is invented here. + +Overworld and runtime-Age chunk loads enqueue bounded sampling work, rather than +accessing SavedData during chunk-storage callbacks. Four snapshots are processed +per server tick, with a queue cap of 2,048. Paletted section counts record watched +block IDs efficiently. Each chunk is sampled once, up to 1,024 per dimension, and +samples/totals persist in per-dimension `rivencraft/block_profile` SavedData. Subsequent +player edits or chunk reloads do not replace or double-count a stored snapshot. + +Live points remain zero until at least **401 Age samples and 500 Overworld baseline +samples** exist, matching 1.21.1. For each watched material, positive excess blocks +per Age chunk above 1.05 times the Overworld average are multiplied by that material's +weight. The total rounds to an integer and caps at 100,000. The watched list retains +Legacy ores/quartz/glowstone/crystal and the existing modern/storage extensions, +including Diamond Blocks, Ancient Debris and Netherite Blocks. + +Recognized ores, mineral storage blocks and source-fluid block types from installed +mods participate automatically via current NeoForge common tags, names and the same +full-block/fluid material eligibility used for Pages. No content mod/API is required. +Unknown ore/storage forms use the 1.21.1 fallback weights; known minerals retain its +named weights. `[instability_modded_materials] block_weight_overrides` accepts exact +`modid:block_id=points` entries, including zero to exclude a modded block. This is +installation-based optional support, not a required mod or per-mod opt-in switch. + +While a profiling-enabled Age is occupied, missing spawn-area samples are requested +gradually: at most one chunk request per second, one in flight across the server, +round-robin between active Ages and the Overworld baseline. Existing samples are +skipped. The modern getChunkFuture API still blocks when invoked on the server +thread, so requests use its off-thread scheduling path and completion returns to +the server thread. Closed levels and stopped-server callbacks are discarded. No +permanent force-load tickets or generation while no Age is occupied are introduced. +Natural exploration also fills the sample pool and can reach readiness sooner. + +Writing contributions and the original saved provider plan remain frozen. The live +score is added only at runtime, and the effective deck is reselected deterministically +from the same saved seed as the total changes. Providers can therefore appear or +change as abundance is measured. Unchanged totals/seeds reuse a cached plan rather +than reshuffling on each query. Older Ages predating instability records remain +neutral; saved neutral arrivals/enable flags do not opt them into new scoring. + +`[instability] difficulty` matches 1.21.1: 0=quarter, 1=half, 2=normal (default), +3=1.75x. It scales writing plus live points without rewriting saved contributions. +A session `test_score` remains an explicit final-score override and is cleared on +restart; clearing it now restores the live effective plan. Erosion threshold checks +use this same effective score. + +Use `/rivencraft_port instability status` inside an Age to inspect frozen/live/effective +scores, difficulty, profile eligibility and both sample counts. Normal book/Binder +screens receive no score/profiler data. Zero live points before both thresholds are +ready are expected. To validate, create/explore an Age with Diamond Block Rocks or +another abundant watched material, watch progress, then confirm live points and +providers change while the frozen writing contribution remains unchanged. Reopen the +world to check sample persistence; repeated reloads should not duplicate counts. + +Build and temporary dedicated-server checks passed. Real loaded snapshots saved 25 +samples per test dimension and captured one deliberately placed fixture Diamond Block. +A restart with synthetic profile records exercised scoring/decoding without generating +hundreds of chunks: resumed real chunk samples produced 423 Age and 522 baseline +samples, with the expected 2,987 live points, frozen score zero and effective providers. +Clearing a session override restored the same live result. This verifies save/scoring +plumbing, not natural bulk generation. The player will check background progress, +actual generated material distribution, modded recognition and difficulty/config +changes. Test server stopped/world deleted; no permanent tests, commit or publication. + + +## .125 ordered controller recovery + +The player passed .124. New authored Ages now recover repeated singular terrain, +biome-distribution, weather, lighting and sky choices: the last choice wins, with +500 instability per duplicate or conflict. Supported category-order mistakes add +100 points instead of blocking binding. Native distribution discards authored +biome choices with a 500-point conflict; Single retains the first authored biome +with a 500-point conflict when extras exist. These diagnostics use the 1.21.1 +writing rules. Season pages remain unsupported until their integration is ported. + +The original pages remain in the book and library source list. Generation receives +recovered choices without rearranging material/feature or per-celestial modifier +chains. Existing Ages are not rescored. OP instability status shows the writing +contributions; normal book/Binder screens do not expose the score or new warnings. +Unknown/unported pages and other unsupported combinations still prevent binding. +Celestial, number and dangling-modifier recovery is a later slice: their existing +validation still applies to the original page sequence, before controller recovery. + +Version 1.0.1.125-beta.1 builds. Thirteen temporary focused checks passed for +last-choice selection, duplicate/conflict/order costs, Native/Single recovery, +unimplemented pages, raw numeric guards, celestial-chain preservation, intentional +feature repeats and Ambient Color ordering. No server/world was launched. In-game +validation: bind books with conflicting terrain/weather choices and confirm the +last choice takes effect; test Native with biome pages and Single with two biomes; +check that all original pages remain and OP status reports the writing costs. +No commit, push or publication. + + +## .126 ordered celestial and number recovery + +The player passed .125. Ported the 1.21.1 grammar treatment of pending celestial +modifiers and numeric adjacency. Repeated Length/Phase/Angle/Size/Direction pages +before a celestial use the final value of that kind without a duplicate penalty. +Only the final unconsumed modifier of each kind adds 100 dangling-modifier points. +A number without an immediately following supported Color/Length/Phase/Angle/Size +page adds 100 points (misplaced, or dangling at the end). Consecutive numbers leave +only the last available for the next target. Colors still mix in written order. + +These defects no longer block binding. Numbers and celestial compilation retain +the original page sequence and seeded color indices; controller recovery never +moves a number next to a new target. A trailing modifier cannot rewrite an earlier +body, including the optimized single-body compilation path. Missing celestial +branches retain their existing seeded completion; dangling controls do not apply +to those generated bodies. All original book/library pages remain. Unsupported +pages and the ten-sun/ten-moon limit still block binding; existing Ages are unchanged. + +Version 1.0.1.126-beta.1 builds. Seventeen temporary focused checks passed, covering +last-value selection, dangling costs/indices, numeric adjacency, per-body isolation, +seeded color stability, valid numbered writing, body limits, unsupported pages and +cycle codec round-trip. No server/world or permanent test fixture was created. +Manual validation: bind a book with Half Length, Double Length, Sun and verify the +last length; bind Sun followed by Large Size and confirm it does not resize that +sun. Test a trailing Number page and inspect the added writing contribution with +`/rivencraft_port instability status`. Original pages should remain visible. +No commit, push or publication. + + +## .127 Link Modifier station: title and core link properties + +The player passed .126. Started the missing 1.21.1 Link Modifier station port, +reusing its block model, textures, horizontal facing variants and Legacy 176x166 +single-book GUI. The item is available in the main creative tab or via +`/give @s rivencraft:link_modifier`; it has no crafting recipe. As in 1.21.1, +creative acquisition does not impose a new permission restriction on ordinary +book edits once the station has been placed. + +One persistent slot accepts Linking and Descriptive Books, including unbound +books, with normal and shift-click transfers. The server menu handles renaming +and Disarm, Maintain Momentum and Intra Linking toggles. Descriptive Books cannot +toggle Intra Linking, which only governs same-dimension Linking Book travel. +Existing target, author, symbols and Age identity are retained. Renaming changes +the item's custom title, not the Age's saved folder/registry identity. Removing +the title restores the item's normal name. Text editing consumes inventory/hotbar +shortcuts while focused; Escape closes the screen. Programmatic text refreshes do +not send rename packets. Menu identity, owner, distance and live station checks +protect edits; unknown button IDs and non-book inputs are rejected. + +The book is saved with modern ValueInput/ValueOutput container serialization. +Station removal uses block-entity preRemoveSideEffects to release the stored book +once, including non-player removal. No tick handler or renderer is needed. + +This is a station slice, not full Link Modifier parity. Seed editing, Age retirement, +Relative Link, Intra-Linking Only and platform overrides remain unported and are +not presented as working controls. Existing core flag behavior is reused unchanged. +Manual validation: place the station in multiple orientations, insert/shift-click +both book types, rename using text containing E, toggle each available property, +remove the book and use it to check travel effects. Confirm Descriptive Book Intra +Linking is unavailable, restart with a book left in the station, and break a loaded +station to recover one book. Build and dedicated persistence results are recorded +below. No commit, push or publication. + +Version 1.0.1.127-beta.1 builds. Packaged model, GUI, texture, item definition, +loot table and localization checks passed; no station recipe is included. A +fresh dedicated server placed the station and serialized a named Linking Book +with all three core flags. Restart retained the same book/name/flags and station +removal completed normally. The first harness run expected the wrong wording +for vanilla force-load cleanup; the corrected harness completed successfully. +Both temporary worlds were deleted and servers stopped. No permanent tests were +added. UI interaction, transfer counts and modifier travel effects await the +player's in-game check. + + +## .128 six Link Modifier controls + +The player reported that .127 exposed only three of 1.21.1's six controls. Added +the remaining Intra-Linking Only, Relative Link and Generate Platform controls in +the existing two-column, three-row Legacy layout, with matching bit values and +server-owned menu actions. Enabling Intra-Linking Only enables Intra Linking too; +disabling Intra Linking clears Intra-Linking Only. Both intra controls are disabled +for Descriptive Books. Unknown actions and out-of-range menu edits remain rejected. + +Linking Book Intra-Linking Only blocks cross-dimension travel. Relative Link uses +the traveller's block offset from the source world's spawn, applied to destination +spawn, preserving destination yaw/pitch. Normal links retain their precise bound +coordinates. Player and non-player portal travel share the new flags and relative +coordinate helper. Descriptive relative arrivals still require proper footing. + +Generate Platform is off by default for Linking Books. When enabled, a successful +link fills one missing floor block with cloud platform only if the block two above +the arrival is air, matching the 1.21.1 helper. Existing floors are retained. +Descriptive Books instead default to enabled and use Disable Platform internally; +turning it off prevents fallback platform creation and refuses travel if no footed +landing is available. Receptacle tint preparation respects that setting too. +New flags serialize in the shared component and appear in book/panel tooltips. +Existing three-flag books remain valid. Seed editing and Age retirement remain +separate, unfinished station work. + +Version 1.0.1.128-beta.1 builds. A temporary dedicated-server fixture passed 19 +checks: menu actions/dependencies, descriptive intra rejection, numeric flag codec, +relative offsets, platform-off/default behavior, platform placement/preservation, +invalid input and remote edit protection. It did not perform real client travel. +The test server stopped and its world/fixture sources were removed. The production +JAR is rebuilt without that fixture. Manual checks: all six buttons and tooltips, +same/cross-dimension Intra-Linking Only, Relative Link offsets, and platform on/off +for both book types (including a new Void Age without footing). No commit, push +or publication. + + +## .129 Link Modifier mouse input repair + +The player reported that none of the six controls responded. Confirmed the +26.3 input constants in the local Minecraft artifact: left/middle/right are +1/2/3, while the custom Link Modifier handler checked the old left-button value +0. Its server actions therefore passed the prior direct-menu fixture while +real mouse clicks never sent an action. + +Replaced the manually drawn/clicked areas with six registered Minecraft Button +widgets at the same Legacy positions. Their callbacks send the existing validated +menu action IDs, and their custom rendering retains the square controls. Native +input provides correct mouse mapping, focus, click sound and keyboard activation. +All six tooltips expose their On/Off state; unavailable Descriptive Book intra +controls remain disabled. Tooltip objects update only when state changes so their +hover delay is not reset every tick. Book mutations/travel code is unchanged. + +Version 1.0.1.129-beta.1 builds. The native widget's left-button mapping and callback +dispatch were verified in the local Minecraft bytecode. No client click test or +new server run was performed; the previous 19 checks covered server actions, not +client input. Please click each option with an inserted Linking Book and verify +its selected shading and tooltip/book properties change, then repeat supported +options with a Descriptive Book. No commit, push or publication. diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.129-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.129-beta.1.md new file mode 100644 index 0000000..08cb8f8 --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.129-beta.1.md @@ -0,0 +1,68 @@ +# Rivencraft 26.3 beta 1.0.1.129-beta.1 + +Collects the player-validated .121–.129 port slices since 1.0.1.120-beta.1. +For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent Minecraft +1.21.1 release is unchanged. Full parity and optional integrations remain in progress. + +## Instability hazards and live profiling + +- Ported lightning, explosions, falling meteor effects, burning and erosion with + bounded loaded-chunk scans, spawn protection, claim events and configurable + world/entity damage. Meteor flight uses the existing sound and particle effects. +- Natural hazards choose randomized locations; operator test commands provide + targeted checks with independent cooldowns. Erosion retains its requested testing + default of enabled above 10,000 effective instability, outside the Legacy deck. +- Ported persistent live material profiling, including native ores, valuable blocks, + recognized installed-mod materials and configurable modded block weight overrides. + No content mod is required. Profiling starts scoring after 401 Age and 500 + Overworld baseline samples, with bounded gradual background sampling while an + eligible Age is occupied. A chunk snapshot is counted once. +- Effective instability and deterministic provider selection now update as material + abundance is measured; the original writing contribution remains frozen. Added + difficulty scaling and operator-only sampling/score diagnostics. Older neutral + Ages remain neutral. Normal book/Binder screens do not reveal scores. + +## Ordered writing recovery + +- Repeated singular terrain, distribution, weather, lighting and sky controllers + use the last choice, adding 500 instability per duplicate/conflict. Category-order + mistakes add 100. Native distribution ignores supplied biomes; Single retains the + first biome when too many are supplied, with conflict penalties. +- Repeated celestial modifiers use the final pending value of each kind. Dangling + modifiers and misplaced/trailing numbers allow binding and add 100 instability. + Numbers only attach to the immediately following supported target. +- Preserved original book/library pages, ordered color mixing and independent + celestial chains. A trailing modifier cannot change an earlier sun or moon. + Unported pages, unsupported combinations and body limits remain enforced. + +## Link Modifier station + +- Added the creative-only Link Modifier with its existing model, textures and + Legacy single-book GUI. There is no crafting recipe. +- Books can be renamed and edited through all six controls: Disarm, Intra-Linking, + Intra-Linking Only, Maintain Momentum, Relative Link and Generate Platform. +- Intra-Linking Only enables Intra-Linking and prevents cross-dimension Linking + Book travel. Both intra controls are unavailable for Descriptive Books. +- Relative Link preserves the traveller's offset from the source dimension spawn + at the destination spawn. Linking Book platform generation defaults off; + Descriptive Book fallback platforms default on and can be disabled. +- Player and non-player portal paths share the new properties. Book contents and + flags persist in the station. Removal releases its stored book. +- Fixed the controls ignoring real clicks: 26.3 left-click is button 1, not 0. + Registered native button widgets now handle input, focus, sound and state tooltips. + +## Validation and remaining work + +The player passed every included slice, including the six controls and click fix. +Both Minecraft builds pass (no standalone Gradle test sources). Targeted temporary +checks covered grammar/celestial recovery, component serialization, station actions, +platform rules and dedicated-server save/restart behavior. Live profiling was checked +with real snapshots plus synthetic threshold records; natural gameplay was validated +by the player. Temporary servers were stopped and their test worlds removed. + +Link Modifier seed editing/retirement, remaining discovery/config policy and optional +integrations are not complete. Live previews remain unavailable; books and portals +use the animated fallback. Crumble has no active 1.21.1 handler and is not invented +in this port. The dual-version follow-up issues and full multiplayer/parity gate +remain open. Use matching client/server beta builds and keep Minecraft version +tracks and their saves separate. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index a55b062..74976bc 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -244,6 +244,12 @@ tasks.named('processResources', ProcessResources).configure { include 'assets/rivencraft/textures/block/book_binder.png' include 'assets/rivencraft/textures/block/book_binder_press.png' include 'assets/rivencraft/textures/gui/book_binder_legacy.png' + include 'assets/rivencraft/textures/gui/link_modifier_legacy.png' + include 'assets/rivencraft/textures/block/link_modifier_*.png' + include 'assets/rivencraft/models/block/link_modifier.json' + include 'assets/rivencraft/models/item/link_modifier.json' + include 'assets/rivencraft/blockstates/link_modifier.json' + include 'data/rivencraft/loot_table/blocks/link_modifier.json' include 'assets/rivencraft/textures/gui/ink_mixer_legacy.png' include 'assets/rivencraft/textures/block/inkwell_base.png' include 'assets/rivencraft/textures/block/inkwell_cup.png' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index 4abf7bd..6c388bc 100644 --- a/ports/mc263/gradle.properties +++ b/ports/mc263/gradle.properties @@ -7,4 +7,4 @@ neo_version=26.3.0.99 mod_id=rivencraft mod_name=Rivencraft mod_license=LGPL-3.0-or-later -mod_version=1.0.1.120-beta.1 +mod_version=1.0.1.129-beta.1 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index e6094a8..ad6f994 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -164,6 +164,15 @@ public final class Rivencraft { "book_binder", PortBookBinderBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()); public static final DeferredItem BOOK_BINDER_ITEM = ITEMS.registerSimpleBlockItem(BOOK_BINDER); + public static final DeferredBlock LINK_MODIFIER = BLOCKS.registerBlock( + "link_modifier", net.dnorberg.rivencraft.block.LinkModifierBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).noOcclusion()); + public static final DeferredItem LINK_MODIFIER_ITEM = ITEMS.registerSimpleBlockItem(LINK_MODIFIER); + public static final Supplier> LINK_MODIFIER_BLOCK_ENTITY = + BLOCK_ENTITY_TYPES.register("link_modifier", () -> new BlockEntityType<>( + net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity::new, LINK_MODIFIER.get())); + public static final Supplier> LINK_MODIFIER_MENU = + MENUS.register("link_modifier", () -> IMenuTypeExtension.create(net.dnorberg.rivencraft.menu.LinkModifierMenu::new)); public static final DeferredBlock INK_MIXER = BLOCKS.registerBlock( "ink_mixer", InkMixerBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()); @@ -254,6 +263,9 @@ public final class Rivencraft { modEventBus.addListener(PortBookNetworking::registerPayloads); net.dnorberg.rivencraft.age.PortStatusEffectProviders.register(); net.dnorberg.rivencraft.age.PortDecayProviders.register(); + net.dnorberg.rivencraft.age.PortHazardProviders.register(); + net.dnorberg.rivencraft.age.PortMeteorProvider.register(); + net.dnorberg.rivencraft.age.PortEnvironmentalProviders.register(); LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)"); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java index 0eaf46f..db5931a 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeColors.java @@ -69,7 +69,8 @@ public record PortAgeColors(int sky, int ambient, int celestial) { } public static PortAgeColors compile(List pages, long seed) { - if (!validNumbers(pages)) throw new IllegalArgumentException("Number pages must precede a supported Color, Length, Phase, Angle, or Size modifier"); + // Numbers only attach to the immediately following target. Misplaced + // numbers are diagnosed by PortWritingRecovery, never moved to a later color. int red = 0, green = 0, blue = 0, sky = -1, ambient = -1, celestial = -1; boolean written = false; for (int i = 0; i < pages.size(); i++) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java index 9a0433c..dab83fa 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeCommands.java @@ -819,8 +819,8 @@ public final class PortAgeCommands { || new PortDayCycle(true, 2, 0).ticks(12000) != 6000 || new PortDayCycle(true, 0, 321).ticks(-123456) != 321 || new PortDayCycle(true, 0, 321).ticks(123456) != 321 - || PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half"))) - || PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half")))) + || !PortDayCycle.validWriting(java.util.List.of(symbol.apply("period/half"))) + || !PortDayCycle.validWriting(java.util.List.of(PortDayCycle.NORMAL_TIME, symbol.apply("period/half")))) throw new IllegalStateException("Day cycle fixture failed"); var server = context.getSource().getServer(); var biomes = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(pages, server.registryAccess()); @@ -1616,7 +1616,7 @@ public final class PortAgeCommands { || Math.abs(moving.bodyAngle(origin + 100) + 3F) > 0.0001F || !PortDayCycle.validWriting(java.util.List.of(target, PortDayCycle.NORMAL_TIME)) || !PortDayCycle.validWriting(java.util.List.of(target, target)) - || PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large")))) + || !PortDayCycle.validWriting(java.util.List.of(target, symbol.apply("size/large")))) throw new IllegalStateException("Single-body modifier/order invariant failed"); } var older = PortDayCycle.CODEC.parse(com.mojang.serialization.JsonOps.INSTANCE, diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java index 370c0a9..bd218e1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortAgeTravel.java @@ -18,33 +18,45 @@ public final class PortAgeTravel { } public static String enter(ServerPlayer player, Identifier id) { + return enter(player, id, net.dnorberg.rivencraft.item.LinkProperties.NONE); + } + public static String enter(ServerPlayer player, Identifier id, net.dnorberg.rivencraft.item.LinkProperties properties) { ServerLevel target = player.level().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); if (target == null) return "Age not loaded: " + id; if (!AgeCatalogSync.ready(player, id)) { return "Age catalog not acknowledged by client; remaining in source dimension"; } - BlockPos landing = resolveLanding(target); + BlockPos landing = resolveLanding(target, player, properties); if (landing == null) return "No safe landing available; remaining in source dimension"; boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, Set.of(), player.getYRot(), player.getXRot(), true); return moved ? null : "Teleportation failed; remaining in source dimension"; } - public static BlockPos resolveLanding(ServerLevel target) { + public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); } + public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity, + net.dnorberg.rivencraft.item.LinkProperties properties) { + BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM)); + if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing; + return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin( + (ServerLevel) entity.level(), target, entity.blockPosition())).orElse(null); + } + public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) { boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features && features.terrainFeatures() != null && features.terrainFeatures().starFissures(); BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null; if (landing != null) landing = isCaveTerrain(target) ? findFootedDestination(target, landing).orElse(null) : findSurfaceLanding(target, landing.getX(), landing.getZ()); - if (landing == null) landing = findLanding(target); + if (landing == null) landing = findLanding(target, generatePlatform); if (landing == null) return null; if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target)); if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing); return landing; } - static BlockPos findLanding(ServerLevel target) { + static BlockPos findLanding(ServerLevel target) { return findLanding(target, true); } + static BlockPos findLanding(ServerLevel target, boolean generatePlatform) { if (isCaveTerrain(target)) { // The cave roof is not a valid arrival surface. Search down from // Legacy's Y=64 start, then construct the normal safety platform @@ -60,7 +72,7 @@ public final class PortAgeTravel { } } } - return makePlatform(target, new BlockPos(0, 64, 0)); + return generatePlatform ? makePlatform(target, new BlockPos(0, 64, 0)) : null; } BlockPos surface = findSurfaceLanding(target, 0, 0); if (surface != null) return surface; @@ -68,7 +80,7 @@ public final class PortAgeTravel { // beneath it. Void Ages retain the usual platform floor at Y=63. BlockPos obstruction = highestObstruction(target, 0, 0); int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3); - return makePlatform(target, new BlockPos(0, y, 0)); + return generatePlatform ? makePlatform(target, new BlockPos(0, y, 0)) : null; } static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockInstability.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockInstability.java new file mode 100644 index 0000000..a16f7e0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockInstability.java @@ -0,0 +1,314 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import net.dnorberg.rivencraft.Rivencraft; + +import net.dnorberg.rivencraft.writing.PortRegistryPages; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.neoforged.neoforge.common.Tags; +import net.neoforged.neoforge.event.level.ChunkEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +/** Legacy-style excess-block profiling with gradual spawn-area generation. */ +@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class PortBlockInstability { + // Legacy's watched blocks and factor-1 values; modern ore variants share their parent weight. + // Storage blocks are deliberate extensions, including the requested diamond-block case. + private static final Map VANILLA_WEIGHTS = weights(); + private static final Map COMMON_MINERAL_WEIGHTS = Map.ofEntries( + Map.entry("coal", 5.0), Map.entry("sulfur", 5.0), Map.entry("sulphur", 5.0), + Map.entry("salt", 5.0), Map.entry("tin", 60.0), Map.entry("lead", 60.0), + Map.entry("aluminum", 60.0), Map.entry("aluminium", 60.0), + Map.entry("zinc", 60.0), Map.entry("copper", 60.0), + Map.entry("iron", 60.0), Map.entry("nickel", 200.0), + Map.entry("osmium", 200.0), Map.entry("silver", 200.0), + Map.entry("emerald", 200.0), Map.entry("ruby", 200.0), + Map.entry("sapphire", 200.0), Map.entry("redstone", 250.0), + Map.entry("gold", 750.0), Map.entry("uranium", 750.0), + Map.entry("platinum", 750.0), Map.entry("diamond", 4000.0), + Map.entry("iridium", 4000.0), Map.entry("netherite", 8000.0)); + private static final int MAX_PENDING = 2048; + private static final int SAMPLES_PER_TICK = 4; + private static final int GENERATION_INTERVAL_TICKS = 20; + private static final List SPAWN_OFFSETS = spawnOffsets(); + private static final Map> PENDING = new java.util.WeakHashMap<>(); + private static final Map> NEXT_SPAWN_SAMPLE = + new java.util.WeakHashMap<>(); + private static final Map NEXT_TARGET = new java.util.WeakHashMap<>(); + private static final Map GENERATION_IN_FLIGHT = + new java.util.WeakHashMap<>(); + + private PortBlockInstability() {} + + /** Track recognized materials from installed mods without requiring any mod-specific API. */ + static boolean tracked(Identifier id) { + if (VANILLA_WEIGHTS.containsKey(id)) return true; + if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false; + if (!BuiltInRegistries.BLOCK.containsKey(id)) return false; + var block = BuiltInRegistries.BLOCK.getValue(id); + return eligibleMaterial(block) && (configuredOverride(id) != null || inferredModdedWeight(id, block) > 0); + } + + static double weight(Identifier id) { + Double vanilla = VANILLA_WEIGHTS.get(id); + if (vanilla != null) return vanilla; + if (id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft") + || !BuiltInRegistries.BLOCK.containsKey(id)) return 0; + var block = BuiltInRegistries.BLOCK.getValue(id); + if (!eligibleMaterial(block)) return 0; + return moddedWeight(id, inferredModdedWeight(id, block), + PortInstabilityConfig.moddedMaterialOverrides()); + } + + private static boolean eligibleMaterial(net.minecraft.world.level.block.Block block) { + return PortRegistryPages.materialCategory(block) != null; + } + + private static Double configuredOverride(Identifier id) { + return configuredOverride(id, PortInstabilityConfig.moddedMaterialOverrides()); + } + + private static Double configuredOverride(Identifier id, List overrides) { + for (String entry : overrides) { + int separator = entry.lastIndexOf('='); + if (separator > 0 && entry.substring(0, separator).equals(id.toString())) + return (double) Integer.parseInt(entry.substring(separator + 1)); + } + return null; + } + + static double moddedWeight(Identifier id, double inferred, List overrides) { + Double override = configuredOverride(id, overrides); + return override == null ? inferred : override; + } + + private static double inferredModdedWeight(Identifier id, net.minecraft.world.level.block.Block block) { + if ("rivencraft:fluid".equals(PortRegistryPages.materialCategory(block))) return 1.25; + var state = block.defaultBlockState(); + String path = id.getPath(); + boolean ore = state.is(Tags.BlockItems.ORES.block()) || path.endsWith("_ore") || path.contains("_ore_"); + boolean storage = state.is(Tags.BlockItems.STORAGE_BLOCKS.block()) + || (path.endsWith("_block") || path.startsWith("block_of_")) + && namedMineralWeight(path) != null; + return inferredModdedWeight(id, ore, storage, false); + } + + static double inferredModdedWeight(Identifier id, boolean ore, boolean storage, boolean fluid) { + if (fluid) return 1.25; + Double named = namedMineralWeight(id.getPath()); + if (ore) return named == null ? 60.0 : named; + if (storage) return named == null ? 120.0 : named; + return 0; + } + + private static Double namedMineralWeight(String path) { + for (String part : path.split("[/_]")) { + Double value = COMMON_MINERAL_WEIGHTS.get(part); + if (value != null) return value; + } + return null; + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onChunkLoad(ChunkEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel level) + || !(event.getChunk() instanceof LevelChunk chunk)) return; + boolean overworld = level.dimension() == net.minecraft.world.level.Level.OVERWORLD; + if (!overworld && !PortInstabilityRuntime.isAge(level)) return; + // Chunk-load callbacks can occur while chunk storage is updating. Defer saved-data + // access to a bounded server-tick lane to avoid reentering that storage path. + synchronized (PENDING) { + var queue = PENDING.computeIfAbsent(level.getServer(), ignored -> new ArrayDeque<>()); + if (queue.size() < MAX_PENDING) queue.addLast(new Pending(level, chunk)); + } + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onServerTickPost(ServerTickEvent.Post event) { + for (int index = 0; index < SAMPLES_PER_TICK; index++) { + Pending pending; + synchronized (PENDING) { + var queue = PENDING.get(event.getServer()); + pending = queue == null ? null : queue.pollFirst(); + } + if (pending == null) break; + var pos = pending.chunk().getPos(); + if (pending.level().getServer().getLevel(pending.level().dimension()) == pending.level() + && pending.level().getChunkSource().getChunkNow(pos.x(), pos.z()) == pending.chunk()) + PortBlockProfileSavedData.get(pending.level()).sample(pending.chunk()); + } + if (event.getServer().getTickCount() % GENERATION_INTERVAL_TICKS == 0) { + generateNextSpawnSample(event.getServer()); + } + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onServerStopping(ServerStoppingEvent event) { + synchronized (PENDING) { PENDING.remove(event.getServer()); } + NEXT_SPAWN_SAMPLE.remove(event.getServer()); + NEXT_TARGET.remove(event.getServer()); + GENERATION_IN_FLIGHT.remove(event.getServer()); + } + + /** Generate at most one chunk per second across the active Age and its Overworld baseline. */ + private static void generateNextSpawnSample(MinecraftServer server) { + if (GENERATION_IN_FLIGHT.containsKey(server)) return; + List targets = new ArrayList<>(); + for (ServerLevel level : server.getAllLevels()) { + if (!PortInstabilityRuntime.isAge(level) + || level.players().isEmpty()) continue; + var data = PortInstabilityData.get(server); + if (!data.supportsProfiling(level)) continue; + var age = data.age(level); + targets.add(new SampleTarget(level, age.spawn().orElseGet(() -> level.getRespawnData().pos()), + PortBlockProfileSavedData.MIN_AGE_SAMPLES)); + } + if (targets.isEmpty()) return; + ServerLevel overworld = server.overworld(); + targets.add(0, new SampleTarget(overworld, overworld.getRespawnData().pos(), + PortBlockProfileSavedData.MIN_BASELINE_SAMPLES)); + int first = Math.floorMod(NEXT_TARGET.getOrDefault(server, 0), targets.size()); + for (int offset = 0; offset < targets.size(); offset++) { + int index = (first + offset) % targets.size(); + SampleTarget target = targets.get(index); + if (generateNextSpawnSample(target.level(), target.origin(), target.minimum(), server)) { + NEXT_TARGET.put(server, index + 1); + return; + } + } + } + + private static boolean generateNextSpawnSample(ServerLevel level, net.minecraft.core.BlockPos origin, + int minimum, MinecraftServer server) { + PortBlockProfileSavedData profile = PortBlockProfileSavedData.get(level); + if (profile.count() >= minimum) return false; + Map cursors = NEXT_SPAWN_SAMPLE.computeIfAbsent(server, + ignored -> new HashMap<>()); + Identifier dimension = level.dimension().identifier(); + int index = cursors.getOrDefault(dimension, 0); + ChunkPos center = ChunkPos.containing(origin); + while (index < SPAWN_OFFSETS.size()) { + ChunkPos offset = SPAWN_OFFSETS.get(index++); + ChunkPos target = new ChunkPos(center.x() + offset.x(), center.z() + offset.z()); + if (profile.contains(target)) continue; + cursors.put(dimension, index); + GENERATION_IN_FLIGHT.put(server, true); + // getChunkFuture blocks when called on the server thread. Its off-thread + // path schedules generation on Minecraft's chunk executor instead. + CompletableFuture.supplyAsync(() -> level.getChunkSource() + .getChunkFuture(target.x(), target.z(), ChunkStatus.FULL, true)) + .thenCompose(future -> future) + .whenComplete((result, error) -> { + if (server.isStopped()) return; + server.execute(() -> { + if (GENERATION_IN_FLIGHT.remove(server) == null) return; + if (error != null) { + Rivencraft.LOGGER.warn("Could not profile chunk {} in {}", target, dimension, error); + } else if (server.getLevel(level.dimension()) == level + && result.orElse(null) instanceof LevelChunk chunk) { + profile.sample(chunk); + } + }); + }); + return true; + } + return false; + } + + private static List spawnOffsets() { + List offsets = new ArrayList<>(PortBlockProfileSavedData.MAX_SAMPLES); + offsets.add(new ChunkPos(0, 0)); + for (int radius = 1; offsets.size() < PortBlockProfileSavedData.MAX_SAMPLES; radius++) { + for (int x = -radius; x <= radius; x++) { + offsets.add(new ChunkPos(x, -radius)); + offsets.add(new ChunkPos(x, radius)); + } + for (int z = -radius + 1; z < radius; z++) { + offsets.add(new ChunkPos(-radius, z)); + offsets.add(new ChunkPos(radius, z)); + } + } + return List.copyOf(offsets.subList(0, PortBlockProfileSavedData.MAX_SAMPLES)); + } + + /** Zero until the Age exceeds 400 samples and the baseline reaches 500. */ + static int score(PortBlockProfileSavedData age, PortBlockProfileSavedData baseline) { + return score(age.totals(), age.count(), baseline.totals(), baseline.count()); + } + + static int score(Map age, int ageChunks, + Map baseline, int baselineChunks) { + if (ageChunks < PortBlockProfileSavedData.MIN_AGE_SAMPLES + || baselineChunks < PortBlockProfileSavedData.MIN_BASELINE_SAMPLES) return 0; + double result = 0; + Set watched = new HashSet<>(age.keySet()); + watched.addAll(baseline.keySet()); + for (Identifier id : watched) { + double weight = weight(id); + if (weight <= 0) continue; + double excess = age.getOrDefault(id, 0L) / (double) ageChunks + - 1.05 * baseline.getOrDefault(id, 0L) / (double) baselineChunks; + if (excess > 0) result += excess * weight; + } + return (int) Math.min(100_000, Math.round(result)); + } + + public static int score(ServerLevel level) { + if (!PortInstabilityRuntime.isAge(level) || !PortInstabilityData.get(level.getServer()).supportsProfiling(level)) return 0; + return score(PortBlockProfileSavedData.get(level), PortBlockProfileSavedData.get(level.getServer().overworld())); + } + public static int sampledChunks(ServerLevel level) { return PortBlockProfileSavedData.get(level).count(); } + + public static int difficultyScore(long rawScore, int difficulty) { + double multiplier = switch (difficulty) { + case 0 -> 0.25; + case 1 -> 0.5; + case 2 -> 1.0; + case 3 -> 1.75; + default -> throw new IllegalArgumentException("Unknown instability difficulty " + difficulty); + }; + return (int) Math.min(Integer.MAX_VALUE, Math.max(0, rawScore * multiplier)); + } + + private static Map weights() { + Map values = new HashMap<>(); + add(values, 5, "coal_ore", "deepslate_coal_ore", "lapis_ore", "deepslate_lapis_ore"); + add(values, 60, "iron_ore", "deepslate_iron_ore"); + add(values, 200, "emerald_ore", "deepslate_emerald_ore"); + add(values, 250, "redstone_ore", "deepslate_redstone_ore"); + add(values, 750, "gold_ore", "deepslate_gold_ore"); + add(values, 4000, "diamond_ore", "deepslate_diamond_ore", "diamond_block"); + add(values, 20, "nether_quartz_ore"); + add(values, 50, "glowstone"); + values.put(Identifier.parse("rivencraft:crystal_block"), 20.0); + // Valuable blocks introduced after Legacy, and storage forms authorable in Rivencraft. + add(values, 200, "emerald_block"); + add(values, 750, "gold_block", "raw_gold_block"); + add(values, 4000, "ancient_debris"); + add(values, 60, "iron_block", "raw_iron_block"); + add(values, 8000, "netherite_block"); + return Map.copyOf(values); + } + + private static void add(Map values, int weight, String... paths) { + for (String path : paths) values.put(Identifier.withDefaultNamespace(path), (double) weight); + } + + private record Pending(ServerLevel level, LevelChunk chunk) {} + + private record SampleTarget(ServerLevel level, net.minecraft.core.BlockPos origin, int minimum) {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockProfileSavedData.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockProfileSavedData.java new file mode 100644 index 0000000..1245e9e --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortBlockProfileSavedData.java @@ -0,0 +1,63 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Persistent generated-chunk snapshots. Each chunk is counted once, up to the 1.21.1 cap. */ +final class PortBlockProfileSavedData extends SavedData { + static final int MIN_AGE_SAMPLES = 401, MIN_BASELINE_SAMPLES = 500, MAX_SAMPLES = 1024; + private static final int VERSION = 2; + private record Sample(long chunk, Map counts) { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.LONG.fieldOf("chunk").forGetter(Sample::chunk), + Codec.unboundedMap(Identifier.CODEC, Codec.intRange(1, Integer.MAX_VALUE)) + .fieldOf("blocks").forGetter(Sample::counts) + ).apply(instance, Sample::new)); + } + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("version").forGetter(data -> VERSION), + Sample.CODEC.listOf(0, MAX_SAMPLES).fieldOf("samples").forGetter(data -> data.samples.entrySet().stream() + .map(entry -> new Sample(entry.getKey(), entry.getValue())).toList()) + ).apply(instance, PortBlockProfileSavedData::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "block_profile"), + () -> new PortBlockProfileSavedData(VERSION, List.of()), CODEC); + private final Map> samples = new HashMap<>(); + private final Map totals = new HashMap<>(); + private PortBlockProfileSavedData(int version, List values) { + if (version == VERSION) for (var value : values) put(value.chunk(), value.counts()); + } + static PortBlockProfileSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); } + int count() { return samples.size(); } + boolean contains(ChunkPos pos) { return samples.containsKey(pos.pack()); } + Map totals() { return Map.copyOf(totals); } + boolean sample(LevelChunk chunk) { + if (count() >= MAX_SAMPLES || contains(chunk.getPos())) return false; + Map counts = new HashMap<>(); + for (var section : chunk.getSections()) { + if (section.hasOnlyAir()) continue; + section.getStates().count((state, amount) -> { + Identifier id = BuiltInRegistries.BLOCK.getKey(state.getBlock()); + if (PortBlockInstability.tracked(id)) counts.merge(id, amount, Integer::sum); + }); + } + put(chunk.getPos().pack(), counts); + setDirty(); + return true; + } + private void put(long pos, Map counts) { + if (count() >= MAX_SAMPLES || samples.containsKey(pos)) return; + samples.put(pos, Map.copyOf(counts)); + counts.forEach((id, amount) -> totals.merge(id, amount.longValue(), Long::sum)); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java index 1b468b8..6eeabf6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDayCycle.java @@ -127,19 +127,15 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, } public static boolean validWriting(List pages) { - int periods = 0, phases = 0, directions = 0, angles = 0, sizes = 0, suns = 0, moons = 0; - for (Identifier page : pages) { - if (isPeriod(page) && ++periods > 1 || isPhase(page) && ++phases > 1 - || isDirection(page) && ++directions > 1 || isAngle(page) && ++angles > 1 - || isSize(page) && ++sizes > 1) return false; - if (isController(page)) { - if (!page.getPath().equals("celestial/moon")) suns++; - if (!page.getPath().equals("celestial/sun")) moons++; - if (suns > 10 || moons > 10) return false; - periods = phases = directions = angles = sizes = 0; - } + int suns = 0, moons = 0; + for (Identifier page : pages) if (isController(page)) { + if (!page.getPath().equals("celestial/moon")) suns++; + if (!page.getPath().equals("celestial/sun")) moons++; + if (suns > 10 || moons > 10) return false; } - return periods + phases + directions + angles + sizes == 0; + // Repeated modifiers overwrite the pending value. Unconsumed modifiers + // earn grammar instability, but never affect an earlier body. + return true; } public static PortDayCycle compile(List pages, long seed) { @@ -148,8 +144,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, /** Creation-only completion. Exact diagnostic fixtures can still use compile. */ public static PortDayCycle resolve(List pages, long seed, long nativeTicks) { - if (!validWriting(pages) || !PortAgeColors.validNumbers(pages)) - throw new IllegalArgumentException("Invalid celestial modifier order"); + if (!validWriting(pages)) + throw new IllegalArgumentException("An Age may have at most ten suns and ten moons"); if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks); // Match root AgeSkyProfile.random's count draw positions: sky style, // mixed sky color, then independent 0..3 sun and moon counts. @@ -167,7 +163,7 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, } public static PortDayCycle compile(List pages, long seed, long nativeTicks) { - if (!validWriting(pages)) throw new IllegalArgumentException("Place at most one of each celestial modifier before its controller; limit ten suns and ten moons"); + if (!validWriting(pages)) throw new IllegalArgumentException("An Age may have at most ten suns and ten moons"); if (pages.stream().filter(PortDayCycle::isController).count() <= 1) return compileOne(pages, seed, nativeTicks, 0); var bodies = new java.util.ArrayList(); @@ -190,8 +186,13 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime, } private static PortDayCycle compileOne(List pages, long seed, long nativeTicks, int ordinal) { - var controller = pages.stream().filter(PortDayCycle::isController).findFirst().orElse(null); - if (controller == null) return NATIVE; + int controllerIndex = -1; + for (int i = 0; i < pages.size(); i++) if (isController(pages.get(i))) { controllerIndex = i; break; } + // Only preceding modifiers belong to this controller. In particular, a + // trailing dangling modifier must not rewrite the single-body fast path. + if (controllerIndex < 0) return NATIVE; + pages = pages.subList(0, controllerIndex + 1); + var controller = pages.getLast(); int mode = controller.getPath().equals("celestial/sun") ? SUN : controller.getPath().equals("celestial/moon") ? MOON : PAIRED; if (mode != PAIRED && !hasBodyModifiers(pages)) return randomUnmodified(seed, ordinal, nativeTicks, mode); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentalProviders.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentalProviders.java new file mode 100644 index 0000000..33b1013 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortEnvironmentalProviders.java @@ -0,0 +1,178 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Random; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.DecayBlock; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.util.BlockSnapshot; +import net.neoforged.neoforge.common.util.FakePlayerFactory; +import net.neoforged.neoforge.event.level.BlockEvent; + +/** Active Legacy burning and the 1.21.1 test-enabled erosion policy. */ +public final class PortEnvironmentalProviders { + public static final Identifier BURNING = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "burning"); + public static final Identifier EROSION = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "erosion"); + private static final Direction[] FLUID_SIDES = { + Direction.UP, Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST + }; + private PortEnvironmentalProviders() {} + public static void register() { + PortInstabilityRuntime.register(BURNING, false, (context, id, level) -> { + if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) scorch(context, level); + }); + PortInstabilityRuntime.register(EROSION, true, (context, id, level) -> { + if (PortInstabilityRuntime.isAge(context.level()) && !context.loadedChunks().isEmpty()) erode(context); + }); + } + public static boolean isEnvironmental(Identifier id) { return id.equals(BURNING) || id.equals(EROSION); } + static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick, + List providers) { + var cards = providers.stream().filter(card -> isEnvironmental(card.id())).toList(); + boolean erosion = PortInstabilityConfig.erosionEnabled() && PortInstabilityRuntime.effectiveScore(level) + >= InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost(); + if (cards.isEmpty() && !erosion) return; + var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks()); + if (chunks.isEmpty()) return; + for (var card : cards) { + int budget = card.id().equals(BURNING) ? PortInstabilityConfig.scorchMaxTargets() : 1; + PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick, + new PortInstabilityRuntime.Budget(budget)), card.id(), card.level()); + } + if (erosion && cards.stream().noneMatch(card -> card.id().equals(EROSION))) + PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, tick, + new PortInstabilityRuntime.Budget(1)), EROSION, 1); + } + /** Skips random burning selection or looks at one erosion target, preserving policy. */ + public static int pulseForTesting(ServerPlayer player, String effect) { + ServerLevel level = player.level(); + if (!PortInstabilityRuntime.isAge(level)) return -1; + var age = PortInstabilityData.get(level.getServer()).age(level); + Identifier id = effect.equals("burning") ? BURNING : EROSION; + if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled() + || !PortInstabilityConfig.instabilityProviderEnabled(id)) return 0; + var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16); + if (chunks.isEmpty()) return 0; + var context = new PortInstabilityRuntime.Context(level, age, chunks, + level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)); + if (effect.equals("burning")) { + if (!PortInstabilityConfig.scorchEnabled() || !PortInstabilityConfig.scorchPlayers() + || !eligibleScorch(context, player)) return 0; + if (player.isInWaterOrRain()) return -2; + player.igniteForSeconds(4); + return 1; + } + if (!effect.equals("erosion") || !PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(level) + || PortInstabilityRuntime.effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost() + || !(player.pick(6.0, 0.0F, false) instanceof BlockHitResult hit) + || hit.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK) return 0; + return erodeAt(context, hit.getBlockPos()) ? 1 : 0; + } + + private static void scorch(PortInstabilityRuntime.Context context, int effectiveLevel) { + if (!PortInstabilityConfig.scorchEnabled() || PortInstabilityConfig.scorchMaxTargets() == 0) return; + ServerLevel level = context.level(); + Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt() + * 0x9E3779B97F4A7C15L ^ BURNING.hashCode()); + int start = random.nextInt(context.loadedChunks().size()); + int affected = 0; + for (int offset = 0; offset < Math.min(8, context.loadedChunks().size()); offset++) { + ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size()); + AABB bounds = new AABB(chunk.getMinBlockX(), level.getMinY(), chunk.getMinBlockZ(), + chunk.getMaxBlockX() + 1, level.getMaxY(), chunk.getMaxBlockZ() + 1); + List candidates = new ArrayList<>(); + level.getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds, + entity -> eligibleScorch(context, entity), candidates, 8); + candidates.sort(Comparator.comparing(Entity::getUUID)); + for (LivingEntity entity : candidates) { + if (affected >= PortInstabilityConfig.scorchMaxTargets() || context.budget().remaining() == 0) return; + // Legacy sampled each chunk each tick with a 1/10 gate. With 20 ticks/pass this + // gives at least one opportunity for nearly every inspected loaded chunk. + if (random.nextDouble() >= PortHazardProviders.legacyChance(1, + PortInstabilityConfig.instabilityCooldown(), 1, 10)) continue; + if (!context.budget().claim()) return; + entity.igniteForSeconds(Math.min(12, 4 * effectiveLevel)); + context.budget().recordApplied(); + affected++; + break; + } + } + } + + private static boolean eligibleScorch(PortInstabilityRuntime.Context context, LivingEntity entity) { + if (!entity.isAlive() || entity.isRemoved() || entity.level() != context.level() + || !context.level().canSeeSky(entity.blockPosition()) + || PortDecayProviders.isSpawnProtected(context.age(), entity.blockPosition())) return false; + if (entity instanceof Player player) return PortInstabilityConfig.scorchPlayers() + && !player.isCreative() && !player.isSpectator(); + return true; + } + + private static void erode(PortInstabilityRuntime.Context context) { + if (!PortInstabilityConfig.erosionEnabled() || !PortHazardProviders.canMutate(context.level())) return; + ServerLevel level = context.level(); + Random random = new Random(context.age().seed() ^ PortHazardSavedData.get(level).nextAttempt() + * 0xC2B2AE3D27D4EB4FL ^ EROSION.hashCode()); + for (int attempt = 0; attempt < 8; attempt++) { + ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size())); + int x = chunk.getMinBlockX() + random.nextInt(16); + int z = chunk.getMinBlockZ() + random.nextInt(16); + int y = level.getMinY() + 1 + random.nextInt(level.getHeight() - 2); + if (erodeAt(context, new BlockPos(x, y, z))) return; + } + } + + static boolean erodeAt(PortInstabilityRuntime.Context context, BlockPos pos) { + ServerLevel level = context.level(); + if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), pos) + || PortDecayProviders.isSpawnProtected(context.age(), pos)) return false; + BlockState prior = level.getBlockState(pos); + if (!PortDecayProviders.canReplace(level, pos, prior, DecayBlock.Color.BLUE)) return false; + for (Direction direction : FLUID_SIDES) { + BlockPos neighbor = pos.relative(direction); + if (!PortDecayProviders.isLoadedTarget(level, context.loadedChunks(), neighbor)) continue; + var fluid = level.getFluidState(neighbor); + if (fluid.isSource() && replaceFluid(context, pos, prior, fluid.createLegacyBlock())) return true; + } + return false; + } + + private static boolean replaceFluid(PortInstabilityRuntime.Context context, BlockPos pos, + BlockState prior, BlockState fluidBlock) { + ServerLevel level = context.level(); + var actor = FakePlayerFactory.getMinecraft(level); + actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + if (!prior.isAir()) { + var breaking = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor); + NeoForge.EVENT_BUS.post(breaking); + if (breaking.isCanceled()) return false; + } + if (level.getBlockState(pos) != prior || !context.budget().claim()) return false; + var snapshot = BlockSnapshot.create(level.dimension(), level, pos); + if (!level.setBlock(pos, fluidBlock, 3)) return false; + var placing = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor); + NeoForge.EVENT_BUS.post(placing); + if (placing.isCanceled()) { + snapshot.restore(); + return false; + } + context.budget().recordApplied(); + return true; + } + +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardProviders.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardProviders.java new file mode 100644 index 0000000..9ad0551 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardProviders.java @@ -0,0 +1,356 @@ +package net.dnorberg.rivencraft.age; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import net.dnorberg.rivencraft.Rivencraft; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LightningBolt; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Explosion; +import net.minecraft.world.level.ExplosionDamageCalculator; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.util.FakePlayerFactory; +import net.neoforged.neoforge.event.level.BlockEvent; + +/** Legacy hazard cards, with one loaded-chunk event and explicit damage budgets. */ +public final class PortHazardProviders { + private static final Identifier LIGHTNING = id("lightning"); + private static final Identifier EXPLOSIONS = id("explosions"); + + private PortHazardProviders() {} + + public static void register() { + // Visuals and entity damage remain available when block mutation is disabled. + PortInstabilityRuntime.register(LIGHTNING, false, PortHazardProviders::apply); + PortInstabilityRuntime.register(EXPLOSIONS, false, PortHazardProviders::apply); + } + + static boolean isHazard(Identifier id) { + return LIGHTNING.equals(id) || EXPLOSIONS.equals(id); + } + + /** Roll every selected hazard card each scheduler pass, independently of other providers. */ + static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick, + List providers) { + if (!PortInstabilityConfig.hazardsEnabled()) return; + List hazards = providers.stream() + .filter(provider -> isHazard(provider.id())).toList(); + if (hazards.isEmpty()) return; + List chunks = loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks()); + if (chunks.isEmpty()) return; + for (PortInstabilityPlan.ProviderLevel hazard : hazards) { + var context = new PortInstabilityRuntime.Context(level, age, chunks, tick, + new PortInstabilityRuntime.Budget(1)); + PortInstabilityRuntime.invoke(context, hazard.id(), hazard.level()); + } + } + + static List loadedChunksNearPlayers(ServerLevel level, int maximum) { + List players = level.players().stream() + .sorted(Comparator.comparing(Entity::getUUID)) + .map(ServerPlayer::chunkPosition).toList(); + return candidateChunks(players, maximum, + chunk -> level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null); + } + + /** Pure candidate selection; the caller's predicate must only report already-loaded chunks. */ + static List candidateChunks(List players, int maximum, + Predicate loaded) { + if (maximum <= 0) return List.of(); + Set chunks = new LinkedHashSet<>(); + for (int radius = 0; radius <= 4; radius++) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != radius) continue; + for (ChunkPos center : players) { + int x = center.x() + dx; + int z = center.z() + dz; + ChunkPos candidate = new ChunkPos(x, z); + if (loaded.test(candidate)) chunks.add(candidate); + if (chunks.size() >= maximum) return List.copyOf(chunks); + } + } + } + } + return List.copyOf(chunks); + } + + /** Operator-only test pulse; skips the random roll, not Age, cooldown, or safety checks. */ + public static int pulseForTesting(ServerPlayer player, boolean lightning) { + return pulseForTesting(player.level(), player.blockPosition(), lightning); + } + + public static int pulseForTesting(ServerLevel level, BlockPos origin, boolean lightning) { + if (!PortInstabilityConfig.hazardsEnabled() + || !PortInstabilityRuntime.isAge(level)) return -1; + var age = PortInstabilityData.get(level.getServer()).age(level); + if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled() + || !PortInstabilityConfig.instabilityProviderEnabled(lightning ? LIGHTNING : EXPLOSIONS)) return -1; + PortHazardSavedData data = PortHazardSavedData.get(level); + if (!data.ready(lightning, level.getGameTime(), PortInstabilityConfig.hazardsMinInterval())) return 0; + ChunkPos center = ChunkPos.containing(origin); + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + ChunkPos chunk = new ChunkPos(center.x() + dx, center.z() + dz); + if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null) continue; + int x = chunk.getMinBlockX() + 8; + int z = chunk.getMinBlockZ() + 8; + int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) + : origin.getY(); + BlockPos pos = new BlockPos(x, y, z); + var context = new PortInstabilityRuntime.Context(level, age, List.of(chunk), + level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)); + if (!safeTarget(context, pos, 8)) continue; + data.nextAttempt(); + data.record(lightning, level.getGameTime()); + return (lightning ? strike(level, age, chunk, pos) : explode(level, age, chunk, pos)) ? 1 : 0; + } + } + return 0; + } + + static void apply(PortInstabilityRuntime.Context context, Identifier id, int effectiveLevel) { + ServerLevel level = context.level(); + if (!PortInstabilityConfig.hazardsEnabled() || context.loadedChunks().isEmpty() + || !PortInstabilityRuntime.isAge(level)) return; + boolean lightning = id.equals(LIGHTNING); + if (!lightning && !id.equals(EXPLOSIONS)) return; + PortHazardSavedData data = PortHazardSavedData.get(level); + long gameTime = level.getGameTime(); + if (!data.ready(lightning, gameTime, PortInstabilityConfig.hazardsMinInterval())) return; + Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L + ^ id.hashCode()); + int legacyDenominator = lightning ? level.isRaining() && level.isThundering() ? 5000 : 100000 : 1000; + // Legacy rolled once per chunk per tick; the shared scheduler visits this Age every >=20 ticks. + double chance = legacyChance(context.loadedChunks().size(), PortInstabilityConfig.instabilityCooldown(), + effectiveLevel, legacyDenominator); + if (random.nextDouble() >= chance) return; + ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size())); + int margin = lightning ? 2 : 6; + int x = chunk.getMinBlockX() + margin + random.nextInt(16 - margin * 2); + int z = chunk.getMinBlockZ() + margin + random.nextInt(16 - margin * 2); + int y = lightning ? level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) + : level.getMinY() + 1 + random.nextInt(level.getHeight() - 2); + BlockPos pos = new BlockPos(x, y, z); + if (!safeTarget(context, pos, 8) || !context.budget().claim()) return; + data.record(lightning, gameTime); // persist before dispatch so a restart cannot replay the event + if (lightning ? strike(level, context.age(), chunk, pos) : explode(level, context.age(), chunk, pos)) + context.budget().recordApplied(); + } + + static double legacyChance(int loadedChunks, int intervalTicks, int copies, int denominator) { + if (loadedChunks <= 0 || intervalTicks <= 0 || copies <= 0 || denominator <= 0) return 0; + return 1.0 - Math.pow(1.0 - 1.0 / denominator, (double) loadedChunks * intervalTicks * copies); + } + + static boolean safeTarget(PortInstabilityRuntime.Context context, BlockPos pos, int spawnMargin) { + if (!PortDecayProviders.isLoadedTarget(context.level(), context.loadedChunks(), pos)) return false; + BlockPos spawn = context.age().spawn().orElse(null); + return spawn == null || Math.abs(pos.getX() - spawn.getX()) > spawnMargin + || Math.abs(pos.getZ() - spawn.getZ()) > spawnMargin; + } + + private static boolean strike(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) { + AABB bounds = new AABB(pos).inflate(2); + if (!level.getEntitiesOfClass(LightningBolt.class, bounds).isEmpty()) return false; + LightningBolt bolt = EntityTypes.LIGHTNING_BOLT.create(level, net.minecraft.world.entity.EntitySpawnReason.TRIGGERED); + if (bolt == null) return false; + bolt.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); + bolt.setVisualOnly(true); // vanilla bolt damage/fire bypasses our explicit budgets + if (!level.addFreshEntity(bolt)) return false; + List targets = level.getEntitiesOfClass(LivingEntity.class, bounds, + entity -> entity.isAlive() && entity.level() == level && eligibleEntity(entity)); + targets.sort(Comparator.comparing(Entity::getUUID)); + int count = 0; + for (LivingEntity target : targets) { + if (count++ >= PortInstabilityConfig.hazardsMaxEntities()) break; + if (PortInstabilityConfig.hazardsMaxDamage() > 0) + target.hurtServer(level, level.damageSources().lightningBolt(), Math.min(5, PortInstabilityConfig.hazardsMaxDamage())); + } + // Legacy lightning started fires. Keep it separately gated by block policy and game rule. + if (canMutate(level) && PortInstabilityConfig.hazardsFire() + && level.canSpreadFireAround(pos) + && !PortDecayProviders.isSpawnProtected(age, pos) + && PortDecayProviders.isLoadedTarget(level, List.of(chunk), pos)) ignite(level, pos); + return true; + } + + private static boolean explode(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos) { + return explodeBounded(level, age, chunk, pos, PortInstabilityConfig.hazardsBlockDamage(), + PortInstabilityConfig.hazardsPlayerDamage(), PortInstabilityConfig.hazardsFire(), + PortInstabilityConfig.hazardsMaxBlocks(), PortInstabilityConfig.hazardsMaxEntities(), + PortInstabilityConfig.hazardsMaxDamage(), PortInstabilityConfig.hazardsExplosionRadius()); + } + + /** Shared blast guard for meteor impacts; each effect supplies its own damage policy. */ + static boolean explodeBounded(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, BlockPos pos, + boolean blockDamage, boolean playerDamage, boolean ignite, int maxBlocks, int maxEntities, + int maxDamage, int radius) { + boolean grief = destructiveBlocksAllowed(PortInstabilityConfig.instabilityWorldMutation(), + level.getGameRules().get(GameRules.MOB_GRIEFING), blockDamage, maxBlocks); + boolean fire = grief && ignite + && level.canSpreadFireAround(pos); + BoundedExplosion calculator = new BoundedExplosion(level, age, chunk, grief, + playerDamage, maxBlocks, maxEntities, maxDamage); + level.explode(null, null, calculator, + pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, + radius, false, + grief ? Level.ExplosionInteraction.BLOCK : Level.ExplosionInteraction.NONE); + if (fire) { + int ignited = 0; + for (BlockPos affected : calculator.approved) { + if (ignited >= 4) break; + if (level.canSpreadFireAround(affected) && !PortDecayProviders.isSpawnProtected(age, affected) + && ignite(level, affected)) ignited++; + } + } + return calculator.started; + } + + private static boolean ignite(ServerLevel level, BlockPos pos) { + if (!level.getBlockState(pos).isAir() || !Blocks.FIRE.defaultBlockState().canSurvive(level, pos)) + return false; + var actor = FakePlayerFactory.getMinecraft(level); + actor.setPos(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); + var snapshot = net.neoforged.neoforge.common.util.BlockSnapshot.create(level.dimension(), level, pos); + BlockState prior = level.getBlockState(pos); + if (!level.setBlock(pos, Blocks.FIRE.defaultBlockState(), 3)) return false; + var place = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor); + NeoForge.EVENT_BUS.post(place); + if (place.isCanceled()) { + snapshot.restore(); + return false; + } + return true; + } + + static boolean canMutate(ServerLevel level) { + return PortInstabilityConfig.instabilityWorldMutation() + && level.getGameRules().get(GameRules.MOB_GRIEFING); + } + + static boolean destructiveBlocksAllowed(boolean worldMutation, boolean mobGriefing, + boolean blockDamage, int maxBlocks) { + return worldMutation && mobGriefing && blockDamage && maxBlocks > 0; + } + + static boolean protectedBlock(ServerLevel level, PortInstabilityData.Age age, BlockPos pos, BlockState state) { + return state.isAir() || state.hasBlockEntity() || state.is(Blocks.TNT) + || state.is(PortDecayProviders.PROTECTED) || state.is(BlockTags.PORTALS) + || PortDecayProviders.isSpawnProtected(age, pos) + || state.getDestroySpeed(level, pos) < 0 + || BuiltInRegistries.BLOCK.getKey(state.getBlock()).getNamespace().equals(Rivencraft.MOD_ID); + } + + private static boolean eligibleEntity(Entity entity) { + return eligibleEntity(entity, PortInstabilityConfig.hazardsPlayerDamage()); + } + + private static boolean eligibleEntity(Entity entity, boolean playerDamage) { + return !(entity instanceof Player player) || playerDamage + && !player.isCreative() && !player.isSpectator(); + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } + + private static final class BoundedExplosion extends ExplosionDamageCalculator { + private final ServerLevel level; + private final PortInstabilityData.Age age; + private final ChunkPos chunk; + private final boolean grief; + private final boolean playerDamage; + private final int maxBlocks; + private final int maxEntities; + private final int maxDamage; + private final Set approved = new LinkedHashSet<>(); + private final Set damagedEntities = new HashSet<>(); + private int damaged; + private boolean started; + + private BoundedExplosion(ServerLevel level, PortInstabilityData.Age age, ChunkPos chunk, boolean grief, + boolean playerDamage, int maxBlocks, int maxEntities, int maxDamage) { + this.level = level; + this.age = age; + this.chunk = chunk; + this.grief = grief; + this.playerDamage = playerDamage; + this.maxBlocks = maxBlocks; + this.maxEntities = maxEntities; + this.maxDamage = maxDamage; + } + + @Override + public Optional getBlockExplosionResistance(Explosion explosion, BlockGetter reader, + BlockPos pos, BlockState state, FluidState fluid) { + started = true; + if (!inLoadedChunk(pos)) return Optional.of(Float.MAX_VALUE); + return super.getBlockExplosionResistance(explosion, reader, pos, state, fluid); + } + + @Override + public boolean shouldBlockExplode(Explosion explosion, BlockGetter reader, BlockPos pos, + BlockState state, float power) { + if (!grief || !inLoadedChunk(pos) || protectedBlock(level, age, pos, state)) return false; + if (approved.contains(pos)) return true; + if (approved.size() >= maxBlocks) return false; + var actor = FakePlayerFactory.getMinecraft(level); + actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + var event = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, state, actor); + NeoForge.EVENT_BUS.post(event); + if (event.isCanceled()) return false; + approved.add(pos.immutable()); + return true; + } + + @Override + public boolean shouldDamageEntity(Explosion explosion, Entity entity) { + if (damaged >= maxEntities || !eligibleEntity(entity, playerDamage) + || maxDamage == 0) return false; + damaged++; + damagedEntities.add(entity.getUUID()); + return true; + } + + @Override + public float getEntityDamageAmount(Explosion explosion, Entity entity, float exposure) { + return Math.min(super.getEntityDamageAmount(explosion, entity, exposure), maxDamage); + } + + @Override + public float getKnockbackMultiplier(Entity entity) { + return damagedEntities.contains(entity.getUUID()) ? 1.0F : 0.0F; + } + + private boolean inLoadedChunk(BlockPos pos) { + return level.isInWorldBounds(pos) && ChunkPos.containing(pos).equals(chunk) + && level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) != null; + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardSavedData.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardSavedData.java new file mode 100644 index 0000000..57801d0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortHazardSavedData.java @@ -0,0 +1,34 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Per-Age random sequence and event cooldowns survive unload and restart. */ +public final class PortHazardSavedData extends SavedData { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + net.minecraft.util.ExtraCodecs.NON_NEGATIVE_LONG.optionalFieldOf("attempts", 0L).forGetter(data -> data.attempts), + Codec.LONG.optionalFieldOf("last_lightning", Long.MIN_VALUE).forGetter(data -> data.lastLightning), + Codec.LONG.optionalFieldOf("last_explosion", Long.MIN_VALUE).forGetter(data -> data.lastExplosion), + Codec.LONG.optionalFieldOf("last_meteor", Long.MIN_VALUE).forGetter(data -> data.lastMeteor) + ).apply(instance, PortHazardSavedData::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "hazards"), + () -> new PortHazardSavedData(0, Long.MIN_VALUE, Long.MIN_VALUE, Long.MIN_VALUE), CODEC); + private long attempts, lastLightning, lastExplosion, lastMeteor; + private PortHazardSavedData(long attempts, long lightning, long explosion, long meteor) { + this.attempts = attempts; lastLightning = lightning; lastExplosion = explosion; lastMeteor = meteor; + } + public static PortHazardSavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); } + public long nextAttempt() { setDirty(); return attempts++; } + public boolean ready(boolean lightning, long time, int interval) { + long last = lightning ? lastLightning : lastExplosion; + return last == Long.MIN_VALUE || time < last || time - last >= interval; + } + public void record(boolean lightning, long time) { if (lightning) lastLightning = time; else lastExplosion = time; setDirty(); } + public boolean meteorReady(long time, int interval) { return lastMeteor == Long.MIN_VALUE || time < lastMeteor || time - lastMeteor >= interval; } + public void recordMeteor(long time) { lastMeteor = time; setDirty(); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityCommands.java index 4b8e16a..d710e1f 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityCommands.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityCommands.java @@ -28,8 +28,17 @@ public final class PortInstabilityCommands { + ": frozen score=" + age.score() + ", enabled=" + age.enabled() + ", session test score=" + PortInstabilityRuntime.testScore(level) + ", protected spawn=" + age.spawn().map(BlockPos::toShortString).orElse("not recorded; re-enter this Age")), false); + source.sendSuccess(() -> Component.literal(" live block score=" + PortBlockInstability.score(level) + + "; effective score=" + PortInstabilityRuntime.effectiveScore(level) + + "; difficulty=" + PortInstabilityConfig.instabilityDifficulty() + + "; samples Age=" + PortBlockInstability.sampledChunks(level) + "/" + PortBlockProfileSavedData.MIN_AGE_SAMPLES + + ", Overworld=" + PortBlockInstability.sampledChunks(source.getServer().overworld()) + "/" + PortBlockProfileSavedData.MIN_BASELINE_SAMPLES + + "; profiling=" + PortInstabilityData.get(source.getServer()).supportsProfiling(level)), false); for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal( " " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false); + source.sendSuccess(() -> Component.literal(" erosion testing policy (not a saved card): " + + PortInstabilityRuntime.providerStatus(level, PortEnvironmentalProviders.EROSION) + + "; current score=" + PortInstabilityRuntime.effectiveScore(level)), false); for (var provider : PortInstabilityRuntime.plan(level).providers()) source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level() + " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false); @@ -56,7 +65,7 @@ public final class PortInstabilityCommands { .then(Commands.literal("clear_test_score").executes(context -> { var level = requireAge(context.getSource()); PortInstabilityRuntime.testScore(level, null); PortStatusEffectProviders.clearAge(level); - context.getSource().sendSuccess(() -> Component.literal("Restored saved provider selection"), false); return 1; + context.getSource().sendSuccess(() -> Component.literal("Cleared test override; restored live effective provider selection"), false); return 1; })) .then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word()) .suggests((context, builder) -> { @@ -66,6 +75,48 @@ public final class PortInstabilityCommands { }).executes(context -> decay(context, 1)) .then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32)) .executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts")))))) + .then(Commands.literal("environment").then(Commands.argument("effect", StringArgumentType.word()) + .suggests((context, builder) -> { + for (String effect : java.util.List.of("burning", "erosion")) + if (effect.startsWith(builder.getRemaining())) builder.suggest(effect); + return builder.buildFuture(); + }).executes(context -> { + var source = context.getSource(); requireAge(source); + String effect = StringArgumentType.getString(context, "effect"); + if (!effect.equals("burning") && !effect.equals("erosion")) { + source.sendFailure(Component.literal("Choose burning or erosion")); return 0; + } + int result = PortEnvironmentalProviders.pulseForTesting(source.getPlayerOrException(), effect); + if (result > 0) source.sendSuccess(() -> Component.literal("Applied " + effect + "; saved score/plan unchanged"), false); + else source.sendFailure(Component.literal(result == -2 ? "Burning would be extinguished by water or rain" + : "No effect applied. Check Age/config/provider policy, spawn protection and sky exposure; burning requires Survival/Adventure, erosion requires score >=10000 and a source fluid beside/above the targeted block.")); + return Math.max(0, result); + }))) + .then(Commands.literal("meteor").executes(context -> { + var source = context.getSource(); requireAge(source); + int result = PortMeteorProvider.pulseForTesting(source.getPlayerOrException()); + if (result > 0) source.sendSuccess(() -> Component.literal("Meteor descent scheduled; impact in 30 ticks. Saved score/plan unchanged."), false); + else source.sendFailure(Component.literal("No meteor scheduled: check cooldown (200 ticks), active flight, safe loaded terrain and Age/config/provider policy.")); + return Math.max(0, result); + })) + .then(Commands.literal("hazard").then(Commands.argument("type", StringArgumentType.word()) + .suggests((context, builder) -> { + for (String type : java.util.List.of("lightning", "explosions")) + if (type.startsWith(builder.getRemaining())) builder.suggest(type); + return builder.buildFuture(); + }).executes(context -> { + var source = context.getSource(); var level = requireAge(source); + String type = StringArgumentType.getString(context, "type"); + if (!type.equals("lightning") && !type.equals("explosions")) { + source.sendFailure(Component.literal("Choose lightning or explosions")); return 0; + } + int result = PortHazardProviders.pulseForTesting(level, BlockPos.containing(source.getPosition()), type.equals("lightning")); + if (result > 0) source.sendSuccess(() -> Component.literal("Triggered " + type + + " in a nearby loaded chunk; saved score/plan unchanged"), false); + else source.sendFailure(Component.literal(result < 0 ? "Hazard disabled by config, Age or provider policy" + : "No hazard triggered: cooldown, protected spawn, unloaded terrain or event cancellation")); + return Math.max(0, result); + }))) .then(Commands.literal("pulse").then(Commands.argument("provider", StringArgumentType.word()) .suggests((context, builder) -> { for (var deck : InstabilityProviderCatalog.Deck.values()) for (var card : InstabilityProviderCatalog.cards(deck)) diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityConfig.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityConfig.java index 13e8d87..61a0732 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityConfig.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityConfig.java @@ -9,11 +9,21 @@ public final class PortInstabilityConfig { private static ModConfigSpec.BooleanValue enabled, status, players, nonplayers, hostiles, worldMutation, decay; private static ModConfigSpec.IntValue cooldown, ages, providers, actions, level, chunks, active, candidates, duration, decayAttempts; private static ModConfigSpec.ConfigValue> disabledProviders; + private static ModConfigSpec.BooleanValue hazards, hazardBlocks, hazardPlayers, hazardFire; + private static ModConfigSpec.IntValue hazardInterval, hazardChunks, hazardMaxBlocks, hazardEntities, hazardDamage, hazardRadius; + private static ModConfigSpec.BooleanValue meteor, meteorBlocks, meteorPlayers, meteorFire; + private static ModConfigSpec.IntValue meteorMaxBlocks, meteorEntities, meteorDamage; + private static ModConfigSpec.BooleanValue scorch, scorchPlayers, erosion; + private static ModConfigSpec.IntValue scorchTargets; + private static ModConfigSpec.IntValue difficulty; + private static ModConfigSpec.ConfigValue> materialOverrides; private PortInstabilityConfig() {} public static void define(ModConfigSpec.Builder builder) { builder.push("instability"); enabled = builder.comment("Run implemented instability providers. Saved scores and plans remain unchanged when disabled.") .define("enabled", true); + difficulty = builder.comment("Legacy instability difficulty: 0=quarter, 1=half, 2=normal, 3=1.75x; saved scores stay unchanged.") + .defineInRange("difficulty", 2, 0, 3); worldMutation = builder.comment("Allow implemented instability providers to change blocks, as in 1.21.1.") .define("world_mutation", true); cooldown = builder.defineInRange("cooldown_ticks", 20, 20, 1200); @@ -42,7 +52,77 @@ public final class PortInstabilityConfig { decayAttempts = builder.comment("Maximum six-neighbor conversion attempts per provider call; at most one block changes.") .defineInRange("max_attempts_per_call", 4, 1, 6); builder.pop(); + builder.push("instability_hazards"); + hazards = builder.comment("Run Legacy lightning and explosion cards in loaded Ages.").define("enabled", true); + hazardBlocks = builder.comment("Allow block destruction; also requires world_mutation and mobGriefing.").define("block_damage", true); + hazardPlayers = builder.comment("Allow damage to Survival/Adventure players.").define("player_damage", true); + hazardFire = builder.comment("Allow ignition where the vanilla fire-spread rule permits it.").define("fire", true); + hazardInterval = builder.defineInRange("minimum_gap_ticks", 20, 20, 12000); + hazardChunks = builder.defineInRange("max_loaded_chunks", 81, 1, 169); + hazardMaxBlocks = builder.defineInRange("max_explosion_blocks", 16, 0, 64); + hazardEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32); + hazardDamage = builder.defineInRange("max_entity_damage", 12, 0, 40); + hazardRadius = builder.comment("Legacy strength 3, bounded to one already-loaded chunk.").defineInRange("explosion_radius", 3, 1, 3); + builder.pop(); + builder.push("instability_meteor"); + meteor = builder.comment("Run the active Legacy meteor card in loaded Ages.").define("enabled", true); + meteorBlocks = builder.comment("Allow impact block destruction; also requires world_mutation and mobGriefing.") + .define("block_damage", true); + meteorPlayers = builder.comment("Allow impacts to hurt Survival/Adventure players.").define("player_damage", true); + meteorFire = builder.comment("Allow impact fire where the vanilla fire-spread rule permits it.").define("fire", true); + meteorMaxBlocks = builder.defineInRange("max_impact_blocks", 24, 0, 64); + meteorEntities = builder.defineInRange("max_damaged_entities", 8, 0, 32); + meteorDamage = builder.defineInRange("max_entity_damage", 12, 0, 40); + builder.pop(); + builder.push("instability_scorching"); + scorch = builder.comment("Run Legacy's active burning card on sky-exposed entities.").define("enabled", true); + scorchPlayers = builder.comment("Allow burning on Survival/Adventure players.").define("affect_players", true); + scorchTargets = builder.defineInRange("max_targets_per_pass", 4, 0, 16); + builder.pop(); + builder.push("instability_erosion"); + erosion = builder.comment("Enable Legacy's dormant erosion effect for testing; no saved card is added.", + "Ages with a score of at least 10000 can spread adjacent source fluid into one eligible block per pass.") + .define("enabled", true); + builder.pop(); + builder.push("instability_modded_materials"); + materialOverrides = builder.comment("Recognized ores, mineral blocks and source fluids from installed mods are scored automatically.", + "Optional exact modid:block_id=points overrides; zero excludes a block. No content mod is required.") + .defineListAllowEmpty("block_weight_overrides", List.of(), () -> "examplemod:rare_ore=4000", PortInstabilityConfig::validMaterialOverride); + builder.pop(); } + private static boolean validMaterialOverride(Object value) { + if (!(value instanceof String entry)) return false; + int separator = entry.lastIndexOf('='); + if (separator < 1) return false; + Identifier id = Identifier.tryParse(entry.substring(0, separator)); + if (id == null || !entry.substring(0, separator).contains(":") + || id.getNamespace().equals("minecraft") || id.getNamespace().equals("rivencraft")) return false; + try { int weight = Integer.parseInt(entry.substring(separator + 1)); return weight >= 0 && weight <= 100000; } + catch (NumberFormatException ignored) { return false; } + } + public static int instabilityDifficulty() { return difficulty.get(); } + public static List moddedMaterialOverrides() { return materialOverrides.get(); } + public static boolean scorchEnabled() { return scorch.get(); } + public static boolean scorchPlayers() { return scorchPlayers.get(); } + public static int scorchMaxTargets() { return scorchTargets.get(); } + public static boolean erosionEnabled() { return erosion.get(); } + public static boolean meteorEnabled() { return meteor.get(); } + public static boolean meteorBlockDamage() { return meteorBlocks.get(); } + public static boolean meteorPlayerDamage() { return meteorPlayers.get(); } + public static boolean meteorFire() { return meteorFire.get(); } + public static int meteorMaxBlocks() { return meteorMaxBlocks.get(); } + public static int meteorMaxEntities() { return meteorEntities.get(); } + public static int meteorMaxDamage() { return meteorDamage.get(); } + public static boolean hazardsEnabled() { return hazards.get(); } + public static boolean hazardsBlockDamage() { return hazardBlocks.get(); } + public static boolean hazardsPlayerDamage() { return hazardPlayers.get(); } + public static boolean hazardsFire() { return hazardFire.get(); } + public static int hazardsMinInterval() { return hazardInterval.get(); } + public static int hazardsMaxChunks() { return hazardChunks.get(); } + public static int hazardsMaxBlocks() { return hazardMaxBlocks.get(); } + public static int hazardsMaxEntities() { return hazardEntities.get(); } + public static int hazardsMaxDamage() { return hazardDamage.get(); } + public static int hazardsExplosionRadius() { return hazardRadius.get(); } public static boolean instabilityWorldMutation() { return worldMutation.get(); } public static boolean decayEnabled() { return decay.get(); } public static int decayMaxAttempts() { return decayAttempts.get(); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java index 3c7bb98..6b4577a 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java @@ -47,6 +47,11 @@ public final class PortInstabilityData extends SavedData { return age != null ? age : new Age(level.getSeed(), List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true); } + /** Older neutral Ages stay neutral even if an arrival or enabled flag was later saved. */ + public boolean supportsProfiling(ServerLevel level) { + Age state = ages.get(level.dimension().identifier()); + return state != null && state.contributions().stream().noneMatch(cost -> cost.reason().equals("predates_instability")); + } public void enabled(ServerLevel level, boolean enabled) { Age previous = age(level); ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), enabled, previous.spawn())); @@ -66,6 +71,8 @@ public final class PortInstabilityData extends SavedData { if (ages.containsKey(id)) throw new IllegalArgumentException("Instability already recorded for " + id); List costs = new ArrayList<>(); costs.add(new Contribution(pages.isEmpty() ? "random_baseline" : "authored_baseline", -1, 0)); + for (var diagnostic : net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages).diagnostics()) + costs.add(new Contribution("writing_" + diagnostic.code(), diagnostic.index(), diagnostic.points())); boolean authoredBright = false; Map structures = new HashMap<>(); var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream() diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityRuntime.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityRuntime.java index a7bc75a..09aeadf 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityRuntime.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityRuntime.java @@ -20,6 +20,8 @@ import net.neoforged.neoforge.event.tick.LevelTickEvent; /** The 1.21.1 bounded Age scheduler. Unported cards remain saved and visibly unavailable to operators. */ @EventBusSubscriber(modid = Rivencraft.MOD_ID) public final class PortInstabilityRuntime { + private static final Map PLANS = new WeakHashMap<>(); + private record CachedPlan(int score, long seed, PortInstabilityPlan plan) {} private static final Map HANDLERS = new HashMap<>(); private static final Map> FAILURES = new WeakHashMap<>(); private static final Map> TEST_SCORES = new WeakHashMap<>(); @@ -35,12 +37,27 @@ public final class PortInstabilityRuntime { public static boolean isAge(ServerLevel level) { return level instanceof PortAgeLevel; } public static PortInstabilityPlan plan(ServerLevel level) { var age = PortInstabilityData.get(level.getServer()).age(level); - Integer test = TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier()); - return test == null ? age.plan() : PortInstabilityPlan.select(test, age.seed()); + Integer test = testScore(level); + int profile = test == null ? PortBlockInstability.score(level) : 0; + int difficulty = PortInstabilityConfig.instabilityDifficulty(); + if (test == null && difficulty == 2 && profile == 0) return age.plan(); + int score = test != null ? test : PortBlockInstability.difficultyScore((long) age.score() + profile, difficulty); + var cached = PLANS.get(level); + if (cached == null || cached.score() != score || cached.seed() != age.seed()) { + cached = new CachedPlan(score, age.seed(), PortInstabilityPlan.select(score, age.seed())); + PLANS.put(level, cached); + } + return cached.plan(); } public static Integer testScore(ServerLevel level) { return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier()); } + public static int effectiveScore(ServerLevel level) { + Integer test = testScore(level); + if (test != null) return test; + return PortBlockInstability.difficultyScore((long) PortInstabilityData.get(level.getServer()).age(level).score() + + PortBlockInstability.score(level), PortInstabilityConfig.instabilityDifficulty()); + } public static void testScore(ServerLevel level, Integer score) { var values = TEST_SCORES.computeIfAbsent(level.getServer(), ignored -> new HashMap<>()); if (score == null) values.remove(level.dimension().identifier()); else values.put(level.dimension().identifier(), score); @@ -69,6 +86,9 @@ public final class PortInstabilityRuntime { var age = PortInstabilityData.get(server).age(level); if (!age.enabled()) return; var providers = plan(level).providers(); + PortHazardProviders.tickPlanned(level, age, tick, providers); + PortMeteorProvider.tickPlanned(level, age, tick, providers); + PortEnvironmentalProviders.tickPlanned(level, age, tick, providers); var chunks = loadedChunks(level); if (providers.isEmpty() || chunks.isEmpty()) return; Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions())); @@ -77,7 +97,8 @@ public final class PortInstabilityRuntime { for (int offset = 0; offset < providers.size() && ran < PortInstabilityConfig.instabilityMaxProviders() && context.budget().remaining() > 0; offset++) { var provider = providers.get((first + offset) % providers.size()); - if (invoke(context, provider.id(), provider.level())) ran++; + if (!PortHazardProviders.isHazard(provider.id()) && !provider.id().equals(PortMeteorProvider.METEORS) + && !PortEnvironmentalProviders.isEnvironmental(provider.id()) && invoke(context, provider.id(), provider.level())) ran++; } } public static boolean invoke(Context context, Identifier id, int level) { @@ -101,9 +122,21 @@ public final class PortInstabilityRuntime { if (!PortInstabilityConfig.instabilityEnabled()) return "global config off"; if (!PortInstabilityData.get(level.getServer()).age(level).enabled()) return "Age disabled"; if (!PortInstabilityConfig.instabilityProviderEnabled(id)) return "provider config off"; - if (HANDLERS.get(id).worldMutating()) { + if (PortHazardProviders.isHazard(id)) { + if (!PortInstabilityConfig.hazardsEnabled()) return "hazards config off"; + } else if (id.equals(PortMeteorProvider.METEORS)) { + if (!PortInstabilityConfig.meteorEnabled()) return "meteor config off"; + } else if (id.equals(PortEnvironmentalProviders.BURNING)) { + if (!PortInstabilityConfig.scorchEnabled()) return "burning config off"; + if (PortInstabilityConfig.scorchMaxTargets() == 0) return "burning target budget zero"; + } else if (HANDLERS.get(id).worldMutating()) { if (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off"; if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off"; + if (id.equals(PortEnvironmentalProviders.EROSION)) { + if (!PortInstabilityConfig.erosionEnabled()) return "erosion config off"; + if (!PortHazardProviders.canMutate(level)) return "mobGriefing off"; + if (effectiveScore(level) < InstabilityProviderCatalog.Deck.DESTRUCTIVE.entryCost()) return "erosion threshold not met"; + } } else { if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off"; if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled"; @@ -126,7 +159,7 @@ public final class PortInstabilityRuntime { return List.copyOf(chunks); } @SubscribeEvent public static void stopping(ServerStoppingEvent event) { - TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); + TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); PLANS.clear(); } public interface ProviderEffect { void apply(Context context, Identifier id, int level); } public record Context(ServerLevel level, PortInstabilityData.Age age, List loadedChunks, diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortMeteorProvider.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortMeteorProvider.java new file mode 100644 index 0000000..aebb901 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortMeteorProvider.java @@ -0,0 +1,133 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.WeakHashMap; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.Heightmap; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.LevelTickEvent; + +/** The 1.21.1 meteor's short particle descent and bounded impact, without a new entity. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class PortMeteorProvider { + public static final Identifier METEORS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "meteors"); + private static final int METEOR_FLIGHT_TICKS = 30; + private static final int MAX_ACTIVE_AGES = 16; + private static final Map ACTIVE = new WeakHashMap<>(); + private PortMeteorProvider() {} + public static void register() { + PortInstabilityRuntime.register(METEORS, false, (context, id, level) -> { + if (PortInstabilityRuntime.isAge(context.level())) scheduleMeteor(context, level, false); + }); + } + static void tickPlanned(ServerLevel level, PortInstabilityData.Age age, long tick, + List providers) { + var card = providers.stream().filter(provider -> provider.id().equals(METEORS)).findFirst(); + if (card.isEmpty() || !PortInstabilityConfig.meteorEnabled()) return; + var chunks = PortHazardProviders.loadedChunksNearPlayers(level, PortInstabilityConfig.hazardsMaxChunks()); + if (!chunks.isEmpty()) PortInstabilityRuntime.invoke(new PortInstabilityRuntime.Context(level, age, chunks, + tick, new PortInstabilityRuntime.Budget(1)), METEORS, card.get().level()); + } + /** Skip the chance roll only; retain per-Age policy, loading, active-flight and cooldown checks. */ + public static int pulseForTesting(ServerPlayer player) { + ServerLevel level = player.level(); + if (!PortInstabilityRuntime.isAge(level)) return -1; + var age = PortInstabilityData.get(level.getServer()).age(level); + if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled() + || !age.enabled() || !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) return 0; + var chunks = PortHazardProviders.loadedChunksNearPlayers(level, 16); + if (chunks.isEmpty()) return 0; + return scheduleMeteor(new PortInstabilityRuntime.Context(level, age, chunks, + level.getServer().getTickCount(), new PortInstabilityRuntime.Budget(1)), 1, true) ? 1 : 0; + } + + private static boolean scheduleMeteor(PortInstabilityRuntime.Context context, int effectiveLevel, boolean force) { + if (!PortInstabilityConfig.meteorEnabled() || ACTIVE.containsKey(context.level()) + || ACTIVE.size() >= MAX_ACTIVE_AGES) return false; + PortHazardSavedData data = PortHazardSavedData.get(context.level()); + long gameTime = context.level().getGameTime(); + if (!data.meteorReady(gameTime, 200)) return false; + Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0xD1B54A32D192ED03L + ^ METEORS.hashCode()); + if (!force && random.nextDouble() >= PortHazardProviders.legacyChance(context.loadedChunks().size(), + PortInstabilityConfig.instabilityCooldown(), effectiveLevel, 50000)) return false; + int start = random.nextInt(context.loadedChunks().size()); + for (int offset = 0; offset < Math.min(16, context.loadedChunks().size()); offset++) { + ChunkPos chunk = context.loadedChunks().get((start + offset) % context.loadedChunks().size()); + int x = chunk.getMinBlockX() + 6 + random.nextInt(4); + int z = chunk.getMinBlockZ() + 6 + random.nextInt(4); + int surfaceY = context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z); + BlockPos impact = new BlockPos(x, surfaceY, z); + if (surfaceY >= context.level().getMaxY() - 8 + || !PortHazardProviders.safeTarget(context, impact, 8)) continue; + if (!context.budget().claim()) return false; + int startY = Math.min(context.level().getMaxY() - 2, surfaceY + 48); + data.recordMeteor(gameTime); + ACTIVE.put(context.level(), new Meteor(chunk, impact, startY, METEOR_FLIGHT_TICKS)); + context.level().playSound(null, impact, Rivencraft.METEOR_ROAR.get(), SoundSource.WEATHER, 2.0F, 1.0F); + context.budget().recordApplied(); + return true; + } + return false; + } + + @SubscribeEvent + public static void onLevelTickPost(LevelTickEvent.Post event) { + if (!(event.getLevel() instanceof ServerLevel level)) return; + Meteor meteor = ACTIVE.get(level); + if (meteor == null) return; + if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.meteorEnabled() + || !PortInstabilityRuntime.isAge(level) + || level.players().isEmpty() + || level.getChunkSource().getChunkNow(meteor.chunk().x(), meteor.chunk().z()) == null) { + ACTIVE.remove(level); + return; + } + var age = PortInstabilityData.get(level.getServer()).age(level); + if (!age.enabled() + || !PortInstabilityConfig.instabilityProviderEnabled(METEORS)) { + ACTIVE.remove(level); + return; + } + int remaining = meteor.remaining() - 1; + if (remaining <= 0) { + ACTIVE.remove(level); // clear before impact so an exception cannot replay it + PortHazardProviders.explodeBounded(level, age, meteor.chunk(), meteor.impact(), + PortInstabilityConfig.meteorBlockDamage(), PortInstabilityConfig.meteorPlayerDamage(), + PortInstabilityConfig.meteorFire(), PortInstabilityConfig.meteorMaxBlocks(), + PortInstabilityConfig.meteorMaxEntities(), PortInstabilityConfig.meteorMaxDamage(), 3); + return; + } + double progress = 1.0 - remaining / (double) METEOR_FLIGHT_TICKS; + double y = meteor.startY() + (meteor.impact().getY() - meteor.startY()) * progress; + double x = meteor.impact().getX() + 0.5 + (1.0 - progress) * 2.0; + double z = meteor.impact().getZ() + 0.5 + (1.0 - progress) * 2.0; + level.sendParticles(ParticleTypes.FLAME, x, y, z, 5, 0.25, 0.25, 0.25, 0.01); + level.sendParticles(ParticleTypes.LARGE_SMOKE, x, y, z, 2, 0.3, 0.3, 0.3, 0.02); + ACTIVE.put(level, new Meteor(meteor.chunk(), meteor.impact(), meteor.startY(), remaining)); + } + + @SubscribeEvent + public static void onServerStopping(ServerStoppingEvent event) { + ACTIVE.clear(); // an interrupted descent is cancelled, never replayed after restart + } + + @SubscribeEvent + public static void onLevelUnload(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) ACTIVE.remove(level); + } + + private record Meteor(ChunkPos chunk, BlockPos impact, int startY, int remaining) {} +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/LinkModifierBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/LinkModifierBlock.java new file mode 100644 index 0000000..c86c405 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/LinkModifierBlock.java @@ -0,0 +1,51 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity; +import net.dnorberg.rivencraft.menu.LinkModifierMenu; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.SimpleMenuProvider; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.phys.BlockHitResult; + +public final class LinkModifierBlock extends BaseEntityBlock { + public static final EnumProperty FACING = BlockStateProperties.HORIZONTAL_FACING; + + public LinkModifierBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH)); + } + + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { builder.add(FACING); } + @Override public BlockState getStateForPlacement(BlockPlaceContext context) { + return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite()); + } + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new LinkModifierBlockEntity(pos, state); + } + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (!level.isClientSide() && player instanceof ServerPlayer serverPlayer + && level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) { + serverPlayer.openMenu(new SimpleMenuProvider( + (id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier), + Component.translatable("container.rivencraft.link_modifier")), + buffer -> buffer.writeBlockPos(pos)); + } + return InteractionResult.SUCCESS; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java index c691e96..6d83bae 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java @@ -34,7 +34,8 @@ public final class BookReceptacleBlockEntity extends BlockEntity { var target = id == null ? null : source.getServer().getLevel(net.minecraft.resources.ResourceKey.create( net.minecraft.core.registries.Registries.DIMENSION, id)); if (target != null) { - var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target) + var pos = destination == null ? net.dnorberg.rivencraft.age.PortAgeTravel.resolveLanding(target, !book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), + net.dnorberg.rivencraft.item.LinkProperties.NONE).has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM)) : BlockPos.containing(destination.x(), destination.y(), destination.z()); if (pos != null) { int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky(); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/LinkModifierBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/LinkModifierBlockEntity.java new file mode 100644 index 0000000..f5712f1 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/LinkModifierBlockEntity.java @@ -0,0 +1,69 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.core.NonNullList; +import net.minecraft.world.Container; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** One persisted book slot; edits are performed by the server menu. */ +public final class LinkModifierBlockEntity extends BlockEntity implements Container { + private NonNullList items = NonNullList.withSize(1, ItemStack.EMPTY); + + public LinkModifierBlockEntity(BlockPos pos, BlockState state) { + super(Rivencraft.LINK_MODIFIER_BLOCK_ENTITY.get(), pos, state); + } + + @Override public int getContainerSize() { return 1; } + @Override public boolean isEmpty() { return items.getFirst().isEmpty(); } + @Override public ItemStack getItem(int slot) { return items.get(slot); } + @Override public ItemStack removeItem(int slot, int amount) { + ItemStack removed = ContainerHelper.removeItem(items, slot, amount); + if (!removed.isEmpty()) setChanged(); + return removed; + } + @Override public ItemStack removeItemNoUpdate(int slot) { + ItemStack removed = ContainerHelper.takeItem(items, slot); + if (!removed.isEmpty()) setChanged(); + return removed; + } + @Override public void setItem(int slot, ItemStack stack) { + items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount()))); + setChanged(); + } + @Override public boolean canPlaceItem(int slot, ItemStack stack) { + return slot == 0 && (stack.is(Rivencraft.LINKING_BOOK.get()) + || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get())); + } + @Override public boolean stillValid(Player player) { + return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this + && player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5, + worldPosition.getZ() + 0.5) <= 64.0; + } + @Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) { + if (level != null && !level.isClientSide()) { + net.minecraft.world.Containers.dropContents(level, pos, this); + clearContent(); + } + super.preRemoveSideEffects(pos, oldState); + } + @Override public void clearContent() { + items = NonNullList.withSize(1, ItemStack.EMPTY); + setChanged(); + } + @Override protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + items = NonNullList.withSize(1, ItemStack.EMPTY); + ContainerHelper.loadAllItems(input, items); + } + @Override protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + ContainerHelper.saveAllItems(output, items); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java new file mode 100644 index 0000000..c65618a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/LinkModifierScreen.java @@ -0,0 +1,120 @@ +package net.dnorberg.rivencraft.client; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.LinkProperties; +import net.dnorberg.rivencraft.menu.LinkModifierMenu; +import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.client.network.ClientPacketDistributor; + +/** Existing Legacy single-slot layout, using the 26.3 extraction/input APIs. */ +public final class LinkModifierScreen extends AbstractContainerScreen { + private static final Identifier BACKGROUND = Identifier.fromNamespaceAndPath( + Rivencraft.MOD_ID, "textures/gui/link_modifier_legacy.png"); + private static final int[] FLAGS = {LinkProperties.DISARM, LinkProperties.INTRA_LINKING, + LinkProperties.INTRA_LINKING_ONLY, LinkProperties.MAINTAIN_MOMENTUM, LinkProperties.RELATIVE_LINK, LinkProperties.DISABLE_PLATFORM}; + private static final int[] IDS = {LinkModifierMenu.TOGGLE_DISARM, LinkModifierMenu.TOGGLE_INTRA, + LinkModifierMenu.TOGGLE_INTRA_ONLY, LinkModifierMenu.TOGGLE_MOMENTUM, LinkModifierMenu.TOGGLE_RELATIVE, LinkModifierMenu.TOGGLE_PLATFORM}; + private static final String[] NAMES = {"disarm", "intra_linking", "intra_linking_only", "maintain_momentum", "relative_link", "generate_platform"}; + private EditBox titleInput; + private ItemStack lastBook = ItemStack.EMPTY; + private boolean refreshing; + private final Button[] options = new Button[6]; + + public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title, 176, 166); + inventoryLabelY = 72; + } + @Override public boolean isPauseScreen() { return false; } + @Override protected void init() { + super.init(); + titleInput = new EditBox(font, leftPos + 80, topPos + 56, 87, 14, + Component.translatable("screen.rivencraft.link_modifier.title")); + titleInput.setMaxLength(32); + titleInput.setResponder(value -> { + if (!refreshing) ClientPacketDistributor.sendToServer(new SetLinkModifierTitlePayload(menu.containerId, value)); + }); + addRenderableWidget(titleInput); + for (int index = 0; index < options.length; index++) { + final int option = index; + options[index] = addRenderableWidget(new Button(leftPos + 5 + (index / 3) * 20, + topPos + 10 + (index % 3) * 20, 18, 18, label(index), button -> { + if (minecraft != null && minecraft.gameMode != null) + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, IDS[option]); + }, java.util.function.Supplier::get) { + @Override protected void extractContents(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + int x = getX(), y = getY(); + graphics.fill(x, y, x + 18, y + 18, isHoveredOrFocused() ? 0xFFFFFFFF : 0xFFAAAAAA); + graphics.fill(x + 2, y + 2, x + 17, y + 17, !active ? 0xFF555555 + : selected(option, menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) + ? 0xFFBBBBBB : 0xFF777777); + } + }); + options[index].setTooltip(Tooltip.create(label(index))); + } + refreshControls(); + } + @Override protected void containerTick() { refreshControls(); } + private void refreshControls() { + ItemStack book = menu.book(); + // Changing book kinds refreshes immediately; otherwise avoid replacing + // focused text with an earlier server echo while the player is typing. + if (!ItemStack.isSameItem(book, lastBook) || !titleInput.isFocused()) { + if (!ItemStack.isSameItemSameComponents(book, lastBook)) { + Component name = book.get(DataComponents.CUSTOM_NAME); + refreshing = true; + titleInput.setValue(name == null ? "" : name.getString()); + refreshing = false; + lastBook = book.copy(); + } + } + titleInput.setEditable(!book.isEmpty()); + for (int i = 0; i < options.length; i++) if (options[i] != null) { + options[i].active = available(i); + Component label = label(i); + if (!options[i].getMessage().equals(label)) { + options[i].setMessage(label); + options[i].setTooltip(Tooltip.create(label)); + } + } + } + private boolean available(int index) { + return !menu.book().isEmpty() && ((index != 1 && index != 2) || menu.book().is(Rivencraft.LINKING_BOOK.get())); + } + private Component label(int index) { + Component name = Component.translatable("item.rivencraft.link_property." + NAMES[index]); + return name.copy().append(selected(index, + menu.book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE)) ? ": On" : ": Off"); + } + @Override public boolean keyPressed(KeyEvent event) { + if (titleInput != null && titleInput.isFocused() && !event.isEscape()) { + titleInput.keyPressed(event); + return true; // Character input must never activate inventory/hotbar shortcuts. + } + return super.keyPressed(event); + } + @Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + super.extractBackground(graphics, mouseX, mouseY, partialTick); + graphics.blit(RenderPipelines.GUI_TEXTURED, BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256); + } + + private boolean selected(int index, LinkProperties properties) { + if (index != 5) return properties.has(FLAGS[index]); + return menu.book().is(Rivencraft.LINKING_BOOK.get()) ? properties.has(LinkProperties.GENERATE_PLATFORM) + : !properties.has(LinkProperties.DISABLE_PLATFORM); + } + @Override protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { + graphics.text(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF404040, false); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java index 6071b54..1a43626 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/PortBookBinderClientEvents.java @@ -43,6 +43,7 @@ public final class PortBookBinderClientEvents { public static void registerScreens(RegisterMenuScreensEvent event) { event.register(Rivencraft.BOOK_BINDER_MENU.get(), PortBookBinderScreen::new); event.register(Rivencraft.INK_MIXER_MENU.get(), InkMixerScreen::new); + event.register(Rivencraft.LINK_MODIFIER_MENU.get(), LinkModifierScreen::new); event.register(Rivencraft.NOTEBOOK_MENU.get(), NotebookScreen::new); event.register(Rivencraft.WRITING_DESK_MENU.get(), WritingDeskScreen::new); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java index 0b3e6a8..d9c9217 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/DescriptiveBookItem.java @@ -36,5 +36,6 @@ public final class DescriptiveBookItem extends Item { Consumer tooltip, TooltipFlag flag) { Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); tooltip.accept(Component.literal(id == null ? "Unbound (26.3 port preview)" : "Age: " + id)); + stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook().appendTooltip(tooltip); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkProperties.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkProperties.java index d524d04..279160f 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkProperties.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkProperties.java @@ -5,12 +5,17 @@ import io.netty.buffer.ByteBuf; import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; -/** The three Ink Mixer modifiers currently supported by the 26.3 port. */ +/** Link Panel flags shared by the mixer, books and Link Modifier station. */ public record LinkProperties(int flags) { public static final int DISARM = 1; + public static final int GENERATE_PLATFORM = 2; + public static final int INTRA_LINKING_ONLY = 16; + public static final int RELATIVE_LINK = 32; + public static final int DISABLE_PLATFORM = 64; public static final int MAINTAIN_MOMENTUM = 4; public static final int INTRA_LINKING = 8; - public static final int SUPPORTED = DISARM | MAINTAIN_MOMENTUM | INTRA_LINKING; + public static final int SUPPORTED = DISARM | GENERATE_PLATFORM | MAINTAIN_MOMENTUM | INTRA_LINKING + | INTRA_LINKING_ONLY | RELATIVE_LINK | DISABLE_PLATFORM; public static final LinkProperties NONE = new LinkProperties(0); public static final Codec CODEC = Codec.INT.xmap(LinkProperties::new, LinkProperties::flags); public static final StreamCodec STREAM_CODEC = StreamCodec.of( @@ -23,6 +28,18 @@ public record LinkProperties(int flags) { } } + public boolean allowsTravel(boolean sameDimension) { + return sameDimension ? has(INTRA_LINKING) || has(INTRA_LINKING_ONLY) : !has(INTRA_LINKING_ONLY); + } + public LinkProperties forDescriptiveBook() { + return new LinkProperties(flags & ~(INTRA_LINKING | INTRA_LINKING_ONLY)); + } + public void appendTooltip(java.util.function.Consumer tooltip) { + int[] flags = {DISARM, MAINTAIN_MOMENTUM, INTRA_LINKING, INTRA_LINKING_ONLY, RELATIVE_LINK, GENERATE_PLATFORM, DISABLE_PLATFORM}; + String[] names = {"disarm", "maintain_momentum", "intra_linking", "intra_linking_only", "relative_link", "generate_platform", "disable_platform"}; + for (int i = 0; i < flags.length; i++) if (has(flags[i])) + tooltip.accept(net.minecraft.network.chat.Component.translatable("item.rivencraft.link_property." + names[i])); + } public boolean has(int flag) { return (flags & flag) != 0; } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java index f7c9a91..7bdd024 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkingBookItem.java @@ -48,7 +48,7 @@ public final class LinkingBookItem extends Item { if (destination == null) return "Unbound Linking Book (26.3 port preview)"; if (!destination.valid()) return "Invalid Linking Book destination"; if (player.level().dimension().identifier().equals(destination.dimension()) - && !properties.has(LinkProperties.INTRA_LINKING)) { + && !properties.allowsTravel(true)) { return "Same-dimension linking requires an Intra-Linking page"; } ServerLevel target = player.level().getServer().getLevel( @@ -57,11 +57,13 @@ public final class LinkingBookItem extends Item { if (!AgeCatalogSync.ready(player, destination.dimension())) { return "Destination catalog not acknowledged by client"; } + if (!properties.allowsTravel(target == player.level())) return "Intra-Linking Only cannot travel between dimensions"; + destination = PortLinkDestinations.resolve(player.level(), target, player.blockPosition(), destination, properties); + if (!destination.valid()) return "Invalid relative destination"; BlockPos feet = BlockPos.containing(destination.x(), destination.y(), destination.z()); if (feet.getY() < target.getMinY() || feet.getY() + 1 >= target.getMaxY() - || !target.getWorldBorder().isWithinBounds(feet)) { - return "Destination is outside the world bounds"; - } + || !target.getWorldBorder().isWithinBounds(feet)) return "Destination is outside the world bounds"; + target.getChunkAt(feet); // Test the player's actual arrival volume: checking whole block shapes // would incorrectly reject links bound while standing on a slab. if (!target.noCollision(player, player.getBoundingBox().move( @@ -74,7 +76,10 @@ public final class LinkingBookItem extends Item { Vec3 incoming = player.getDeltaMovement(); boolean moved = player.teleportTo(target, destination.x(), destination.y(), destination.z(), Set.of(), destination.yaw(), destination.pitch(), true); - if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal); + if (moved) { + PortLinkDestinations.generatePlatform(target, player.position(), properties); + applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal); + } return moved ? null : "Teleportation failed"; } @@ -133,11 +138,6 @@ public final class LinkingBookItem extends Item { destinationName(stack, target).getString(), pos.getX(), pos.getY(), pos.getZ())); } LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); - if (properties.has(LinkProperties.DISARM)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm")); - if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum")); - if (properties.has(LinkProperties.INTRA_LINKING)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking")); + properties.appendTooltip(tooltip); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 0daf46c..d0050c2 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -96,15 +96,7 @@ public final class PageItem extends Item { return; } LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); - if (properties.has(LinkProperties.DISARM)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.disarm")); - if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.maintain_momentum")); - if (properties.has(LinkProperties.INTRA_LINKING)) - tooltip.accept(Component.translatable("item.rivencraft.link_property.intra_linking")); - tooltip.accept(Component.translatable(isLinkPanel(stack) - ? "item.rivencraft.page.link_panel.port_status" - : "item.rivencraft.page.blank.port_status")); + properties.appendTooltip(tooltip); } private static boolean isCorrupted(Identifier id) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortLinkDestinations.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortLinkDestinations.java new file mode 100644 index 0000000..d4adf5c --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/PortLinkDestinations.java @@ -0,0 +1,30 @@ +package net.dnorberg.rivencraft.item; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.Vec3; + +/** 1.21.1 Relative Link and optional Linking Book arrival-floor rules. */ +public final class PortLinkDestinations { + private PortLinkDestinations() {} + public static BlockPos relativeOrigin(BlockPos sourceSpawn, BlockPos destinationSpawn, BlockPos departure) { + return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(), departure.getY() - sourceSpawn.getY(), + departure.getZ() - sourceSpawn.getZ()); + } + public static BlockPos relativeOrigin(ServerLevel source, ServerLevel target, BlockPos departure) { + return relativeOrigin(source.getRespawnData().pos(), target.getRespawnData().pos(), departure); + } + public static LinkBookTarget resolve(ServerLevel source, ServerLevel target, BlockPos departure, + LinkBookTarget written, LinkProperties properties) { + if (!properties.has(LinkProperties.RELATIVE_LINK)) return written; + BlockPos pos = relativeOrigin(source, target, departure); + return new LinkBookTarget(written.dimension(), pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5, written.yaw(), written.pitch()); + } + public static void generatePlatform(ServerLevel level, Vec3 arrival, LinkProperties properties) { + if (!properties.has(LinkProperties.GENERATE_PLATFORM)) return; + BlockPos feet = BlockPos.containing(arrival), floor = feet.below(); + if (level.isEmptyBlock(floor) && level.isEmptyBlock(feet.above(2))) + level.setBlock(floor, Rivencraft.CLOUD_PLATFORM.get().defaultBlockState(), 3); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java new file mode 100644 index 0000000..c113dee --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/menu/LinkModifierMenu.java @@ -0,0 +1,94 @@ +package net.dnorberg.rivencraft.menu; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity; +import net.dnorberg.rivencraft.item.LinkProperties; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +/** 1.21.1's persistent single-book editing station, adapted to 26.3. */ +public final class LinkModifierMenu extends AbstractContainerMenu { + public static final int TOGGLE_DISARM = 1, TOGGLE_MOMENTUM = 2, TOGGLE_INTRA = 3, + TOGGLE_INTRA_ONLY = 5, TOGGLE_RELATIVE = 6, TOGGLE_PLATFORM = 7; + private final LinkModifierBlockEntity modifier; + private final Container books; + private final Player owner; + public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) { + this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos()) + instanceof LinkModifierBlockEntity found ? found : null); + } + public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) { + super(Rivencraft.LINK_MODIFIER_MENU.get(), id); + this.modifier = modifier; + books = modifier == null ? new SimpleContainer(1) : modifier; + owner = inventory.player; + addSlot(new Slot(books, 0, 80, 35) { + @Override public boolean mayPlace(ItemStack stack) { return isBook(stack); } + @Override public int getMaxStackSize() { return 1; } + }); + for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++) + addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 84 + row * 18)); + for (int col = 0; col < 9; col++) addSlot(new Slot(inventory, col, 8 + col * 18, 142)); + } + private static boolean isBook(ItemStack stack) { + return stack.is(Rivencraft.LINKING_BOOK.get()) || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()); + } + public ItemStack book() { return books.getItem(0); } + public boolean setTitle(Player player, String title) { + if (player.level().isClientSide() || !stillValid(player) || !isBook(book()) + || title == null || title.length() > 32) return false; + String clean = title.strip(); + if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME); + else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean)); + books.setChanged(); + broadcastChanges(); + return true; + } + @Override public boolean clickMenuButton(Player player, int id) { + if (player.level().isClientSide() || !stillValid(player) || !isBook(book())) return false; + int flag = switch (id) { + case TOGGLE_DISARM -> LinkProperties.DISARM; + case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM; + case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING; + case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY; + case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK; + case TOGGLE_PLATFORM -> book().is(Rivencraft.LINKING_BOOK.get()) + ? LinkProperties.GENERATE_PLATFORM : LinkProperties.DISABLE_PLATFORM; + default -> 0; + }; + // Descriptive Books travel to an Age; Intra Linking is a Linking Book property. + if (flag == 0 || (flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY) && book().is(Rivencraft.DESCRIPTIVE_BOOK.get())) return false; + int flags = book().getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).flags(); + if (flag == LinkProperties.INTRA_LINKING_ONLY && (flags & LinkProperties.INTRA_LINKING_ONLY) == 0) + flags |= LinkProperties.INTRA_LINKING; + if (flag == LinkProperties.INTRA_LINKING && (flags & LinkProperties.INTRA_LINKING) != 0) + flags &= ~LinkProperties.INTRA_LINKING_ONLY; + book().set(Rivencraft.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag)); + books.setChanged(); + broadcastChanges(); + return true; + } + @Override public boolean stillValid(Player player) { + return player == owner && modifier != null && modifier.stillValid(player); + } + @Override public ItemStack quickMoveStack(Player player, int index) { + if (!stillValid(player) || index < 0 || index >= slots.size()) return ItemStack.EMPTY; + Slot slot = slots.get(index); + if (!slot.hasItem()) return ItemStack.EMPTY; + ItemStack source = slot.getItem(), copy = source.copy(); + if (index == 0) { + if (!moveItemStackTo(source, 1, slots.size(), true)) return ItemStack.EMPTY; + } else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY; + if (source.isEmpty()) slot.set(ItemStack.EMPTY); + else slot.setChanged(); + return copy; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java index 4b5814b..d76d0fe 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/PortBookNetworking.java @@ -33,6 +33,12 @@ public final class PortBookNetworking { public static void registerPayloads(RegisterPayloadHandlersEvent event) { var registrar = event.registrar("1"); + registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC, + (payload, context) -> { + if (context.player() instanceof ServerPlayer player + && player.containerMenu instanceof net.dnorberg.rivencraft.menu.LinkModifierMenu menu + && menu.containerId == payload.containerId()) menu.setTitle(player, payload.title()); + }); registrar.playToClient(WritingDeskCatalogPayload.TYPE, WritingDeskCatalogPayload.STREAM_CODEC); registrar.playToServer(BindLinkingBookPayload.TYPE, BindLinkingBookPayload.STREAM_CODEC, (payload, context) -> { @@ -117,6 +123,8 @@ public final class PortBookNetworking { public static Identifier createAge(ServerPlayer player, String title, List pages) { if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null; long seed = player.getRandom().nextLong(); + var recovery = net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages); + List activePages = recovery.activePages(); net.dnorberg.rivencraft.age.PortBiomePlan biomePlan; PortAgeTerrain terrain; List generationPages; @@ -148,7 +156,7 @@ public final class PortBookNetworking { id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name); } try { - var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(pages, seed, terrain); + var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain); var randomFeatures = pages.isEmpty() ? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null; var level = RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain, @@ -210,7 +218,8 @@ public final class PortBookNetworking { Vec3 departure = player.position(); Vec3 incoming = player.getDeltaMovement(); java.util.function.Supplier travel = () -> { - String result = PortAgeTravel.enter(player, id); + String result = PortAgeTravel.enter(player, id, + stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).forDescriptiveBook()); if (result == null) LinkingBookItem.applySuccessfulLink(player, stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE), source, departure, incoming, player.getYRot(), portal); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierTitlePayload.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierTitlePayload.java new file mode 100644 index 0000000..45ded13 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/network/SetLinkModifierTitlePayload.java @@ -0,0 +1,18 @@ +package net.dnorberg.rivencraft.network; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record SetLinkModifierTitlePayload(int containerId, String title) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_title")); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierTitlePayload::containerId, + ByteBufCodecs.stringUtf8(32), SetLinkModifierTitlePayload::title, + SetLinkModifierTitlePayload::new); + @Override public Type type() { return TYPE; } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java index fc259b6..e0c1d22 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java @@ -43,14 +43,17 @@ public final class PortalBookTravel { var id = book.get(Rivencraft.AGE_BOOK_ID.get()); ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)); if (level == null) return; - var landing = PortAgeTravel.resolveLanding(level); + var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook()); if (landing == null) return; destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, entity.getYRot(), entity.getXRot()); } if (destination == null || !destination.valid()) return; ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension())); - if (target == null || !entity.canTeleport(source, target) || !descriptive && target == source && !properties.has(LinkProperties.INTRA_LINKING)) return; + if (target == null || !entity.canTeleport(source, target) || !descriptive && !properties.allowsTravel(target == source)) return; + if (!descriptive) destination = net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(source, target, + entity.blockPosition(), destination, properties); + if (!destination.valid()) return; var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z()); if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY() || !target.getWorldBorder().isWithinBounds(feet)) return; @@ -68,6 +71,7 @@ public final class PortalBookTravel { Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(), destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET)); if (arrived == null) return; + if (!descriptive) net.dnorberg.rivencraft.item.PortLinkDestinations.generatePlatform(target, arrival, properties); if (properties.has(LinkProperties.DISARM)) { LinkDisarmService.disarmAt(arrived, source, departure); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 81dfe70..efdcf9a 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -33,6 +33,7 @@ public final class ModCreativeTabs { output.accept(Rivencraft.INK_VIAL.get()); output.accept(Rivencraft.BOOK_BINDER_ITEM.get()); output.accept(Rivencraft.INK_MIXER_ITEM.get()); + output.accept(Rivencraft.LINK_MODIFIER_ITEM.get()); output.accept(Rivencraft.WRITING_DESK_ITEM.get()); output.accept(Rivencraft.NOTEBOOK.get()); output.accept(Rivencraft.COLLATION_FOLDER.get()); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredTerrainSelection.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredTerrainSelection.java index ea5d249..a41bc1e 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredTerrainSelection.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredTerrainSelection.java @@ -17,6 +17,20 @@ public final class PortAuthoredTerrainSelection { } public static Selection select(MinecraftServer server, List pages, long seed) { + if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) + throw new IllegalArgumentException("An Age may have at most ten suns and ten moons"); + for (var page : pages) { + var entry = PortSymbolPages.find(page); + if (entry != null && entry.role().equals("biome")) { + var biome = PortRegistryPages.biomeId(page); + if (biome == null || server.registryAccess().lookupOrThrow(net.minecraft.core.registries.Registries.BIOME) + .get(net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.BIOME, biome)).isEmpty()) + throw new IllegalArgumentException("Unknown biome page: " + page); + } + } + var recovered = PortWritingRecovery.recover(pages); + if (!recovered.implemented()) throw new IllegalArgumentException("Unimplemented symbol page in this book"); + pages = recovered.activePages(); var explicit = PortAuthoredWriting.explicitTerrain(pages); if (explicit != null) return compile(server, pages, seed, explicit); var preferred = PortRandomAgeSelection.terrain(seed); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java index 9cda878..07c04c6 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortAuthoredWriting.java @@ -72,20 +72,25 @@ public final class PortAuthoredWriting { || PortStructurePages.supports(page) || PortCarverPages.supports(page); } - /** A page never disappears into an Age unless this port can apply its effect. */ + /** Recover known category defects; unimplemented pages and unsupported runtime combinations still block binding. */ public static boolean supports(List pages) { - if (pages.stream().noneMatch(PortAuthoredWriting::isTerrain)) + if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false; + var recovered = PortWritingRecovery.recover(pages); + if (!recovered.implemented()) return false; + List active = recovered.activePages(); + if (active.stream().noneMatch(PortAuthoredWriting::isTerrain)) return net.dnorberg.rivencraft.age.PortRandomAgeSelection.terrains().stream() - .anyMatch(terrain -> supportsOnTerrain(pages, terrain, 0)); - try { - return supportsExplicit(PortMaterialCompletion.complete(pages, 0)); - } catch (IllegalArgumentException exception) { return false; } + .anyMatch(terrain -> supportsOnTerrain(active, terrain, 0)); + try { return supportsExplicit(PortMaterialCompletion.complete(active, 0)); } + catch (IllegalArgumentException exception) { return false; } } static boolean supportsOnTerrain(List pages, PortAgeTerrain terrain, long seed) { - try { - return supportsExplicit(PortMaterialCompletion.complete(withTerrain(pages, terrain), seed)); - } catch (IllegalArgumentException exception) { return false; } + if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false; + var recovered = PortWritingRecovery.recover(pages); + if (!recovered.implemented()) return false; + try { return supportsExplicit(PortMaterialCompletion.complete(withTerrain(recovered.activePages(), terrain), seed)); } + catch (IllegalArgumentException exception) { return false; } } static List withTerrain(List pages, PortAgeTerrain terrain) { @@ -104,7 +109,6 @@ public final class PortAuthoredWriting { } private static boolean supportsExplicit(List pages) { - if (!net.dnorberg.rivencraft.age.PortAgeColors.validNumbers(pages)) return false; if (!net.dnorberg.rivencraft.age.PortDayCycle.validWriting(pages)) return false; boolean terrain = false; int biomes = 0; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java index c687e7c..6b14aab 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortRegistryPages.java @@ -101,7 +101,7 @@ public final class PortRegistryPages { || path.equals("deep_dark") || path.endsWith("_caves"); } - private static String materialCategory(Block block) { + public static String materialCategory(Block block) { if (block == null || block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR || block == Rivencraft.CLOUD_PLATFORM.get() || block instanceof EntityBlock || block.defaultBlockState().hasBlockEntity()) return null; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java index d30a6a6..b5aa5eb 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortSymbolPages.java @@ -17,7 +17,13 @@ import net.dnorberg.rivencraft.item.PageItem; /** Bundled 1.21.1 symbol identities, shared by the port's creative pages. */ public final class PortSymbolPages { - public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) {} + public record Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem, String role) { + public Entry(Identifier id, String category, String nameKey, String descriptionKey, String poem) { + this(id, category, nameKey, descriptionKey, poem, + category.equals("rivencraft:biome") ? "biome" + : category.equals("rivencraft:block") || category.equals("rivencraft:fluid") ? "block" : "unknown"); + } + } private static final Map ENTRIES = load(); @@ -100,7 +106,9 @@ public final class PortSymbolPages { category = "rivencraft:biome_generation"; entries.put(id, new Entry(id, category, definition.get("name").getAsString(), definition.get("description").getAsString(), - definition.has("poem") ? definition.get("poem").getAsString() : "")); + definition.has("poem") ? definition.get("poem").getAsString() : "", + definition.has("semantics") ? definition.getAsJsonObject("semantics").get("role").getAsString() + : id.getPath().equals("lighting/ambient_color") ? "ambient_color" : "unknown")); }); return Collections.unmodifiableMap(entries); } catch (java.io.IOException exception) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortWritingRecovery.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortWritingRecovery.java new file mode 100644 index 0000000..8dd18b0 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/PortWritingRecovery.java @@ -0,0 +1,99 @@ +package net.dnorberg.rivencraft.writing; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.resources.Identifier; + +/** 1.21.1 grammar diagnostics and singular-controller recovery; preserve ordered modifier chains. */ +public final class PortWritingRecovery { + private static final Set SINGULAR = Set.of("terrain", "biome_controller", "weather", "season", "lighting", "sky"); + private static final Set MODIFIERS = Set.of("period_modifier", "angle_modifier", "phase_modifier", "size_modifier", + "celestial_color", "direction_modifier"); + public record Diagnostic(String code, int index, int points) {} + public record Result(List activePages, List diagnostics, boolean implemented) { + public Result { activePages = List.copyOf(activePages); diagnostics = List.copyOf(diagnostics); } + } + private PortWritingRecovery() {} + public static Result recover(List pages) { + Map last = new HashMap<>(); + List diagnostics = new ArrayList<>(); + boolean implemented = pages.size() <= NotebookContents.MAX_PAGES; + int previousStage = -1, biomes = 0; + Map pendingModifiers = new java.util.LinkedHashMap<>(); + Integer pendingNumber = null; + for (int index = 0; index < pages.size(); index++) { + Identifier page = pages.get(index); + var entry = PortSymbolPages.find(page); + if (entry == null || !PortAuthoredWriting.isPageImplemented(page)) { implemented = false; continue; } + String role = entry.role(); + if (pendingNumber != null && !numberTarget(role)) { + diagnostics.add(new Diagnostic("misplaced_modifier", pendingNumber, 100)); + pendingNumber = null; + } + if (role.equals("color_number")) pendingNumber = index; + else if (numberTarget(role)) pendingNumber = null; + if (MODIFIERS.contains(role)) pendingModifiers.put(role, index); + else if (role.equals("celestial")) pendingModifiers.clear(); + if (SINGULAR.contains(role)) { + Integer prior = last.put(role, index); + if (prior != null) { + diagnostics.add(new Diagnostic(pages.get(prior).equals(page) ? "duplicate" : "conflict", index, 500)); + // Match WritingGrammar: a repeated singular choice does not also earn an order penalty. + continue; + } + } + if (role.equals("biome")) biomes++; + int stage = stage(role); + if (stage < previousStage && !MODIFIERS.contains(role) && !role.equals("color") && !role.equals("color_number")) + diagnostics.add(new Diagnostic("out_of_order", index, 100)); + else if (!role.equals("color") && !role.equals("color_number")) previousStage = stage; + } + if (pendingNumber != null) diagnostics.add(new Diagnostic("dangling_modifier", pendingNumber, 100)); + for (int index : pendingModifiers.values()) + diagnostics.add(new Diagnostic("dangling_modifier", index, 100)); + Integer controller = last.get("biome_controller"); + boolean nativeBiomes = controller != null && pages.get(controller).getPath().equals("biome/native"); + boolean single = controller != null && pages.get(controller).getPath().equals("biome/single"); + if (nativeBiomes && biomes > 0 || single && biomes > 1) + diagnostics.add(new Diagnostic("conflict", controller, 500)); + List active = new ArrayList<>(); + boolean retainedBiome = false; + for (int index = 0; index < pages.size(); index++) { + var page = pages.get(index); + var entry = PortSymbolPages.find(page); + if (entry != null) { + String role = entry.role(); + if (SINGULAR.contains(role) && last.getOrDefault(role, -1) != index) continue; + if (role.equals("biome")) { + if (nativeBiomes || single && retainedBiome) continue; + retainedBiome = true; + } + } + active.add(page); + } + // Keep source order for material pairing and per-celestial modifier chains. + // The port's category compilers select by role, so no physical resort is necessary. + return new Result(active, diagnostics, implemented); + } + private static boolean numberTarget(String role) { + return role.equals("color") || role.equals("period_modifier") || role.equals("angle_modifier") + || role.equals("phase_modifier") || role.equals("size_modifier"); + } + private static int stage(String role) { + return switch (role) { + case "terrain" -> 0; + case "biome" -> 1; + case "biome_controller" -> 2; + case "weather" -> 3; + case "season" -> 4; + case "lighting" -> 5; + case "sky", "sky_color", "ambient_color", "color", "color_number" -> 6; + case "period_modifier", "angle_modifier", "phase_modifier", "size_modifier", "celestial_color", "direction_modifier" -> 7; + case "celestial" -> 8; + default -> 9; + }; + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/link_modifier.json b/ports/mc263/src/main/resources/assets/rivencraft/items/link_modifier.json new file mode 100644 index 0000000..851dfee --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/link_modifier.json @@ -0,0 +1 @@ +{"model":{"type":"minecraft:model","model":"rivencraft:item/link_modifier"}}