diff --git a/README.md b/README.md index c881752..26e8dd8 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,14 @@ independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and feature completion, and random structure selection. Nether/End noise-biome materials now follow their native rules. Books now have Legacy-style spreads, animated fallback panels and saved destination labels; lectern stands, open-book -drops, page collections, stations and basic progression are also ported. +drops, page collections, stations and basic progression are also ported. The latest +beta adds Age libraries, crystal portals with animated fallback surfaces, saved +instability scoring/provider selection, potion providers and four-color decay. +Full writing recovery, live material profiling and other hazards remain pending. The beta does not yet provide full 1.21.1 gameplay or optional-integration parity. See the [26.3 beta guide](ports/mc263/README.md) and -[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.115-beta.1.md). +[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md). Keep the builds and their saves separate, and back up worlds before updating. ## Getting started diff --git a/docs/MINECRAFT_26_3_PORT.md b/docs/MINECRAFT_26_3_PORT.md index 1009f32..7bf82a4 100644 --- a/docs/MINECRAFT_26_3_PORT.md +++ b/docs/MINECRAFT_26_3_PORT.md @@ -2169,3 +2169,160 @@ Server stopped and temporary world removed. Manual checks: Q/inventory toss, death/Disarm, broken stand, full-inventory delivery/retrieval, open-book visual orientation, motion, ownership/pickup, component preservation and restart. Root 1.21.1 remains unchanged and #114 stays open until both tracks are complete. + +## Handheld Link Panel resource policy + +`1.0.1.116-beta.1` adapts root WritingInkService and the configurable +`writing.link_panel_ink_cost` (2 default, 1–16 allowed). The item delegates the +server transaction to the service; Writing Desk cost reads also use its shared +getter. Mixer cost remains independently fixed at one ink per Link Panel. +Unsynchronized LOCAL config is not consulted for client affordability prediction; +the server validates both inputs and constructs outputs before mutation. + +Build passed. No additional server fixtures were added; the user offered manual +validation. Check default/custom cost, insufficient ink, final bottle, both hands, +stacked/full-inventory inputs and dedicated-server cost differing from the client. +Root 1.21.1 is unchanged; no commit/push/publication was performed. + +## Library discovery port + +`1.0.1.117-beta.1` ports MystcraftLibraryFeature from the 1.21.1 implementation, +using the shared schematic/loot table and final five-stand orientation. Natural +placement runs after all vanilla/authored decoration via the Age feature wrapper, +with the same bounded footprint, candidate RNG/spacing, slope/footing and obstruction +rules. The wrapper now applies to every runtime Age; Cave terrain disables libraries +as in root. Existing chunks are not rewritten and vanilla Overworld generation is +not modified. + +A separate saved writing map holds the original symbol list for AGE_ONLY loot, +avoiding expansion of the already-large backend index record. Creation stores it +before constructing/publishing the level; the wrapper captures an immutable list +and serializes library flags/list in its codec. Reload reconstructs the same pool. +LEGACY is the default; AGE_ONLY uses known distinct written IDs. Full random-page +policy integration remains pending. The snapshot list does not alter generation +traits or the book's original contents. + +Added operator placement, session-only spacing override, cheap candidate reporting, +current-chunk probe/loot inspection and a locator restricted to loaded nearby chunks. +The locator checks a clear, dry, footed aisle; it never performs a mass remote +chunk-generation search. Explicit test placement checks empty space and entities. + +Build passed; shared schematic/Sealed Notebook loot packaging verified. Dedicated +server startup passed. The user offered manual testing, so generation/loot checks +were handed over before running them. Temporary server stopped and world removed. +No new test fixture, commit or publication. This is a substantial discovery-system +slice; instability/recovery, crystal portals and remaining optional integrations +remain the other large parity gaps. + + +## .118 crystal portal gameplay port + +The player confirmed .117 library generation and retracted the missing-library report. +Ported the 1.21.1 Book Receptacle, bounded crystal flood/tension network, portal +surface ownership and orientation, activation protection checks and rollback, removal +cleanup, queued transport and cooldown. Reused existing crystal, portal and receptacle +assets, recipe and progression node. The book renderer retains all final six mounting +orientations and explicitly clears its extracted presence when the book is removed. + +Player traversal uses existing Linking/Descriptive Book paths, with portal audio +precedence. Descriptive arrival resolution is now shared; no separate portal offset +or generated exit position is introduced. Existing Intra-Linking, Disarm, momentum, +client Age acknowledgement, Return Link configuration and progression remain in force. +Non-player traversal uses modern TeleportTransition, collision/permission checks, +and the same landing resolver. A shared Disarm helper supports container/horse/mob +inventory and equipment without requiring the old protected-method mixin. + +Book and owner pointers use modern ValueInput/ValueOutput and update packets. Startup +waits for loaded frame chunks; periodic bounded reconciliation retains root's cleanup +of disconnected cells, staggered every 200 ticks per holder. The surface remains the +animated facing-only fallback, tinted to the saved destination sky. Live portal views +are still optional integration work, not advertised as ported. + +Build/resources and dedicated-server startup passed. A command-seeded 3x3 opening +verified exact-coordinate entity transport, persistence and traversal after restart, +and support-removal teardown/open-book recovery. Player interaction and client visuals +are handed over for manual testing. Temporary server stopped/world deleted; no new +permanent GameTest, commit, push or publication. Version: 1.0.1.118-beta.1. + + +## .119 instability foundation and status-effect gameplay + +The player passed .118. Ported the complete five-deck catalog and deterministic +provider selection from 1.21.1. Separate modern codec SavedData freezes new Ages' +scoring contributions, seed, selected providers/levels and an enabled flag. Older +Ages remain neutral. Creation-time scoring currently covers Bright Lighting, +Accelerated, all three Corrupted sizes, ignored Cave weather/celestial modifiers, +excess/biome-forced structures and additional suns. Original page indices are retained. +Corrupted Pages now bind and contribute their Legacy costs; existing redacted rendering +is reused. The new Abandoned Camps structure follows the same modern-structure costs. + +Adapted the bounded occupied-Age scheduler and the 18 local/global/general/hostile +potion providers, preserving 1.21.1 defaults, effect durations, level-based amplifiers, +sky exposure and Survival/Adventure targeting. The owned-instance lease system revokes +only Rivencraft's effects on exit/disable/shutdown, preserving other sources. Runtime +uses already-loaded chunks and shared per-Age work budgets; failed providers receive +a temporary quarantine. The config retains server ownership and can disable providers +without rerolling stored plans. + +OP-only `/rivencraft_port instability` supplies status/breakdown, persistent enabled +control, session-only score override/reset and one-provider pulses. Pulse output tracks +successful application separately from attempted work so event-cancelled effects are +not reported as applied. No scoring data is sent to player book/Binder UI. Unimplemented +hazards stay selected with an explicit status; they are never replaced or rerolled. + +Build/startup passed. Temporary dedicated-server checks verified neutral creation, +provider selection at a test score, Poison application/duration, immediate owned-effect +revocation, unrelated stronger-effect retention, local-versus-global roof behavior, +enabled-state persistence and test-score reset across restart. Server stopped/world +deleted; no new permanent GameTests, commit, push or publication. Manual checks cover +authored Corrupted/Accelerated/lighting score contributions, automatic player-occupied +scheduling, Survival/Creative policy, exit cleanup and hostile buffs. Version .119. + +Still pending: full writing grammar recovery/penalties, live block abundance, decay, +lightning/explosions, meteors, burning and erosion, plus optional integration parity. + + +## .120 colored decay gameplay + +The player passed .119. Ported the 1.21.1 DecayBlock, four provider handlers and bounded +frontier persistence. Reused all four active 16x16 block textures/models and the +protected-block tag. Modern adaptation uses BreakBlockEvent, Value/codec SavedData, +ChunkPos accessors, loaded-chunk lookup and server-side magic damage for White. +No decay item/recipe/drop is added. + +Provider metadata now distinguishes world-mutating handlers, so world_mutation gates +decay independently of potion policy. Defaults remain enabled/four neighbor attempts, +one successful conversion per call under the shared scheduler budget. Conversion +hardness/air/color odds, infrastructure/BE/fluid/unbreakable protection, positioned +fake-player claim events and placement rollback are retained from 1.21.1. + +The instability record adds an optional first resolved spawn, preserving old records' +scores/plans and enable flags. Shared Descriptive Book landing resolution records it +once; decay protects the same +/-4 horizontal and -2..+4 vertical region as root. +Older Ages record this on the next book/portal arrival; OP status exposes the point. +Frontiers and attempt counters live in each Age, capped at 1,024 positions per color. + +Added OP-only `/rivencraft_port instability decay [1..32 attempts]`, reporting +actual changes and nearby tracked positions. Existing generic pulses now report block +as well as entity actions. Invalid input/out-of-Age commands use proper command errors. +White block contact damage retains root's decay/world_mutation policy; disabling an +Age scheduler stops spread but does not remove existing decay or alter its block form. + +Build/startup passed. All four colors seeded/spread through bounded command pulses. +A full restart retained each frontier and advanced attempts from 32 to 40 while Red +continued growing. Bedrock and water generated empty frontiers, and the saved Age +disable blocked further provider changes. Textures verified 16x16. A stationary NoAI +sheep did not exercise the stepping hook, so White stepping damage remains a player +manual check, alongside visuals, arrival protection, normal scheduler/claim integration. +Server stopped/test world removed; no permanent tests, commit, push or publication. +Version: 1.0.1.120-beta.1. Other hazards, full writing recovery and live abundance +profiling remain pending. + + +## .120 release sign-off + +The user passed .120 and authorized commit, push and publication of the accumulated +.116–.120 work. Both target builds passed. Consolidated release notes distinguish +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. diff --git a/ports/mc263/README.md b/ports/mc263/README.md index 782ede6..b741a56 100644 --- a/ports/mc263/README.md +++ b/ports/mc263/README.md @@ -8,9 +8,10 @@ author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported biome-distribution pages, selected material/vegetation/structure features, and independent Age weather and lighting, multiple authored or seeded celestial bodies and eclipses, biome/material completion, and random features/structures. -Instability, crystal portals, remaining discovery/stations, writing recovery and optional -integration parity remain unfinished. The latest published beta is -[1.0.1.115-beta.1](RELEASE_NOTES_1.0.1.115-beta.1.md). +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). 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. @@ -1891,3 +1892,253 @@ offered to perform the remaining validation, so no further automated restart run was started. Server stopped and world removed. Shared issue #114 remains open: 1.21.1 implementation and manual client validation are pending. Nothing committed or published. + +### Configurable handheld Link Panel ink cost + +The local `1.0.1.116-beta.1` slice ports root WritingInkService and its +`writing.link_panel_ink_cost` option in `config/rivencraft-local.toml`. +The allowed range is 1–16, default 2. Preparing a baseline Link Panel by hand +consumes one Paper and that many ink units; insufficient ink changes neither +input. A drained vial returns a vanilla Glass Bottle. Stacked inputs and +inventory-full give/drop behavior retain the root transaction rules. The +Ink Mixer still consumes exactly one ink unit and one Paper per panel, separately +from this setting. Writing Desk symbol-copy cost uses the shared service getter. + +Handheld client prediction checks only eligible item types and a valid nonempty +vial; the server uses its own configured cost to accept/refuse and consume inputs. +This avoids relying on unsynchronized LOCAL config for client affordability. +No new recipe, packet, Page variant or progression award is introduced. + +The build passed. Following the player's offer to do testing, no test server +or new fixtures were started for this small resource-policy port. In game, check +two ink per panel by default; then set `link_panel_ink_cost = 4` under `[writing]` +and restart to check four ink per panel, insufficient-ink refusal, last-use bottle, +both hands and stacked/full-inventory cases. Confirm the Mixer still costs one +ink. On a dedicated server, change the server's value; the client need not match. +Nothing committed, pushed or published. + +### Age library discovery + +The local `1.0.1.117-beta.1` slice ports the 1.21.1 library discovery system: +the existing 11×11×11 schematic, five stocked lecterns, chest loot, deterministic +32-chunk-region spread and the terrain/obstruction checks. Libraries generate +at the end of decoration in newly generated, non-Cave Age chunks. The footprint +stays inside its candidate chunk. Trees/foliage can be cleared, while fluids, +block entities, solid obstructions, excessive slopes and absent sturdy footing +reject placement. Vanilla Overworld generation is unchanged. Old chunks are +not revisited; existing Ages gain libraries only in eligible new chunks. + +`[libraries] loot_mode` in `config/rivencraft-local.toml` defaults to `LEGACY`: +writing supplies and possible Sealed Notebooks in chests, with random symbol +Pages on five lecterns. `AGE_ONLY` limits new library chest/lectern Pages to +known symbols written into that Age, using distinct IDs. Page-free Ages then +have no symbol loot and the stands display ordinary Books, matching root. +The original written list is saved independently and copied into the generator +before generation, then restored on reload. It does not change the book's writing, +seed, folder or other trait completion. Older Ages without this saved list have +an empty Age-only pool. The broader random-page eligibility policy remains a +separate unported system. The Legacy pool may include currently unsupported red +pages, like the existing Sealed Notebook pool. + +Operator tools under `/rivencraft_port library`: +- `test_place`: place a raised library in clear space ahead, without overwriting + existing blocks/entities. A console source may supply position via `execute`. +- `nearest_loaded`: teleport into the nearest marked library aisle in loaded + chunks within 16 chunks. It checks footing/fluids/collision and does not scan + or generate remote chunks. +- `test_spacing 1` through `32`: session-only increased candidate density in an + Age, for new chunks only. `32` restores normal spacing; restart clears overrides. +- `candidate`: report the closest deterministic candidate without loading it; + eligibility is still required and a library is not guaranteed. +- `probe`: identify a marked library or explain current-chunk placement rejection. +- `inspect_loot`: materialize the current chunk's library chest as opening it would, + then print contents and the number of stands in that chunk. + +Build passed and the schematic/loot resources were verified in the JAR. A local +dedicated server started with the data; at the player's request it was stopped +before generation/loot tests. Its world was deleted. The player will check actual +natural placement, five lectern pages and orientation/retrieval, chest loot/Sealed +Notebook opening, both loot modes, locator/test tools and persistence after restart. +No new GameTest fixtures were added. No commit, push or publication was made. + + +## Beta .118 — crystal portals and Book Receptacles + +The player confirmed natural library generation in .117; the earlier missing-library +report was withdrawn. .118 adapts the complete fallback crystal network from 1.21.1: + +- Book Receptacles mount on any of the six faces of Crystal Blocks, with the final + 1.21.1 model/book orientations. Eight Crystal Blocks surrounding an empty crafting + square craft one receptacle; its recipe unlocks on obtaining a Crystal Block. +- Insert a bound Linking or Descriptive Book into a valid connected crystal frame. + The Legacy tension/flood algorithm accepts supported irregular openings, using + the same 16-block radius, 2,048-crystal and 1,024-surface limits. Placement respects + NeoForge protection events and rolls back rejected activation. +- Empty-hand interaction removes the book and closes the portal. Breaking its + support or holder closes it and drops the book as the recoverable open-book entity. +- Player travel delegates to the existing book transport, catalog acknowledgement, + safe landing, Link Panel effects and configured Return Link handling. Descriptive + Books share the exact same landing resolver with portals. Linking Books retain + their exact coordinates, facing and same-dimension Intra-Linking requirement. +- Non-player transport uses the same target rules, collision checks and momentum + rotation. Disarm refuses loose items and leaves container contents/eligible mob + equipment behind. The shared Disarm helper also replaces the player-only loop. +- Queued collision travel waits until the entering movement packet is complete. + A 40-tick session-only cooldown prevents repeated traversal. Portal owner pointers, + the one stored book, and destination sky tint persist and synchronize to clients. + Startup waits for required frame chunks before repair. Loaded networks receive a + staggered bounded maintenance pass every 200 ticks, without generating chunks. +- The animated fallback only renders its two facing surfaces. Its tint follows the + destination sky, including authored colors and the 1.21.1 Nether/End palette. + Optional live destination views remain part of the integration port; installing + another portal mod alone does not hide this fallback. +- Successful activation awards the restored Open Portal advancement. + +Validation: build and dedicated-server startup passed. A small command-seeded +crystal ring verified persisted book/owner data, entity collision travel to exact +saved coordinates before and after a full server restart, and removal/drop cleanup +when the support was broken. This was not a substitute for player-driven activation: +the player should test book insertion/removal, the six model orientations, both book +types and their Link Panel effects, survival/creative item handling, frame edits, +reopening after restart, and travel with destination chunks initially unloaded. +The temporary server was stopped and its world deleted. No new permanent GameTests, +commit, push or publication. + + +## Beta .119 — saved instability and status-effect providers + +The player passed .118 crystal portal testing. This slice begins the major instability +port using the 1.21.1 scoring rules, complete Legacy card catalog, deterministic deck +selection, bounded scheduler and potion-provider implementation. + +New Ages save creation-time contributions and selected provider IDs/levels in separate +Overworld SavedData (`rivencraft/instability`), alongside an independent enabled flag. +Creation saves this with the Age identity before catalog publication. Reload and +configuration changes do not reroll the frozen plan. Older Ages remain neutral rather +than being rescored. No score or provider detail is added to player books or Binder UI. + +The first scoring set includes Bright Lighting (+500, also when seed-completed), +Accelerated (+1,000 each), Corrupted Medium (+1,000), Small (+2,000) and Large (+0), +Cave-ineffective weather/celestial modifiers (+100 each), structure occurrences after +the first three (+100 each), authored structures forced into non-native biomes (+100 +per occurrence), and growing costs for the third and later suns (+1,000, +2,000, ...). +The same structure policy includes the new 26.3 Abandoned Camps page. Corrupted Pages +are now implemented in authoring/creative availability; they retain animated redacted +text and supply instability rather than a terrain decoration. + +All five Legacy decks and card costs/copy counts are retained. Scoring rounds down to +500 before selecting/shuffling from the Age seed. Unported hazard cards remain selected +and are reported as unavailable; no replacement reroll biases the deck toward potions. +Writing-recovery penalties, live valuable-material profiling, decay, lightning, +explosions, meteors, burning and erosion remain later slices. This is not yet full +instability parity, and the existing authoring validation rules still apply. + +Implemented providers: local/global Blindness, Mining Fatigue, Hunger, Nausea, Poison, +Slowness, Weakness and Wither, plus global hostile Regeneration and Resistance. Local +variants require visible sky; global variants work under cover. Harmful general effects +can affect Survival/Adventure players and non-player living entities; Creative and +Spectator are exempt. Hostile buffs target monsters. Duration follows the 1.21.1 values: +60 ticks Blindness/Nausea, 80 Fatigue/Hunger/Poison/Slowness/Weakness, 30 Wither, and 200 +hostile buffs. Provider level raises amplifier, not duration; default effective cap is 3. + +The scheduler runs only in occupied runtime Ages, selecting already-loaded nearby +chunks. Defaults match 1.21.1: 20-tick interval, four Ages, two providers and one action +per run, at most 16 chunks. Status leases cap at 32 per Age, eight candidates per attempt, +and 200 ticks per effect. A failed handler is quarantined temporarily. Owned effects +are revoked on departure/logout/disable/shutdown without deleting replacements or +stronger/extended effects from another source. Config entries are under `[instability]` +and `[instability_status_effects]` in the server's `rivencraft-local.toml`; provider +exclusions accept `poison` or `rivencraft:poison`. + +Operator commands, used inside a runtime Age: + +- `/rivencraft_port instability status`: frozen score/contributions, saved or test + provider levels, enabled/availability information. +- `/rivencraft_port instability enabled false` (or `true`): persistently pause/resume + this Age; pausing immediately clears owned effects without changing its saved plan. +- `/rivencraft_port instability test_score 5000`: session-only deterministic provider + selection at a test score, leaving creation-time contributions unchanged. +- `/rivencraft_port instability clear_test_score`: restore saved selection. Restart + also clears the test override. +- `/rivencraft_port instability pulse poison_global`: attempt one implemented level-1 + effect on an eligible living entity in the current already-loaded chunk. The command + respects config, enabled state, target policy and effect ownership; it reports when + no effect was applied. Existing effects are not replaced. Other catalog providers + are suggested too, with unavailable handlers explicitly reported. + +Validation: build and fresh dedicated-server startup passed. A small temporary Age +confirmed neutral scoring, deterministic test deck selection, exact Poison duration, +owned-effect cleanup on disable, retention of a stronger command-supplied Weakness, +local shelter versus global effects, saved enabled state after restart and session +score reset. The server was stopped and its world deleted. No permanent test fixtures +were added. Player authoring/scoring, automatic occupied-Age scheduling, Survival versus +Creative behavior, departure cleanup, hostile buffs and multiplayer fairness remain +manual checks. No commit, push or publication. + + +## Beta .120 — colored decay + +The player passed .119. This slice ports the four active 1.21.1 colored decay cards, +the block and all four existing 16x16 textures, per-Age frontier persistence, surface +seeding and six-neighbor conversion. Decay is selected by the existing saved Legacy +decks; older saved provider selections are not rerolled. + +Blue, Red and Purple use the same hardness-dependent conversion odds as 1.21.1. +White converts eligible solid blocks without a hardness roll. Air conversion remains +rarer (Blue/Red 1-in-20, Purple 1-in-8, White 1-in-50). Other decay colors can be +converted; White wins that conversion immediately and others use a 1-in-5 roll. +White also deals one point of magic damage on stepping contact, using the modern +server damage API. All decay variants are solid, hardness 5/resistance 10, have no +item form or drops, and do not spread through autonomous/random block ticks. + +A provider call samples at most four six-neighbor targets by default and changes at +most one block, sharing the existing scheduler's Age/provider/action budgets. It only +examines supplied already-loaded player-nearby chunks. Per-Age SavedData retains a +monotonic attempt sequence and at most 1,024 frontier coordinates per color; no chunk +or entity references are persisted. Destroyed/recolored stale sources are discarded. + +Protection matches 1.21.1: fluids, unbreakable/critical blocks, block entities, +portals and Rivencraft infrastructure resist conversion. Data packs can add entries +to `rivencraft:decay_protected`. A positioned fake player supplies modern NeoForge +BreakBlockEvent and placement events, with rollback for cancelled placement. + +The first resolved Descriptive Book landing is saved as a protected spawn without +changing scoring or provider selection. It protects +/-4 blocks horizontally and +Y-2 through Y+4. Portals and hand-held books share that resolver. For an Age entered +before .120, re-enter it once to record this point; OP status reports whether it is +recorded. Cloud Platforms and Rivencraft infrastructure remain protected independently. + +`[instability] world_mutation` and `[instability_decay] enabled` default to true, +matching 1.21.1. `max_attempts_per_call` defaults to 4 (range 1–6). Per-Age enable, +global enable and provider exclusions also gate seeding/spread. Disabling does not +remove existing decay. As in 1.21.1, existing White block contact damage follows the +decay/world-mutation toggles rather than the Age provider scheduler toggle. + +Operator testing, inside an Age and preferably away from its protected arrival: + +- `/rivencraft_port instability decay blue 32` makes up to 32 bounded provider + attempts in your current loaded chunk. Use `purple`, `red` or `white` too. It prints + the number of actual changes and up to four nearby tracked coordinates, without + changing score/selection. A failed roll or protected location can yield no change. +- The existing `/rivencraft_port instability pulse decayblue` (and other colors) + attempts one call. Its output now describes successful actions for either blocks + or living entities. +- `/rivencraft_port instability test_score 50000` allows natural deck selection for + observation; inspect `status` for the selected colors, then use `clear_test_score` + to restore the saved plan. Merely placing decay does not register an active card. + +Validation: build and fresh dedicated-server startup passed. Bounded pulses seeded +and spread all four colors. On restart, the saved attempt sequence advanced from +32 to 40 and the original frontiers persisted, with further Red growth. Bedrock and +water seeding produced no conversions/frontier entries; disabling the Age blocked +further changes. All packaged textures are 16x16. The temporary server was stopped +and world deleted. Manual checks remain for visual variants, White stepping damage, +protected arrival, normal occupied-Age spreading, claim-mod cancellation and controls. +No permanent test fixtures, commit, push or publication. + + +## .116–.120 release validation + +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. diff --git a/ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md b/ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md new file mode 100644 index 0000000..1f156f6 --- /dev/null +++ b/ports/mc263/RELEASE_NOTES_1.0.1.120-beta.1.md @@ -0,0 +1,85 @@ +# Rivencraft 26.3 beta 1.0.1.120-beta.1 + +Collects the player-validated .116–.120 port slices since 1.0.1.115-beta.1. +For Minecraft 26.3, NeoForge 26.3.0.99 and Java 25. The independent 1.21.1 +release is unchanged; full gameplay and optional-integration parity remain incomplete. + +## Libraries and writing + +- Ported Legacy library generation, shared schematic, chest loot and five correctly + oriented book/page stands. Libraries generate after decoration in eligible new + non-Cave Age chunks, using the 1.21.1 spacing, terrain and clearance checks. +- Library loot defaults to `LEGACY`; optional `AGE_ONLY` limits new library Pages + to known symbols originally written into that Age. Original writing is saved + separately and restored on reload. Older Ages without it have an empty Age-only + pool. Ordinary Books replace stand Pages when that pool is empty. +- Added operator library placement, candidate/probe/loot diagnostics, session-only + test spacing and a locator limited to nearby already-loaded chunks. +- Hand-prepared Link Panel ink cost is server-configurable (default two). The + Ink Mixer still consumes one ink unit and one paper per panel. Writing Desk + ink handling now uses the shared service. + +## Crystal portals + +- Ported craftable Book Receptacles with all six mounting orientations and the + existing 1.21.1 model/book transforms. A bound Linking or Descriptive Book + activates a valid connected crystal frame, including supported irregular frames. +- Reused the bounded crystal flood/tension algorithm, placement protection events, + rollback, owner persistence, restart repair, frame cleanup and traversal cooldown. +- Empty-hand removal closes the portal and returns the book. Breaking the holder + or its crystal support closes it and drops a recoverable open-book entity. +- Player and non-player travel reuse book destinations, safe arrival, Intra-Linking, + Disarm and momentum rules. Descriptive Books share the same landing resolver + with portals; no separate portal exit offset is added. Player Return Link policy + and progression remain in force. +- The animated fallback surface renders its facing sides and uses destination sky + tint. The Open Portal advancement and receptacle recipe unlock are restored. + Live destination views are still pending optional integration work. + +## Instability and colored decay + +- New Ages save operator-only creation-time score contributions and deterministic + provider selections using all five Legacy decks. Existing Ages remain neutral; + reloads/config changes do not reroll stored plans. +- Initial scoring covers Bright Lighting, Accelerated, Corrupted Pages, ineffective + Cave sky/weather modifiers, excess and biome-forced structures, and extra suns. + Corrupted Pages now bind successfully and retain their animated redacted text. +- Ported 18 potion providers: local/global Blindness, Fatigue, Hunger, Nausea, + Poison, Slowness, Weakness and Wither, plus hostile Regeneration/Resistance. + General effects affect eligible Survival/Adventure players and living entities; + Creative/Spectator remain exempt. Local variants require sky exposure. +- Added a bounded occupied-Age scheduler, owned-effect cleanup, provider controls + and operator score/status, enable/disable, session test-score and pulse commands. + Effects supplied or extended by another source are preserved. +- Ported Blue, Purple, Red and White decay with the existing 16x16 textures, + hardness/air conversion odds, saved per-color frontiers and attempt sequence. + White retains stepping-contact magic damage. Decay has no item form or drops. +- Spread uses only already-loaded candidate chunks, bounded attempts and action + budgets. Fluids, block entities, critical/unbreakable blocks, infrastructure, + protected tags and claim events prevent conversion. +- The first resolved book/portal arrival is saved for spawn protection. Re-enter + an older Age once to record it. Global/Age/provider and world-mutation controls + gate spread; existing decay blocks remain when spreading is disabled. +- Test a color with `/rivencraft_port instability decay blue 32` (also `purple`, + `red`, `white`); output includes actual changes and nearby tracked coordinates. + Scores are not added to ordinary book or Binder displays. + +## Validation and remaining work + +Player in-game checks passed .116–.120. Both the 26.3 and 1.21.1 builds passed; +Gradle has no standalone test sources. Targeted disposable dedicated-server checks +covered library placement, portal entity travel and restart ownership/cleanup, +potion application/cleanup and persistence, all four decay colors, saved spread +continuation, and bedrock/fluid protection. Test servers were stopped and their +worlds removed. + +Full writing recovery/penalties, live material-abundance profiling, other instability +hazards, remaining discovery/stations and optional integrations still need port work. +Unported hazard cards remain saved and are reported as unavailable, rather than +being replaced with different cards. Book previews remain the animated fallback. +Machine acceleration (#112), Writing Desk model placement (#113), and the matching +1.21.1 universal dropped-book fix (#114) remain tracked separately. + +Use matching client/server beta builds (catalog protocol 10). Keep Minecraft tracks +and saves separate, back up beta worlds, and do not downgrade saves containing new +blocks or data to earlier betas. diff --git a/ports/mc263/build.gradle b/ports/mc263/build.gradle index 816a7a5..a55b062 100644 --- a/ports/mc263/build.gradle +++ b/ports/mc263/build.gradle @@ -193,6 +193,10 @@ tasks.named('processResources', ProcessResources).configure { include 'data/rivencraft/worldgen/structure_set/bastions.json' include 'assets/rivencraft/models/block/star_fissure.json' include 'assets/rivencraft/blockstates/star_fissure.json' + include 'assets/rivencraft/blockstates/decay.json' + include 'assets/rivencraft/models/block/decay_*.json' + include 'assets/rivencraft/textures/block/decay_*.png' + include 'data/rivencraft/tags/block/decay_protected.json' include 'assets/rivencraft/sounds.json' include 'assets/rivencraft/sounds/**/*.ogg' include 'assets/rivencraft/textures/item/crystal_shard.png' @@ -221,7 +225,6 @@ tasks.named('processResources', ProcessResources).configure { include 'assets/rivencraft/blockstates/writing_desk.json' include 'data/rivencraft/loot_table/blocks/writing_desk.json' include 'data/rivencraft/advancement/progression/*.json' - exclude 'data/rivencraft/advancement/progression/open_portal.json' exclude 'data/rivencraft/advancement/progression/root.json' include 'assets/rivencraft/textures/item/ink_vial.png.mcmeta' include 'assets/rivencraft/models/item/ink_vial.json' @@ -253,8 +256,19 @@ tasks.named('processResources', ProcessResources).configure { include 'assets/rivencraft/models/block/book_binder.json' include 'assets/rivencraft/models/item/book_binder.json' include 'assets/rivencraft/blockstates/book_binder.json' + include 'assets/rivencraft/blockstates/book_receptacle.json' + include 'assets/rivencraft/blockstates/crystal_portal.json' + include 'assets/rivencraft/models/block/book_receptacle.json' + include 'assets/rivencraft/models/block/crystal_portal.json' + include 'assets/rivencraft/models/item/book_receptacle.json' + include 'assets/rivencraft/textures/block/book_receptacle.png' + include 'assets/rivencraft/textures/block/crystal_portal.png' + include 'assets/rivencraft/textures/block/crystal_portal.png.mcmeta' + include 'data/rivencraft/loot_table/blocks/book_receptacle.json' include 'assets/rivencraft/blockstates/book_stand.json' include 'data/rivencraft/loot_table/blocks/book_stand.json' + include 'data/rivencraft/loot_table/chests/mystcraft_library.json' + include 'data/rivencraft/structure/mystcraft_library.nbt' include 'data/rivencraft/loot_table/blocks/book_binder.json' include 'assets/rivencraft/models/block/crystal_block.json' include 'assets/rivencraft/blockstates/crystal_block.json' diff --git a/ports/mc263/gradle.properties b/ports/mc263/gradle.properties index c4f3936..4abf7bd 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.115-beta.1 +mod_version=1.0.1.120-beta.1 diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java index 5f5aa48..08e588f 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/PortConfig.java @@ -4,9 +4,12 @@ import net.neoforged.neoforge.common.ModConfigSpec; /** Server-owned gameplay policies adapted from 1.21.1. */ public final class PortConfig { + public enum LibraryLootMode { LEGACY, AGE_ONLY } + public static final ModConfigSpec.EnumValue LIBRARY_LOOT_MODE; public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED } public static final ModConfigSpec SPEC; public static final ModConfigSpec.IntValue SYMBOL_INK_COST; + public static final ModConfigSpec.IntValue LINK_PANEL_INK_COST; public static final ModConfigSpec.EnumValue RETURN_LINK_MODE; public static final ModConfigSpec.IntValue ACCELERATED_MAX_CHUNKS; public static final ModConfigSpec.IntValue ACCELERATED_MAX_ATTEMPTS; @@ -18,9 +21,16 @@ public final class PortConfig { "ON_CREATION issues one on the first arrival per Age/world; ALWAYS issues one each arrival; DISABLED issues none.") .defineEnum("return_link_mode", ReturnLinkMode.DISABLED); builder.pop(); + builder.push("libraries"); + LIBRARY_LOOT_MODE = builder.comment("LEGACY uses writing supplies/Sealed Notebooks and random lectern pages; AGE_ONLY uses only pages written into this Age.") + .defineEnum("loot_mode", LibraryLootMode.LEGACY); + builder.pop(); builder.push("writing"); SYMBOL_INK_COST = builder.comment("Ink units consumed for one Writing Desk symbol copy.") .defineInRange("symbol_ink_cost", 1, 1, 16); + LINK_PANEL_INK_COST = builder.comment("Ink units consumed when preparing one baseline Link Panel by hand with paper.", + "The Ink Mixer uses one ink unit per panel independently of this setting.") + .defineInRange("link_panel_ink_cost", 2, 1, 16); builder.pop(); builder.push("accelerated_age"); ACCELERATED_MAX_CHUNKS = builder.comment("Maximum already-loaded player-nearby chunks given extra random ticks per Age tick.") @@ -28,6 +38,7 @@ public final class PortConfig { ACCELERATED_MAX_ATTEMPTS = builder.comment("Maximum extra random-tick positions sampled per Age tick, shared fairly across loaded sections; zero disables Accelerated.") .defineInRange("max_random_tick_attempts_per_tick", 16384, 0, 65536); builder.pop(); + net.dnorberg.rivencraft.age.PortInstabilityConfig.define(builder); SPEC = builder.build(); } 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 7c843c3..e6094a8 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -131,6 +131,23 @@ public final class Rivencraft { BLOCKS.registerBlock("cloud_platform", net.dnorberg.rivencraft.block.CloudPlatformBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK) .mapColor(MapColor.COLOR_BLUE).strength(0.3F).noLootTable()); + public static final DeferredBlock BOOK_RECEPTACLE = + BLOCKS.registerBlock("book_receptacle", net.dnorberg.rivencraft.block.BookReceptacleBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).noOcclusion().mapColor(MapColor.COLOR_BLUE)); + public static final DeferredItem BOOK_RECEPTACLE_ITEM = ITEMS.registerSimpleBlockItem(BOOK_RECEPTACLE); + public static final DeferredBlock CRYSTAL_PORTAL = + BLOCKS.registerBlock("crystal_portal", net.dnorberg.rivencraft.block.CrystalPortalBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.NETHER_PORTAL) + .noCollision().noOcclusion().lightLevel(state -> 11).noLootTable()); + public static final Supplier> BOOK_RECEPTACLE_BLOCK_ENTITY = + BLOCK_ENTITY_TYPES.register("book_receptacle", () -> new BlockEntityType<>( + net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity::new, BOOK_RECEPTACLE.get())); + public static final Supplier> CRYSTAL_PORTAL_BLOCK_ENTITY = + BLOCK_ENTITY_TYPES.register("crystal_portal", () -> new BlockEntityType<>( + net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity::new, CRYSTAL_PORTAL.get())); + public static final DeferredBlock DECAY = + BLOCKS.registerBlock("decay", net.dnorberg.rivencraft.block.DecayBlock::new, + () -> BlockBehaviour.Properties.ofFullCopy(Blocks.STONE).strength(5.0F, 10.0F).noLootTable()); public static final DeferredBlock STAR_FISSURE = BLOCKS.registerBlock("star_fissure", StarFissureBlock::new, () -> BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL).lightLevel(state -> 6)); @@ -235,6 +252,8 @@ public final class Rivencraft { NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onServerStopped); modEventBus.addListener(AgeCatalogSync::registerPayloads); modEventBus.addListener(PortBookNetworking::registerPayloads); + net.dnorberg.rivencraft.age.PortStatusEffectProviders.register(); + net.dnorberg.rivencraft.age.PortDecayProviders.register(); LOGGER.info("Initializing Rivencraft 26.3 port slice (not release-ready)"); } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java index 7cee20b..57906a1 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/advancement/ProgressionAdvancements.java @@ -7,6 +7,7 @@ import net.minecraft.server.level.ServerPlayer; /** Awards action-only progression after the server has committed the action. */ public final class ProgressionAdvancements { + public static final String OPEN_PORTAL = "open_portal"; public static final String LINK_PANEL = "link_panel"; public static final String COPY_PAGE = "copy_page"; public static final String BIND_AGE = "bind_age"; diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/InstabilityProviderCatalog.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/InstabilityProviderCatalog.java new file mode 100644 index 0000000..efd2b9e --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/InstabilityProviderCatalog.java @@ -0,0 +1,74 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; + +/** Frozen Legacy 0.13.7.06 deck cards and default activation costs, not effect implementations. */ +public final class InstabilityProviderCatalog { + public enum Deck { + BASIC(0), HARSH(2_500), DESTRUCTIVE(10_000), EATING(15_000), DEATH(20_000); + + private final int entryCost; + + Deck(int entryCost) { this.entryCost = entryCost; } + public int entryCost() { return entryCost; } + } + + public record Card(Identifier id, int cost) { + public Card { + if (cost <= 0) throw new IllegalArgumentException("Provider cost must be positive"); + } + } + + private static final List> DECKS = createDecks(); + + private InstabilityProviderCatalog() {} + + public static List cards(Deck deck) { return DECKS.get(deck.ordinal()); } + + public static boolean contains(Identifier id) { + return DECKS.stream().flatMap(List::stream).anyMatch(card -> card.id().equals(id)); + } + + private static List> createDecks() { + List> decks = new ArrayList<>(); + for (Deck ignored : Deck.values()) decks.add(new ArrayList<>()); + add(decks, "blindness", 1000, 0, 0, 0, 1, 0); + add(decks, "blindness_global", 1500, 0, 0, 0, 0, 1); + add(decks, "enemyregen_global", 1000, 5, 2, 0, 0, 0); + add(decks, "enemyresist_global", 1000, 2, 1, 0, 0, 0); + add(decks, "fatigue", 500, 5, 0, 0, 0, 0); + add(decks, "fatigue_global", 1000, 0, 5, 0, 0, 0); + add(decks, "hunger", 500, 8, 2, 0, 0, 0); + add(decks, "hunger_global", 1000, 0, 5, 0, 0, 0); + add(decks, "nausea", 1000, 0, 0, 0, 1, 0); + add(decks, "nausea_global", 1500, 0, 0, 0, 0, 1); + add(decks, "poison", 500, 9, 3, 0, 0, 0); + add(decks, "poison_global", 1000, 0, 5, 0, 0, 0); + add(decks, "slow", 500, 6, 1, 0, 0, 0); + add(decks, "slow_global", 1000, 0, 5, 0, 0, 0); + add(decks, "weakness", 500, 8, 2, 0, 0, 0); + add(decks, "weakness_global", 1000, 0, 5, 0, 0, 0); + add(decks, "wither", 1000, 0, 1, 1, 2, 0); + add(decks, "wither_global", 2000, 0, 0, 1, 1, 1); + add(decks, "burning", 500, 0, 1, 0, 0, 0); + add(decks, "crumble", 2000, 0, 0, 6, 0, 0); + add(decks, "decayblue", 2000, 0, 0, 0, 2, 1); + add(decks, "decaypurple", 2000, 0, 0, 0, 2, 1); + add(decks, "decayred", 2000, 0, 0, 0, 2, 1); + add(decks, "decaywhite", 5000, 0, 0, 0, 1, 3); + add(decks, "explosions", 1000, 0, 0, 8, 0, 0); + add(decks, "lightning", 1000, 0, 4, 4, 0, 0); + add(decks, "meteors", 1000, 0, 0, 4, 0, 0); + return decks.stream().map(List::copyOf).toList(); + } + + private static void add(List> decks, String path, int cost, int... copies) { + Card card = new Card(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path), cost); + for (Deck deck : Deck.values()) { + for (int copy = 0; copy < copies[deck.ordinal()]; copy++) decks.get(deck.ordinal()).add(card); + } + } +} 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 105f39d..370c0a9 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 @@ -23,6 +23,14 @@ public final class PortAgeTravel { if (!AgeCatalogSync.ready(player, id)) { return "Age catalog not acknowledged by client; remaining in source dimension"; } + BlockPos landing = resolveLanding(target); + 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) { boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features && features.terrainFeatures() != null && features.terrainFeatures().starFissures(); BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null; @@ -30,11 +38,10 @@ public final class PortAgeTravel { ? findFootedDestination(target, landing).orElse(null) : findSurfaceLanding(target, landing.getX(), landing.getZ()); if (landing == null) landing = findLanding(target); - if (landing == null) return "No safe test landing available; remaining in source dimension"; + if (landing == null) return null; if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target)); - boolean moved = player.teleportTo(target, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5, - Set.of(), player.getYRot(), player.getXRot(), true); - return moved ? null : "Teleportation failed; remaining in source dimension"; + if (target instanceof PortAgeLevel) PortInstabilityData.get(target.getServer()).recordSpawn(target, landing); + return landing; } static BlockPos findLanding(ServerLevel target) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecayProviders.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecayProviders.java new file mode 100644 index 0000000..53b5650 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecayProviders.java @@ -0,0 +1,202 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import java.util.Map; +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.core.registries.Registries; +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.tags.TagKey; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; +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; + +/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */ +public final class PortDecayProviders { + public static final TagKey PROTECTED = TagKey.create(Registries.BLOCK, + Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected")); + private static final Map COLORS = Map.of( + id("decayblue"), DecayBlock.Color.BLUE, + id("decaypurple"), DecayBlock.Color.PURPLE, + id("decayred"), DecayBlock.Color.RED, + id("decaywhite"), DecayBlock.Color.WHITE); + private static final Direction[] DIRECTIONS = Direction.values(); + + private PortDecayProviders() {} + + public static void register() { + COLORS.keySet().forEach(id -> PortInstabilityRuntime.register(id, true, PortDecayProviders::apply)); + } + + /** Operator test hook; never changes the Age's saved instability score or provider plan. */ + public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) { + ServerLevel level = player.level(); + if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation() + || !PortInstabilityRuntime.isAge(level) + || level.getChunkSource().getChunkNow(player.chunkPosition().x(), player.chunkPosition().z()) == null) return -1; + var age = PortInstabilityData.get(level.getServer()).age(level); + Identifier id = id("decay" + color.getSerializedName()); + if (!PortInstabilityConfig.instabilityEnabled() || !age.enabled() + || !PortInstabilityConfig.instabilityProviderEnabled(id)) return -1; + int changed = 0; + for (int i = 0; i < Math.min(count, 32); i++) { + PortInstabilityRuntime.Budget budget = new PortInstabilityRuntime.Budget(1); + apply(new PortInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()), + level.getServer().getTickCount(), budget), id, 1); + changed += budget.applied(); + } + return changed; + } + + static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) { + if (!PortInstabilityConfig.decayEnabled() || !PortInstabilityConfig.instabilityWorldMutation() + || !PortInstabilityRuntime.isAge(context.level()) + || context.loadedChunks().isEmpty()) return; + DecayBlock.Color color = COLORS.get(id); + if (color == null) return; + PortDecaySavedData data = PortDecaySavedData.get(context.level()); + Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L + ^ id.hashCode()); + List frontier = data.frontier(color); + BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random); + if (source == null) { + seed(context, color, data, random); + return; + } + BlockState sourceState = context.level().getBlockState(source); + if (!sourceState.is(Rivencraft.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) { + data.remove(color, source); + return; + } + for (int attempt = 0; attempt < PortInstabilityConfig.decayMaxAttempts(); attempt++) { + BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]); + if (!isLoadedTarget(context.level(), context.loadedChunks(), target) + || isSpawnProtected(context.age(), target)) continue; + BlockState prior = context.level().getBlockState(target); + if (!canReplace(context.level(), target, prior, color)) continue; + int difficulty = conversionDifficulty(context.level(), target, prior, color); + if (random.nextInt(difficulty) != 0) continue; + if (replace(context, target, prior, color)) data.add(color, target); + return; + } + } + + private static void seed(PortInstabilityRuntime.Context context, DecayBlock.Color color, + PortDecaySavedData data, Random random) { + ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size())); + int x = chunk.getMinBlockX() + random.nextInt(16); + int z = chunk.getMinBlockZ() + random.nextInt(16); + BlockPos pos = new BlockPos(x, + context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z); + if (!isLoadedTarget(context.level(), context.loadedChunks(), pos) + || isSpawnProtected(context.age(), pos)) return; + BlockState prior = context.level().getBlockState(pos); + if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) { + data.add(color, pos); + } + } + + private static BlockPos selectLoadedSource(ServerLevel level, List chunks, + List frontier, Random random) { + if (frontier.isEmpty()) return null; + int start = random.nextInt(frontier.size()); + for (int i = 0; i < Math.min(frontier.size(), 16); i++) { + BlockPos pos = frontier.get((start + i) % frontier.size()); + if (isLoadedTarget(level, chunks, pos)) return pos; + } + return null; + } + + static boolean isLoadedTarget(ServerLevel level, List chunks, BlockPos pos) { + return level.isInWorldBounds(pos) && level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) != null + && chunks.contains(ChunkPos.containing(pos)); + } + + static boolean isSpawnProtected(PortInstabilityData.Age age, BlockPos pos) { + BlockPos spawn = age.spawn().orElse(null); + return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4 + && Math.abs(pos.getZ() - spawn.getZ()) <= 4 + && pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4; + } + + static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) { + if (!PortInstabilityRuntime.isAge(level) + || !level.isInWorldBounds(pos) || level.getChunkSource().getChunkNow(pos.getX() >> 4, pos.getZ() >> 4) == null + || prior.is(PROTECTED) || prior.is(BlockTags.PORTALS) + || prior.hasBlockEntity() || level.getBlockEntity(pos) != null + || prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty() + || isCritical(prior)) return false; + return !prior.is(Rivencraft.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color; + } + + private static boolean isCritical(BlockState prior) { + return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER) + || prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME) + || prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL) + || prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK) + || prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK) + || prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT) + || prior.is(Blocks.REINFORCED_DEEPSLATE) + || prior.getBlock() != Rivencraft.DECAY.get() + && net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock()) + .getNamespace().equals(Rivencraft.MOD_ID); + } + + static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) { + if (state.isAir()) return switch (color) { + case BLUE, RED -> 20; + case PURPLE -> 8; + case WHITE -> 50; + }; + if (state.is(Rivencraft.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5; + float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos))); + return switch (color) { + case BLUE -> Math.max(1, (int) hardness * 2); + case RED -> Math.max(1, (int) hardness); + case PURPLE -> Math.max(1, ((int) hardness + 1) * 10); + case WHITE -> 1; + }; + } + + private static boolean replace(PortInstabilityRuntime.Context context, BlockPos pos, BlockState prior, + DecayBlock.Color color) { + ServerLevel level = context.level(); + // A fake player gives claim/protection listeners the Age and exact block position. + var actor = FakePlayerFactory.getMinecraft(level); + actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + if (!prior.isAir()) { + var breakEvent = new net.neoforged.neoforge.event.level.block.BreakBlockEvent(level, pos, prior, actor); + NeoForge.EVENT_BUS.post(breakEvent); + if (breakEvent.isCanceled()) return false; + } + if (level.getBlockState(pos) != prior || !context.budget().claim()) return false; + var snapshot = BlockSnapshot.create(level.dimension(), level, pos); + BlockState decay = Rivencraft.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color); + if (!level.setBlock(pos, decay, 3)) return false; + var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor); + NeoForge.EVENT_BUS.post(placeEvent); + if (placeEvent.isCanceled()) { + snapshot.restore(); + return false; + } + context.budget().recordApplied(); + return true; + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecaySavedData.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecaySavedData.java new file mode 100644 index 0000000..d0124ba --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortDecaySavedData.java @@ -0,0 +1,44 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import net.dnorberg.rivencraft.block.DecayBlock; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.StringRepresentable; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Persisted per-Age frontier and attempt sequence, bounded to 1,024 positions per color. */ +public final class PortDecaySavedData extends SavedData { + private static final int MAX_FRONTIER_PER_COLOR = 1024; + 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.unboundedMap(StringRepresentable.fromEnum(DecayBlock.Color::values), + BlockPos.CODEC.listOf(0, MAX_FRONTIER_PER_COLOR)).fieldOf("frontiers").forGetter(data -> data.frontiers) + ).apply(instance, PortDecaySavedData::new)); + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "decay"), () -> new PortDecaySavedData(0, Map.of()), CODEC); + private final EnumMap> frontiers = new EnumMap<>(DecayBlock.Color.class); + private long attempts; + private PortDecaySavedData(long attempts, Map> positions) { + this.attempts = attempts; + for (var color : DecayBlock.Color.values()) frontiers.put(color, + new ArrayList<>(positions.getOrDefault(color, List.of()).stream().distinct().map(BlockPos::immutable).toList())); + } + public static PortDecaySavedData get(ServerLevel level) { return level.getDataStorage().computeIfAbsent(TYPE); } + public long nextAttempt() { setDirty(); return attempts++; } + public List frontier(DecayBlock.Color color) { return frontiers.get(color); } + public void add(DecayBlock.Color color, BlockPos pos) { + var frontier = frontiers.get(color); + if (frontier.contains(pos)) return; + if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.removeFirst(); + frontier.add(pos.immutable()); setDirty(); + } + public void remove(DecayBlock.Color color, BlockPos pos) { if (frontiers.get(color).remove(pos)) setDirty(); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java index a2f4e8b..929cc17 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortFeatureChunkGenerator.java @@ -70,10 +70,12 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { .forGetter(generator -> Optional.ofNullable(generator.lakes)), PortTerrainFeaturePlan.CODEC.optionalFieldOf("terrain_features") .forGetter(generator -> Optional.ofNullable(generator.terrainFeatures)), - com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed) - ).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed) -> + com.mojang.serialization.Codec.LONG.fieldOf("age_seed").forGetter(generator -> generator.ageSeed), + com.mojang.serialization.Codec.BOOL.optionalFieldOf("libraries", false).forGetter(generator -> generator.libraries), + Identifier.CODEC.listOf().optionalFieldOf("library_symbols", List.of()).forGetter(generator -> generator.librarySymbols) + ).apply(instance, (base, rocks, vegetation, dungeons, lakes, terrainFeatures, seed, libraries, symbols) -> new PortFeatureChunkGenerator(base, rocks.orElse(null), vegetation.orElse(null), - dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed))); + dungeons, lakes.orElse(null), terrainFeatures.orElse(null), seed, libraries, symbols))); private final ChunkGenerator base; private final PortRockPlan rocks; @@ -82,10 +84,19 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { private final PortLakePlan lakes; private final PortTerrainFeaturePlan terrainFeatures; private final long ageSeed; + private final boolean libraries; + private final List librarySymbols; public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks, PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, long ageSeed) { + this(base, rocks, vegetation, dungeons, lakes, terrainFeatures, ageSeed, false, List.of()); + } + + public PortFeatureChunkGenerator(ChunkGenerator base, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures, long ageSeed, + boolean libraries, List librarySymbols) { super(base.getBiomeSource(), base::getBiomeGenerationSettings); this.base = base; this.rocks = rocks; @@ -94,6 +105,8 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { this.lakes = lakes; this.terrainFeatures = terrainFeatures; this.ageSeed = ageSeed; + this.libraries = libraries; + this.librarySymbols = List.copyOf(librarySymbols); } public ChunkGenerator base() { return base; } @@ -286,5 +299,6 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator { if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null; } } + if (libraries) PortLibraryFeature.generate(level, chunk, ageSeed, librarySymbols); } } 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 new file mode 100644 index 0000000..4b8e16a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityCommands.java @@ -0,0 +1,121 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.brigadier.arguments.BoolArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.commands.Commands; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; + +/** Scores and test overrides are deliberately operator-only, never book tooltip data. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class PortInstabilityCommands { + private PortInstabilityCommands() {} + @SubscribeEvent public static void register(RegisterCommandsEvent event) { + event.getDispatcher().register(Commands.literal("rivencraft_port") + .requires(Commands.hasPermission(Commands.LEVEL_ADMINS)) + .then(Commands.literal("instability") + .then(Commands.literal("status").executes(context -> { + var source = context.getSource(); var level = requireAge(source); + var age = PortInstabilityData.get(source.getServer()).age(level); + source.sendSuccess(() -> Component.literal("Age " + level.dimension().identifier() + + ": 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); + for (var cost : age.contributions()) source.sendSuccess(() -> Component.literal( + " " + cost.reason() + " page=" + cost.page() + " +" + cost.points()), false); + for (var provider : PortInstabilityRuntime.plan(level).providers()) + source.sendSuccess(() -> Component.literal(" " + provider.id() + " level=" + provider.level() + + " [" + PortInstabilityRuntime.providerStatus(level, provider.id()) + "]"), false); + return 1; + })) + .then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool()) + .executes(context -> { + var level = requireAge(context.getSource()); + boolean enabled = BoolArgumentType.getBool(context, "enabled"); + PortInstabilityData.get(level.getServer()).enabled(level, enabled); + if (!enabled) PortStatusEffectProviders.clearAge(level); + context.getSource().sendSuccess(() -> Component.literal("Age instability enabled=" + enabled + + "; saved score and provider selection unchanged"), false); + return 1; + }))) + .then(Commands.literal("test_score").then(Commands.argument("score", IntegerArgumentType.integer(0, 1_000_000)) + .executes(context -> { + var level = requireAge(context.getSource()); + PortInstabilityRuntime.testScore(level, IntegerArgumentType.getInteger(context, "score")); + PortStatusEffectProviders.clearAge(level); + context.getSource().sendSuccess(() -> Component.literal("Session-only score override set; frozen score unchanged. Restart or clear_test_score removes it."), false); + return 1; + }))) + .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; + })) + .then(Commands.literal("decay").then(Commands.argument("color", StringArgumentType.word()) + .suggests((context, builder) -> { + for (var color : net.dnorberg.rivencraft.block.DecayBlock.Color.values()) + if (color.getSerializedName().startsWith(builder.getRemaining())) builder.suggest(color.getSerializedName()); + return builder.buildFuture(); + }).executes(context -> decay(context, 1)) + .then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32)) + .executes(context -> decay(context, IntegerArgumentType.getInteger(context, "attempts")))))) + .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)) + if (card.id().getPath().startsWith(builder.getRemaining())) builder.suggest(card.id().getPath()); + return builder.buildFuture(); + }).executes(context -> { + var source = context.getSource(); var level = requireAge(source); + String name = StringArgumentType.getString(context, "provider"); + Identifier id = Identifier.tryParse(name.contains(":") ? name : "rivencraft:" + name); + if (id == null || !InstabilityProviderCatalog.contains(id)) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Unknown instability provider")).create(); + var chunk = net.minecraft.world.level.ChunkPos.containing(BlockPos.containing(source.getPosition())); + if (level.getChunkSource().getChunkNow(chunk.x(), chunk.z()) == null) + throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType(Component.literal("Current chunk must already be loaded")).create(); + var budget = new PortInstabilityRuntime.Budget(1); + var effect = new PortInstabilityRuntime.Context(level, PortInstabilityData.get(level.getServer()).age(level), + java.util.List.of(chunk), source.getServer().getTickCount(), budget); + boolean attempted = PortInstabilityRuntime.invoke(effect, id, 1); + boolean applied = attempted && budget.applied() > 0; + if (applied) source.sendSuccess(() -> Component.literal("Applied " + id + " (" + budget.applied() + " successful action in this chunk)"), false); + else source.sendFailure(Component.literal("No effect applied. Check eligible targets/terrain, sky exposure, existing effects and protection. Provider status: " + + PortInstabilityRuntime.providerStatus(level, id))); + return applied ? 1 : 0; + }))))); + } + private static int decay(com.mojang.brigadier.context.CommandContext context, int attempts) + throws com.mojang.brigadier.exceptions.CommandSyntaxException { + requireAge(context.getSource()); + var color = java.util.Arrays.stream(net.dnorberg.rivencraft.block.DecayBlock.Color.values()) + .filter(value -> value.getSerializedName().equalsIgnoreCase(StringArgumentType.getString(context, "color"))) + .findFirst().orElse(null); + if (color == null) { + context.getSource().sendFailure(Component.literal("Choose blue, purple, red or white")); return 0; + } + int changed = PortDecayProviders.pulseForTesting(context.getSource().getPlayerOrException(), color, attempts); + if (changed < 0) { + context.getSource().sendFailure(Component.literal("Decay is disabled by config, Age or provider policy")); return 0; + } + context.getSource().sendSuccess(() -> Component.literal("Decay " + color.getSerializedName() + ": " + changed + + " block changes from " + attempts + " bounded attempts; saved score/plan unchanged"), false); + var player = context.getSource().getPlayerOrException(); + var nearby = PortDecaySavedData.get(player.level()).frontier(color).reversed().stream() + .filter(pos -> net.minecraft.world.level.ChunkPos.containing(pos).equals(player.chunkPosition())) + .limit(4).map(BlockPos::toShortString).toList(); + if (!nearby.isEmpty()) context.getSource().sendSuccess(() -> Component.literal("Nearby tracked decay: " + nearby), false); + return changed; + } + private static net.minecraft.server.level.ServerLevel requireAge(CommandSourceStack source) + throws com.mojang.brigadier.exceptions.CommandSyntaxException { + if (!PortInstabilityRuntime.isAge(source.getLevel())) throw new com.mojang.brigadier.exceptions.SimpleCommandExceptionType( + Component.literal("Use this command inside a Rivencraft Age")).create(); + return source.getLevel(); + } +} 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 new file mode 100644 index 0000000..13e8d87 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityConfig.java @@ -0,0 +1,67 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import net.minecraft.resources.Identifier; +import net.neoforged.neoforge.common.ModConfigSpec; + +/** The 1.21.1 scheduler and status-provider defaults, in the port's server-owned config. */ +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 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); + 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); + ages = builder.defineInRange("max_ages_per_tick", 4, 1, 16); + providers = builder.defineInRange("max_providers_per_age", 2, 1, 16); + actions = builder.defineInRange("max_actions_per_age", 1, 1, 16); + level = builder.defineInRange("max_effect_level", 3, 1, 16); + chunks = builder.defineInRange("max_loaded_chunks", 16, 1, 64); + disabledProviders = builder.comment("Disable particular providers without changing saved selections. Accepts poison or rivencraft:poison.") + .defineListAllowEmpty("disabled_providers", List.of(), () -> "poison", value -> value instanceof String text + && Identifier.tryParse(text) != null); + builder.pop(); + builder.push("instability_status_effects"); + status = builder.define("enabled", true); + players = builder.comment("Affect Survival/Adventure players; Creative and Spectator remain exempt.") + .define("affect_players", true); + nonplayers = builder.define("affect_nonplayers", true); + hostiles = builder.define("buff_hostiles", true); + active = builder.defineInRange("max_active_per_age", 32, 1, 128); + candidates = builder.defineInRange("max_candidates_per_attempt", 8, 1, 16); + duration = builder.defineInRange("max_duration_ticks", 200, 20, 200); + builder.pop(); + builder.push("instability_decay"); + decay = builder.comment("Allow colored decay in loaded Ages. Also requires instability.world_mutation.") + .define("enabled", true); + 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(); + } + public static boolean instabilityWorldMutation() { return worldMutation.get(); } + public static boolean decayEnabled() { return decay.get(); } + public static int decayMaxAttempts() { return decayAttempts.get(); } + public static boolean instabilityEnabled() { return enabled.get(); } + public static boolean instabilityProviderEnabled(Identifier id) { + return disabledProviders.get().stream().noneMatch(value -> value.equals(id.toString()) + || id.getNamespace().equals("rivencraft") && value.equals(id.getPath())); + } + public static int instabilityCooldown() { return cooldown.get(); } + public static int instabilityMaxAges() { return ages.get(); } + public static int instabilityMaxProviders() { return providers.get(); } + public static int instabilityMaxActions() { return actions.get(); } + public static int instabilityMaxLevel() { return level.get(); } + public static int instabilityMaxChunks() { return chunks.get(); } + public static boolean statusEffectsEnabled() { return status.get(); } + public static boolean statusEffectsPlayers() { return players.get(); } + public static boolean statusEffectsNonplayers() { return nonplayers.get(); } + public static boolean statusEffectsHostiles() { return hostiles.get(); } + public static int statusEffectsMaxActive() { return active.get(); } + public static int statusEffectsMaxCandidates() { return candidates.get(); } + public static int statusEffectsMaxDuration() { return duration.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 new file mode 100644 index 0000000..3c7bb98 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityData.java @@ -0,0 +1,108 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Frozen creation-time contributions and Legacy deck selections. Never silently rescores older Ages. */ +public final class PortInstabilityData extends SavedData { + public record Contribution(String reason, int page, int points) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("reason").forGetter(Contribution::reason), + Codec.INT.fieldOf("page").forGetter(Contribution::page), + Codec.intRange(0, Integer.MAX_VALUE).fieldOf("points").forGetter(Contribution::points) + ).apply(instance, Contribution::new)); + } + public record Age(long seed, List contributions, PortInstabilityPlan plan, boolean enabled, java.util.Optional spawn) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.LONG.fieldOf("seed").forGetter(Age::seed), + Contribution.CODEC.listOf().fieldOf("contributions").forGetter(Age::contributions), + PortInstabilityPlan.CODEC.fieldOf("plan").forGetter(Age::plan), + Codec.BOOL.optionalFieldOf("enabled", true).forGetter(Age::enabled), + net.minecraft.core.BlockPos.CODEC.optionalFieldOf("spawn").forGetter(Age::spawn) + ).apply(instance, Age::new)); + public Age { contributions = List.copyOf(contributions); spawn = spawn.map(net.minecraft.core.BlockPos::immutable); } + public Age(long seed, List contributions, PortInstabilityPlan plan, boolean enabled) { + this(seed, contributions, plan, enabled, java.util.Optional.empty()); + } + public int score() { return contributions.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); } + } + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "instability"), () -> new PortInstabilityData(Map.of()), + Codec.unboundedMap(Identifier.CODEC, Age.CODEC).fieldOf("ages").codec() + .xmap(PortInstabilityData::new, data -> Map.copyOf(data.ages))); + private final Map ages = new HashMap<>(); + private PortInstabilityData(Map values) { ages.putAll(values); } + public static PortInstabilityData get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); } + public Age age(ServerLevel level) { + Age age = ages.get(level.dimension().identifier()); + return age != null ? age : new Age(level.getSeed(), + List.of(new Contribution("predates_instability", -1, 0)), PortInstabilityPlan.empty(), true); + } + 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())); + setDirty(); + } + /** First resolved arrival remains protected without rescoring or rerolling the Age. */ + public void recordSpawn(ServerLevel level, net.minecraft.core.BlockPos spawn) { + Age previous = age(level); + if (previous.spawn().isPresent()) return; + ages.put(level.dimension().identifier(), new Age(previous.seed(), previous.contributions(), previous.plan(), + previous.enabled(), java.util.Optional.of(spawn.immutable()))); + setDirty(); + } + public void create(ServerLevel level, List pages, PortAgeTerrain terrain, + PortLighting lighting, PortDayCycle cycle) { + Identifier id = level.dimension().identifier(); + 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)); + boolean authoredBright = false; + Map structures = new HashMap<>(); + var biomes = level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream() + .flatMap(holder -> holder.unwrapKey().stream()).map(key -> key.identifier()).toList(); + for (int i = 0; i < pages.size(); i++) { + Identifier page = pages.get(i); + if (!page.getNamespace().equals("rivencraft")) continue; + String path = page.getPath(); + if (path.equals("lighting/bright")) { costs.add(new Contribution("bright_lighting", i, 500)); authoredBright = true; } + if (page.equals(PortAcceleratedTicks.PAGE)) costs.add(new Contribution("accelerated", i, 1000)); + if (isCorrupted(page)) costs.add(new Contribution("corrupted_" + path.substring(10), i, + path.endsWith("small") ? 2000 : path.endsWith("medium") ? 1000 : 0)); + // Legacy charges for sky/weather controllers that Cave terrain cannot apply. + if (terrain == PortAgeTerrain.CAVE && (PortWeatherMode.forPage(page) != null + || PortDayCycle.isController(page) || PortDayCycle.isPeriod(page) + || PortDayCycle.isPhase(page) || PortDayCycle.isAngle(page) || PortDayCycle.isDirection(page))) + costs.add(new Contribution("cave_ignored_" + path.replace('/', '_'), i, 100)); + if (PortStructurePages.supports(page)) { + int count = structures.merge(page, 1, Integer::sum); + if (count > 3) costs.add(new Contribution("excess_" + path, i, 100)); + var allowed = PortStructurePages.compatibleBiomes(page, level.registryAccess()); + if (!allowed.isEmpty() && biomes.stream().anyMatch(biome -> !allowed.contains(biome))) + costs.add(new Contribution("forced_biome_" + path, i, 100)); + } + } + if (lighting == PortLighting.BRIGHT && !authoredBright) costs.add(new Contribution("seeded_bright_lighting", -1, 500)); + int suns = 0; + for (var body : cycle.bodies()) if (body.kind() == PortDayCycle.SUN && ++suns > 2) + costs.add(new Contribution("extra_sun_" + suns, -1, 1000 * (suns - 2))); + int score = costs.stream().mapToInt(Contribution::points).reduce(0, Math::addExact); + ages.put(id, new Age(level.getSeed(), costs, PortInstabilityPlan.select(score, level.getSeed()), true)); + setDirty(); + } + public static boolean isCorrupted(Identifier page) { + return page.getNamespace().equals("rivencraft") && switch (page.getPath()) { + case "corrupted/large", "corrupted/medium", "corrupted/small" -> true; + default -> false; + }; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityPlan.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityPlan.java new file mode 100644 index 0000000..0ff5107 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityPlan.java @@ -0,0 +1,60 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import net.minecraft.resources.Identifier; + +/** Frozen provider selection; handler availability and server settings never reroll this plan. */ +public record PortInstabilityPlan(int version, List providers) { + public static final com.mojang.serialization.Codec CODEC = + com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group( + com.mojang.serialization.Codec.INT.fieldOf("version").forGetter(PortInstabilityPlan::version), + ProviderLevel.CODEC.listOf().fieldOf("providers").forGetter(PortInstabilityPlan::providers) + ).apply(instance, PortInstabilityPlan::new)); + public static final int CURRENT_VERSION = 1; + + public PortInstabilityPlan { + if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported provider-plan version " + version); + providers = List.copyOf(providers); + if (providers.stream().map(ProviderLevel::id).distinct().count() != providers.size()) { + throw new IllegalArgumentException("Duplicate provider ID in saved Age plan"); + } + } + + public static PortInstabilityPlan empty() { return new PortInstabilityPlan(CURRENT_VERSION, List.of()); } + + public static PortInstabilityPlan select(int score, long ageSeed) { + int roundedScore = score / 500 * 500; + Map levels = new LinkedHashMap<>(); + for (InstabilityProviderCatalog.Deck deck : InstabilityProviderCatalog.Deck.values()) { + int remaining = roundedScore - deck.entryCost(); + if (remaining <= 0) continue; + List cards = new ArrayList<>(InstabilityProviderCatalog.cards(deck)); + Collections.shuffle(cards, new Random(ageSeed ^ (0x9E3779B97F4A7C15L * (deck.ordinal() + 1)))); + for (InstabilityProviderCatalog.Card card : cards) { + if (remaining < card.cost()) break; + remaining -= card.cost(); + levels.merge(card.id(), 1, Integer::sum); + } + } + return new PortInstabilityPlan(CURRENT_VERSION, levels.entrySet().stream() + .map(entry -> new ProviderLevel(entry.getKey(), entry.getValue())).toList()); + } + + public record ProviderLevel(Identifier id, int level) { + public static final com.mojang.serialization.Codec CODEC = + com.mojang.serialization.codecs.RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("id").forGetter(ProviderLevel::id), + com.mojang.serialization.Codec.intRange(1, 255).fieldOf("level").forGetter(ProviderLevel::level) + ).apply(instance, ProviderLevel::new)); + public ProviderLevel { + Objects.requireNonNull(id, "id"); + if (level < 1 || level > 255) throw new IllegalArgumentException("Invalid provider level " + level); + } + } +} 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 new file mode 100644 index 0000000..a7bc75a --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortInstabilityRuntime.java @@ -0,0 +1,145 @@ +package net.dnorberg.rivencraft.age; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +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 HANDLERS = new HashMap<>(); + private static final Map> FAILURES = new WeakHashMap<>(); + private static final Map> TEST_SCORES = new WeakHashMap<>(); + private static final Map TICKS = new WeakHashMap<>(); + private PortInstabilityRuntime() {} + public static void register(Identifier id, ProviderEffect handler) { + register(id, false, handler); + } + public static void register(Identifier id, boolean worldMutating, ProviderEffect handler) { + if (HANDLERS.putIfAbsent(id, new Handler(worldMutating, handler)) != null) + throw new IllegalArgumentException("Duplicate instability provider " + id); + } + 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()); + } + public static Integer testScore(ServerLevel level) { + return TEST_SCORES.getOrDefault(level.getServer(), Map.of()).get(level.dimension().identifier()); + } + 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); + } + @SubscribeEvent public static void tick(LevelTickEvent.Post event) { + if (!(event.getLevel() instanceof ServerLevel level) || !isAge(level) || level.players().isEmpty() + || !PortInstabilityConfig.instabilityEnabled()) return; + var server = level.getServer(); + long tick = server.getTickCount(); + int cooldown = PortInstabilityConfig.instabilityCooldown(); + if (tick % cooldown != 0) return; + TickState state = TICKS.computeIfAbsent(server, ignored -> new TickState()); + if (state.tick != tick) { + state.tick = tick; + var eligible = new ArrayList(); + for (var loaded : server.getAllLevels()) if (isAge(loaded) && !loaded.players().isEmpty() + && PortInstabilityData.get(server).age(loaded).enabled() && !plan(loaded).providers().isEmpty()) + eligible.add(loaded.dimension().identifier()); + eligible.sort(Comparator.comparing(Identifier::toString)); + state.eligible = List.copyOf(eligible); + } + int index = state.eligible.indexOf(level.dimension().identifier()); + if (index < 0 || state.eligible.size() > PortInstabilityConfig.instabilityMaxAges() + && Math.floorMod(index - (int) Math.floorMod(tick / cooldown, state.eligible.size()), state.eligible.size()) + >= PortInstabilityConfig.instabilityMaxAges()) return; + var age = PortInstabilityData.get(server).age(level); + if (!age.enabled()) return; + var providers = plan(level).providers(); + var chunks = loadedChunks(level); + if (providers.isEmpty() || chunks.isEmpty()) return; + Context context = new Context(level, age, chunks, tick, new Budget(PortInstabilityConfig.instabilityMaxActions())); + int first = (int) Math.floorMod(tick / cooldown + age.seed(), providers.size()); + int ran = 0; + 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++; + } + } + public static boolean invoke(Context context, Identifier id, int level) { + if (!PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.instabilityProviderEnabled(id) + || !PortInstabilityData.get(context.level().getServer()).age(context.level()).enabled()) return false; + Handler handler = HANDLERS.get(id); + if (handler == null || handler.worldMutating() && !PortInstabilityConfig.instabilityWorldMutation()) return false; + var failures = FAILURES.computeIfAbsent(context.level(), ignored -> new HashMap<>()); + if (context.serverTick() < failures.getOrDefault(id, 0L)) return false; + try { + handler.effect().apply(context, id, Math.min(level, PortInstabilityConfig.instabilityMaxLevel())); + } catch (RuntimeException exception) { + failures.put(id, context.serverTick() + Math.max(200L, PortInstabilityConfig.instabilityCooldown() * 10L)); + Rivencraft.LOGGER.error("Instability provider {} failed in {}; temporarily quarantined", id, + context.level().dimension().identifier(), exception); + } + return true; + } + public static String providerStatus(ServerLevel level, Identifier id) { + if (!HANDLERS.containsKey(id)) return "not ported yet"; + 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 (!PortInstabilityConfig.instabilityWorldMutation()) return "world mutation off"; + if (id.getPath().startsWith("decay") && !PortInstabilityConfig.decayEnabled()) return "decay config off"; + } else { + if (!PortInstabilityConfig.statusEffectsEnabled()) return "status effects config off"; + if (id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsHostiles()) return "hostile buffs disabled"; + if (!id.getPath().startsWith("enemy") && !PortInstabilityConfig.statusEffectsPlayers() + && !PortInstabilityConfig.statusEffectsNonplayers()) return "all general targets disabled"; + } + if (level.getServer().getTickCount() < FAILURES.getOrDefault(level, Map.of()).getOrDefault(id, 0L)) return "quarantined"; + return level.players().isEmpty() ? "no players in Age" : "eligible; sky, targets and budgets still apply"; + } + static List loadedChunks(ServerLevel level) { + var chunks = new LinkedHashSet(); + for (var player : level.players()) { + ChunkPos center = player.chunkPosition(); + for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) { + if (level.getChunkSource().getChunkNow(center.x() + x, center.z() + z) != null) + chunks.add(new ChunkPos(center.x() + x, center.z() + z)); + if (chunks.size() >= PortInstabilityConfig.instabilityMaxChunks()) return List.copyOf(chunks); + } + } + return List.copyOf(chunks); + } + @SubscribeEvent public static void stopping(ServerStoppingEvent event) { + TEST_SCORES.remove(event.getServer()); TICKS.remove(event.getServer()); FAILURES.clear(); + } + public interface ProviderEffect { void apply(Context context, Identifier id, int level); } + public record Context(ServerLevel level, PortInstabilityData.Age age, List loadedChunks, + long serverTick, Budget budget) { public Context { loadedChunks = List.copyOf(loadedChunks); } } + public static final class Budget { + private int remaining; + private int applied; + public Budget(int maximum) { remaining = maximum; } + public boolean claim() { if (remaining <= 0) return false; remaining--; return true; } + public int remaining() { return remaining; } + public void recordApplied() { applied++; } + public int applied() { return applied; } + } + private record Handler(boolean worldMutating, ProviderEffect effect) {} + private static final class TickState { long tick = Long.MIN_VALUE; List eligible = List.of(); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryCommands.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryCommands.java new file mode 100644 index 0000000..053bea6 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryCommands.java @@ -0,0 +1,121 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.phys.Vec3; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; + +/** Explicit operator tools; normal generation does not search or force-load remote chunks. */ +@EventBusSubscriber(modid = "rivencraft") +public final class PortLibraryCommands { + @SubscribeEvent public static void register(RegisterCommandsEvent event) { + var root = Commands.literal("rivencraft_port").requires(Commands.hasPermission(Commands.LEVEL_ADMINS)); + root.then(Commands.literal("library") + .then(Commands.literal("test_place").executes(context -> { + var level = context.getSource().getLevel(); + var direction = context.getSource().getEntity() instanceof net.minecraft.world.entity.player.Player player + ? player.getDirection() : net.minecraft.core.Direction.NORTH; + var result = PortLibraryFeature.testPlace(level, BlockPos.containing(context.getSource().getPosition()), direction, + level.getSeed(), PortLibraryWriting.get(level.getServer()).symbols(level.dimension().identifier())); + if (result.isEmpty()) { context.getSource().sendFailure(Component.literal("No clear placement nearby; use open ground with headroom.")); return 0; } + context.getSource().sendSuccess(() -> Component.literal("Library placed. Chest: " + result.get().toShortString()), false); + return 1; + })) + .then(Commands.literal("test_spacing").then(Commands.argument("chunks", IntegerArgumentType.integer(1, 32)) + .executes(context -> { + var level = context.getSource().getLevel(); + if (!RuntimeAgeBackend.savedAges(level.getServer()).containsKey(level.dimension().identifier())) + throw new IllegalArgumentException("Use this test override inside a Rivencraft Age"); + int spacing = IntegerArgumentType.getInteger(context, "chunks"); + PortLibraryFeature.setTestSpacing(level.getSeed(), spacing); + context.getSource().sendSuccess(() -> Component.literal("Session-only library spacing: " + spacing + + " chunks. Applies to newly generated chunks; 32 restores normal spacing."), false); + return 1; + }))) + .then(Commands.literal("candidate").executes(context -> { + var level = context.getSource().getLevel(); + var origin = ChunkPos.containing(BlockPos.containing(context.getSource().getPosition())); + ChunkPos best = null; + long distance = Long.MAX_VALUE; + for (int spacing : new int[]{32, PortLibraryFeature.spacing(level.getSeed())}) { + int rx = Math.floorDiv(origin.x(), spacing), rz = Math.floorDiv(origin.z(), spacing); + for (int dx = -2; dx <= 2; dx++) for (int dz = -2; dz <= 2; dz++) { + var candidate = PortLibraryFeature.candidateChunk(level.getSeed(), rx + dx, rz + dz, spacing); + long x = candidate.x() - origin.x(), z = candidate.z() - origin.z(); + long d = x * x + z * z; + if (d < distance) { best = candidate; distance = d; } + } + } + var candidate = best; + context.getSource().sendSuccess(() -> Component.literal("Nearest candidate chunk " + candidate + + ", center X=" + (candidate.getMinBlockX() + 8) + " Z=" + (candidate.getMinBlockZ() + 8) + + ". A candidate is not a guaranteed library: footing, slope and clear space must pass."), false); + return 1; + })) + .then(Commands.literal("probe").executes(context -> { + var level = context.getSource().getLevel(); + var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition())); + var chest = PortLibraryFeature.libraryChest(chunk); + if (chest.isPresent()) context.getSource().sendSuccess(() -> Component.literal("Generated library chest: " + chest.get().toShortString()), false); + else { + var check = PortLibraryFeature.check(level, chunk.getPos(), level.getSeed()); + context.getSource().sendSuccess(() -> Component.literal(check.eligible() ? "Eligible library candidate, floor Y=" + check.floorY() + : "No library in this chunk: " + check.reason()), false); + } + return 1; + })) + .then(Commands.literal("inspect_loot").executes(context -> { + var level = context.getSource().getLevel(); + var chunk = level.getChunkAt(BlockPos.containing(context.getSource().getPosition())); + var pos = PortLibraryFeature.libraryChest(chunk); + if (pos.isEmpty()) { context.getSource().sendFailure(Component.literal("No marked library chest in this chunk.")); return 0; } + var chest = (net.minecraft.world.level.block.entity.ChestBlockEntity) level.getBlockEntity(pos.get()); + var items = new java.util.ArrayList(); + for (int i = 0; i < chest.getContainerSize(); i++) { + var item = chest.getItem(i); // Materializes loot exactly as opening the chest does. + if (!item.isEmpty()) items.add(item.getCount() + "x " + item.getHoverName().getString()); + } + long stands = chunk.getBlockEntities().values().stream() + .filter(be -> be instanceof net.dnorberg.rivencraft.block.entity.BookStandBlockEntity).count(); + context.getSource().sendSuccess(() -> Component.literal("Library at " + pos.get().toShortString() + + "; stands in this chunk=" + stands + "; chest=" + items), false); + return 1; + })) + .then(Commands.literal("nearest_loaded").executes(context -> { + var player = context.getSource().getPlayerOrException(); + var level = player.level(); + var origin = player.chunkPosition(); + BlockPos best = null; double distance = Double.MAX_VALUE; + for (int x = origin.x() - 16; x <= origin.x() + 16; x++) for (int z = origin.z() - 16; z <= origin.z() + 16; z++) { + var chunk = level.getChunkSource().getChunkNow(x, z); + if (chunk == null) continue; + var found = PortLibraryFeature.libraryChest(chunk); + if (found.isPresent()) { + double d = player.distanceToSqr(Vec3.atCenterOf(found.get())); + if (d < distance) { best = found.get(); distance = d; } + } + } + if (best == null) { context.getSource().sendFailure(Component.literal("No library in loaded chunks within 16 chunks. Use candidate or test_spacing to explore new terrain.")); return 0; } + var facing = level.getBlockState(best).getValue(ChestBlock.FACING); + var feet = best.relative(facing, 2).relative(facing.getCounterClockWise()); + var target = Vec3.atBottomCenterOf(feet); + if (!level.getBlockState(feet.below()).isFaceSturdy(level, feet.below(), net.minecraft.core.Direction.UP) + || !level.getFluidState(feet).isEmpty() || !level.getFluidState(feet.above()).isEmpty() + || !level.noCollision(player, player.getBoundingBox().move(target.subtract(player.position())))) { + context.getSource().sendFailure(Component.literal("Library found at " + best.toShortString() + " but its aisle is obstructed.")); return 0; + } + player.teleportTo(level, target.x, target.y, target.z, java.util.Set.of(), facing.getOpposite().toYRot(), 0, true); + return 1; + }))); + event.getDispatcher().register(root); + } + @SubscribeEvent public static void stopped(net.neoforged.neoforge.event.server.ServerStoppedEvent event) { + PortLibraryFeature.clearTestSpacing(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java new file mode 100644 index 0000000..79dce30 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryFeature.java @@ -0,0 +1,358 @@ +package net.dnorberg.rivencraft.age; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import net.dnorberg.rivencraft.PortConfig; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.writing.PortSymbolPages; +import net.dnorberg.rivencraft.block.BookStandBlock; +import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; +import net.dnorberg.rivencraft.item.PageItem; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.tags.BlockTags; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.Identifier; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** Sparse, page-free Age library. Its entire footprint lives in its candidate chunk. */ +public final class PortLibraryFeature { + public static final int REGION = 32; + static final int FOOTPRINT = 11; + private static final int OFFSET_BOUND = 24; + private static final int MAX_SUPPORT_DEPTH = 24; + private static final int SALT = 14357617; + private static final int TEST_SALT = 0x4C494252; + private static final String CHEST_MARKER = "RivencraftLibrary"; + private static final ConcurrentHashMap TEST_SPACING = new ConcurrentHashMap<>(); + // x, y, z, clockwise turns relative to the left stand. The middle row + // faces across the room; the far stand faces back toward the entrance. + private static final int[][] STANDS = {{6, 1, 2, 0}, {8, 1, 4, 1}, {8, 1, 5, 1}, + {8, 1, 6, 1}, {6, 1, 8, 2}}; + private static final Identifier TEMPLATE = Identifier.fromNamespaceAndPath( + "rivencraft", "mystcraft_library"); + private static final ResourceKey CHEST_LOOT = ResourceKey.create(Registries.LOOT_TABLE, + Identifier.fromNamespaceAndPath("rivencraft", "chests/mystcraft_library")); + + private PortLibraryFeature() {} + + record Site(int x, int z, int rotation) {} + + public static int spacing(long seed) { + return TEST_SPACING.getOrDefault(seed, REGION); + } + + /** Session-only testing override; normal 32-chunk sites always remain eligible. */ + public static void setTestSpacing(long seed, int chunks) { + if (chunks < 1 || chunks > REGION) throw new IllegalArgumentException("Invalid library spacing " + chunks); + if (chunks == REGION) TEST_SPACING.remove(seed); + else TEST_SPACING.put(seed, chunks); + } + + public static void clearTestSpacing() { + TEST_SPACING.clear(); + } + + /** The single deterministic candidate in one Legacy-sized region. */ + public static ChunkPos candidateChunk(long seed, int regionX, int regionZ) { + return candidateChunk(seed, regionX, regionZ, REGION); + } + + public static ChunkPos candidateChunk(long seed, int regionX, int regionZ, int spacing) { + if (spacing < 1 || spacing > REGION) throw new IllegalArgumentException("Invalid library spacing " + spacing); + WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L)); + random.setLargeFeatureWithSalt(seed, regionX, regionZ, + spacing == REGION ? SALT : TEST_SALT ^ spacing); + int bound = spacing == REGION ? OFFSET_BOUND : spacing; + return new ChunkPos(regionX * spacing + random.nextInt(bound), + regionZ * spacing + random.nextInt(bound)); + } + + static Site site(long seed, ChunkPos chunk) { + Site normal = site(seed, chunk, REGION); + return normal != null ? normal : spacing(seed) == REGION ? null : site(seed, chunk, spacing(seed)); + } + + private static Site site(long seed, ChunkPos chunk, int spacing) { + int rx = Math.floorDiv(chunk.x(), spacing); + int rz = Math.floorDiv(chunk.z(), spacing); + WorldgenRandom random = new WorldgenRandom(new LegacyRandomSource(0L)); + random.setLargeFeatureWithSalt(seed, rx, rz, + spacing == REGION ? SALT : TEST_SALT ^ spacing); + int bound = spacing == REGION ? OFFSET_BOUND : spacing; + int candidateX = rx * spacing + random.nextInt(bound); + int candidateZ = rz * spacing + random.nextInt(bound); + if (chunk.x() != candidateX || chunk.z() != candidateZ) return null; + return new Site(chunk.getMinBlockX() + 2, chunk.getMinBlockZ() + 2, random.nextInt(4)); + } + + /** A saved marker distinguishes a generated library from player-built chests. */ + public static Optional libraryChest(LevelChunk chunk) { + return chunk.getBlockEntities().entrySet().stream() + .filter(entry -> entry.getValue() instanceof ChestBlockEntity chest + && chest.getPersistentData().getBooleanOr(CHEST_MARKER, false)) + .map(java.util.Map.Entry::getKey).findFirst(); + } + + /** Recognize libraries generated before the chest marker was introduced. */ + public static Optional libraryChest(LevelChunk chunk, long seed) { + Optional marked = libraryChest(chunk); + if (marked.isPresent()) return marked; + Site site = site(seed, chunk.getPos()); + if (site == null) return Optional.empty(); + for (var entry : chunk.getBlockEntities().entrySet()) { + if (!(entry.getValue() instanceof ChestBlockEntity chest)) continue; + int y = entry.getKey().getY(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + if (!entry.getKey().equals(mapped(site, cursor, 4, y, 2)) + || !chunk.getBlockState(mapped(site, cursor, 2, y, 2)).is(Blocks.BOOKSHELF) + || !chunk.getBlockState(mapped(site, cursor, 2, y + 1, 2)).is(Blocks.BOOKSHELF) + || !chunk.getBlockState(mapped(site, cursor, 4, y + 6, 2)).is(Blocks.OAK_PLANKS)) + continue; + chest.getPersistentData().putBoolean(CHEST_MARKER, true); + chest.setChanged(); + return Optional.of(entry.getKey()); + } + return Optional.empty(); + } + + /** Open interior aisle, directly above the cobblestone floor. */ + public static BlockPos arrival(long seed, ChunkPos chunk, int chestY) { + Site site = site(seed, chunk); + if (site == null) throw new IllegalArgumentException("Not a library candidate chunk: " + chunk); + return mapped(site, new BlockPos.MutableBlockPos(), 5, chestY, 4).immutable(); + } + + public record PlacementCheck(String reason, int floorY, int[][] heights) { + public boolean eligible() { return reason == null; } + } + + /** Inspect one candidate without placing blocks. The same test gates natural placement. */ + public static PlacementCheck check(WorldGenLevel level, ChunkPos chunk, long seed) { + Site site = site(seed, chunk); + if (site == null) return rejected("not a candidate chunk"); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int low = Integer.MAX_VALUE; + int high = Integer.MIN_VALUE; + int[][] heights = new int[FOOTPRINT][FOOTPRINT]; + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) { + int wx = site.x() + x; + int wz = site.z() + z; + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, wx, wz); + // The heightmap includes tree canopies. Libraries sit on terrain, not leaves. + while (y > level.getMinY() + 2) { + BlockState below = level.getBlockState(cursor.set(wx, y - 1, wz)); + if (!below.isAir() && !isVegetation(below)) break; + y--; + } + if (y < level.getMinY() + 2) return rejected("no surface footing"); + BlockState ground = level.getBlockState(cursor.set(wx, y - 1, wz)); + if (!ground.getFluidState().isEmpty()) return rejected("fluid at ground level"); + if (ground.hasBlockEntity() || !ground.isFaceSturdy(level, cursor, Direction.UP)) + return rejected("ground is not sturdy terrain"); + heights[x][z] = y; + low = Math.min(low, y); + high = Math.max(high, y); + } + if (high + 10 >= level.getMaxY()) return rejected("insufficient build height"); + if (high - low > MAX_SUPPORT_DEPTH) return rejected("slope exceeds support depth (" + + (high - low) + " > " + MAX_SUPPORT_DEPTH + ")"); + // Check the entire raised foundation and house. Foliage can be cleared, but not + // structures, fluids, or block entities already occupying this volume. + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) + for (int y = heights[x][z]; y <= high + 10; y++) { + BlockState state = level.getBlockState(cursor.set(site.x() + x, y, site.z() + z)); + if (!state.getFluidState().isEmpty()) return rejected("fluid inside footprint"); + if (state.hasBlockEntity()) return rejected("block entity inside footprint"); + if (!(state.isAir() || state.canBeReplaced() || isVegetation(state))) + return rejected("solid obstruction inside footprint: " + + state.getBlock().getDescriptionId()); + } + return new PlacementCheck(null, high, heights); + } + + private static PlacementCheck rejected(String reason) { + return new PlacementCheck(reason, 0, null); + } + + private static boolean isVegetation(BlockState state) { + return state.is(BlockTags.LEAVES) || state.is(BlockTags.LOGS) + || state.is(BlockTags.FLOWERS) || state.is(BlockTags.SAPLINGS) + || !state.isAir() && state.canBeReplaced() && state.getFluidState().isEmpty(); + } + + static boolean generate(WorldGenLevel level, ChunkAccess chunk, long seed, + List ageSymbols) { + Site site = site(seed, chunk.getPos()); + if (site == null) return false; + PlacementCheck check = check(level, chunk.getPos(), seed); + if (!check.eligible()) return false; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) + for (int y = check.heights()[x][z]; y <= check.floorY() + 10; y++) { + BlockPos pos = cursor.set(site.x() + x, y, site.z() + z); + if (isVegetation(level.getBlockState(pos))) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 2); + } + return build(level, site, check.floorY(), check.heights(), seed, ageSymbols); + } + + /** Explicit OP testing placement. Never overwrites existing blocks or entities. */ + public static Optional testPlace(ServerLevel level, BlockPos playerPos, Direction facing, + long seed, List ageSymbols) { + Direction horizontal = facing.getAxis().isHorizontal() ? facing : Direction.NORTH; + BlockPos center = playerPos.relative(horizontal, 13); + int rotation = switch (horizontal) { + case NORTH -> 2; + case EAST -> 3; + case WEST -> 1; + default -> 0; + }; + Site site = new Site(center.getX() - FOOTPRINT / 2, center.getZ() - FOOTPRINT / 2, rotation); + int floorY = playerPos.getY() + 2; + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) + floorY = Math.max(floorY, level.getHeight(Heightmap.Types.WORLD_SURFACE, + site.x() + x, site.z() + z) + 1); + if (floorY + 10 >= level.getMaxY() || floorY - playerPos.getY() > 20) + return Optional.empty(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) + for (int y = floorY; y <= floorY + 10; y++) { + BlockPos pos = cursor.set(site.x() + x, y, site.z() + z); + if (!level.getWorldBorder().isWithinBounds(pos) || !level.getBlockState(pos).isAir()) + return Optional.empty(); + } + if (!level.getEntities(null, new AABB(site.x(), floorY, site.z(), + site.x() + FOOTPRINT, floorY + 11, site.z() + FOOTPRINT)).isEmpty()) + return Optional.empty(); + if (!build(level, site, floorY, null, seed, ageSymbols)) return Optional.empty(); + return Optional.of(mapped(site, cursor, 4, floorY + 1, 2).immutable()); + } + + private static boolean build(WorldGenLevel level, Site site, int floorY, int[][] heights, + long seed, List ageSymbols) { + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + var template = level.getLevel().getStructureTemplateManager().get(TEMPLATE).orElse(null); + if (template == null || template.getSize().getX() != FOOTPRINT + || template.getSize().getY() != FOOTPRINT || template.getSize().getZ() != FOOTPRINT) + return false; + for (int x = 0; x < FOOTPRINT; x++) for (int z = 0; z < FOOTPRINT; z++) { + for (int y = heights == null ? floorY : heights[x][z] - 1; y <= floorY; y++) + place(level, site, cursor, x, y, z, Blocks.COBBLESTONE.defaultBlockState()); + } + Rotation rotation = switch (site.rotation()) { + case 1 -> Rotation.CLOCKWISE_90; + case 2 -> Rotation.CLOCKWISE_180; + case 3 -> Rotation.COUNTERCLOCKWISE_90; + default -> Rotation.NONE; + }; + BlockPos origin = new BlockPos(site.x(), floorY, site.z()); + var settings = new StructurePlaceSettings().setRotation(rotation) + .setRotationPivot(new BlockPos(5, 0, 5)).setIgnoreEntities(true); + long positionSalt = new ChunkPos(site.x() >> 4, site.z() >> 4).pack(); + if (!template.placeInWorld(level, origin, origin, settings, + RandomSource.create(seed ^ positionSalt), 2)) return false; + + BlockPos chestPos = mapped(site, cursor, 4, floorY + 1, 2).immutable(); + level.setBlock(chestPos, Blocks.CHEST.defaultBlockState().setValue(ChestBlock.FACING, + Direction.from2DDataValue(site.rotation())), 2); + PortConfig.LibraryLootMode mode = PortConfig.LIBRARY_LOOT_MODE.get(); + boolean ageOnly = mode == PortConfig.LibraryLootMode.AGE_ONLY; + List symbols = symbolsForLoot(ageSymbols, mode); + RandomSource random = RandomSource.create(seed ^ positionSalt ^ SALT); + if (level.getBlockEntity(chestPos) instanceof ChestBlockEntity chest) { + chest.getPersistentData().putBoolean(CHEST_MARKER, true); + chest.setChanged(); + if (ageOnly) { + // Pages are frozen in the Age generator, then saved as real chest items. + boolean[] occupied = new boolean[chest.getContainerSize()]; + for (int i = 0, count = symbols.isEmpty() ? 0 : 4 + random.nextInt(5); i < count; i++) { + int slot; + do slot = random.nextInt(occupied.length); while (occupied[slot]); + occupied[slot] = true; + chest.setItem(slot, page(symbols, random)); + } + } else { + chest.setLootTable(CHEST_LOOT, seed ^ chestPos.asLong()); + } + } + for (int[] standCoords : STANDS) { + BlockPos pos = mapped(site, cursor, standCoords[0], floorY + standCoords[1], standCoords[2]).immutable(); + if (!level.getBlockState(pos).isAir()) continue; + level.setBlock(pos, Rivencraft.BOOK_STAND.get().defaultBlockState() + .setValue(BookStandBlock.FACING, standFacing(site, standCoords[3])) + .setValue(BookStandBlock.HAS_BOOK, true), 2); + if (level.getBlockEntity(pos) instanceof BookStandBlockEntity stand) { + stand.setBook(symbols.isEmpty() ? new ItemStack(Items.BOOK) : page(symbols, random)); + } + } + return true; + } + + static Direction standFacing(Site site, int clockwiseTurns) { + return Direction.from2DDataValue((site.rotation() + clockwiseTurns) & 3); + } + + /** Correct older generated stands in place, retaining their block entities and pages. */ + public static int repairStandFacings(ServerLevel level, LevelChunk chunk, long seed) { + Site site = site(seed, chunk.getPos()); + if (site == null || libraryChest(chunk, seed).isEmpty()) return -1; + int changed = 0; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int chestY = libraryChest(chunk, seed).orElseThrow().getY(); + for (int[] standCoords : STANDS) { + BlockPos pos = mapped(site, cursor, standCoords[0], chestY, standCoords[2]); + BlockState state = level.getBlockState(pos); + if (!state.is(Rivencraft.BOOK_STAND.get())) continue; + Direction facing = standFacing(site, standCoords[3]); + if (state.getValue(BookStandBlock.FACING) != facing) { + level.setBlock(pos, state.setValue(BookStandBlock.FACING, facing), 3); + changed++; + } + } + return changed; + } + + static List symbolsForLoot(List ageSymbols, PortConfig.LibraryLootMode mode) { + return mode == PortConfig.LibraryLootMode.AGE_ONLY + ? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList() + : PortSymbolPages.sealedCandidateIds(); + } + + private static ItemStack page(List symbols, RandomSource random) { + return PageItem.createSymbolPage(symbols.get(random.nextInt(symbols.size()))); + } + + private static BlockPos.MutableBlockPos mapped(Site site, BlockPos.MutableBlockPos cursor, int x, int y, int z) { + return switch (site.rotation()) { + case 1 -> cursor.set(site.x() + FOOTPRINT - 1 - z, y, site.z() + x); + case 2 -> cursor.set(site.x() + FOOTPRINT - 1 - x, y, site.z() + FOOTPRINT - 1 - z); + case 3 -> cursor.set(site.x() + z, y, site.z() + FOOTPRINT - 1 - x); + default -> cursor.set(site.x() + x, y, site.z() + z); + }; + } + + private static void place(WorldGenLevel level, Site site, BlockPos.MutableBlockPos cursor, + int x, int y, int z, BlockState state) { + level.setBlock(mapped(site, cursor, x, y, z), state, 2); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryWriting.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryWriting.java new file mode 100644 index 0000000..d8de62b --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortLibraryWriting.java @@ -0,0 +1,26 @@ +package net.dnorberg.rivencraft.age; + +import com.mojang.serialization.Codec; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +/** Written pages for library Age-only loot, kept outside the legacy backend index codec. */ +final class PortLibraryWriting extends SavedData { + static final Codec CODEC = Codec.unboundedMap(Identifier.CODEC, Identifier.CODEC.listOf()) + .fieldOf("ages").codec().xmap(PortLibraryWriting::new, data -> Map.copyOf(data.pages)); + static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath("rivencraft", "library_writing"), () -> new PortLibraryWriting(Map.of()), CODEC); + private final Map> pages = new HashMap<>(); + private PortLibraryWriting(Map> pages) { pages.forEach((id, list) -> this.pages.put(id, List.copyOf(list))); } + static PortLibraryWriting get(MinecraftServer server) { return server.overworld().getDataStorage().computeIfAbsent(TYPE); } + List symbols(Identifier id) { return pages.getOrDefault(id, List.of()); } + void put(Identifier id, List symbols) { + if (symbols.isEmpty()) pages.remove(id); else pages.put(id, List.copyOf(symbols)); + setDirty(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStatusEffectProviders.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStatusEffectProviders.java new file mode 100644 index 0000000..4f18ffc --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/PortStatusEffectProviders.java @@ -0,0 +1,233 @@ +package net.dnorberg.rivencraft.age; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +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.Holder; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.effect.MobEffect; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.event.entity.EntityTravelToDimensionEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +/** Legacy potion cards adapted to bounded, Age-local server effects. */ +@net.neoforged.fml.common.EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class PortStatusEffectProviders { + private static final int MAX_TRACKED_AGES = 64; + private static final Map SPECS = createSpecs(); + private static final Map> LEASES = new WeakHashMap<>(); + + private PortStatusEffectProviders() {} + + public static void register() { + SPECS.keySet().forEach(id -> PortInstabilityRuntime.register(id, PortStatusEffectProviders::apply)); + } + + static void apply(PortInstabilityRuntime.Context context, Identifier id, int level) { + if (!PortInstabilityConfig.statusEffectsEnabled() + || !PortInstabilityRuntime.isAge(context.level())) return; + Spec spec = SPECS.get(id); + if (spec == null || spec.target() == Target.HOSTILE && !PortInstabilityConfig.statusEffectsHostiles() + || spec.target() == Target.GENERAL && !PortInstabilityConfig.statusEffectsPlayers() + && !PortInstabilityConfig.statusEffectsNonplayers()) return; + List leases = LEASES.get(context.level()); + if (leases == null) { + if (LEASES.size() >= MAX_TRACKED_AGES) return; + leases = new ArrayList<>(); + LEASES.put(context.level(), leases); + } + prune(context.level(), leases, context.serverTick(), false); + if (leases.size() >= PortInstabilityConfig.statusEffectsMaxActive()) return; + + Random random = new Random(context.age().seed() ^ (context.serverTick() * 0x9E3779B97F4A7C15L) + ^ id.hashCode()); + ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size())); + AABB bounds = new AABB(chunk.getMinBlockX(), context.level().getMinY(), chunk.getMinBlockZ(), + chunk.getMaxBlockX() + 1, context.level().getMaxY(), chunk.getMaxBlockZ() + 1); + int duration = Math.min(spec.legacyDuration(), PortInstabilityConfig.statusEffectsMaxDuration()); + int amplifier = Math.max(0, level - 1); + MobEffectInstance instance = new MobEffectInstance(spec.effect(), duration, amplifier); + List candidates = new ArrayList<>(); + context.level().getEntities(EntityTypeTest.forClass(LivingEntity.class), bounds, + entity -> eligible(context.level(), entity, spec, instance), candidates, + PortInstabilityConfig.statusEffectsMaxCandidates()); + if (candidates.isEmpty()) return; + candidates.sort(Comparator.comparing(Entity::getUUID)); + LivingEntity target = candidates.get(random.nextInt(candidates.size())); + if (!context.budget().claim() || !target.addEffect(instance)) return; + context.budget().recordApplied(); + leases.add(new Lease(id, new WeakReference<>(target), instance, + context.serverTick() + duration, spec.target())); + } + + private static boolean eligible(ServerLevel level, LivingEntity entity, Spec spec, + MobEffectInstance instance) { + if (!entity.isAlive() || entity.isRemoved() || entity.level() != level + || entity.getEffect(spec.effect()) != null || !entity.canBeAffected(instance) + || !spec.global() && !level.canSeeSky(entity.blockPosition())) return false; + return allowedByPolicy(spec.target(), entity); + } + + private static boolean allowedByPolicy(Target target, LivingEntity entity) { + if (entity instanceof Player player) { + return target == Target.GENERAL && PortInstabilityConfig.statusEffectsPlayers() + && !player.isCreative() && !player.isSpectator(); + } + return target == Target.GENERAL ? PortInstabilityConfig.statusEffectsNonplayers() + : entity instanceof Monster && PortInstabilityConfig.statusEffectsHostiles(); + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onServerTickPost(ServerTickEvent.Post event) { + if (event.getServer().getTickCount() % 20 != 0) return; + boolean disabled = !PortInstabilityConfig.instabilityEnabled() || !PortInstabilityConfig.statusEffectsEnabled(); + long tick = event.getServer().getTickCount(); + for (var entry : LEASES.entrySet()) { + prune(entry.getKey(), entry.getValue(), tick, disabled + || !PortInstabilityData.get(entry.getKey().getServer()).age(entry.getKey()).enabled()); + } + LEASES.values().removeIf(List::isEmpty); + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onEntityTravel(EntityTravelToDimensionEvent event) { + Entity entity = event.getEntity(); + if (entity instanceof LivingEntity living && !entity.level().dimension().equals(event.getDimension())) { + clearEntity(living); + } + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) { + if (event.getEntity() instanceof ServerPlayer player) clearEntity(player); + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { + if (event.getEntity() instanceof ServerPlayer player) clearEntity(player); + } + + @net.neoforged.bus.api.SubscribeEvent + public static void onServerStopping(ServerStoppingEvent event) { + long tick = event.getServer().getTickCount(); + for (var entry : LEASES.entrySet()) prune(entry.getKey(), entry.getValue(), tick, true); + LEASES.clear(); + } + + public static void clearAge(ServerLevel level) { + var leases = LEASES.remove(level); + if (leases != null) prune(level, leases, level.getServer().getTickCount(), true); + } + + private static void clearEntity(LivingEntity entity) { + for (List leases : LEASES.values()) { + Iterator iterator = leases.iterator(); + while (iterator.hasNext()) { + Lease lease = iterator.next(); + if (lease.entity().get() != entity) continue; + removeOwnedEffect(entity, lease); + iterator.remove(); + } + } + } + + private static void prune(ServerLevel level, List leases, long tick, boolean removeAll) { + Iterator iterator = leases.iterator(); + while (iterator.hasNext()) { + Lease lease = iterator.next(); + LivingEntity entity = lease.entity().get(); + if (entity == null || entity.isRemoved() || entity.getEffect(lease.instance().getEffect()) == null) { + iterator.remove(); + continue; + } + boolean revoke = removeAll || !PortInstabilityConfig.instabilityProviderEnabled(lease.provider()) + || entity.level() != level + || !allowedByPolicy(lease.target(), entity); + if (revoke || tick >= lease.expiresAt()) { + removeOwnedEffect(entity, lease); + iterator.remove(); + } + } + } + + private static void removeOwnedEffect(LivingEntity entity, Lease lease) { + // addEffect stores the supplied instance when no effect exists. Do not remove + // a replacement from another mod (or a stronger/extended effect). + if (entity.getEffect(lease.instance().getEffect()) == lease.instance() + && lease.instance().getAmplifier() == lease.amplifier() + && lease.instance().getDuration() <= lease.originalDuration()) { + entity.removeEffect(lease.instance().getEffect()); + } + } + + static int legacyDuration(Identifier id) { + Spec spec = SPECS.get(id); + return spec == null ? -1 : spec.legacyDuration(); + } + + static boolean localRequiresSky(Identifier id) { + Spec spec = SPECS.get(id); + return spec != null && !spec.global(); + } + + static boolean eligibleTarget(ServerLevel level, LivingEntity entity, Identifier id) { + Spec spec = SPECS.get(id); + return spec != null && eligible(level, entity, spec, + new MobEffectInstance(spec.effect(), spec.legacyDuration(), 0)); + } + + private static Map createSpecs() { + Map specs = new HashMap<>(); + addPair(specs, "blindness", MobEffects.BLINDNESS, 60); + addPair(specs, "fatigue", MobEffects.MINING_FATIGUE, 80); + addPair(specs, "hunger", MobEffects.HUNGER, 80); + addPair(specs, "nausea", MobEffects.NAUSEA, 60); + addPair(specs, "poison", MobEffects.POISON, 80); + addPair(specs, "slow", MobEffects.SLOWNESS, 80); + addPair(specs, "weakness", MobEffects.WEAKNESS, 80); + addPair(specs, "wither", MobEffects.WITHER, 30); + specs.put(id("enemyregen_global"), new Spec(MobEffects.REGENERATION, 200, true, Target.HOSTILE)); + specs.put(id("enemyresist_global"), new Spec(MobEffects.RESISTANCE, 200, true, Target.HOSTILE)); + return Map.copyOf(specs); + } + + private static void addPair(Map specs, String name, + Holder effect, int duration) { + specs.put(id(name), new Spec(effect, duration, false, Target.GENERAL)); + specs.put(id(name + "_global"), new Spec(effect, duration, true, Target.GENERAL)); + } + + private static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } + + private enum Target { GENERAL, HOSTILE } + private record Spec(Holder effect, int legacyDuration, boolean global, Target target) {} + private record Lease(Identifier provider, WeakReference entity, MobEffectInstance instance, long expiresAt, + Target target, int amplifier, int originalDuration) { + private Lease(Identifier provider, WeakReference entity, MobEffectInstance instance, long expiresAt, + Target target) { + this(provider, entity, instance, expiresAt, target, instance.getAmplifier(), instance.getDuration()); + } + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java index d1dd298..86a4675 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/age/RuntimeAgeBackend.java @@ -149,14 +149,26 @@ public final class RuntimeAgeBackend { PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle) { + return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, + terrainFeatures, weather, lighting, sky, colors, dayCycle, List.of()); + } + + public static ServerLevel create(MinecraftServer server, Identifier id, long seed, + PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks, + PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes, + PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather, + PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle, + List librarySymbols) { checkServerThread(server); ageName(id); AgeLevelIndex index = index(server); if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id); + PortLibraryWriting.get(server).put(id, librarySymbols); ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); ((PortAgeLevel) level).configureWeather(weather); index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures, lighting, sky, colors, dayCycle); + PortInstabilityData.get(server).create(level, librarySymbols, terrain, lighting, dayCycle); // A newly bound book may be used immediately. Persist its Age identity // before publishing the catalog so a later crash cannot leave that // book pointing at a world omitted from the on-disk index. @@ -482,8 +494,8 @@ public final class RuntimeAgeBackend { }; generator = new NoiseBasedChunkGenerator(source, PortBiomeMaterialRules.apply(server.registryAccess(), settings.value())); } - if (rocks != null || vegetation != null || dungeons > 0 || lakes != null || terrainFeatures != null) - generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed); + generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed, + terrain != PortAgeTerrain.CAVE, PortLibraryWriting.get(server).symbols(id)); LevelStem stem = new LevelStem(lighting.dimensionType(server, terrain, overworldStem.type(), sky), generator, OptionalLong.of(seed)); long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed()); diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java new file mode 100644 index 0000000..0478828 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/BookReceptacleBlock.java @@ -0,0 +1,136 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.advancement.ProgressionAdvancements; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.portal.PortalBookTravel; + + + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityTicker; +import net.minecraft.world.level.block.entity.BlockEntityType; + +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.level.block.state.properties.BooleanProperty; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** A book-holding crystal attachment, with server-authoritative frame activation. */ +public final class BookReceptacleBlock extends BaseEntityBlock { + public static final EnumProperty FACING = BlockStateProperties.FACING; + public static final BooleanProperty HAS_BOOK = BlockStateProperties.HAS_BOOK; + + public BookReceptacleBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(HAS_BOOK, false)); + } + + @Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; } + @Override protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, + CollisionContext context) { + return switch (state.getValue(FACING)) { + case NORTH -> Block.box(0, 0, 10, 16, 16, 16); + case SOUTH -> Block.box(0, 0, 0, 16, 16, 6); + case EAST -> Block.box(0, 0, 0, 6, 16, 16); + case WEST -> Block.box(10, 0, 0, 16, 16, 16); + case UP -> Block.box(0, 0, 0, 16, 6, 16); + case DOWN -> Block.box(0, 10, 0, 16, 16, 16); + }; + } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING, HAS_BOOK); + } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new BookReceptacleBlockEntity(pos, state); + } + @Override public BlockEntityTicker getTicker(Level level, BlockState state, + BlockEntityType type) { + return level instanceof ServerLevel + ? createTickerHelper(type, Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), (world, pos, current, holder) -> BookReceptacleBlockEntity.serverTick((ServerLevel) world, pos, holder)) + : null; + } + @Override public BlockState getStateForPlacement(BlockPlaceContext context) { + Direction face = context.getClickedFace(); + if (!context.getLevel().getBlockState( + context.getClickedPos().relative(face.getOpposite())).is(Rivencraft.CRYSTAL_BLOCK.get())) return null; + return defaultBlockState().setValue(FACING, face); + } + + @Override protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level, BlockPos pos, + Player player, InteractionHand hand, BlockHitResult hit) { + if (!isBoundBook(stack)) return InteractionResult.TRY_WITH_EMPTY_HAND; + if (level.isClientSide()) return InteractionResult.SUCCESS; + if (!(level instanceof ServerLevel server) || + !(level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder)) return InteractionResult.FAIL; + if (!holder.getBook().isEmpty()) return InteractionResult.FAIL; + CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(server, pos); + if (!plan.valid()) { + if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.portal.invalid_frame")); + return InteractionResult.FAIL; + } + if (!PortalBookTravel.available(server, stack)) { + if (player instanceof ServerPlayer serverPlayer) serverPlayer.sendOverlayMessage(Component.translatable("message.rivencraft.descriptive_book.unavailable")); + return InteractionResult.FAIL; + } + holder.setBook(stack.copyWithCount(1)); + if (!CrystalPortalNetwork.activate(server, pos, plan, player)) { + holder.takeBook(); + return InteractionResult.FAIL; + } + if (!player.getAbilities().instabuild) stack.shrink(1); + level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1); + if (player instanceof ServerPlayer serverPlayer) + ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL); + return InteractionResult.SUCCESS; + } + + @Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (level.isClientSide()) return InteractionResult.SUCCESS; + if (level instanceof ServerLevel server && level.getBlockEntity(pos) instanceof BookReceptacleBlockEntity holder + && !holder.getBook().isEmpty()) { + CrystalPortalNetwork.shutdown(server, pos); + ItemStack book = holder.takeBook(); + if (!player.getInventory().add(book)) player.drop(book, false, net.minecraft.util.Prediction.SERVER_ONLY); + level.playSound(null, pos, SoundEvents.BOOK_PAGE_TURN, SoundSource.BLOCKS, 1, 1); + return InteractionResult.SUCCESS; + } + return InteractionResult.PASS; + } + + @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, + net.minecraft.world.level.redstone.Orientation orientation, boolean moved) { + super.neighborChanged(state, level, pos, neighbor, orientation, moved); + BlockPos support = pos.relative(state.getValue(FACING).getOpposite()); + if (!level.isClientSide() && level.hasChunkAt(support) && !level.getBlockState(support) + .is(Rivencraft.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true); + } + + private static boolean isBoundBook(ItemStack stack) { + return stack.is(Rivencraft.LINKING_BOOK.get()) && stack.has(Rivencraft.LINK_BOOK_TARGET.get()) + || stack.is(Rivencraft.DESCRIPTIVE_BOOK.get()) && stack.has(Rivencraft.AGE_BOOK_ID.get()); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java new file mode 100644 index 0000000..bead86e --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java @@ -0,0 +1,79 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.dnorberg.rivencraft.portal.PortalTravelCooldown; +import net.dnorberg.rivencraft.portal.PortalTravelQueue; +import net.dnorberg.rivencraft.Rivencraft; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +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.BooleanProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.level.material.Fluid; + +/** Non-solid linked surface; all travel is delegated to the held book's rules. */ +public final class CrystalPortalBlock extends BaseEntityBlock { + // Retained so portal blocks already saved with this state still deserialize. + public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view"); + public static final EnumProperty AXIS = EnumProperty.create("axis", Direction.Axis.class); + + public CrystalPortalBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z)); + } + @Override protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(LIVE_VIEW, AXIS); + } + @Override protected RenderShape getRenderShape(BlockState state) { + // Keep the animated fallback until the optional destination view has + // actually been established, including after a world restart. + return RenderShape.MODEL; + } + @Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new CrystalPortalBlockEntity(pos, state); + } + @Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; } + + @Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity, net.minecraft.world.entity.InsideBlockEffectApplier effects, boolean precise) { + if (!(level instanceof ServerLevel server) || !entity.canUsePortal(false)) return; + long now = server.getServer().getTickCount(); + if (PortalTravelCooldown.blocked(entity, now)) return; + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || portal.receptacle() == null || !level.hasChunkAt(portal.receptacle()) + || !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return; + ItemStack book = holder.getBook(); + if (book.isEmpty()) return; + PortalTravelCooldown.mark(entity, now); + PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now); + } + + @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, + net.minecraft.world.level.redstone.Orientation orientation, boolean moved) { + super.neighborChanged(state, level, pos, neighbor, orientation, moved); + if (!(level instanceof ServerLevel server) + || !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || portal.receptacle() == null) return; + // Like a vanilla portal, validate when the frame changes, not on a + // repeating timer that can race block entities loading after restart. + if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle()) + == CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false); + } + + @Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) { + // Ignore validation ticks saved by older builds. Frame edits are + // handled by neighborChanged, and the receptacle maintains the view. + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java new file mode 100644 index 0000000..2d486ac --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java @@ -0,0 +1,46 @@ +package net.dnorberg.rivencraft.block; + +import net.dnorberg.rivencraft.age.PortInstabilityRuntime; +import net.dnorberg.rivencraft.age.PortInstabilityConfig; +import net.minecraft.core.BlockPos; +import net.minecraft.util.StringRepresentable; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.EnumProperty; + +/** The four colored decay variants selected by the current instability deck. */ +public final class DecayBlock extends Block { + public static final EnumProperty COLOR = EnumProperty.create("color", Color.class); + + public DecayBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(COLOR); + } + + @Override + public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) { + if (level instanceof net.minecraft.server.level.ServerLevel server && state.getValue(COLOR) == Color.WHITE + && PortInstabilityConfig.decayEnabled() && PortInstabilityConfig.instabilityWorldMutation() + && PortInstabilityRuntime.isAge(server)) { + entity.hurtServer(server, level.damageSources().magic(), 1.0F); + } + super.stepOn(level, pos, state, entity); + } + + public enum Color implements StringRepresentable { + BLUE, PURPLE, RED, WHITE; + + @Override + public String getSerializedName() { + return name().toLowerCase(java.util.Locale.ROOT); + } + } +} 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 new file mode 100644 index 0000000..c691e96 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/BookReceptacleBlockEntity.java @@ -0,0 +1,101 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** One persisted book owns the whole crystal network. Portal cells retain only its position. */ +public final class BookReceptacleBlockEntity extends BlockEntity { + private ItemStack book = ItemStack.EMPTY; + private boolean restore = true; + private int skyColor = 0xffffff; + public BookReceptacleBlockEntity(BlockPos pos, BlockState state) { + super(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), pos, state); + } + public ItemStack getBook() { return book; } + public int skyColor() { return skyColor; } + public void setBook(ItemStack value) { + book = value.copyWithCount(1); + skyColor = 0xffffff; + if (level instanceof ServerLevel source) { + var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get()); + var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get()) + : destination == null ? null : destination.dimension(); + 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) + : BlockPos.containing(destination.x(), destination.y(), destination.z()); + if (pos != null) { + int written = net.dnorberg.rivencraft.age.PortAgeColors.forLevel(target).sky(); + String type = target.dimensionTypeRegistration().unwrapKey() + .map(key -> key.identifier().getPath()).orElse(""); + skyColor = written >= 0 ? written + : target.dimension() == net.minecraft.world.level.Level.NETHER || type.endsWith("_sky_nether") + ? 0x6b2725 + : target.dimension() == net.minecraft.world.level.Level.END || type.endsWith("_sky_end") + ? 0x211835 + : net.minecraft.util.ARGB.colorFromVector3f(target.environmentAttributes().getValue( + net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR, pos)) & 0xffffff; + } + } + } + sync(); + } + public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; } + private void sync() { + setChanged(); + if (level != null && !level.isClientSide()) { + BlockState current = level.getBlockState(worldPosition); + if (current.is(Rivencraft.BOOK_RECEPTACLE.get())) { + level.setBlock(worldPosition, current.setValue(BookReceptacleBlock.HAS_BOOK, !book.isEmpty()), 2); + level.sendBlockUpdated(worldPosition, current, level.getBlockState(worldPosition), 2); + } + } + } + public static void serverTick(ServerLevel level, BlockPos pos, BookReceptacleBlockEntity holder) { + if (holder.book.isEmpty()) { holder.restore = false; return; } + int interval = holder.restore ? 20 : 200; + if (level.getGameTime() % interval != Math.floorMod(pos.asLong(), interval)) return; + var plan = CrystalPortalNetwork.plan(level, pos); + if (plan.waitingForChunks()) return; + boolean restoring = holder.restore; + if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos)) + plan = CrystalPortalNetwork.plan(level, pos); + CrystalPortalNetwork.reconcile(level, pos, plan); + holder.restore = false; + if (restoring) holder.sync(); + } + @Override protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + book = input.read("Book", ItemStack.CODEC).orElse(ItemStack.EMPTY); + skyColor = input.getIntOr("SkyColor", 0xffffff); + restore = true; + } + @Override protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + if (!book.isEmpty()) output.store("Book", ItemStack.CODEC, book); + output.putInt("SkyColor", skyColor); + } + @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); } + @Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); } + @Override public void preRemoveSideEffects(BlockPos pos, BlockState oldState) { + if (level instanceof ServerLevel server) { + CrystalPortalNetwork.shutdown(server, pos); + ItemStack removed = book; + book = ItemStack.EMPTY; + net.minecraft.world.Containers.dropItemStack(level, pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, removed); + } + super.preRemoveSideEffects(pos, oldState); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java new file mode 100644 index 0000000..f30986c --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/CrystalPortalBlockEntity.java @@ -0,0 +1,33 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +/** One stored book or page, synchronized independently of the player's inventory. */ +public final class CrystalPortalBlockEntity extends BlockEntity { + private BlockPos receptacle; + public CrystalPortalBlockEntity(BlockPos pos, BlockState state) { super(Rivencraft.CRYSTAL_PORTAL_BLOCK_ENTITY.get(), pos, state); } + public BlockPos receptacle() { return receptacle; } + public void setReceptacle(BlockPos pos) { + receptacle = pos.immutable(); + setChanged(); + if (level != null && !level.isClientSide()) level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 2); + } + @Override protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + receptacle = input.read("Receptacle", BlockPos.CODEC).orElse(null); + } + @Override protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + if (receptacle != null) output.store("Receptacle", BlockPos.CODEC, receptacle); + } + @Override public CompoundTag getUpdateTag(HolderLookup.Provider registries) { return saveWithoutMetadata(registries); } + @Override public ClientboundBlockEntityDataPacket getUpdatePacket() { return ClientboundBlockEntityDataPacket.create(this); } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java index 6c948e8..45d2f82 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/block/entity/WritingDeskBlockEntity.java @@ -116,13 +116,13 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain public boolean canConsumeCopyMaterials() { ItemStack paper = items.get(PAPER); if (!canPlaceItem(PAPER, paper) || paper.isEmpty()) return false; - int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get(); + int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost(); return InkVialExchange.canPay(items, INK, RETURN, inkRemaining, cost); } public boolean consumeCopyMaterials() { if (!canConsumeCopyMaterials()) return false; - int cost = net.dnorberg.rivencraft.PortConfig.SYMBOL_INK_COST.get(); + int cost = net.dnorberg.rivencraft.writing.WritingInkService.symbolCopyCost(); if (inkRemaining < cost) { inkRemaining += InkVialExchange.exchange(items, INK, RETURN); } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookReceptacleRenderer.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookReceptacleRenderer.java new file mode 100644 index 0000000..a38ff92 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/client/BookReceptacleRenderer.java @@ -0,0 +1,67 @@ +package net.dnorberg.rivencraft.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.model.object.book.BookModel; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.Direction; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; + +/** The 1.21.1 receptacle book orientation in the 26.3 extracted renderer. */ +public final class BookReceptacleRenderer implements BlockEntityRenderer { + private static final Identifier LINKING = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/linking_book.png"); + private static final Identifier DESCRIPTIVE = Identifier.fromNamespaceAndPath("rivencraft", "textures/entity/descriptive_book.png"); + private final BookModel model; + public BookReceptacleRenderer(BlockEntityRendererProvider.Context context) { + model = new BookModel(context.bakeLayer(ModelLayers.BOOK)); + } + public static final class State extends BlockEntityRenderState { + boolean present, descriptive; + Direction facing = Direction.NORTH; + } + @Override public State createRenderState() { return new State(); } + @Override public void extractRenderState(BookReceptacleBlockEntity holder, State state, float partialTick, + Vec3 camera, ModelFeatureRenderer.CrumblingOverlay overlay) { + BlockEntityRenderer.super.extractRenderState(holder, state, partialTick, camera, overlay); + state.present = !holder.getBook().isEmpty() && holder.getBlockState().getValue(BookReceptacleBlock.HAS_BOOK); + state.descriptive = holder.getBook().is(Rivencraft.DESCRIPTIVE_BOOK.get()); + state.facing = holder.getBlockState().getValue(BookReceptacleBlock.FACING); + } + @Override public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { + if (!state.present) return; + pose.pushPose(); + pose.translate(0.5F, 0.5F, 0.5F); + switch (state.facing) { + case UP -> { + pose.rotateDegrees(Axis.ZP, 180); + pose.rotateDegrees(Axis.XP, -90); + pose.rotateDegrees(Axis.YP, 90); + pose.rotateDegrees(Axis.ZP, 180); + } + case DOWN -> { + pose.rotateDegrees(Axis.XP, -90); + pose.rotateDegrees(Axis.YP, 90); + pose.rotateDegrees(Axis.ZP, 180); + } + case NORTH -> pose.rotateDegrees(Axis.YP, -90); + case SOUTH -> pose.rotateDegrees(Axis.YP, 90); + case EAST -> pose.rotateDegrees(Axis.YP, 180); + case WEST -> { } + } + pose.scale(0.8F, 0.8F, 0.8F); + collector.submitModel(model, BookModel.State.forAnimation(0, 0.1F, 0.1F, 0.005F), pose, + state.descriptive ? DESCRIPTIVE : LINKING, state.lightCoords, OverlayTexture.NO_OVERLAY, 0); + pose.popPose(); + } +} 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 a28c3de..6071b54 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 @@ -21,6 +21,22 @@ public final class PortBookBinderClientEvents { event.registerEntityRenderer(Rivencraft.DROPPED_BOOK.get(), DroppedBookRenderer::new); event.registerBlockEntityRenderer(Rivencraft.WRITING_DESK_BLOCK_ENTITY.get(), WritingDeskRenderer::new); event.registerBlockEntityRenderer(Rivencraft.BOOK_STAND_BLOCK_ENTITY.get(), BookStandRenderer::new); + event.registerBlockEntityRenderer(Rivencraft.BOOK_RECEPTACLE_BLOCK_ENTITY.get(), BookReceptacleRenderer::new); + } + + @SubscribeEvent + public static void portalTint(net.neoforged.neoforge.client.event.RegisterColorHandlersEvent.BlockTintSources event) { + event.register(java.util.List.of(new net.minecraft.client.color.block.BlockTintSource() { + @Override public int color(net.minecraft.world.level.block.state.BlockState state) { return 0xffffffff; } + @Override public int colorInWorld(net.minecraft.world.level.block.state.BlockState state, + net.minecraft.client.renderer.block.BlockAndTintGetter level, net.minecraft.core.BlockPos pos) { + if (level.getBlockEntity(pos) instanceof net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity portal + && portal.receptacle() != null + && level.getBlockEntity(portal.receptacle()) instanceof net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity holder) + return 0xff000000 | holder.skyColor(); + return 0xffffffff; + } + }), Rivencraft.CRYSTAL_PORTAL.get()); } @SubscribeEvent diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/InkVialItem.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/InkVialItem.java index a5a9e78..2c53370 100644 --- a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/InkVialItem.java +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/InkVialItem.java @@ -3,13 +3,13 @@ package net.dnorberg.rivencraft.item; import java.util.function.Consumer; import net.dnorberg.rivencraft.Rivencraft; import net.minecraft.network.chat.Component; -import net.minecraft.util.Prediction; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; +import net.dnorberg.rivencraft.writing.WritingInkService; import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.level.Level; @@ -17,8 +17,6 @@ import net.minecraft.world.level.Level; /** A stackable 16-use vial; using it with paper in the other hand prepares a Link Panel. */ public final class InkVialItem extends Item { public static final int CAPACITY = 16; - // Match the 1.21.1 default; port the configurable cost with the full configuration system. - public static final int LINK_PANEL_COST = 2; public InkVialItem(Properties properties) { super(properties); @@ -30,37 +28,16 @@ public final class InkVialItem extends Item { @Override public InteractionResult use(Level level, Player player, InteractionHand hand) { - InteractionHand paperHand = hand == InteractionHand.MAIN_HAND - ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; - ItemStack vial = player.getItemInHand(hand); - ItemStack paper = player.getItemInHand(paperHand); - int ink = remaining(vial); - if (!vial.is(Rivencraft.INK_VIAL.get()) || !paper.is(Items.PAPER) - || ink < LINK_PANEL_COST || ink > CAPACITY) { - return InteractionResult.PASS; + if (level.isClientSide()) { + // LOCAL gameplay config is not synchronized. Predict only item eligibility; + // the server decides affordability using its own configured cost. + var paperHand = hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; + int ink = remaining(player.getItemInHand(hand)); + return player.getItemInHand(hand).is(Rivencraft.INK_VIAL.get()) + && player.getItemInHand(paperHand).is(Items.PAPER) && ink > 0 && ink <= CAPACITY + ? InteractionResult.SUCCESS : InteractionResult.PASS; } - if (level.isClientSide()) return InteractionResult.SUCCESS; - - ItemStack panel = PageItem.createLinkPanelPage(); - int newInk = ink - LINK_PANEL_COST; - ItemStack spentVial = newInk == 0 - ? new ItemStack(Items.GLASS_BOTTLE) - : new ItemStack(Rivencraft.INK_VIAL.get()); - if (newInk > 0) spentVial.set(Rivencraft.INK_REMAINING.get(), newInk); - - if (paper.getCount() == 1) { - player.setItemInHand(paperHand, panel); - } else { - paper.shrink(1); - giveOrDrop(player, panel); - } - if (vial.getCount() == 1) { - player.setItemInHand(hand, spentVial); - } else { - vial.shrink(1); - giveOrDrop(player, spentVial); - } - return InteractionResult.SUCCESS; + return WritingInkService.prepareLinkPanel(player, hand) ? InteractionResult.SUCCESS : InteractionResult.PASS; } @Override @@ -70,7 +47,4 @@ public final class InkVialItem extends Item { tooltip.accept(Component.translatable("item.rivencraft.ink_vial.usage")); } - private static void giveOrDrop(Player player, ItemStack stack) { - if (!player.getInventory().add(stack)) player.drop(stack, false, Prediction.SERVER_ONLY); - } } diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkDisarmService.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkDisarmService.java new file mode 100644 index 0000000..f1851ad --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/item/LinkDisarmService.java @@ -0,0 +1,64 @@ +package net.dnorberg.rivencraft.item; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.Container; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.animal.equine.AbstractHorse; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */ +public final class LinkDisarmService { + private LinkDisarmService() {} + + public static boolean mayTravel(Entity entity) { + // Legacy refuses loose items and dropped Link Books on Disarm links. + return !(entity instanceof ItemEntity); + } + + public static void disarmAt(Entity entity, ServerLevel source, Vec3 departure) { + if (entity instanceof ServerPlayer player) { + eject(player.getInventory(), source, departure); + player.containerMenu.broadcastChanges(); + } + if (entity instanceof Container container) eject(container, source, departure); + if (entity instanceof AbstractHorse horse) eject(horse.getInventory(), source, departure); + if (entity instanceof Mob mob) dropEquipment(mob, source, departure); + } + + private static void eject(Container inventory, ServerLevel source, Vec3 departure) { + for (int slot = 0; slot < inventory.getContainerSize(); slot++) { + ItemStack stack = inventory.getItem(slot); + if (stack.isEmpty()) continue; + inventory.setItem(slot, ItemStack.EMPTY); + drop(stack, source, departure); + } + inventory.setChanged(); + } + + private static void dropEquipment(Mob mob, ServerLevel source, Vec3 departure) { + for (EquipmentSlot slot : EquipmentSlot.values()) { + ItemStack stack = mob.getItemBySlot(slot); + if (stack.isEmpty()) continue; + float chance = mob.getDropChances().byEquipment(slot); + mob.setItemSlot(slot, ItemStack.EMPTY); + if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue; + if (chance <= 1 && stack.isDamageableItem()) { + int minimum = Math.max(stack.getMaxDamage() - 25, 1); + int damage = stack.getMaxDamage() - mob.getRandom().nextInt( + 1 + mob.getRandom().nextInt(minimum)); + stack.setDamageValue(Math.clamp(damage, 1, minimum)); + } + drop(stack, source, departure); + } + } + private static void drop(ItemStack stack, ServerLevel source, Vec3 position) { + var dropped = new ItemEntity(source, position.x, position.y, position.z, stack); + dropped.setDefaultPickUpDelay(); + source.addFreshEntity(dropped); + } +} 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 9e846f3..f7c9a91 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 @@ -18,7 +18,6 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.TooltipFlag; import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.level.Level; -import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.phys.Vec3; /** Handheld Linking Book; destination binding and travel are server-authoritative packets. */ @@ -40,6 +39,10 @@ public final class LinkingBookItem extends Item { } public static String travel(ServerPlayer player, ItemStack book) { + return travel(player, book, false); + } + + public static String travel(ServerPlayer player, ItemStack book, boolean portal) { LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get()); LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); if (destination == null) return "Unbound Linking Book (26.3 port preview)"; @@ -71,14 +74,20 @@ 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()); + if (moved) applySuccessfulLink(player, properties, source, departure, incoming, destination.yaw(), portal); return moved ? null : "Teleportation failed"; } /** Link Panel effects are shared by Linking and Descriptive Books. */ public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties, ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) { - LinkVisualEffects.play(source, departure, player, properties, false); + applySuccessfulLink(player, properties, source, departure, incoming, destinationYaw, false); + } + + public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties, + ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw, + boolean portal) { + LinkVisualEffects.play(source, departure, player, properties, portal); if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) { double speed = Math.hypot(incoming.x, incoming.z); double yaw = Math.toRadians(destinationYaw); @@ -88,17 +97,7 @@ public final class LinkingBookItem extends Item { player.setDeltaMovement(Vec3.ZERO); } if (properties.has(LinkProperties.DISARM)) { - for (int slot = 0; slot < player.getInventory().getContainerSize(); slot++) { - ItemStack carried = player.getInventory().getItem(slot); - if (carried.isEmpty()) continue; - player.getInventory().setItem(slot, ItemStack.EMPTY); - ItemEntity dropped = new ItemEntity(source, departure.x, departure.y, departure.z, - carried.copy()); - dropped.setDefaultPickUpDelay(); - source.addFreshEntity(dropped); - } - player.getInventory().setChanged(); - player.containerMenu.broadcastChanges(); + LinkDisarmService.disarmAt(player, source, departure); } } 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 24d0c40..4b5814b 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 @@ -160,7 +160,7 @@ public final class PortBookNetworking { environment.weather(), environment.lighting(), environment.sky(), net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed), net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed, - net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()))); + net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages); net.dnorberg.rivencraft.age.PortAcceleratedTicks.configure(level, net.dnorberg.rivencraft.age.PortAcceleratedTicks.pageCount(pages)); RuntimeAgeBackend.setAgeDisplayName(server, id, title); @@ -199,6 +199,10 @@ public final class PortBookNetworking { } private static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand) { + travelDescriptive(player, stack, heldHand, false); + } + + public static void travelDescriptive(ServerPlayer player, ItemStack stack, InteractionHand heldHand, boolean portal) { Identifier id = stack.get(Rivencraft.AGE_BOOK_ID.get()); if (id == null) return; ServerLevel source = player.level(); @@ -209,7 +213,7 @@ public final class PortBookNetworking { String result = PortAgeTravel.enter(player, id); if (result == null) LinkingBookItem.applySuccessfulLink(player, stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE), - source, departure, incoming, player.getYRot()); + source, departure, incoming, player.getYRot(), portal); return result; }; String failure = heldHand == null ? travel.get() diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java new file mode 100644 index 0000000..e0264ff --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalNetwork.java @@ -0,0 +1,352 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.CrystalPortalBlock; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.util.BlockSnapshot; +import net.neoforged.neoforge.event.level.BlockEvent; + +/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */ +public final class CrystalPortalNetwork { + public static final int MAX_RADIUS = 16; + private static final int CLEANUP_RADIUS = MAX_RADIUS + 1; + private static final int MAX_CRYSTALS = 2048; + private static final int MAX_PORTALS = 1024; + private static final int[][] EXPANSION = { + {1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}, + {1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0}, + {1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1}, + {0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1} + }; + + private CrystalPortalNetwork() {} + + public record Plan(List portalCells, boolean waitingForChunks) { + public Plan(List portalCells) { this(portalCells, false); } + public boolean valid() { return !portalCells.isEmpty(); } + } + + public enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS } + + public static Plan plan(ServerLevel level, BlockPos receptacle) { + return plan(level, receptacle, false); + } + + private static Plan plan(ServerLevel level, BlockPos receptacle, boolean allowUnownedCells) { + BlockState holder = level.getBlockState(receptacle); + if (!holder.is(Rivencraft.BOOK_RECEPTACLE.get())) return new Plan(List.of()); + BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite()); + if (!level.hasChunkAt(base)) return new Plan(List.of(), true); + if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return new Plan(List.of()); + + Set crystals = new HashSet<>(); + ArrayDeque pendingCrystals = new ArrayDeque<>(); + pendingCrystals.add(base); + while (!pendingCrystals.isEmpty()) { + BlockPos pos = pendingCrystals.removeFirst(); + if (!within(base, pos) || !level.isInWorldBounds(pos)) continue; + if (!level.hasChunkAt(pos)) return new Plan(List.of(), true); + if (!level.getBlockState(pos).is(Rivencraft.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue; + if (atBoundary(base, pos)) return new Plan(List.of()); + if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of()); + for (Direction direction : Direction.values()) pendingCrystals.add(pos.relative(direction)); + } + + LinkedHashSet portals = new LinkedHashSet<>(); + ArrayDeque candidates = new ArrayDeque<>(); + Set queued = new HashSet<>(); + for (BlockPos crystal : crystals) addCandidates(crystal, base, queued, candidates); + while (!candidates.isEmpty()) { + BlockPos pos = candidates.removeFirst(); + queued.remove(pos); + if (!level.isInWorldBounds(pos)) return new Plan(List.of()); + if (!level.hasChunkAt(pos)) return new Plan(List.of(), true); + if (portals.contains(pos) || !mayFill(level, pos, receptacle, allowUnownedCells)) continue; + if (adjacentCount(pos, crystals, portals) < 2) continue; + portals.add(pos); + if (portals.size() > MAX_PORTALS) return new Plan(List.of()); + addCandidates(pos, base, queued, candidates); + } + + // Legacy's second pass deletes every cell without opposing neighbors on + // at least two axes. Repeat because deleting one cell can unbrace another. + boolean changed; + do { + changed = portals.removeIf(pos -> !hasTension(pos, crystals, portals)); + } while (changed); + if (portals.isEmpty()) return new Plan(List.of()); + + // Every surviving cell must still be reachable from the receptacle's + // crystal base through this one network, never through another portal. + Set reachable = new HashSet<>(); + ArrayDeque frontier = new ArrayDeque<>(); + frontier.add(base); + while (!frontier.isEmpty()) { + BlockPos pos = frontier.removeFirst(); + if (!reachable.add(pos)) continue; + for (Direction direction : Direction.values()) { + BlockPos next = pos.relative(direction); + if (!reachable.contains(next) && (crystals.contains(next) || portals.contains(next))) + frontier.add(next); + } + } + if (!reachable.containsAll(portals)) return new Plan(List.of()); + return new Plan(List.copyOf(portals)); + } + + public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) { + return activate(level, receptacle, plan, null); + } + + /** Repairs only existing, framed cells whose owner pointer was lost by an older live-view update. */ + public static boolean recoverSavedOwners(ServerLevel level, BlockPos receptacle) { + if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder) + || holder.getBook().isEmpty()) return false; + Plan plan = plan(level, receptacle, true); + if (!plan.valid()) return false; + for (BlockPos pos : plan.portalCells()) { + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || portal.receptacle() != null && !receptacle.equals(portal.receptacle())) return false; + } + for (BlockPos pos : plan.portalCells()) { + CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos); + if (portal.receptacle() == null) portal.setReceptacle(receptacle); + } + return true; + } + + /** The player who inserted the book owns each newly placed portal cell for claim checks. */ + public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan, Entity actor) { + if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder) + || holder.getBook().isEmpty()) return false; + List placed = new ArrayList<>(); + for (BlockPos pos : plan.portalCells()) { + if (!mayFill(level, pos, receptacle)) { + rollback(level, placed); + return false; + } + if (level.getBlockState(pos).isAir()) { + BlockSnapshot snapshot = actor == null ? null : BlockSnapshot.create(level.dimension(), level, pos); + BlockState prior = level.getBlockState(pos); + // Defer neighbor notifications until the entire opening exists. + // Otherwise the first cells see an incomplete brace and the + // portal's neighbor validator removes them during activation. + if (!level.setBlock(pos, Rivencraft.CRYSTAL_PORTAL.get().defaultBlockState(), 2)) { + rollback(level, placed); + return false; + } + if (snapshot != null) { + var event = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor); + NeoForge.EVENT_BUS.post(event); + if (event.isCanceled()) { + snapshot.restore(); + rollback(level, placed); + return false; + } + } + placed.add(pos); + } + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) { + portal.setReceptacle(receptacle); + } else { + rollback(level, placed); + return false; + } + } + orientSurfaces(level, plan); + for (BlockPos pos : plan.portalCells()) { + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || !receptacle.equals(portal.receptacle())) { + rollback(level, placed); + return false; + } + } + return true; + } + + public static void shutdown(ServerLevel level, BlockPos receptacle) { + // A broken support can disconnect the graph. Scan a bounded volume rather + // than trusting a path through blocks that no longer exist. + for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS), + receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) { + if (!level.hasChunkAt(pos)) continue; + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && receptacle.equals(portal.receptacle())) + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + } + + /** One bounded repair pass per receptacle, rather than a full flood per portal cell. */ + public static boolean reconcile(ServerLevel level, BlockPos receptacle) { + return reconcile(level, receptacle, plan(level, receptacle)); + } + + public static boolean reconcile(ServerLevel level, BlockPos receptacle, Plan plan) { + if (!plan.valid()) { + // Neighboring chunks can still be loading when the receptacle first + // ticks after a restart. An incomplete view is not a broken frame. + if (!plan.waitingForChunks()) shutdown(level, receptacle); + return false; + } + Set wanted = new HashSet<>(plan.portalCells()); + // A background pass must not create blocks without the owner present: + // that would bypass the placement/protection event used at activation. + for (BlockPos pos : wanted) { + if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) + || !receptacle.equals(portal.receptacle())) { + shutdown(level, receptacle); + return false; + } + } + for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS), + receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) { + if (!level.hasChunkAt(pos)) continue; + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && receptacle.equals(portal.receptacle()) && !wanted.contains(pos)) + level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + orientSurfaces(level, plan); + return true; + } + + private static void orientSurfaces(ServerLevel level, Plan plan) { + for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) { + for (BlockPos pos : surface.cells()) { + BlockState state = level.getBlockState(pos); + Direction.Axis axis = surface.normal().getAxis(); + if (state.getValue(CrystalPortalBlock.AXIS) != axis) + updatePortalState(level, pos, state.setValue(CrystalPortalBlock.AXIS, axis)); + } + } + } + + /** The vanilla-style surface remains visible until its optional live view is ready. */ + public static void setLiveView(ServerLevel level, Plan plan, boolean live) { + for (BlockPos pos : plan.portalCells()) { + BlockState state = level.getBlockState(pos); + if (state.is(Rivencraft.CRYSTAL_PORTAL.get()) && state.getValue(CrystalPortalBlock.LIVE_VIEW) != live) + updatePortalState(level, pos, state.setValue(CrystalPortalBlock.LIVE_VIEW, live)); + } + } + + private static void updatePortalState(ServerLevel level, BlockPos pos, BlockState replacement) { + BlockPos owner = level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + ? portal.receptacle() : null; + if (!level.setBlock(pos, replacement, 2) || owner == null) return; + if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && !owner.equals(portal.receptacle())) portal.setReceptacle(owner); + } + + public static CellStatus cellStatus(ServerLevel level, BlockPos pos, BlockPos receptacle) { + if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS + || Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS + || Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID; + if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS; + BlockState state = level.getBlockState(receptacle); + if (!state.is(Rivencraft.BOOK_RECEPTACLE.get())) return CellStatus.INVALID; + BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite()); + if (!level.hasChunkAt(base)) return CellStatus.WAITING_FOR_CHUNKS; + if (!level.getBlockState(base).is(Rivencraft.CRYSTAL_BLOCK.get())) return CellStatus.INVALID; + if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)) + return CellStatus.WAITING_FOR_CHUNKS; + if (holder.getBook().isEmpty()) return CellStatus.INVALID; + // A neighboring chunk may hold one of the braces. Do not tear down a + // persisted portal until all six neighbors are available to inspect. + for (Direction direction : Direction.values()) + if (!level.hasChunkAt(pos.relative(direction))) return CellStatus.WAITING_FOR_CHUNKS; + for (Direction.Axis axis : Direction.Axis.values()) { + // Quick tension check before the bounded network/path revalidation. + if (!axisBraced(level, pos, axis)) continue; + int other = 0; + for (Direction.Axis alternative : Direction.Axis.values()) + if (alternative != axis && axisBraced(level, pos, alternative)) other++; + if (other > 0) return CellStatus.VALID; + } + return CellStatus.INVALID; + } + + private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) { + Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis); + Direction negative = positive.getOpposite(); + BlockPos a = pos.relative(positive); + BlockPos b = pos.relative(negative); + return level.hasChunkAt(a) && level.hasChunkAt(b) + && isFrameOrPortal(level.getBlockState(a)) && isFrameOrPortal(level.getBlockState(b)); + } + + private static boolean isFrameOrPortal(BlockState state) { + return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get()); + } + + private static void rollback(ServerLevel level, List placed) { + for (BlockPos pos : placed) level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3); + } + + private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) { + return mayFill(level, pos, receptacle, false); + } + + private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle, boolean allowUnowned) { + if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false; + BlockState state = level.getBlockState(pos); + return state.isAir() || state.is(Rivencraft.CRYSTAL_PORTAL.get()) + && level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal + && (receptacle.equals(portal.receptacle()) || allowUnowned && portal.receptacle() == null); + } + + private static int adjacentCount(BlockPos pos, Set crystals, Set portals) { + int count = 0; + for (Direction direction : Direction.values()) + if (crystals.contains(pos.relative(direction)) || portals.contains(pos.relative(direction))) count++; + return count; + } + + private static boolean hasTension(BlockPos pos, Set crystals, Set portals) { + int axes = 0; + for (Direction.Axis axis : Direction.Axis.values()) { + Direction positive = Direction.get(Direction.AxisDirection.POSITIVE, axis); + if (member(pos.relative(positive), crystals, portals) + && member(pos.relative(positive.getOpposite()), crystals, portals)) axes++; + } + return axes >= 2; + } + + private static boolean member(BlockPos pos, Set crystals, Set portals) { + return crystals.contains(pos) || portals.contains(pos); + } + + private static void addCandidates(BlockPos around, BlockPos base, Set queued, + ArrayDeque pending) { + for (int[] offset : EXPANSION) { + BlockPos candidate = around.offset(offset[0], offset[1], offset[2]); + if (within(base, candidate) && queued.add(candidate)) pending.add(candidate); + } + } + + private static boolean within(BlockPos center, BlockPos pos) { + return Math.abs(center.getX() - pos.getX()) <= MAX_RADIUS + && Math.abs(center.getY() - pos.getY()) <= MAX_RADIUS + && Math.abs(center.getZ() - pos.getZ()) <= MAX_RADIUS; + } + + private static boolean atBoundary(BlockPos center, BlockPos pos) { + return Math.abs(center.getX() - pos.getX()) == MAX_RADIUS + || Math.abs(center.getY() - pos.getY()) == MAX_RADIUS + || Math.abs(center.getZ() - pos.getZ()) == MAX_RADIUS; + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java new file mode 100644 index 0000000..61b8f36 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/CrystalPortalSurfaces.java @@ -0,0 +1,59 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.phys.AABB; + +/** Groups portal cells into coherent, coplanar views while retaining irregular outlines. */ +public final class CrystalPortalSurfaces { + private CrystalPortalSurfaces() {} + + public record Surface(Direction normal, AABB bounds, List cells) {} + private record Plane(Direction normal, int coordinate) {} + + public static List group(ServerLevel level, CrystalPortalNetwork.Plan plan) { + Map> planes = new LinkedHashMap<>(); + for (BlockPos cell : plan.portalCells()) { + Direction normal = planeNormal(level, cell); + if (normal == null) continue; + int coordinate = normal.getAxis().choose(cell.getX(), cell.getY(), cell.getZ()); + planes.computeIfAbsent(new Plane(normal, coordinate), ignored -> new ArrayList<>()).add(cell); + } + List result = new ArrayList<>(); + for (Map.Entry> entry : planes.entrySet()) { + List cells = entry.getValue(); + int minX = cells.stream().mapToInt(BlockPos::getX).min().orElseThrow(); + int minY = cells.stream().mapToInt(BlockPos::getY).min().orElseThrow(); + int minZ = cells.stream().mapToInt(BlockPos::getZ).min().orElseThrow(); + int maxX = cells.stream().mapToInt(BlockPos::getX).max().orElseThrow() + 1; + int maxY = cells.stream().mapToInt(BlockPos::getY).max().orElseThrow() + 1; + int maxZ = cells.stream().mapToInt(BlockPos::getZ).max().orElseThrow() + 1; + result.add(new Surface(entry.getKey().normal(), new AABB(minX, minY, minZ, maxX, maxY, maxZ), + List.copyOf(cells))); + } + return List.copyOf(result); + } + + private static Direction planeNormal(ServerLevel level, BlockPos cell) { + if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.EAST)) return Direction.NORTH; + if (braced(level, cell, Direction.UP) && braced(level, cell, Direction.SOUTH)) return Direction.EAST; + if (braced(level, cell, Direction.EAST) && braced(level, cell, Direction.SOUTH)) return Direction.UP; + return null; + } + + private static boolean braced(ServerLevel level, BlockPos cell, Direction direction) { + return linked(level, cell.relative(direction)) && linked(level, cell.relative(direction.getOpposite())); + } + + private static boolean linked(ServerLevel level, BlockPos pos) { + if (!level.hasChunkAt(pos)) return false; + var state = level.getBlockState(pos); + return state.is(Rivencraft.CRYSTAL_BLOCK.get()) || state.is(Rivencraft.CRYSTAL_PORTAL.get()); + } +} 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 new file mode 100644 index 0000000..fc259b6 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalBookTravel.java @@ -0,0 +1,80 @@ +package net.dnorberg.rivencraft.portal; + +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.age.PortAgeTravel; +import net.dnorberg.rivencraft.item.LinkBookTarget; +import net.dnorberg.rivencraft.item.LinkProperties; +import net.dnorberg.rivencraft.item.LinkDisarmService; +import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.item.LinkVisualEffects; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.phys.Vec3; + +/** Portal transport uses the book's arrival rules; no independent portal exit offset. */ +public final class PortalBookTravel { + private PortalBookTravel() {} + public static boolean available(ServerLevel source, ItemStack book) { + var id = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()) ? book.get(Rivencraft.AGE_BOOK_ID.get()) + : book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null; + return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null; + } + public static void travel(Entity entity, ServerLevel source, ItemStack book) { + if (entity instanceof ServerPlayer player) { + if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) { + net.dnorberg.rivencraft.network.PortBookNetworking.travelDescriptive(player, book, null, true); + } else { + String failure = LinkingBookItem.travel(player, book, true); + if (failure != null) player.sendOverlayMessage(net.minecraft.network.chat.Component.literal(failure)); + else net.dnorberg.rivencraft.advancement.ProgressionAdvancements.awardAgeTravel(player); + } + return; + } + LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE); + if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return; + boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get()); + LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get()); + if (descriptive) { + 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); + 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; + 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; + Vec3 arrival = new Vec3(destination.x(), destination.y(), destination.z()); + target.getChunkAt(feet); + if (!target.noCollision(entity, entity.getBoundingBox().move(arrival.subtract(entity.position())))) return; + Vec3 incoming = entity.getDeltaMovement(); + Vec3 velocity = Vec3.ZERO; + if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) { + double speed = Math.hypot(incoming.x, incoming.z), yaw = Math.toRadians(destination.yaw()); + velocity = new Vec3(speed * Math.cos(yaw), incoming.y + 0.2, speed * Math.sin(yaw)); + } + Vec3 departure = entity.position(); + // Disarm only after a successful transfer, leaving equipment in the source world. + Entity arrived = entity.teleport(new TeleportTransition(target, arrival, velocity, destination.yaw(), + destination.pitch(), TeleportTransition.PLACE_PORTAL_TICKET)); + if (arrived == null) return; + if (properties.has(LinkProperties.DISARM)) { + LinkDisarmService.disarmAt(arrived, source, departure); + } + arrived.resetFallDistance(); + PortalTravelCooldown.mark(arrived, source.getServer().getTickCount()); + var sound = LinkVisualEffects.sound(properties, true); + source.playSound(null, departure.x, departure.y, departure.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1); + target.playSound(null, arrival.x, arrival.y, arrival.z, sound, net.minecraft.sounds.SoundSource.BLOCKS, 0.8F, 1); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java new file mode 100644 index 0000000..f4782bf --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelCooldown.java @@ -0,0 +1,33 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.Map; +import java.util.WeakHashMap; +import net.minecraft.world.entity.Entity; + +/** Session-only portal cooldowns: tick counters must never be saved with players. */ +public final class PortalTravelCooldown { + public static final long TICKS = 40; + private static final Map LAST_USE = new WeakHashMap<>(); + + private PortalTravelCooldown() {} + + public static boolean blocked(long now, long previous) { + return previous >= 0 && now >= previous && now - previous < TICKS; + } + + public static boolean blocked(Entity entity, long now) { + return blocked(now, LAST_USE.getOrDefault(entity, -1L)); + } + + public static void mark(Entity entity, long now) { + LAST_USE.put(entity, now); + } + + public static void forget(Entity entity) { + LAST_USE.remove(entity); + } + + public static void clear() { + LAST_USE.clear(); + } +} diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java new file mode 100644 index 0000000..4c743f8 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java @@ -0,0 +1,54 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.Map; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import net.neoforged.neoforge.event.server.ServerStoppedEvent; + +/** Collision callbacks enqueue once; transport occurs after the entering movement packet finishes. */ +@EventBusSubscriber(modid = Rivencraft.MOD_ID) +public final class PortalTravelQueue { + private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long tick) {} + private static final Map PENDING = new IdentityHashMap<>(); + private PortalTravelQueue() {} + public static void enqueue(Entity entity, ServerLevel source, BlockPos portal, BlockPos receptacle, ItemStack book, long now) { + PENDING.putIfAbsent(entity, new Pending(source, portal.immutable(), receptacle.immutable(), book.copyWithCount(1), now)); + } + @SubscribeEvent public static void tick(ServerTickEvent.Post event) { + var server = event.getServer(); + var ready = new ArrayList>(); + var iterator = PENDING.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + if (server.getTickCount() <= entry.getValue().tick()) continue; + ready.add(Map.entry(entry.getKey(), entry.getValue())); + iterator.remove(); + } + for (var entry : ready) { + Entity entity = entry.getKey(); + Pending pending = entry.getValue(); + if (entity.isRemoved() || !entity.isAlive() || entity.level() != pending.source() + || entity instanceof ServerPlayer player && server.getPlayerList().getPlayer(player.getUUID()) != player + || !pending.source().hasChunkAt(pending.portal()) || !pending.source().hasChunkAt(pending.receptacle()) + || !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal) + || !pending.receptacle().equals(portal.receptacle()) + || !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder) + || !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book()) + || CrystalPortalNetwork.cellStatus(pending.source(), pending.portal(), pending.receptacle()) + != CrystalPortalNetwork.CellStatus.VALID) continue; + PortalBookTravel.travel(entity, pending.source(), pending.book()); + } + } + @SubscribeEvent public static void stopped(ServerStoppedEvent event) { PENDING.clear(); PortalTravelCooldown.clear(); } +} 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 4730094..81dfe70 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 @@ -23,6 +23,7 @@ public final class ModCreativeTabs { output.accept(Rivencraft.DESCRIPTIVE_BOOK.get()); output.accept(Rivencraft.LINKING_BOOK.get()); output.accept(Rivencraft.CRYSTAL_BLOCK_ITEM.get()); + output.accept(Rivencraft.BOOK_RECEPTACLE_ITEM.get()); output.accept(Rivencraft.BUDDING_CRYSTAL_ITEM.get()); output.accept(Rivencraft.SMALL_CRYSTAL_BUD_ITEM.get()); output.accept(Rivencraft.MEDIUM_CRYSTAL_BUD_ITEM.get()); 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 f17d230..9cda878 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 @@ -57,7 +57,8 @@ public final class PortAuthoredWriting { if (entry == null) return false; if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) return true; - if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) return true; + if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE) + || net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) return true; if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null || PortLighting.forPage(page) != null || PortSkyStyle.forPage(page) != null) return true; if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true; @@ -129,6 +130,8 @@ public final class PortAuthoredWriting { if (net.dnorberg.rivencraft.age.PortAgeColors.isModifier(page) || net.dnorberg.rivencraft.age.PortDayCycle.supports(page)) { // Shared colors target Sky, Ambient Light Color or Normal Time; trailing colors tint the sky. + } else if (net.dnorberg.rivencraft.age.PortInstabilityData.isCorrupted(page)) { + // Legacy corrupted pages contribute instability, not terrain features. } else if (page.equals(net.dnorberg.rivencraft.age.PortAcceleratedTicks.PAGE)) { // Every Accelerated page adds random-tick attempts; it is not a controller. } else if (isTerrain(page) && !terrain) { diff --git a/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingInkService.java b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingInkService.java new file mode 100644 index 0000000..9edb238 --- /dev/null +++ b/ports/mc263/src/main/java/net/dnorberg/rivencraft/writing/WritingInkService.java @@ -0,0 +1,77 @@ +package net.dnorberg.rivencraft.writing; + +import net.dnorberg.rivencraft.PortConfig; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.util.Prediction; +import net.dnorberg.rivencraft.item.InkVialItem; +import net.dnorberg.rivencraft.item.PageItem; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; + +/** Server-owned costs and the baseline blank-page-to-link-panel transaction. */ +public final class WritingInkService { + private WritingInkService() { + } + + public static int symbolCopyCost() { + return PortConfig.SYMBOL_INK_COST.get(); + } + + public static int linkPanelCost() { + return PortConfig.LINK_PANEL_INK_COST.get(); + } + + public static boolean canPrepareLinkPanel(Player player, InteractionHand vialHand) { + ItemStack vial = player.getItemInHand(vialHand); + ItemStack page = player.getItemInHand(other(vialHand)); + int remaining = InkVialItem.remaining(vial); + return vial.is(Rivencraft.INK_VIAL.get()) && remaining >= linkPanelCost() + && remaining <= InkVialItem.CAPACITY + && page.is(Items.PAPER); + } + + public static boolean prepareLinkPanel(Player player, InteractionHand vialHand) { + if (player.level().isClientSide() || !canPrepareLinkPanel(player, vialHand)) { + return false; + } + + InteractionHand pageHand = other(vialHand); + ItemStack page = player.getItemInHand(pageHand); + ItemStack vial = player.getItemInHand(vialHand); + int newInk = InkVialItem.remaining(vial) - linkPanelCost(); + ItemStack panel = PageItem.createLinkPanelPage(); + ItemStack spentVial = newInk == 0 + ? new ItemStack(Items.GLASS_BOTTLE) + : new ItemStack(Rivencraft.INK_VIAL.get()); + if (newInk > 0) { + spentVial.set(Rivencraft.INK_REMAINING.get(), newInk); + } + + // All validation and output construction is complete before changing either input. + if (page.getCount() == 1) { + player.setItemInHand(pageHand, panel); + } else { + page.shrink(1); + giveOrDrop(player, panel); + } + if (vial.getCount() == 1) { + player.setItemInHand(vialHand, spentVial); + } else { + vial.shrink(1); + giveOrDrop(player, spentVial); + } + return true; + } + + private static InteractionHand other(InteractionHand hand) { + return hand == InteractionHand.MAIN_HAND ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND; + } + + private static void giveOrDrop(Player player, ItemStack stack) { + if (!player.getInventory().add(stack)) { + player.drop(stack, false, Prediction.SERVER_ONLY); + } + } +} diff --git a/ports/mc263/src/main/resources/assets/rivencraft/items/book_receptacle.json b/ports/mc263/src/main/resources/assets/rivencraft/items/book_receptacle.json new file mode 100644 index 0000000..42066a8 --- /dev/null +++ b/ports/mc263/src/main/resources/assets/rivencraft/items/book_receptacle.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "rivencraft:item/book_receptacle" + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json new file mode 100644 index 0000000..c65ee2f --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json @@ -0,0 +1,34 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_material": { + "trigger": "minecraft:inventory_changed", + "conditions": { + "items": [ + { + "items": "rivencraft:crystal_block" + } + ] + } + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": { + "recipes": [ + "rivencraft:book_receptacle" + ] + } + } + }, + "requirements": [ + [ + "has_material", + "has_the_recipe" + ] + ], + "rewards": { + "recipes": [ + "rivencraft:book_receptacle" + ] + } +} diff --git a/ports/mc263/src/main/resources/data/rivencraft/recipe/book_receptacle.json b/ports/mc263/src/main/resources/data/rivencraft/recipe/book_receptacle.json new file mode 100644 index 0000000..7a49b1c --- /dev/null +++ b/ports/mc263/src/main/resources/data/rivencraft/recipe/book_receptacle.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": [ + "CCC", + "C C", + "CCC" + ], + "key": { + "C": "rivencraft:crystal_block" + }, + "result": { + "id": "rivencraft:book_receptacle", + "count": 1 + } +}