From 49a256bc13bfb4e272c859f109462fd0c5a227f9 Mon Sep 17 00:00:00 2001 From: xefensor Date: Wed, 19 Aug 2026 22:17:30 +0200 Subject: [PATCH] Add shared village storage and ambient sightings --- ASSET_CREDITS.md | 1 + CHANGELOG.md | 2 + ROADMAP.md | 2 +- docs/internal/design_implementation_status.md | 6 +- docs/internal/mob_tps_benchmark.md | 13 + docs/internal/retold_mob_ai_system.md | 19 +- docs/internal/retold_mod_system.md | 21 +- docs/internal/retold_roadmap.md | 21 +- docs/internal/testing_strategy.md | 8 + .../retold/ambient/RetoldHorizonData.java | 83 +++ .../retold/ambient/RetoldHorizonEvents.java | 92 +++ .../retold/ambient/RetoldHorizonSchedule.java | 128 +++++ .../retold/client/RetoldClientEvents.java | 3 + .../render/RetoldHorizonAmbientClient.java | 461 +++++++++++++++ .../RetoldGeneratedHorizonTexture.java | 105 ++++ ...ockEntityVillageStorageKnowledgeMixin.java | 22 + .../retold/module/RetoldAtmosphereModule.java | 13 + .../retold/module/RetoldSubsystems.java | 1 + .../network/RetoldHorizonCuePayload.java | 31 ++ .../retold/network/RetoldNetworking.java | 7 + .../RetoldVillageContainerOwnership.java | 46 +- .../RetoldVillageStorageKnowledge.java | 525 ++++++++++++++++++ .../RetoldVillagerCommunalFoodGameTests.java | 164 ++++++ .../RetoldVillagerCommunalFoodSearch.java | 281 +++++++++- src/main/resources/retold.mixins.json | 1 + .../ambient/RetoldHorizonScheduleTest.java | 90 +++ 26 files changed, 2109 insertions(+), 37 deletions(-) create mode 100644 src/main/java/cz/xefensor/retold/ambient/RetoldHorizonData.java create mode 100644 src/main/java/cz/xefensor/retold/ambient/RetoldHorizonEvents.java create mode 100644 src/main/java/cz/xefensor/retold/ambient/RetoldHorizonSchedule.java create mode 100644 src/main/java/cz/xefensor/retold/client/render/RetoldHorizonAmbientClient.java create mode 100644 src/main/java/cz/xefensor/retold/client/texture/RetoldGeneratedHorizonTexture.java create mode 100644 src/main/java/cz/xefensor/retold/mixin/BlockEntityVillageStorageKnowledgeMixin.java create mode 100644 src/main/java/cz/xefensor/retold/module/RetoldAtmosphereModule.java create mode 100644 src/main/java/cz/xefensor/retold/network/RetoldHorizonCuePayload.java create mode 100644 src/main/java/cz/xefensor/retold/villager/RetoldVillageStorageKnowledge.java create mode 100644 src/test/java/cz/xefensor/retold/ambient/RetoldHorizonScheduleTest.java diff --git a/ASSET_CREDITS.md b/ASSET_CREDITS.md index 5b816572..1df90687 100644 --- a/ASSET_CREDITS.md +++ b/ASSET_CREDITS.md @@ -8,6 +8,7 @@ This record complements [`LICENSE`](LICENSE) and [`LICENSE-ASSETS.md`](LICENSE-A | --- | --- | --- | | Jesse Schramm | Extinguished-torch textures | Included | | Xefensor | Gameplay screenshots dated 2026-07-18 | Repository documentation | +| Xefensor | Runtime-generated horizon-figure skin encoded from a developer-supplied and approved visual source | Included; 2026-08-19 | | Xefensor, with drawing instruction and review from Codex | Hand-drawn Aender Sand texture created in Krita | Original replacement included; 2026-08-14 | | OpenAI image generation, directed and processed by Codex | Aender wood-family placeholder textures: planks, stripped log, sapling, door, trapdoor, signs, boats, and derived model atlases | AI-generated placeholder; 2026-07-22 | | OpenAI image generation, directed and processed by Codex | Aender Chronolith block and Aender Eye/Gale Core spawn-egg placeholder textures | AI-generated placeholder; 2026-07-22 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ca35db..39d8b3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Each release should be readable in two passes: ### Player-Facing +- Villagers now share persistent knowledge of food, emeralds, and every other item they have observed in accessible village chests and barrels. Once a store is known, other villagers can go directly to the right container instead of repeating a nearby block search; player, hopper, and Villager inventory changes refresh active-village knowledge, while the physical container is still checked before use. - Villagers now keep their body and head aimed at an extinguished torch throughout both magical and Flint-and-Steel relighting. After a successful relight, they continue directly to other eligible nearby torches, handling up to eight per maintenance run before taking a break. Other stationary Retold interactions with a concrete subject now visibly face it as well, including golem construction, communal storage and livestock tending, feeding, desperate barrier breaking, Polar Bear warnings, and Spider web placement. - Healthy Wolves, Foxes, Cats, Ocelots, Dolphins, Spiders, and Cave Spiders now defend themselves after being attacked. This includes tamed Wolves, while tame-owner safety and the rule that badly wounded wild predators flee instead of fighting remain intact. - Stage 1 Zombie and Skeleton families now coordinate only at short range. In Stage 2 their existing awareness and same-family target sharing expand, and a stable minority of nearby Zombie-family and Skeleton-family mobs can answer one another when they hear or see a fight. Stage 2 also adds about 25% of each existing tagged Undead entry's natural-spawn weight without changing vanilla mob caps, placement checks, or pack sizes. This escalation changes population composition and convergence rather than attack stats. @@ -44,6 +45,7 @@ Each release should be readable in two passes: ### Technical +- Added `RetoldVillageStorageKnowledge`, a server-authoritative SavedData index of observed chest/barrel positions, slot capacity, and exact item stacks. Chunk-indexed lookups feed the existing communal food, Farmer deposit, livestock-tending, and golem-emerald search paths before a budgeted world scan. A narrow block-entity change hook refreshes known or village-local storage without polling; every target is loaded, accessible, and physically revalidated, and stale entries are repaired or removed. Focused shared-knowledge, food-route, Farmer-route, livestock, golem-currency, and Villager TPS coverage passes. - Added `RetoldActionFacing` as the shared body/head/look-control contract for stationary Retold actions. Magical torch casts and Villager golem work receive continuous presentation updates only while the Villager is actively using its subject; movement, pursuit, combat, migration, and free-flight behavior retain ordinary look control so body rotation does not fight navigation. Successful torch work now reuses the indexed, budgeted search to start the next eligible nearby torch immediately, capped at eight relights per interruptible run. Focused facing regressions pass for torch relighting, golem construction, feeding, and weak-barrier breaking. - Added `RetoldUndeadStagePressure` to stage-gate Zombie- and Skeleton-family notice/share radii and bounded cross-family assistance. Stage 2 responders use cached scans and sight, stable one-in-three sampling, source-aware `FACTION_ASSIST` targets, and existing combat/movement ownership. `RetoldUndeadSpawnPressure` mirrors already-present entries from the additive `retold:stage_2_undead_spawn_pressure` entity-type tag with a rounded 25% bonus weight while vanilla retains selection, caps, placement, and pack rules. Both exact Stage 1/Stage 2 tests pass, and all forty affected Stage 2 50-mob phases remain below 50 ms/tick; Zombie hunt/targeting was the 6.046 ms/tick peak. - Added a data-driven Soul Sand Valley Wither Skeleton biome spawn with weight 1 and pack size 1. An exact registry-backed GameTest guards the biome boundary and spawn parameters; no repeated AI work or profile registration changed. diff --git a/ROADMAP.md b/ROADMAP.md index bb620c05..e6f8cc61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,7 @@ This concise public roadmap may change as features are designed and tested. Deta ## Now +- Focus the next development pass on Villager and village systems: verify the implemented communal storage, farming, livestock, reputation, Iron Golem construction, torch-maintenance, and trade-stock loops in natural villages, then design the next coherent village-society slice. - Verify and refine the initial Stage 2 cartographer explorer-map path for finding Air Temples in fresh and upgraded worlds. - Continue tuning and testing the Gale Core encounter and Air Element reward. - Verify Aender portals, terrain regeneration, stabilizers, and progression in fresh worlds, upgraded worlds, dedicated servers, and multiplayer. @@ -19,7 +20,6 @@ This concise public roadmap may change as features are designed and tested. Deta ## Later -- Naturally verify the completed village-property reputation loop, including tended livestock, trade-price effects, witnesses, and Iron Golem hostility in multiplayer and existing villages. - Tool, armor, ore, station, enchanting, and combat progression reworks. - Stage 3 piglin or pigman cooperation. - Nether portal environmental effects and broader world-reactivity work. diff --git a/docs/internal/design_implementation_status.md b/docs/internal/design_implementation_status.md index b02e1975..f38d6c04 100644 --- a/docs/internal/design_implementation_status.md +++ b/docs/internal/design_implementation_status.md @@ -34,7 +34,7 @@ Status labels: ## Current Developer Direction Source -Last design clarification: 2026-08-11. +Last design clarification: 2026-08-19. Last documentation consolidation pass: 2026-07-17. Active developer direction lives in [`retold_roadmap.md`](retold_roadmap.md). This status tracker applies that direction to individual original-design rows so implementation status stays visible without duplicating the roadmap. @@ -197,7 +197,7 @@ Largest missing or partial design areas: | Villagers magical pacifists lore | Design only / partial | Villager teaching and golem/illager lore support. No full villager society simulation. | | Villagers refresh trades every day | Implemented / needs in-game verification | `RetoldVillagerTradeRefresh` guarantees one refresh of existing professional-adult Villager offers when the Overworld day advances. It resets stock uses and updates demand without rerolling offers, professions, teaching data, or special map trades; Wandering Traders remain independent. The per-villager day marker persists through saves, and a GameTest covers first observation, same-day exhaustion, next-day refresh, and offer identity. | | Equipment alternatives respect material progression | Implemented / needs natural and existing-world verification | Fixed server datapack rules apply identically in single-player and multiplayer: bonus chests supply a Flint Multi-tool, safe Village smith chests stop at Copper equipment and at most two Iron Ingots, and Armorer/Toolsmith/Weaponsmith equipment unlocks at Apprentice Copper (8–12 Emeralds), Expert Iron (24–32), and Master Diamond (48–64) with rarer enchanted variants. The Wandering Trader retains an enchanted Iron Pickaxe averaging roughly 48 Emeralds. Focused live-registry and sampled-loot coverage passes. Existing saved Villager offers are not rerolled; naturally verify profession leveling, discounts, restocking, interfaces, multiplayer, and new versus existing worlds. | -| Villagers relight village torches | Implemented / needs in-game verification | In every stage, `RetoldVillagerTorchRelighting` lets an adult Villager restore dry weather-extinguished normal, soul, or copper floor/wall torches within eight horizontal/five vertical blocks and within 32 blocks of remembered or live village context. Most professions stop and use a one-second ranged magical cast. Nitwits cannot cast: during eligible idle/play time they path to a supported adjacent cell and visibly use a temporary Flint and Steel which never enters inventory and consumes no item or durability. Both active casts receive lightweight per-tick presentation updates and use `RetoldActionFacing` to keep the Villager's body, head, and look control aimed at the torch throughout the interaction; idle search and travel keep the normal dispatcher cadence. After each success the Villager immediately performs another indexed, work-budgeted search and can relight up to eight eligible nearby torches in one interruptible maintenance run before the normal success cooldown. Both methods preserve wall facing, require `mobGriefing`, and yield to hunger, danger, targets, sleep, trading, incompatible activity, and higher ownership. Discovery reuses `TorchWeatherEvents`' loaded-chunk extinguished index, shared block-search/path budgets, LOD cooldowns, and a bounded physical-route timeout. Four focused GameTests and the extinguished-drop regression pass, including forced continuous alignment and an exact eight-of-nine consecutive batch regression; the latest affected Villager TPS test peaks at 7.259 ms/tick. Natural Nitwit multi-torch routing, rain, visual casting, crowded-village, save/reload, multiplayer, dedicated-server, and existing-world behavior remain unverified. | +| Villagers relight village torches | Implemented / needs in-game verification | In every stage, `RetoldVillagerTorchRelighting` lets an adult Villager restore dry weather-extinguished normal, soul, or copper floor/wall torches within eight horizontal/five vertical blocks and within 32 blocks of remembered or live village context. Most professions stop and use a one-second ranged magical cast. Nitwits cannot cast: during eligible idle/play time they path to a supported adjacent cell and visibly use a temporary Flint and Steel which never enters inventory and consumes no item or durability. Both active casts receive lightweight per-tick presentation updates and use `RetoldActionFacing` to keep the Villager's body, head, and look control aimed at the torch throughout the interaction; idle search and travel keep the normal dispatcher cadence. After each success the Villager immediately performs another indexed, work-budgeted search and can relight up to eight eligible nearby torches in one interruptible maintenance run before the normal success cooldown. Both methods preserve wall facing, require `mobGriefing`, and yield to hunger, danger, targets, sleep, trading, incompatible activity, and higher ownership. Discovery reuses `TorchWeatherEvents`' loaded-chunk extinguished index, shared block-search/path budgets, LOD cooldowns, and a bounded physical-route timeout. Four focused GameTests and the extinguished-drop regression pass, including forced continuous alignment and an exact eight-of-nine consecutive batch regression; the latest Villager TPS test peaks at 7.915 ms/tick after shared storage knowledge was added. Natural Nitwit multi-torch routing, rain, visual casting, crowded-village, save/reload, multiplayer, dedicated-server, and existing-world behavior remain unverified. | | Villages angered by stealing/crops/animals | Implemented / needs broader natural verification | Generated `chests/village/*` loot and future Farmer/Villager-controlled deposits carry exact persisted village-owned quantities. Player deposits remain unowned and leave first. Crops newly planted or replanted by vanilla Farmers carry separately persisted position ownership through growth; player planting clears it. Livestock becomes village-owned only after an assigned Villager profession actually feeds it. Offspring of two owned parents inherit village ownership; automatic offspring of an unowned player-associated parent inherit player protection. Witnessed mature harvest applies minor theft gossip, immature breaking or trampling applies stronger vandalism, and directly killing owned livestock in Survival applies `-50` reputation. Breaking protected storage remains severe enough for immediate vanilla Iron Golem hostility. Creative and Spectator players are ignored, and ambiguous existing-world storage, crops, and animals are not retroactively claimed. `/retold village status` summarizes the executing player's nearby per-Villager vanilla reputation and hostility risk. Four container/status, four crop, and four livestock GameTests cover persistence, provenance, the real vanilla Farmer hook, player protection, offenses, witnesses, Creative exclusion, aggregation, and thresholds. Natural save/reload, complete Farmer cycles, unusual farm/storage layouts, transported animals, multiplayer, dedicated-server, and existing-world checks remain unverified. | ## Structures And Worldgen @@ -275,7 +275,7 @@ Largest missing or partial design areas: | Wither Skeletons spawn rarely in Soul Sand Valleys | Implemented / needs natural verification | A NeoForge biome modifier adds one solitary Wither Skeleton monster entry at the smallest positive weight to `minecraft:soul_sand_valley` only. Their existing `TERRITORY_GUARD` profile still requires a fortress anchor, so valley spawns remain ordinary hostile wanderers rather than valley guards. A registry-backed GameTest verifies the exact biome, weight, and pack size without adding an AI hot path. Natural frequency, terrain placement, fortress populations, datapack composition, and existing-world newly generated chunks remain unverified. | | Golems as magical defenders | Partial | Iron/snow golem defender/guard support exists. Stage 2+ Villagers now visibly construct eligible Iron Golems under vanilla's village eligibility rules, spending one emerald, and player-built Iron Golems cost five experience levels on successful animation. Copper/tuff golem behavior remains incomplete or unavailable. | | Elementals: blaze/breeze/wildfire | Partial | Blaze territory guard; breeze special vanilla; wildfire missing. | -| Villagers pacifist / communal food / golem creators | Partial / communal food, livestock tending, and golem construction implemented | `VILLAGER_COMMUNAL` gives loaded Villagers persisted hunger. Hungry Villagers first consume their highest-value carried vanilla food. Only an empty personal food supply triggers cached, LOD-aware, budgeted discovery of accessible village chests/barrels; at the supported side they transfer up to 12 food points, consume one item, and retain the remainder. Adult Farmers deposit only food above a 24-point reserve. Shepherds, Leatherworkers, and Butchers use the same bounded dispatcher/storage architecture to retrieve and consume two suitable items, path to their assigned valid adult livestock pair, and relieve both animals' hunger; global satisfaction breeding decides reproduction later. Completed storage transfers face the container and tending faces the livestock pair. Hunger, danger, sleep, trading, ownership, and higher-priority work win. At Stage 2+, an otherwise vanilla-eligible five-Villager golem decision becomes a persisted, path-backed staged build that spends one emerald, but only Clerics, Librarians, Armorers, Toolsmiths, and Weaponsmiths may build it. A builder that has reached the work site continuously faces the structure while construction is active. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/construction/animal state saves normally. Bounded unloaded reconciliation covers Villager food consumption and one real Farmer-owned crop/replant/surplus-deposit cycle per simulated day; other profession work remains loaded-only. Seven communal-food, four animal, five golem, and four torch-maintenance tests pass; the staged fixture now trade-locks its test professions, explicitly exercises construction during an allowed activity, and guards continuous body/head/look alignment. The latest focused 50-Villager TPS peak is 7.259 ms/tick. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. | +| Villagers pacifist / communal food / golem creators | Partial / communal food, shared storage knowledge, livestock tending, and golem construction implemented | `VILLAGER_COMMUNAL` gives loaded Villagers persisted hunger. Hungry Villagers first consume their highest-value carried vanilla food. Only an empty personal food supply starts an accessible village chest/barrel route; observed positions, capacity, and exact contents are persisted and shared across the village, so food, emerald, livestock-feed, and arbitrary exact-item/count requests consult chunk-indexed knowledge before a cached, LOD-aware, budgeted world scan. Player, hopper, Villager, and compatible container changes refresh known or active-village storage at event time. Knowledge never force-loads chunks or overrides live storage, item/count, village-boundary, or supported-access validation. At the supported side Villagers transfer up to 12 food points, consume one item, and retain the remainder. Adult Farmers deposit only food above a 24-point reserve. Shepherds, Leatherworkers, and Butchers use the same bounded dispatcher/storage architecture to retrieve and consume two suitable items, path to their assigned valid adult livestock pair, and relieve both animals' hunger; global satisfaction breeding decides reproduction later. Completed storage transfers face the container and tending faces the livestock pair. Hunger, danger, sleep, trading, ownership, and higher-priority work win. At Stage 2+, an otherwise vanilla-eligible five-Villager golem decision becomes a persisted, path-backed staged build that spends one emerald, but only Clerics, Librarians, Armorers, Toolsmiths, and Weaponsmiths may build it. A builder that has reached the work site continuously faces the structure while construction is active. Players spend five levels only on successful Survival Iron Golem animation. Entity/container/knowledge/construction/animal state saves normally. Bounded unloaded reconciliation covers Villager food consumption and one real Farmer-owned crop/replant/surplus-deposit cycle per simulated day; other profession work remains loaded-only. Focused shared-knowledge, communal route, Farmer route, livestock, golem-currency, and Villager TPS selectors pass. Daily stock refresh and all-stage magical/Nitwit-physical torch maintenance also exist; wider society/progression remains incomplete. The Animal Feeder remains animal-only. | | Village property ownership and reputation | Implemented / needs natural verification | Generated and Villager-produced storage quantities, Farmer-planted crops, and profession-tended livestock use provenance rather than village-location inference. Player deposits/crops, player-handled livestock, tamed animals, and ambiguous existing-world property remain outside initial ownership. Offspring of two owned parents inherit village ownership; automatic offspring of a player-associated unowned parent inherit player protection instead. Witnessed Survival offenses use vanilla gossip: storage/crop theft is minor, vandalism and direct village-livestock killing are `-50`, and breaking protected storage reaches golem hostility. Monsters, environment, Creative, and Spectator are excluded. Container, crop, and animal focused selectors pass; `/retold village status` reports the bounded loaded standing. Natural livestock routes, witness/trade/golem consequences, multiplayer, dedicated servers, and existing worlds remain unverified. | | Witches as exiled villagers, raid-only loose ally | Implemented / needs verification | Witches are not full Illager or territory members and remain mutually neutral with Illagers outside raids. Combat alignment requires an active raid, cooperation requires the same raid instance, and Retold-owned assist targets are cleared on raid exit. A deterministic GameTest covers these boundaries; verify natural Stage 3 raid wave participation and healing/support behavior in-game. | | Four-structure territory warning contract | Implemented / needs verification | Tags cover bastions, Nether fortresses, pillager outposts, and woodland mansions. GameTests cover every configured warning-capable faction member, Nether dimension restriction, Witch exclusion, survival-player observation, creative/spectator exclusion, suppression of premature targets, the minimum final-warning transition, territory target ownership, and immediate retaliation. Actual structure detection, formation movement, sounds/particles, and navigation still need focused in-game checks in all four generated structures. | diff --git a/docs/internal/mob_tps_benchmark.md b/docs/internal/mob_tps_benchmark.md index f5a0d037..6d9ea2c4 100644 --- a/docs/internal/mob_tps_benchmark.md +++ b/docs/internal/mob_tps_benchmark.md @@ -373,6 +373,19 @@ hunt/targeting, 6.569 danger/social, and 3.378 habitat/day-night ms/tick. Idle/r ms/tick peak. The complete matrix was not selected because only Villager torch-success behavior changed and the shared index and work-budget implementations remain unchanged. +### Shared Village Storage Knowledge Villager Focused Rerun + +The exact `retold:mob_tps_villager` selector was rerun on 2026-08-15 because communal food, +Farmer deposits, livestock tending, and golem currency now consult a persistent chunk-indexed +storage-knowledge layer before falling back to the existing budgeted block-entity scan. Storage +change observation is event-time and does not add a Villager or block-entity polling cadence. + +All five 50-Villager phases passed below 50 ms/tick: 7.915 idle/rest, 5.074 +dropped-food/forage, 5.895 hunt/targeting, 6.903 danger/social, and 3.672 habitat/day-night +ms/tick. Idle/rest was the 7.915 ms/tick peak. Positive storage work recorded 96 cache hits from 97 +searches in dropped-food/forage, while the other active phases recorded no broad block-position +work. The complete profile matrix was not selected because only the Villager storage owner changed. + ## Results The table below records the original clean baseline described above; later rerun summaries are diff --git a/docs/internal/retold_mob_ai_system.md b/docs/internal/retold_mob_ai_system.md index f10dac13..04aca3e2 100644 --- a/docs/internal/retold_mob_ai_system.md +++ b/docs/internal/retold_mob_ai_system.md @@ -296,7 +296,13 @@ an implementation claim. The completion matrix below and near their remembered home, meeting point, or job site, regardless of its placement origin. They first eat the highest-value vanilla food already in their inventory. A Villager with no personal food walks to storage, withdraws up to 12 food points while preferring higher-value items, eats - one, and carries the remainder for later meals. Machine inventories are ignored. Adult Farmers + one, and carries the remainder for later meals. Observed chest/barrel positions, slot capacity, + and exact contents form persistent shared village knowledge, so another Villager can directly + resolve food, emeralds, livestock feed, or any other requested item without repeating the world + scan. Container changes by players, hoppers, Villagers, or other systems refresh known or + active-village storage at event time. Knowledge is advisory: the chunk, storage type, contents, + village boundary, supported access cell, and exact count are revalidated before use, and stale + entries are refreshed or removed. Machine inventories are ignored. Adult Farmers use vanilla crop harvesting, replanting, and bread making, then deliver only surplus Bread, Carrots, Potatoes, and Beetroot to the same stores while retaining 24 vanilla food points for themselves. Delivery uses low-priority communal search @@ -741,8 +747,15 @@ operation rather than terrain modification, so it remains available with `mobGri The Animal Feeder remains animal-only. Villagers instead use `RetoldVillagerCommunalFood` and `RetoldVillagerCommunalFoodSearch`: a 16-horizontal/four-vertical loaded-chunk block-entity scan, bounded to accessible chests and barrels within 32 blocks of a remembered HOME, MEETING_POINT, or -JOB_SITE. A live vanilla village near the Villager is the fallback context. The scan is cached, -LOD-aware, and charged to the shared block-search budget. The Villager claims ordinary +JOB_SITE. A live vanilla village near the Villager is the fallback context. Each scan observes all +eligible storage it encounters into the chunk-indexed, server-global +`RetoldVillageStorageKnowledge` SavedData. Later food, arbitrary exact-item/count, and deposit-space +requests consult that shared index before spending a world-search budget. Generated village loot, +Villager/player transactions, and chest/barrel `setChanged` calls for known or active-village +storage keep it current without an always-on tick subscriber. Lookups never force-load chunks and +must still pass the existing live container/access validation before movement or transfer. A miss +falls back to the original scan, which remains cached, LOD-aware, and charged to the shared +block-search budget. The Villager claims ordinary `FOOD`/`FEED` movement, and first consumes the highest-value Bread, Carrot, Potato, or Beetroot already in its inventory. Only an empty personal food supply starts a storage route. At a supported adjacent cell, the Villager transfers the highest-value available items up to a 12-food-point stock, diff --git a/docs/internal/retold_mod_system.md b/docs/internal/retold_mod_system.md index 097995bb..053805d4 100644 --- a/docs/internal/retold_mod_system.md +++ b/docs/internal/retold_mod_system.md @@ -69,6 +69,7 @@ The main event registration is intentionally explicit. When adding a new system, | `aender/generation` | Aender floating island terrain and volatility | | `aender/portal` | horizontal Aender portal shapes, indexing, destination logic, and countdown warm-up | | `aender/stability` | Aender stabilizer chunks, regeneration, forcefield visuals | +| `ambient` | dimension-independent, globally scheduled atmosphere state | | `behavior` | Retold mob AI system | | `block` | custom blocks and block interaction behavior | | `chronolith` | Aender chronolith time-acceleration system | @@ -77,6 +78,7 @@ The main event registration is intentionally explicit. When adding a new system, | `client/render` | entity/beam/enderman rendering hooks | | `client/sky` | End/Aender sky seed and generated sky texture | | `client/stage` | client-side current world stage | +| `client/texture` | runtime-generated client textures and resource-derived sprites | | `combat` | Retold-owned target source/memory helpers | | `command` | `/retold` command tree | | `effect` | ritual visual/audio effects | @@ -126,6 +128,7 @@ split into these modules: | `RetoldMobModule` | undead, piglin, golem, enderman, and elder guardian events | | `RetoldWorldgenModule` | worldgen registries, attachments, spawn cache, Air Temple, and delayed structures | | `RetoldAenderModule` | Aender registries, stability events, world ticks, and Chronolith events | +| `RetoldAtmosphereModule` | global server-authoritative atmosphere scheduling across dimensions | | `RetoldFactionModule` | invalid-target cleanup, faction combat, and faction assist | | `RetoldTerritoryModule` | territory runtime, illegal actions, and reputation diagnostics | | `RetoldBehaviorModule` | AI dispatcher, food, hunting, combat control, stamina, and behavior diagnostics | @@ -838,6 +841,16 @@ Behavior: the accepted count is removed from the container. Villager hunger and inventory persist on the entity and exact container contents persist through the vanilla block entity. Bounded unloaded reconciliation reuses this personal-first consumer transaction without synthesizing movement. +- `RetoldVillageStorageKnowledge` persists every observed village chest/barrel position, slot + capacity, and exact stack/component snapshot in server-global SavedData, with a secondary + dimension/chunk index rebuilt on load. Communal food, Farmer deposits, livestock feed, golem + emeralds, and future arbitrary-item consumers query this shared knowledge before a world scan. + Loot unpacking and completed player/Villager transactions refresh it directly; a narrow + `BlockEntity.setChanged` hook also refreshes already-known or live-village chest/barrel contents, + covering hopper and compatible mod mutations without polling. Candidate lookup checks only the + relevant loaded chunks and the existing search/village bounds. The physical container, exact + contents/count, capacity, supported access cell, and path remain authoritative; stale entries are + repaired or removed and an index miss falls back to the existing budgeted loaded-chunk scan. - Adult loaded Farmers continue using vanilla crop harvesting, replanting, and wheat-to-Bread production. When they hold more than a 24-food-point personal reserve, they use the same storage search to deliver surplus Bread, Carrots, Potatoes, or Beetroot through low-priority @@ -936,7 +949,9 @@ Teaching data is resource-driven by profession. Add or tune teaching through `vi JSON before changing code. Daily stock refresh is routed through the central entity dispatcher and stored per villager rather than implemented as another always-on event subscriber. Communal consumption and Farmer supply share that dispatcher and one storage-discovery owner instead of -adding parallel scans or a second persistence format. Golem construction owns its saved state on +adding parallel scans. Persistent shared storage knowledge belongs to that discovery owner; keep +it advisory and chunk-indexed, update it from storage transactions/change events, and never use it +to force-load a chunk or bypass live content/access validation. Golem construction owns its saved state on the builder, reuses that storage discovery and central AI budgets, and intercepts vanilla's spawn decision rather than introducing a parallel village-cap system. Container provenance is a transaction-time persisted ledger shared by loot unpacking, Villager @@ -1354,6 +1369,7 @@ Payloads: | `RetoldRequestTeachingPreviewPayload` | client -> server | request teaching preview refresh | | `RetoldTeachingPreviewPayload` | server -> client | update teaching UI state | | `RetoldChronolithBeamPayload` | server -> client | start/stop chronolith beam rendering | +| `RetoldHorizonCuePayload` | server -> client | trigger a player-scoped ambient horizon presentation | Design rule: @@ -1392,7 +1408,7 @@ Main mixin groups: | Area | Mixins | | --- | --- | | Recipe/progression | `ServerRecipeBookMixin`, `AdvancementVisibilityEvaluatorMixin`, `AbstractFurnaceBlockEntityMixin` | -| Villager teaching/storage/reputation | `MerchantMenuAccessor`, `MerchantMenuTeachingSlotMixin`, `MerchantScreenMixin`, `VillagerInvoker`, `AbstractContainerMenuMixin`, `RandomizableContainerMixin`, `CompoundContainerAccessor`, `HarvestFarmlandMixin` | +| Villager teaching/storage/reputation | `MerchantMenuAccessor`, `MerchantMenuTeachingSlotMixin`, `MerchantScreenMixin`, `VillagerInvoker`, `AbstractContainerMenuMixin`, `RandomizableContainerMixin`, `BlockEntityVillageStorageKnowledgeMixin`, `CompoundContainerAccessor`, `HarvestFarmlandMixin` | | World/stage/worldgen | `DelayedStructurePlacementMixin`, `RuinedPortalPieceMixin`, `NoVillageNearWorldSpawnMixin`, `EndDragonFightMixin`, `EndGatewayGenerationMixin`, `EndPortalBlockMixin` | | Aender physics/rendering | `AenderBucketItemMixin`, `AenderFlowingFluidMixin`, `AenderWaterFluidMixin`, `AenderWeatherMixin`, `AenderEntityLightingMixin`, `AenderRenderSectionRegionLightingMixin` | | Mob AI/targeting | `MobTargetMixin`, `MobAggressiveMixin`, `MobBrainMemoryOwnerMixin`, `BrainMemoryMixin`, `PiglinAiMixin`, `PathNavigationMixin`, `MobHurtTargetMixin`, `AbstractCubeMobPushMixin` | @@ -1461,6 +1477,7 @@ Main performance-sensitive systems: - Aender portal-ticket warm-up with a TPS-aware 12 ms/32-chunk per-player maximum and an indefinite safe-core gate - section-level Aender regeneration instead of full-height per-block clearing - chronolith active channel map +- once-per-second staggered player atmosphere scheduling - recipe/villager preview server checks General performance rules: diff --git a/docs/internal/retold_roadmap.md b/docs/internal/retold_roadmap.md index 63d92f33..41c612b7 100644 --- a/docs/internal/retold_roadmap.md +++ b/docs/internal/retold_roadmap.md @@ -33,15 +33,17 @@ Retold is still built around: These are the strongest next design-aligned areas: -1. Finish the four-element progression model. -2. Add missing Fire and Earth element item/challenge paths, verify the initial cartographer Air Temple discovery map, and continue tuning the Air Temple/Gale Core path. -3. Decide whether Stage 1 needs Wither/Nether star End portal activation. -4. Add remaining Aender in-dimension teleportation and late-game travel/building rewards. -5. Replace the provisional `dev_aender_portal_frame` name/assets when the final portal-frame design is chosen. -6. Audit and verify survival removal for End Cities, outer End progression, Ancient Cities, Deep Dark/Warden, and Trial Chambers; keep Trail Ruins and fossils, and keep the implemented Sniffer and Endermite removals regression-tested. -7. Naturally verify the implemented village-reputation loop: generated/Villager-produced storage, - Farmer-planted crops, profession-tended livestock, witness sight, trade prices, and Iron Golem - hostility in ordinary, multiplayer, dedicated-server, and existing villages. +1. Focus the next development pass on Villager and village systems. Naturally verify the current + communal storage, Farmer supply, livestock tending, property reputation, Iron Golem construction, + torch-maintenance, and trade-stock loops in ordinary, multiplayer, dedicated-server, and existing + villages; identify remaining coordination and survival gaps; then design and implement the next + coherent village-society slice. +2. Finish the four-element progression model. +3. Add missing Fire and Earth element item/challenge paths, verify the initial cartographer Air Temple discovery map, and continue tuning the Air Temple/Gale Core path. +4. Decide whether Stage 1 needs Wither/Nether star End portal activation. +5. Add remaining Aender in-dimension teleportation and late-game travel/building rewards. +6. Replace the provisional `dev_aender_portal_frame` name/assets when the final portal-frame design is chosen. +7. Audit and verify survival removal for End Cities, outer End progression, Ancient Cities, Deep Dark/Warden, and Trial Chambers; keep Trail Ruins and fossils, and keep the implemented Sniffer and Endermite removals regression-tested. 8. Naturally verify hunger-satisfaction breeding across representative ordinary, aquatic, Nether, egg-laying, pregnant, mixed-equine, and tamed animals, including population growth and save/load. 9. Naturally verify the bounded unloaded ecosystem with crowded returns, starvation outcomes, @@ -319,7 +321,6 @@ Examples of the intended distinction: These areas are not finished forever, but the current direction is acceptable for now: -- villages only need current distance/scarcity work for now - current Stage 3 illager behavior, including the Stage 3-only raid-start gate, is enough for now - Stage 3 should only remove/cleanse undead and zombified piglins for now, not broadly make the Overworld easier - mansions and outposts should stay delayed to Stage 2 as currently designed diff --git a/docs/internal/testing_strategy.md b/docs/internal/testing_strategy.md index a8b152b4..b36a4cec 100644 --- a/docs/internal/testing_strategy.md +++ b/docs/internal/testing_strategy.md @@ -46,6 +46,7 @@ Use the exact `retold:` test ID for the changed contract. For example: ```bash ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:villager_paths_to_communal_food_storage" +./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:villagers_share_persistent_knowledge_of_village_storage" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:farmer_communal_supply_paths_to_storage" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:golem_construction_stages_and_conserves_village_emerald" ./gradlew runGameTestServer --args="net.neoforged.fml.startup.GameTestServer --tests retold:villager_relights_extinguished_torches_in_every_stage" @@ -96,6 +97,13 @@ mutation routing changed broadly enough to justify it. The Villager consumer transaction, consumer route, and Farmer supply tests intentionally use separate isolated GameTest environments. Run only the exact route or transaction test changed. +For shared village-storage knowledge, run +`retold:villagers_share_persistent_knowledge_of_village_storage`; it proves one Villager's scan is +shared under an exhausted world-search budget, persists exact food/emerald/arbitrary-item contents +and counts through SavedData, and updates after a physical withdrawal. Add the one exact food, +Farmer-deposit, livestock-feed, or golem-emerald route whose consumer contract changed. Run +`retold:mob_tps_villager` when the repeated lookup, candidate validation, or storage-discovery path +changes; event-time container refresh alone does not justify a TPS selector. For village-container provenance or witnessed theft, select the one exact `retold:village_container_ownership_...` test for the changed transaction. The available tests cover diff --git a/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonData.java b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonData.java new file mode 100644 index 00000000..180d9c41 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonData.java @@ -0,0 +1,83 @@ +package cz.xefensor.retold.ambient; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import cz.xefensor.retold.Retold; +import net.minecraft.core.UUIDUtil; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +import java.util.UUID; + +final class RetoldHorizonData extends SavedData { + private static final Codec PLAYER_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + UUIDUtil.STRING_CODEC.fieldOf("player").forGetter( + RetoldHorizonSchedule.PlayerEntry::playerId + ), + Codec.LONG.fieldOf("next").forGetter( + RetoldHorizonSchedule.PlayerEntry::nextCueAt + ) + ).apply(instance, RetoldHorizonSchedule.PlayerEntry::new)); + + private static final Codec STATE_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("version").forGetter( + RetoldHorizonSchedule.SerializedState::version + ), + PLAYER_CODEC.listOf().fieldOf("players").forGetter( + RetoldHorizonSchedule.SerializedState::players + ) + ).apply(instance, RetoldHorizonSchedule.SerializedState::new)); + + private static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath(Retold.MODID, "ambient_horizon"), + RetoldHorizonData::new, + STATE_CODEC.xmap(RetoldHorizonData::new, RetoldHorizonData::serialize) + ); + + private final RetoldHorizonSchedule schedule; + + private RetoldHorizonData() { + schedule = new RetoldHorizonSchedule(); + } + + private RetoldHorizonData(RetoldHorizonSchedule.SerializedState state) { + schedule = RetoldHorizonSchedule.fromSerializedState(state); + } + + static RetoldHorizonData get(ServerLevel level) { + return level.getServer().getDataStorage().computeIfAbsent(TYPE); + } + + boolean scheduleIfAbsent(UUID playerId, long gameTime, long delayTicks) { + boolean changed = schedule.scheduleIfAbsent(playerId, gameTime, delayTicks); + + if (changed) { + setDirty(); + } + + return changed; + } + + boolean isDue(UUID playerId, long gameTime) { + return schedule.isDue(playerId, gameTime); + } + + void reschedule(UUID playerId, long gameTime, long delayTicks) { + schedule.reschedule(playerId, gameTime, delayTicks); + setDirty(); + } + + void capDelay(UUID playerId, long gameTime, long maximumDelayTicks) { + if (schedule.capDelay(playerId, gameTime, maximumDelayTicks)) { + setDirty(); + } + } + + private RetoldHorizonSchedule.SerializedState serialize() { + return schedule.serialize(); + } +} diff --git a/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonEvents.java b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonEvents.java new file mode 100644 index 00000000..3c4ad57e --- /dev/null +++ b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonEvents.java @@ -0,0 +1,92 @@ +package cz.xefensor.retold.ambient; + +import cz.xefensor.retold.network.RetoldHorizonCuePayload; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.RandomSource; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.tick.PlayerTickEvent; +import net.neoforged.neoforge.network.PacketDistributor; + +public final class RetoldHorizonEvents { + static final int MINIMUM_FIRST_INTERVAL_DAYS = 2; + static final int MAXIMUM_FIRST_INTERVAL_DAYS = 7; + static final int MINIMUM_RECURRING_INTERVAL_DAYS = 5; + static final int MAXIMUM_RECURRING_INTERVAL_DAYS = 20; + static final int MINIMUM_DURATION_TICKS = 5 * 20; + static final int MAXIMUM_DURATION_TICKS = 10 * 20; + + private static final int CHECK_INTERVAL_TICKS = 20; + + private RetoldHorizonEvents() { + } + + @SubscribeEvent + public static void onPlayerTickPost(PlayerTickEvent.Post event) { + if (!(event.getEntity() instanceof ServerPlayer player) + || !(player.level() instanceof ServerLevel level) + || !player.isAlive() + || player.isSpectator()) { + return; + } + + ServerLevel scheduleLevel = level.getServer().overworld(); + long gameTime = scheduleLevel.getGameTime(); + + if (Math.floorMod(gameTime + player.getId(), CHECK_INTERVAL_TICKS) != 0L) { + return; + } + + RandomSource random = level.getRandom(); + RetoldHorizonData data = RetoldHorizonData.get(scheduleLevel); + + if (data.scheduleIfAbsent(player.getUUID(), gameTime, randomFirstIntervalTicks(random))) { + return; + } + + // Existing saves may still contain a next appearance from the former 10-100-day range. + data.capDelay( + player.getUUID(), + gameTime, + RetoldHorizonSchedule.intervalTicksForDays(MAXIMUM_RECURRING_INTERVAL_DAYS) + ); + + if (!data.isDue(player.getUUID(), gameTime)) { + return; + } + + data.reschedule(player.getUUID(), gameTime, randomRecurringIntervalTicks(random)); + sendCue(player, random); + } + + private static void sendCue(ServerPlayer player, RandomSource random) { + PacketDistributor.sendToPlayer( + player, + new RetoldHorizonCuePayload( + random.nextLong(), + random.nextIntBetweenInclusive( + MINIMUM_DURATION_TICKS, + MAXIMUM_DURATION_TICKS + ) + ) + ); + } + + static long randomFirstIntervalTicks(RandomSource random) { + return RetoldHorizonSchedule.intervalTicksForDays( + random.nextIntBetweenInclusive( + MINIMUM_FIRST_INTERVAL_DAYS, + MAXIMUM_FIRST_INTERVAL_DAYS + ) + ); + } + + static long randomRecurringIntervalTicks(RandomSource random) { + return RetoldHorizonSchedule.intervalTicksForDays( + random.nextIntBetweenInclusive( + MINIMUM_RECURRING_INTERVAL_DAYS, + MAXIMUM_RECURRING_INTERVAL_DAYS + ) + ); + } +} diff --git a/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonSchedule.java b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonSchedule.java new file mode 100644 index 00000000..cb9fb942 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/ambient/RetoldHorizonSchedule.java @@ -0,0 +1,128 @@ +package cz.xefensor.retold.ambient; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +final class RetoldHorizonSchedule { + static final int SAVE_VERSION = 1; + static final long TICKS_PER_DAY = 24_000L; + + private final Map nextCueByPlayer = new HashMap<>(); + + boolean scheduleIfAbsent(UUID playerId, long gameTime, long delayTicks) { + validateDelay(delayTicks); + + if (nextCueByPlayer.containsKey(playerId)) { + return false; + } + + nextCueByPlayer.put(playerId, addWithoutOverflow(gameTime, delayTicks)); + return true; + } + + boolean isDue(UUID playerId, long gameTime) { + Long nextCue = nextCueByPlayer.get(playerId); + return nextCue != null && gameTime >= nextCue; + } + + void reschedule(UUID playerId, long gameTime, long delayTicks) { + validateDelay(delayTicks); + nextCueByPlayer.put(playerId, addWithoutOverflow(gameTime, delayTicks)); + } + + boolean capDelay(UUID playerId, long gameTime, long maximumDelayTicks) { + validateDelay(maximumDelayTicks); + Long nextCue = nextCueByPlayer.get(playerId); + + if (nextCue == null) { + return false; + } + + long latestAllowedCue = addWithoutOverflow(gameTime, maximumDelayTicks); + + if (nextCue <= latestAllowedCue) { + return false; + } + + nextCueByPlayer.put(playerId, latestAllowedCue); + return true; + } + + long nextCue(UUID playerId) { + return nextCueByPlayer.getOrDefault(playerId, -1L); + } + + SerializedState serialize() { + List players = nextCueByPlayer.entrySet().stream() + .map(entry -> new PlayerEntry(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(PlayerEntry::playerId)) + .toList(); + return new SerializedState(SAVE_VERSION, players); + } + + static RetoldHorizonSchedule fromSerializedState(SerializedState state) { + if (state.version() != SAVE_VERSION) { + throw new IllegalArgumentException( + "Unsupported horizon schedule version: " + state.version() + ); + } + + RetoldHorizonSchedule schedule = new RetoldHorizonSchedule(); + Set playerIds = new HashSet<>(); + + for (PlayerEntry player : state.players()) { + if (!playerIds.add(player.playerId())) { + throw new IllegalArgumentException( + "Duplicate horizon schedule player: " + player.playerId() + ); + } + + if (player.nextCueAt() < 0L) { + throw new IllegalArgumentException( + "Negative horizon schedule tick for player: " + player.playerId() + ); + } + + schedule.nextCueByPlayer.put(player.playerId(), player.nextCueAt()); + } + + return schedule; + } + + static long intervalTicksForDays(int days) { + if (days <= 0) { + throw new IllegalArgumentException("Horizon schedule days must be positive"); + } + + return days * TICKS_PER_DAY; + } + + private static void validateDelay(long delayTicks) { + if (delayTicks <= 0L) { + throw new IllegalArgumentException("Horizon schedule delay must be positive"); + } + } + + private static long addWithoutOverflow(long gameTime, long delayTicks) { + if (gameTime > Long.MAX_VALUE - delayTicks) { + return Long.MAX_VALUE; + } + + return gameTime + delayTicks; + } + + record PlayerEntry(UUID playerId, long nextCueAt) { + } + + record SerializedState(int version, List players) { + SerializedState { + players = List.copyOf(new ArrayList<>(players)); + } + } +} diff --git a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java index 271d2527..1d901444 100644 --- a/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java +++ b/src/main/java/cz/xefensor/retold/client/RetoldClientEvents.java @@ -10,6 +10,7 @@ import cz.xefensor.retold.client.render.RetoldAenderEyeRenderer; import cz.xefensor.retold.client.render.RetoldEndermanEyesLayer; import cz.xefensor.retold.client.render.RetoldChronolithBeamClient; +import cz.xefensor.retold.client.render.RetoldHorizonAmbientClient; import cz.xefensor.retold.client.sky.RetoldClientEndSky; import cz.xefensor.retold.client.sky.RetoldEndSkyPatcher; import cz.xefensor.retold.client.texture.AenderPortalSpriteSource; @@ -57,6 +58,7 @@ public static void register(IEventBus modEventBus) { modEventBus.addListener(RetoldClientEvents::addEntityRenderLayers); modEventBus.addListener(RetoldClientEvents::registerSpriteSources); RetoldChronolithBeamClient.register(modEventBus); + RetoldHorizonAmbientClient.register(); NeoForge.EVENT_BUS.addListener(RetoldClientEvents::onClientTick); NeoForge.EVENT_BUS.addListener(RetoldClientEvents::onClientLogout); NeoForge.EVENT_BUS.addListener(RetoldEnchantmentTooltip::onItemTooltip); @@ -66,6 +68,7 @@ private static void onClientLogout(ClientPlayerNetworkEvent.LoggingOut event) { RetoldClientEnchantmentCatalog.clear(); RetoldClientEnchantmentKnowledge.clear(); RetoldClientRecipeKnowledge.clear(); + RetoldHorizonAmbientClient.clear(); } private static void registerSpriteSources(RegisterSpriteSourcesEvent event) { diff --git a/src/main/java/cz/xefensor/retold/client/render/RetoldHorizonAmbientClient.java b/src/main/java/cz/xefensor/retold/client/render/RetoldHorizonAmbientClient.java new file mode 100644 index 00000000..cd2d43d2 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/render/RetoldHorizonAmbientClient.java @@ -0,0 +1,461 @@ +package cz.xefensor.retold.client.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import cz.xefensor.retold.client.texture.RetoldGeneratedHorizonTexture; +import cz.xefensor.retold.network.RetoldHorizonCuePayload; +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.HumanoidModel; +import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.model.player.PlayerModel; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.Lightmap; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.LightCoordsUtil; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.VoxelShape; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.SubmitCustomGeometryEvent; +import net.neoforged.neoforge.common.NeoForge; + +import java.util.List; + +public final class RetoldHorizonAmbientClient { + private static final int PLACEMENT_ATTEMPTS_PER_RETRY = 24; + private static final int PLACEMENT_RETRY_INTERVAL_TICKS = 10; + private static final int MAXIMUM_PLACEMENT_RETRIES = 8; + private static final double FIGURE_EYE_HEIGHT = 1.62D; + private static final int MINIMUM_RENDER_DISTANCE_BLOCKS = 24; + private static final int RENDER_BOUNDARY_INSET_BLOCKS = 1; + private static final int ENCLOSED_VERTICAL_SEARCH_RADIUS = 32; + private static final double APPROACH_DISMISSAL_FRACTION = 0.25D; + private static final double MINIMUM_APPROACH_DISMISSAL_BLOCKS = 12.0D; + private static final int SUSTAINED_LOOK_DISMISSAL_TICKS = 2 * 20; + private static final double SUSTAINED_LOOK_DOT_PRODUCT = 0.995D; + private static final float PLAYER_SHADOW_RADIUS = 0.5F; + + private static PlayerModel model; + private static AvatarRenderState renderState; + private static RenderType renderType; + private static PendingCue pendingCue; + private static ActiveCue activeCue; + private static ClientLevel currentLevel; + + private RetoldHorizonAmbientClient() { + } + + public static void register() { + NeoForge.EVENT_BUS.addListener(RetoldHorizonAmbientClient::onClientTickPost); + NeoForge.EVENT_BUS.addListener(RetoldHorizonAmbientClient::submitCustomGeometry); + } + + public static void handleCue(RetoldHorizonCuePayload payload) { + ClientLevel level = Minecraft.getInstance().level; + + if (currentLevel != level) { + pendingCue = null; + activeCue = null; + currentLevel = level; + } + + int duration = Mth.clamp(payload.durationTicks(), 5 * 20, 10 * 20); + pendingCue = new PendingCue(payload.phase(), duration, 0, 0); + activeCue = null; + } + + public static void clear() { + pendingCue = null; + activeCue = null; + currentLevel = null; + } + + private static void onClientTickPost(ClientTickEvent.Post event) { + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel level = minecraft.level; + LocalPlayer player = minecraft.player; + + if (level == null || player == null) { + clear(); + return; + } + + if (currentLevel != level) { + pendingCue = null; + activeCue = null; + currentLevel = level; + return; + } + + if (activeCue != null) { + int remainingTicks = activeCue.remainingTicks() - 1; + int lookTicks = isLookingAtFigure(level, player, activeCue.position()) + ? activeCue.lookTicks() + 1 + : Math.max(0, activeCue.lookTicks() - 2); + + if (remainingTicks <= 0 + || !level.isLoaded(BlockPos.containing(activeCue.position())) + || player.position().distanceToSqr(activeCue.position()) + <= activeCue.approachDismissalDistanceSqr() + || lookTicks >= SUSTAINED_LOOK_DISMISSAL_TICKS) { + activeCue = null; + } else { + activeCue = new ActiveCue( + activeCue.position(), + remainingTicks, + activeCue.approachDismissalDistanceSqr(), + lookTicks + ); + } + } + + if (pendingCue == null) { + return; + } + + if (pendingCue.retryDelayTicks() > 0) { + pendingCue = new PendingCue( + pendingCue.phase(), + pendingCue.durationTicks(), + pendingCue.retries(), + pendingCue.retryDelayTicks() - 1 + ); + return; + } + + Vec3 position = findPlacement(minecraft, pendingCue.phase(), pendingCue.retries()); + + if (position != null) { + double initialDistance = player.position().distanceTo(position); + double requiredApproachDistance = Math.max( + MINIMUM_APPROACH_DISMISSAL_BLOCKS, + initialDistance * APPROACH_DISMISSAL_FRACTION + ); + double dismissalDistance = Math.max( + 4.0D, + initialDistance - requiredApproachDistance + ); + activeCue = new ActiveCue( + position, + pendingCue.durationTicks(), + dismissalDistance * dismissalDistance, + 0 + ); + pendingCue = null; + return; + } + + int retries = pendingCue.retries() + 1; + + if (retries >= MAXIMUM_PLACEMENT_RETRIES) { + pendingCue = null; + } else { + pendingCue = new PendingCue( + pendingCue.phase(), + pendingCue.durationTicks(), + retries, + PLACEMENT_RETRY_INTERVAL_TICKS + ); + } + } + + private static Vec3 findPlacement(Minecraft minecraft, long phase, int retry) { + ClientLevel level = minecraft.level; + LocalPlayer player = minecraft.player; + + if (level == null || player == null) { + return null; + } + + int configuredDistance = minecraft.options.renderDistance().get() * 16; + int edgeDistance = Math.max( + MINIMUM_RENDER_DISTANCE_BLOCKS, + configuredDistance - RENDER_BOUNDARY_INSET_BLOCKS + ); + RandomSource random = RandomSource.create(phase + retry * 0x6A09E667F3BCC909L); + float viewYaw = player.getYRot(); + + for (int attempt = 0; attempt < PLACEMENT_ATTEMPTS_PER_RETRY; attempt++) { + double angleOffset = (random.nextDouble() - 0.5D) * 80.0D; + double yawRadians = Math.toRadians(viewYaw + angleOffset); + int x = Mth.floor(player.getX() - Math.sin(yawRadians) * edgeDistance); + int z = Mth.floor(player.getZ() + Math.cos(yawRadians) * edgeDistance); + + if (!level.hasChunk(x >> 4, z >> 4)) { + continue; + } + + BlockPos feet = findStandingPosition(level, player, x, z); + + if (feet == null) { + continue; + } + + Vec3 position = Vec3.atBottomCenterOf(feet); + + if (hasClearView(level, player, position)) { + return position; + } + } + + return null; + } + + private static BlockPos findStandingPosition( + ClientLevel level, + LocalPlayer player, + int x, + int z + ) { + if (level.dimensionType().hasCeiling()) { + int minimumY = level.getMinY() + 1; + int maximumY = level.getMaxY() - 2; + int originY = Mth.clamp(player.blockPosition().getY(), minimumY, maximumY); + + for (int offset = 0; offset <= ENCLOSED_VERTICAL_SEARCH_RADIUS; offset++) { + int belowY = originY - offset; + + if (belowY >= minimumY) { + BlockPos belowCandidate = new BlockPos(x, belowY, z); + + if (isValidStandingPosition(level, belowCandidate)) { + return belowCandidate; + } + } + + if (offset == 0) { + continue; + } + + int aboveY = originY + offset; + + if (aboveY <= maximumY) { + BlockPos aboveCandidate = new BlockPos(x, aboveY, z); + + if (isValidStandingPosition(level, aboveCandidate)) { + return aboveCandidate; + } + } + } + + return null; + } + + int surfaceY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z); + BlockPos surface = new BlockPos(x, surfaceY, z); + return isValidStandingPosition(level, surface) ? surface : null; + } + + private static boolean isValidStandingPosition(ClientLevel level, BlockPos feet) { + BlockPos head = feet.above(); + BlockPos ground = feet.below(); + + return level.isInsideBuildHeight(feet) + && level.isInsideBuildHeight(head) + && level.isLoaded(feet) + && level.getBlockState(feet).isAir() + && level.getBlockState(head).isAir() + && level.getFluidState(ground).isEmpty() + && level.getBlockState(ground).isFaceSturdy(level, ground, Direction.UP); + } + + private static boolean hasClearView( + ClientLevel level, + LocalPlayer player, + Vec3 position + ) { + Vec3 target = position.add(0.0D, FIGURE_EYE_HEIGHT, 0.0D); + HitResult hit = level.clip(new ClipContext( + player.getEyePosition(), + target, + ClipContext.Block.VISUAL, + ClipContext.Fluid.NONE, + player + )); + + return hit.getType() == HitResult.Type.MISS + || hit.getLocation().distanceToSqr(target) < 0.25D; + } + + private static boolean isLookingAtFigure( + ClientLevel level, + LocalPlayer player, + Vec3 position + ) { + Vec3 targetOffset = position + .add(0.0D, FIGURE_EYE_HEIGHT, 0.0D) + .subtract(player.getEyePosition()); + double targetDistanceSqr = targetOffset.lengthSqr(); + + if (targetDistanceSqr < 1.0E-6D) { + return true; + } + + double alignment = player.getViewVector(1.0F).dot( + targetOffset.scale(1.0D / Math.sqrt(targetDistanceSqr)) + ); + return alignment >= SUSTAINED_LOOK_DOT_PRODUCT + && hasClearView(level, player, position); + } + + private static void submitCustomGeometry(SubmitCustomGeometryEvent event) { + if (activeCue == null) { + return; + } + + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel level = minecraft.level; + LocalPlayer player = minecraft.player; + + if (level == null || player == null) { + return; + } + + ensureRenderResources(minecraft); + + Vec3 figure = activeCue.position(); + Vec3 camera = event.getLevelRenderState().cameraRenderState.pos; + double deltaX = player.getX() - figure.x; + double deltaY = player.getEyeY() - (figure.y + FIGURE_EYE_HEIGHT); + double deltaZ = player.getZ() - figure.z; + double horizontalDistance = Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + float bodyYaw = (float) Math.toDegrees(Math.atan2(-deltaX, deltaZ)); + float headPitch = (float) -Math.toDegrees(Math.atan2(deltaY, horizontalDistance)); + + renderState.yRot = 0.0F; + renderState.xRot = Mth.clamp(headPitch, -45.0F, 45.0F); + renderState.ageInTicks = level.getGameTime(); + renderState.lightCoords = LightCoordsUtil.getLightCoords( + level, + BlockPos.containing(figure.add(0.0D, FIGURE_EYE_HEIGHT, 0.0D)) + ); + + PoseStack poseStack = event.getPoseStack(); + poseStack.pushPose(); + poseStack.translate(figure.x - camera.x, figure.y - camera.y, figure.z - camera.z); + poseStack.mulPose(Axis.YP.rotationDegrees(180.0F - bodyYaw)); + poseStack.scale(-1.0F, -1.0F, 1.0F); + poseStack.translate(0.0F, -1.501F, 0.0F); + + event.getSubmitNodeCollector().submitModel( + model, + renderState, + poseStack, + renderType, + renderState.lightCoords, + OverlayTexture.NO_OVERLAY, + -1, + null + ); + poseStack.popPose(); + + submitPlayerShadow(event, minecraft, level, figure, camera); + } + + private static void submitPlayerShadow( + SubmitCustomGeometryEvent event, + Minecraft minecraft, + ClientLevel level, + Vec3 figure, + Vec3 camera + ) { + if (!minecraft.options.entityShadows().get()) { + return; + } + + BlockPos feet = BlockPos.containing(figure); + BlockPos ground = feet.below(); + BlockState groundState = level.getBlockState(ground); + int brightness = level.getMaxLocalRawBrightness(feet); + + if (brightness <= 3 + || groundState.getRenderShape() == RenderShape.INVISIBLE + || !groundState.isCollisionShapeFullBlock(level, ground)) { + return; + } + + VoxelShape groundShape = groundState.getShape(level, ground); + + if (groundShape.isEmpty()) { + return; + } + + float alpha = 0.5F * Lightmap.getBrightness(level.dimensionType(), brightness); + EntityRenderState.ShadowPiece shadow = new EntityRenderState.ShadowPiece( + (float) (feet.getX() - figure.x), + (float) (feet.getY() - figure.y), + (float) (feet.getZ() - figure.z), + groundShape, + alpha + ); + PoseStack poseStack = event.getPoseStack(); + poseStack.pushPose(); + poseStack.translate(figure.x - camera.x, figure.y - camera.y, figure.z - camera.z); + event.getSubmitNodeCollector().submitShadow( + poseStack, + PLAYER_SHADOW_RADIUS, + List.of(shadow) + ); + poseStack.popPose(); + } + + private static void ensureRenderResources(Minecraft minecraft) { + if (model != null) { + return; + } + + model = new PlayerModel( + minecraft.getEntityModels().bakeLayer(ModelLayers.PLAYER), + false + ); + renderState = new AvatarRenderState(); + renderState.scale = 1.0F; + renderState.ageScale = 1.0F; + renderState.speedValue = 1.0F; + renderState.pose = Pose.STANDING; + renderState.mainArm = HumanoidArm.RIGHT; + renderState.attackArm = HumanoidArm.RIGHT; + renderState.leftArmPose = HumanoidModel.ArmPose.EMPTY; + renderState.rightArmPose = HumanoidModel.ArmPose.EMPTY; + renderState.showHat = false; + renderState.showJacket = false; + renderState.showLeftPants = false; + renderState.showRightPants = false; + renderState.showLeftSleeve = false; + renderState.showRightSleeve = false; + // Custom geometry must not participate in the entity outline pass. Doing so makes the + // otherwise ordinary world-lit model look as if it has the glowing status effect. + renderType = RenderTypes.entityCutout(RetoldGeneratedHorizonTexture.get(), false); + } + + private record PendingCue( + long phase, + int durationTicks, + int retries, + int retryDelayTicks + ) { + } + + private record ActiveCue( + Vec3 position, + int remainingTicks, + double approachDismissalDistanceSqr, + int lookTicks + ) { + } +} diff --git a/src/main/java/cz/xefensor/retold/client/texture/RetoldGeneratedHorizonTexture.java b/src/main/java/cz/xefensor/retold/client/texture/RetoldGeneratedHorizonTexture.java new file mode 100644 index 00000000..992e45f4 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/client/texture/RetoldGeneratedHorizonTexture.java @@ -0,0 +1,105 @@ +package cz.xefensor.retold.client.texture; + +import com.mojang.blaze3d.platform.NativeImage; +import cz.xefensor.retold.Retold; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.zip.GZIPInputStream; + +public final class RetoldGeneratedHorizonTexture { + private static final int TEXTURE_SIZE = 64; + private static final int LEGACY_TEXTURE_HEIGHT = 32; + private static final int COLOR_CHANNELS = 3; + + /* + * Developer-supplied legacy-layout RGB samples. The data is decoded directly into a + * NativeImage and expanded for the modern player model; no packaged image resource exists. + */ + private static final String SOURCE = + "H4sIAAAAAAACA9WW2U9TQRTG+8CDUlqBaCSUfa1lKQiJoQTZN0M0QjBKDRCqZREkadAHiQGVyJagD6ZJScXEBNKwPrg9GHz0if8Jv7mnHMa7YCXQtDdfJnNnvrn3N+eembmHh/qXI9MCFaRd4AqUnWx+1u7U1WGMXcTszL4EMT8E1LGGirc91VRCMctP8EXpiVp+k3ThNgb5KfhyCtFtvPAzMOIPcSLFEb8K3oi/214Um/xEfi0jCRXsPDK/SrHJT+Qs+iKxtn/abYkQ8IptZgBfz08uyxFy5lhKssQSsGeYsYRhwC34qRc2mDEE7fQEo/MiavxQaZYVVOCsKkhBSUK9yZFa77iCXsyFumDDLY1ift3z4rz5BUaaCKNCbrXbEG2qW74vju+v+H77Z/eXffsrU9vP+6oLU9EFA2wlymTFFNLMeIjReRGN+KdbyjKtIrw2kUWoOPNSNl96v85NoNyZnQj6Hv5c9AVHuyB0wQAbzKhgIIajbnReRCd/lBRKyr96cf6+K+hpddkvA54M36aHAU91TAddMMAGM4bwcKPzIkr86RYlDVLmuiv9A40LfTVBT9uX6aEfr320Q/5aGP881j7bU40uGGCDGUMo+Lx+tedFFPIfb0caDDU7P47dWfW00C/Z+/5m/0BDY27SUlcd1Jpnfdd/81VPDbpggA1mDBH5oySS0Xlx3vz0D0nYweEOxHbpQe1Uh3OytXSi2QH+psxEwENvemtf3K5CFwyfRjphXn10S5TKWJq1fF7QTKN5FtzrPGDZpcvIbzo40B5kaDQNDurK8DmhkBgFhULI1buVhVRGgV/7L3EafoJX9LiuvL+mxNtyIy75QyFggx/CROIxfzh5UEbCXGH3QUW5bpRtrnUWN8rtrspF08xMWG63KRAwra1hU8LWihLCrRB7INwCj7S3J0R1rYe65HbyKzqBH5wR8kPisSMjQqgQiTKFMDy9jj0QY7NoFBtg3tkJ22ju3B4xv5bTkF9+L/PgvfgWjMoeSAXPQ1Rx2NpSz/0s+MuLn6r53e4Ez6h4PvKHn0/8fIsuYmAP9YKTKzInf7gzjT/gqQtpzzJ5vYKKBBjGpgq1sIH4ZewjPw5K0nxvLX6rWLj9MNBEXXJ7hPwEyfHX4WcwhIj5Nzb+IqTIk4cntb0d7lXSTMXvn3QT5/JgC1pOzR9R/sv8HHxMQcXP+c/YmMLRlBlS5kdJLcwvWpSuk/fP/+CX81Pm5w0QdXn/URk0/HKcZX5VXp2CX7ddf30peAnrm+G6wRqUxZBaft11YcRfr1xG/NT7RLrCbHS+UDAptru7xxFWfSNeJlwJhRhSlefMz/lD5cn8Wk6jds4rlPJ8w6fzEb98JoYXPqVWIED7g5afUFXf5fz4Vd/reAqBgOG/RyDAt3KcjxfvpFu1Lv7J/wfsm8d/ABgAAA=="; + + private static final Identifier TEXTURE_ID = Identifier.fromNamespaceAndPath( + Retold.MODID, + "dynamic/horizon_detail" + ); + + private static DynamicTexture texture; + + private RetoldGeneratedHorizonTexture() { + } + + public static Identifier get() { + if (texture == null) { + NativeImage image = generate(); + texture = new DynamicTexture(() -> "Retold generated horizon detail", image); + Minecraft.getInstance().getTextureManager().register(TEXTURE_ID, texture); + } + + return TEXTURE_ID; + } + + private static NativeImage generate() { + byte[] pixels = decodeSource(); + int expectedLength = TEXTURE_SIZE * LEGACY_TEXTURE_HEIGHT * COLOR_CHANNELS; + + if (pixels.length != expectedLength) { + throw new IllegalStateException( + "Invalid generated horizon texture data length: " + pixels.length + ); + } + + NativeImage image = new NativeImage(TEXTURE_SIZE, TEXTURE_SIZE, true); + int sourceIndex = 0; + + for (int y = 0; y < LEGACY_TEXTURE_HEIGHT; y++) { + for (int x = 0; x < TEXTURE_SIZE; x++) { + int red = Byte.toUnsignedInt(pixels[sourceIndex++]); + int green = Byte.toUnsignedInt(pixels[sourceIndex++]); + int blue = Byte.toUnsignedInt(pixels[sourceIndex++]); + image.setPixel(x, y, argb(red, green, blue)); + } + } + + expandLegacyLimbs(image); + return image; + } + + private static byte[] decodeSource() { + byte[] compressed = Base64.getDecoder().decode(SOURCE); + + try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + return input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Failed to decode generated horizon texture", exception); + } + } + + private static void expandLegacyLimbs(NativeImage image) { + image.fillRect(0, LEGACY_TEXTURE_HEIGHT, TEXTURE_SIZE, LEGACY_TEXTURE_HEIGHT, 0); + + // Mirror the legacy right leg into the modern left-leg slots. + image.copyRect(4, 16, 16, 32, 4, 4, true, false); + image.copyRect(8, 16, 16, 32, 4, 4, true, false); + image.copyRect(0, 20, 24, 32, 4, 12, true, false); + image.copyRect(4, 20, 16, 32, 4, 12, true, false); + image.copyRect(8, 20, 8, 32, 4, 12, true, false); + image.copyRect(12, 20, 16, 32, 4, 12, true, false); + + // Mirror the legacy right arm into the modern left-arm slots. + image.copyRect(44, 16, -8, 32, 4, 4, true, false); + image.copyRect(48, 16, -8, 32, 4, 4, true, false); + image.copyRect(40, 20, 0, 32, 4, 12, true, false); + image.copyRect(44, 20, -8, 32, 4, 12, true, false); + image.copyRect(48, 20, -16, 32, 4, 12, true, false); + image.copyRect(52, 20, -8, 32, 4, 12, true, false); + } + + private static int argb(int red, int green, int blue) { + return 0xFF000000 | red << 16 | green << 8 | blue; + } +} diff --git a/src/main/java/cz/xefensor/retold/mixin/BlockEntityVillageStorageKnowledgeMixin.java b/src/main/java/cz/xefensor/retold/mixin/BlockEntityVillageStorageKnowledgeMixin.java new file mode 100644 index 00000000..32a334d9 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/mixin/BlockEntityVillageStorageKnowledgeMixin.java @@ -0,0 +1,22 @@ +package cz.xefensor.retold.mixin; + +import cz.xefensor.retold.villager.RetoldVillageContainerOwnership; + +import net.minecraft.world.level.block.entity.BlockEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Event-time storage observation; this does not add a block-entity tick path. + */ +@Mixin(BlockEntity.class) +public abstract class BlockEntityVillageStorageKnowledgeMixin { + @Inject(method = "setChanged", at = @At("TAIL")) + private void retold$refreshVillageStorageKnowledge(CallbackInfo ci) { + RetoldVillageContainerOwnership.onContainerChanged( + (BlockEntity) (Object) this + ); + } +} diff --git a/src/main/java/cz/xefensor/retold/module/RetoldAtmosphereModule.java b/src/main/java/cz/xefensor/retold/module/RetoldAtmosphereModule.java new file mode 100644 index 00000000..112fa51f --- /dev/null +++ b/src/main/java/cz/xefensor/retold/module/RetoldAtmosphereModule.java @@ -0,0 +1,13 @@ +package cz.xefensor.retold.module; + +import cz.xefensor.retold.ambient.RetoldHorizonEvents; +import net.neoforged.bus.api.IEventBus; + +public final class RetoldAtmosphereModule { + private RetoldAtmosphereModule() { + } + + public static void registerGameBus(IEventBus gameEventBus) { + gameEventBus.register(RetoldHorizonEvents.class); + } +} diff --git a/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java b/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java index 02c4c9e8..e1beb956 100644 --- a/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java +++ b/src/main/java/cz/xefensor/retold/module/RetoldSubsystems.java @@ -27,6 +27,7 @@ private static void registerGameBus(IEventBus gameEventBus) { RetoldMobModule.registerGameBus(gameEventBus); RetoldWorldgenModule.registerGameBus(gameEventBus); RetoldAenderModule.registerGameBus(gameEventBus); + RetoldAtmosphereModule.registerGameBus(gameEventBus); RetoldFactionModule.registerGameBus(gameEventBus); RetoldTerritoryModule.registerGameBus(gameEventBus); RetoldBehaviorModule.registerGameBus(gameEventBus); diff --git a/src/main/java/cz/xefensor/retold/network/RetoldHorizonCuePayload.java b/src/main/java/cz/xefensor/retold/network/RetoldHorizonCuePayload.java new file mode 100644 index 00000000..ddb4d914 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/network/RetoldHorizonCuePayload.java @@ -0,0 +1,31 @@ +package cz.xefensor.retold.network; + +import cz.xefensor.retold.Retold; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +public record RetoldHorizonCuePayload( + long phase, + int durationTicks +) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.fromNamespaceAndPath(Retold.MODID, "horizon_ambient") + ); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.LONG, + RetoldHorizonCuePayload::phase, + ByteBufCodecs.VAR_INT, + RetoldHorizonCuePayload::durationTicks, + RetoldHorizonCuePayload::new + ); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/cz/xefensor/retold/network/RetoldNetworking.java b/src/main/java/cz/xefensor/retold/network/RetoldNetworking.java index 3f8c98d4..02c862d3 100644 --- a/src/main/java/cz/xefensor/retold/network/RetoldNetworking.java +++ b/src/main/java/cz/xefensor/retold/network/RetoldNetworking.java @@ -6,6 +6,7 @@ import cz.xefensor.retold.client.enchanting.RetoldEnchantingScreenFeedback; import cz.xefensor.retold.client.recipe.RetoldClientRecipeKnowledge; import cz.xefensor.retold.client.render.RetoldChronolithBeamClient; +import cz.xefensor.retold.client.render.RetoldHorizonAmbientClient; import cz.xefensor.retold.client.stage.RetoldClientStage; import cz.xefensor.retold.enchanting.RetoldEnchantingMenuActions; import cz.xefensor.retold.stage.RetoldWorldStage; @@ -126,6 +127,12 @@ public static void registerPayloads(RegisterPayloadHandlersEvent event) { (payload, context) -> RetoldChronolithBeamClient.handleSync(payload) ); + registrar.playToClient( + RetoldHorizonCuePayload.TYPE, + RetoldHorizonCuePayload.STREAM_CODEC, + (payload, context) -> RetoldHorizonAmbientClient.handleCue(payload) + ); + registrar.playToClient( RetoldTeachingPreviewPayload.TYPE, RetoldTeachingPreviewPayload.STREAM_CODEC, diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java index 4e56d26f..2887bc11 100644 --- a/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java +++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillageContainerOwnership.java @@ -16,6 +16,7 @@ import net.minecraft.world.level.block.BarrelBlock; import net.minecraft.world.level.block.ChestBlock; import net.minecraft.world.level.block.entity.BaseContainerBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.storage.loot.LootTable; import java.util.ArrayList; @@ -97,6 +98,34 @@ public static void afterLootUnpack(RandomizableContainer container) { storage.getBlockPos(), snapshot(storage).items() ); + RetoldVillageStorageKnowledge.observe( + level, + storage.getBlockPos(), + storage + ); + } + + /** + * Keeps already-known or village-local storage knowledge current when + * vanilla, players, hoppers, or another mod changes a chest or barrel. + */ + public static void onContainerChanged(BlockEntity blockEntity) { + if (!(blockEntity instanceof BaseContainerBlockEntity storage) + || !(storage.getLevel() instanceof ServerLevel level) + || !isVillageStorage(storage)) { + return; + } + + BlockPos pos = storage.getBlockPos(); + RetoldVillageStorageKnowledge knowledge = + RetoldVillageStorageKnowledge.get(level); + + if (knowledge.knowsStorage(level, pos) + || RetoldVillageContainerOwnershipData.get(level) + .totalOwned(level, pos) > 0 + || level.isCloseToVillage(pos, 1)) { + RetoldVillageStorageKnowledge.observe(level, pos, storage); + } } public static PlayerTransaction beginPlayerTransaction( @@ -141,11 +170,17 @@ public static void finishPlayerTransaction(PlayerTransaction transaction) { continue; } + InventorySnapshot after = snapshot(storage); stolen += applyPlayerChanges( level, storage.getBlockPos(), entry.getValue(), - snapshot(storage) + after + ); + RetoldVillageStorageKnowledge.observe( + level, + storage.getBlockPos(), + storage ); } @@ -187,13 +222,19 @@ static void finishSystemMutation( continue; } + InventorySnapshot after = snapshot(storage); applySystemChanges( level, storage.getBlockPos(), entry.getValue(), - snapshot(storage), + after, additionsAreVillageOwned ); + RetoldVillageStorageKnowledge.observe( + level, + storage.getBlockPos(), + storage + ); } } @@ -208,6 +249,7 @@ static int handleProtectedContainerBreak( RetoldVillageContainerOwnershipData data = RetoldVillageContainerOwnershipData.get(level); + RetoldVillageStorageKnowledge.forget(level, pos); if (level.getBlockEntity(pos) instanceof BaseContainerBlockEntity storage && isVillageStorage(storage)) { diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillageStorageKnowledge.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillageStorageKnowledge.java new file mode 100644 index 00000000..332120c1 --- /dev/null +++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillageStorageKnowledge.java @@ -0,0 +1,525 @@ +package cz.xefensor.retold.villager; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import cz.xefensor.retold.Retold; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Container; +import net.minecraft.world.entity.npc.villager.Villager; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Persistent, shared knowledge of storage that villagers have observed. + * Entries are indexed by chunk so a lookup never scans every known container. + * The physical container remains authoritative and is revalidated before use. + */ +final class RetoldVillageStorageKnowledge extends SavedData { + private static final int SAVE_VERSION = 1; + + private static final Codec STORAGE_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("dimension").forGetter( + SerializedStorage::dimension + ), + Codec.LONG.fieldOf("position").forGetter( + SerializedStorage::position + ), + Codec.INT.fieldOf("slots").forGetter( + SerializedStorage::slots + ), + ItemStack.CODEC.listOf().fieldOf("items").forGetter( + SerializedStorage::items + ) + ).apply(instance, SerializedStorage::new)); + + private static final Codec RAW_CODEC = + RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("version").forGetter( + SerializedState::version + ), + STORAGE_CODEC.listOf().fieldOf("storages").forGetter( + SerializedState::storages + ) + ).apply(instance, SerializedState::new)); + + private static final Codec CODEC = + RAW_CODEC.flatXmap( + RetoldVillageStorageKnowledge::decode, + RetoldVillageStorageKnowledge::encode + ); + + private static final SavedDataType TYPE = + new SavedDataType<>( + Identifier.fromNamespaceAndPath( + Retold.MODID, + "village_storage_knowledge" + ), + RetoldVillageStorageKnowledge::new, + CODEC + ); + + private final Map storages = new HashMap<>(); + private final Map> storagesByChunk = + new HashMap<>(); + + private RetoldVillageStorageKnowledge() { + } + + private RetoldVillageStorageKnowledge(SerializedState state) { + for (SerializedStorage serialized : state.storages()) { + StorageKey key = new StorageKey( + serialized.dimension(), + serialized.position() + ); + put( + key, + new KnownStorage( + serialized.slots(), + copyItems(serialized.items()) + ) + ); + } + } + + static RetoldVillageStorageKnowledge get(ServerLevel level) { + return level.getServer().getDataStorage().computeIfAbsent(TYPE); + } + + static void observe( + ServerLevel level, + BlockPos pos, + Container container + ) { + if (level == null || pos == null || container == null) { + return; + } + + get(level).observeStorage(level, pos, container); + } + + static void forget(ServerLevel level, BlockPos pos) { + if (level != null && pos != null) { + get(level).forgetStorage(level, pos); + } + } + + static List foodCandidates( + ServerLevel level, + BlockPos center, + BlockPos villageAnchor, + int horizontalRadius, + int verticalRadius, + int villageRadius + ) { + return get(level).candidates( + level, + center, + villageAnchor, + horizontalRadius, + verticalRadius, + villageRadius, + RetoldVillageStorageKnowledge::hasVillagerFood + ); + } + + static List itemCandidates( + ServerLevel level, + BlockPos center, + BlockPos villageAnchor, + int horizontalRadius, + int verticalRadius, + int villageRadius, + ItemStack wanted, + int minimumCount + ) { + return get(level).candidates( + level, + center, + villageAnchor, + horizontalRadius, + verticalRadius, + villageRadius, + storage -> hasItem(storage, wanted, minimumCount) + ); + } + + static List depositCandidates( + ServerLevel level, + BlockPos center, + BlockPos villageAnchor, + int horizontalRadius, + int verticalRadius, + int villageRadius, + ItemStack offered + ) { + return get(level).candidates( + level, + center, + villageAnchor, + horizontalRadius, + verticalRadius, + villageRadius, + storage -> hasRoomFor(storage, offered) + ); + } + + synchronized SerializedState serializeState() { + List serialized = new ArrayList<>(); + + for (Map.Entry entry : storages.entrySet()) { + serialized.add(new SerializedStorage( + entry.getKey().dimension(), + entry.getKey().position(), + entry.getValue().slots(), + copyItems(entry.getValue().items()) + )); + } + + return new SerializedState(SAVE_VERSION, List.copyOf(serialized)); + } + + static RetoldVillageStorageKnowledge fromSerializedState( + SerializedState state + ) { + validate(state); + return new RetoldVillageStorageKnowledge(state); + } + + synchronized boolean knowsItem( + ServerLevel level, + BlockPos pos, + ItemStack wanted, + int minimumCount + ) { + KnownStorage storage = storages.get(key(level, pos)); + return storage != null && hasItem(storage, wanted, minimumCount); + } + + synchronized boolean knowsStorage(ServerLevel level, BlockPos pos) { + return storages.containsKey(key(level, pos)); + } + + private synchronized void observeStorage( + ServerLevel level, + BlockPos pos, + Container container + ) { + List items = new ArrayList<>(); + + for (int slot = 0; slot < container.getContainerSize(); slot++) { + ItemStack stack = container.getItem(slot); + + if (!stack.isEmpty()) { + items.add(stack.copy()); + } + } + + put( + key(level, pos), + new KnownStorage(container.getContainerSize(), List.copyOf(items)) + ); + setDirty(); + } + + private synchronized void forgetStorage( + ServerLevel level, + BlockPos pos + ) { + StorageKey key = key(level, pos); + + if (storages.remove(key) == null) { + return; + } + + ChunkKey chunkKey = chunkKey(key); + Set chunkStorages = storagesByChunk.get(chunkKey); + + if (chunkStorages != null) { + chunkStorages.remove(key); + + if (chunkStorages.isEmpty()) { + storagesByChunk.remove(chunkKey); + } + } + + setDirty(); + } + + private synchronized List candidates( + ServerLevel level, + BlockPos center, + BlockPos villageAnchor, + int horizontalRadius, + int verticalRadius, + int villageRadius, + Predicate purpose + ) { + if (level == null + || center == null + || villageAnchor == null + || purpose == null) { + return List.of(); + } + + int minChunkX = Math.floorDiv(center.getX() - horizontalRadius, 16); + int maxChunkX = Math.floorDiv(center.getX() + horizontalRadius, 16); + int minChunkZ = Math.floorDiv(center.getZ() - horizontalRadius, 16); + int maxChunkZ = Math.floorDiv(center.getZ() + horizontalRadius, 16); + Identifier dimension = level.dimension().identifier(); + List matches = new ArrayList<>(); + + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + if (!level.hasChunk(chunkX, chunkZ)) { + continue; + } + + Set chunkStorages = storagesByChunk.get( + new ChunkKey( + dimension, + ChunkPos.pack(chunkX, chunkZ) + ) + ); + + if (chunkStorages == null) { + continue; + } + + for (StorageKey storageKey : chunkStorages) { + BlockPos pos = BlockPos.of(storageKey.position()); + KnownStorage storage = storages.get(storageKey); + + if (storage != null + && isInsideSearch( + center, + pos, + horizontalRadius, + verticalRadius + ) + && pos.distSqr(villageAnchor) + <= (double) villageRadius * villageRadius + && purpose.test(storage)) { + matches.add(pos); + } + } + } + } + + matches.sort( + Comparator.comparingDouble( + (BlockPos pos) -> center.distSqr(pos) + ).thenComparingLong(BlockPos::asLong) + ); + return List.copyOf(matches); + } + + private void put(StorageKey key, KnownStorage storage) { + storages.put(key, storage); + storagesByChunk.computeIfAbsent( + chunkKey(key), + ignored -> new HashSet<>() + ).add(key); + } + + private static boolean hasVillagerFood(KnownStorage storage) { + for (ItemStack stack : storage.items()) { + if (Villager.FOOD_POINTS.getOrDefault(stack.getItem(), 0) > 0) { + return true; + } + } + + return false; + } + + private static boolean hasItem( + KnownStorage storage, + ItemStack wanted, + int minimumCount + ) { + if (wanted == null || wanted.isEmpty()) { + return false; + } + + int found = 0; + + for (ItemStack stored : storage.items()) { + if (ItemStack.isSameItemSameComponents(stored, wanted)) { + found += stored.getCount(); + + if (found >= Math.max(1, minimumCount)) { + return true; + } + } + } + + return false; + } + + private static boolean hasRoomFor( + KnownStorage storage, + ItemStack offered + ) { + if (offered == null || offered.isEmpty()) { + return false; + } + + if (storage.items().size() < storage.slots()) { + return true; + } + + for (ItemStack stored : storage.items()) { + if (ItemStack.isSameItemSameComponents(stored, offered) + && stored.getCount() < stored.getMaxStackSize()) { + return true; + } + } + + return false; + } + + private static boolean isInsideSearch( + BlockPos center, + BlockPos pos, + int horizontalRadius, + int verticalRadius + ) { + int dx = pos.getX() - center.getX(); + int dy = Math.abs(pos.getY() - center.getY()); + int dz = pos.getZ() - center.getZ(); + return dy <= verticalRadius + && dx * dx + dz * dz <= horizontalRadius * horizontalRadius; + } + + private static StorageKey key(ServerLevel level, BlockPos pos) { + return new StorageKey( + level.dimension().identifier(), + pos.asLong() + ); + } + + private static ChunkKey chunkKey(StorageKey key) { + BlockPos pos = BlockPos.of(key.position()); + return new ChunkKey( + key.dimension(), + ChunkPos.pack(pos.getX() >> 4, pos.getZ() >> 4) + ); + } + + private static List copyItems(List items) { + List copies = new ArrayList<>(); + + if (items != null) { + for (ItemStack item : items) { + if (item != null && !item.isEmpty()) { + copies.add(item.copy()); + } + } + } + + return List.copyOf(copies); + } + + private static DataResult decode( + SerializedState state + ) { + try { + return DataResult.success(fromSerializedState(state)); + } catch (IllegalArgumentException exception) { + return DataResult.error(exception::getMessage); + } + } + + private static DataResult encode( + RetoldVillageStorageKnowledge data + ) { + return DataResult.success(data.serializeState()); + } + + private static void validate(SerializedState state) { + if (state == null || state.version() != SAVE_VERSION) { + throw new IllegalArgumentException( + "Unsupported village storage knowledge version" + ); + } + + if (state.storages() == null) { + throw new IllegalArgumentException( + "Village storage knowledge has no storage list" + ); + } + + Set seen = new HashSet<>(); + + for (SerializedStorage storage : state.storages()) { + if (storage == null + || storage.dimension() == null + || storage.slots() <= 0 + || storage.items() == null) { + throw new IllegalArgumentException( + "Invalid village storage knowledge entry" + ); + } + + StorageKey key = new StorageKey( + storage.dimension(), + storage.position() + ); + + if (!seen.add(key)) { + throw new IllegalArgumentException( + "Duplicate village storage knowledge entry" + ); + } + + if (storage.items().size() > storage.slots()) { + throw new IllegalArgumentException( + "Village storage knowledge contains too many stacks" + ); + } + + for (ItemStack item : storage.items()) { + if (item == null || item.isEmpty()) { + throw new IllegalArgumentException( + "Invalid known village storage item" + ); + } + } + } + } + + record SerializedState(int version, List storages) { + } + + record SerializedStorage( + Identifier dimension, + long position, + int slots, + List items + ) { + } + + private record StorageKey(Identifier dimension, long position) { + } + + private record ChunkKey(Identifier dimension, long chunk) { + } + + private record KnownStorage(int slots, List items) { + } +} diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java index dd36b7d9..e8886f63 100644 --- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java +++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodGameTests.java @@ -5,6 +5,7 @@ import cz.xefensor.retold.behavior.control.RetoldAiControlMode; import cz.xefensor.retold.behavior.control.RetoldAiControlOwner; import cz.xefensor.retold.behavior.ecology.RetoldUnloadedEcosystemCatchUp; +import cz.xefensor.retold.behavior.performance.RetoldAiWorkBudget; import cz.xefensor.retold.behavior.profiles.RetoldMobState; import cz.xefensor.retold.behavior.profiles.RetoldMobStates; import cz.xefensor.retold.behavior.profiles.RetoldMobRules; @@ -24,6 +25,7 @@ import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.world.entity.npc.villager.Villager; import net.minecraft.world.entity.npc.villager.VillagerProfession; +import net.minecraft.world.entity.schedule.Activity; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.block.Blocks; @@ -91,6 +93,13 @@ public static void register(RegisterGameTestsEvent event) { 80, RetoldVillagerCommunalFoodGameTests::villagerCommunalFoodStaysInsideItsVillageAndYieldsToPanic ); + registerTest( + event, + environment, + "villagers_share_persistent_knowledge_of_village_storage", + 80, + RetoldVillagerCommunalFoodGameTests::villagersSharePersistentStorageKnowledge + ); registerTest( event, pathingEnvironment, @@ -195,6 +204,148 @@ private static void villagerCommunalFoodConsumesAndPersistsExactlyOneItem( helper.succeed(); } + private static void villagersSharePersistentStorageKnowledge( + GameTestHelper helper + ) { + placeFloor(helper, 1, 6, 1, 4); + BlockPos chestPos = new BlockPos(4, 2, 2); + BlockPos absoluteChest = helper.absolutePos(chestPos); + helper.setBlock(chestPos, Blocks.CHEST); + Container chest = containerAt(helper, chestPos); + chest.setItem(0, new ItemStack(Items.BREAD, 2)); + chest.setItem(1, new ItemStack(Items.EMERALD)); + chest.setItem(2, new ItemStack(Items.NAUTILUS_SHELL, 3)); + chest.setChanged(); + + Villager scout = helper.spawn(EntityTypes.VILLAGER, 2, 2, 2); + Villager informed = helper.spawn(EntityTypes.VILLAGER, 3, 2, 2); + setVillageHome(helper, scout, new BlockPos(2, 2, 3)); + setVillageHome(helper, informed, new BlockPos(2, 2, 3)); + long gameTime = helper.getLevel().getGameTime(); + + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.findWithItem( + helper.getLevel(), + scout, + Items.EMERALD.getDefaultInstance(), + gameTime, + 100 + ), + absoluteChest, + "The first Villager must discover the emerald storage" + ); + RetoldVillagerCommunalFoodSearch.forget(scout); + RetoldVillagerCommunalFoodSearch.forget(informed); + + RetoldVillageStorageKnowledge saved = + RetoldVillageStorageKnowledge.get(helper.getLevel()); + RetoldVillageStorageKnowledge restored = + RetoldVillageStorageKnowledge.fromSerializedState( + saved.serializeState() + ); + helper.assertTrue( + restored.knowsItem( + helper.getLevel(), + absoluteChest, + Items.EMERALD.getDefaultInstance(), + 1 + ) + && restored.knowsItem( + helper.getLevel(), + absoluteChest, + Items.NAUTILUS_SHELL.getDefaultInstance(), + 3 + ), + "Shared knowledge must preserve exact arbitrary item contents across saves" + ); + + chest.setItem(3, Items.DIAMOND.getDefaultInstance()); + chest.setChanged(); + helper.assertTrue( + RetoldVillageStorageKnowledge.get(helper.getLevel()).knowsItem( + helper.getLevel(), + absoluteChest, + Items.DIAMOND.getDefaultInstance(), + 1 + ), + "A known chest change must refresh shared knowledge without polling" + ); + + while (RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) { + // Exhaust this tick's world-search allowance. Knowledge lookups must still work. + } + + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.find( + helper.getLevel(), + informed, + gameTime, + 100 + ), + absoluteChest, + "An informed Villager must locate food without another world scan" + ); + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.findWithItem( + helper.getLevel(), + informed, + Items.EMERALD.getDefaultInstance(), + gameTime, + 100 + ), + absoluteChest, + "Shared knowledge must locate emeralds without another world scan" + ); + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.findWithItemCount( + helper.getLevel(), + informed, + Items.NAUTILUS_SHELL.getDefaultInstance(), + 3, + gameTime, + 100 + ), + absoluteChest, + "Shared knowledge must locate any requested stored item and count" + ); + + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.takeOne( + helper.getLevel(), + informed, + absoluteChest, + Items.EMERALD.getDefaultInstance() + ), + 1, + "The informed Villager must take the physical emerald" + ); + helper.assertFalse( + RetoldVillageStorageKnowledge.get(helper.getLevel()).knowsItem( + helper.getLevel(), + absoluteChest, + Items.EMERALD.getDefaultInstance(), + 1 + ), + "Shared knowledge must update immediately after a Villager withdrawal" + ); + helper.assertTrue( + RetoldVillagerCommunalFoodSearch.findWithItem( + helper.getLevel(), + informed, + Items.EMERALD.getDefaultInstance(), + gameTime, + 100 + ) == null, + "Villagers must not route to an emerald that is no longer present" + ); + + RetoldVillageStorageKnowledge.forget(helper.getLevel(), absoluteChest); + chest.clearContent(); + scout.discard(); + informed.discard(); + helper.succeed(); + } + private static void villagerUsesPersonalFoodBeforeStorage( GameTestHelper helper ) { @@ -690,6 +841,18 @@ private static void farmerPathsToStockCommunalFoodStorage( helper.getLevel().getGameTime() ); state.setHunger(0); + helper.assertValueEqual( + RetoldVillagerCommunalFoodSearch.findForDeposit( + helper.getLevel(), + farmer, + Items.BREAD.getDefaultInstance(), + helper.getLevel().getGameTime(), + 100 + ), + helper.absolutePos(barrelPos), + "The Farmer must resolve its village storage before routing" + ); + RetoldVillagerCommunalFoodSearch.forget(farmer); driveCommunalSupplyUntilStocked(helper, farmer, barrel, 48); helper.succeedWhen(() -> { @@ -791,6 +954,7 @@ private static void driveCommunalSupplyUntilStocked( return; } + farmer.getBrain().setActiveActivityIfPossible(Activity.IDLE); RetoldVillagerCommunalSupply.tick( helper.getLevel(), farmer, diff --git a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java index 5594ac7d..f3a82aef 100644 --- a/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java +++ b/src/main/java/cz/xefensor/retold/villager/RetoldVillagerCommunalFoodSearch.java @@ -59,6 +59,7 @@ static synchronized BlockPos find( StorageTarget cached = FOOD_TARGETS.get(villager); if (cached != null + && cached.target() != null && gameTime < cached.expiresAt() && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED && context.anchor().equals(cached.villageAnchor()) @@ -68,6 +69,34 @@ && isValidForFood(level, villager, context, cached.target())) { return cached.target(); } + BlockPos known = knownFoodTarget(level, villager, context, center); + + if (known != null) { + DEFERRED_FOOD_SEARCHES.remove(villager); + RetoldBehaviorPerf.recordBlockSearchCache(true); + FOOD_TARGETS.put( + villager, + new StorageTarget( + center.immutable(), + context.anchor(), + cacheExpiry(villager, gameTime, cacheTicks), + known + ) + ); + return known; + } + + if (isReusableNegativeTarget( + cached, + center, + context.anchor(), + gameTime + )) { + DEFERRED_FOOD_SEARCHES.remove(villager); + RetoldBehaviorPerf.recordBlockSearchCache(true); + return null; + } + if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) { DEFERRED_FOOD_SEARCHES.put(villager, Boolean.TRUE); RetoldBehaviorPerf.recordBlockSearchCache(false); @@ -84,10 +113,7 @@ && isValidForFood(level, villager, context, cached.target())) { new StorageTarget( center.immutable(), context.anchor(), - gameTime + Math.max( - MIN_CACHE_TICKS, - RetoldAiLod.cacheTicks(villager, cacheTicks) - ), + cacheExpiry(villager, gameTime, cacheTicks), target ) ); @@ -123,6 +149,7 @@ static synchronized BlockPos findForDeposit( DepositTarget cached = DEPOSIT_TARGETS.get(villager); if (cached != null + && cached.target() != null && ItemStack.isSameItemSameComponents(cached.offered(), offered) && gameTime < cached.expiresAt() && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED @@ -139,6 +166,41 @@ && isValidForDeposit( return cached.target(); } + BlockPos known = knownDepositTarget( + level, + villager, + context, + center, + offered + ); + + if (known != null) { + DEFERRED_DEPOSIT_SEARCHES.remove(villager); + RetoldBehaviorPerf.recordBlockSearchCache(true); + DEPOSIT_TARGETS.put( + villager, + new DepositTarget( + center.immutable(), + context.anchor(), + cacheExpiry(villager, gameTime, cacheTicks), + known, + offered.copyWithCount(1) + ) + ); + return known; + } + + if (cached != null + && cached.target() == null + && ItemStack.isSameItemSameComponents(cached.offered(), offered) + && gameTime < cached.expiresAt() + && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED + && context.anchor().equals(cached.villageAnchor())) { + DEFERRED_DEPOSIT_SEARCHES.remove(villager); + RetoldBehaviorPerf.recordBlockSearchCache(true); + return null; + } + if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) { DEFERRED_DEPOSIT_SEARCHES.put(villager, Boolean.TRUE); RetoldBehaviorPerf.recordBlockSearchCache(false); @@ -155,10 +217,7 @@ && isValidForDeposit( new DepositTarget( center.immutable(), context.anchor(), - gameTime + Math.max( - MIN_CACHE_TICKS, - RetoldAiLod.cacheTicks(villager, cacheTicks) - ), + cacheExpiry(villager, gameTime, cacheTicks), target, offered.copyWithCount(1) ) @@ -232,6 +291,7 @@ static synchronized BlockPos findWithItemCount( ItemTarget cached = ITEM_TARGETS.get(villager); if (cached != null + && cached.target() != null && ItemStack.isSameItemSameComponents(cached.wanted(), wanted) && cached.minimumCount() == required && gameTime < cached.expiresAt() @@ -249,6 +309,42 @@ && isValidForItem( return cached.target(); } + BlockPos known = knownItemTarget( + level, + villager, + context, + center, + wanted, + required + ); + + if (known != null) { + RetoldBehaviorPerf.recordBlockSearchCache(true); + ITEM_TARGETS.put( + villager, + new ItemTarget( + center.immutable(), + context.anchor(), + cacheExpiry(villager, gameTime, cacheTicks), + known, + wanted.copyWithCount(1), + required + ) + ); + return known; + } + + if (cached != null + && cached.target() == null + && ItemStack.isSameItemSameComponents(cached.wanted(), wanted) + && cached.minimumCount() == required + && gameTime < cached.expiresAt() + && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED + && context.anchor().equals(cached.villageAnchor())) { + RetoldBehaviorPerf.recordBlockSearchCache(true); + return null; + } + if (!RetoldAiWorkBudget.tryUseBlockSearch(gameTime)) { RetoldBehaviorPerf.recordBlockSearchCache(false); RetoldBehaviorPerf.recordBlockSearchBudgetSkip(); @@ -270,10 +366,7 @@ && isValidForItem( new ItemTarget( center.immutable(), context.anchor(), - gameTime + Math.max( - MIN_CACHE_TICKS, - RetoldAiLod.cacheTicks(villager, cacheTicks) - ), + cacheExpiry(villager, gameTime, cacheTicks), target, wanted.copyWithCount(1), required @@ -462,8 +555,13 @@ private static BlockPos scan( for (BlockPos pos : level.getChunk(chunkX, chunkZ).getBlockEntities().keySet()) { positionsChecked++; - if (!isInsideSearch(center, pos) - || !isValidForPurpose( + if (!isInsideSearch(center, pos)) { + continue; + } + + observeStorage(level, context, pos); + + if (!isValidForPurpose( level, villager, context, @@ -513,8 +611,13 @@ private static BlockPos scanForItem( .getBlockEntities().keySet()) { positionsChecked++; - if (!isInsideSearch(center, pos) - || !isValidForItem( + if (!isInsideSearch(center, pos)) { + continue; + } + + observeStorage(level, context, pos); + + if (!isValidForItem( level, villager, context, @@ -548,6 +651,144 @@ private static boolean isInsideSearch(BlockPos center, BlockPos pos) { && dx * dx + dz * dz <= HORIZONTAL_RADIUS * HORIZONTAL_RADIUS; } + private static BlockPos knownFoodTarget( + ServerLevel level, + Villager villager, + VillageContext context, + BlockPos center + ) { + for (BlockPos pos : RetoldVillageStorageKnowledge.foodCandidates( + level, + center, + context.anchor(), + HORIZONTAL_RADIUS, + VERTICAL_RADIUS, + VILLAGE_RADIUS + )) { + if (isValidForFood(level, villager, context, pos)) { + return pos; + } + + refreshKnowledge(level, pos); + } + + return null; + } + + private static BlockPos knownDepositTarget( + ServerLevel level, + Villager villager, + VillageContext context, + BlockPos center, + ItemStack offered + ) { + for (BlockPos pos : RetoldVillageStorageKnowledge.depositCandidates( + level, + center, + context.anchor(), + HORIZONTAL_RADIUS, + VERTICAL_RADIUS, + VILLAGE_RADIUS, + offered + )) { + if (isValidForDeposit(level, villager, context, pos, offered)) { + return pos; + } + + refreshKnowledge(level, pos); + } + + return null; + } + + private static BlockPos knownItemTarget( + ServerLevel level, + Villager villager, + VillageContext context, + BlockPos center, + ItemStack wanted, + int minimumCount + ) { + for (BlockPos pos : RetoldVillageStorageKnowledge.itemCandidates( + level, + center, + context.anchor(), + HORIZONTAL_RADIUS, + VERTICAL_RADIUS, + VILLAGE_RADIUS, + wanted, + minimumCount + )) { + if (isValidForItem( + level, + villager, + context, + pos, + wanted, + minimumCount + )) { + return pos; + } + + refreshKnowledge(level, pos); + } + + return null; + } + + private static void observeStorage( + ServerLevel level, + VillageContext context, + BlockPos pos + ) { + if (pos.distSqr(context.anchor()) + > VILLAGE_RADIUS * VILLAGE_RADIUS + || !isStorageBlock(level, pos)) { + return; + } + + refreshKnowledge(level, pos); + } + + private static void refreshKnowledge(ServerLevel level, BlockPos pos) { + if (!level.hasChunkAt(pos) || !isStorageBlock(level, pos)) { + RetoldVillageStorageKnowledge.forget(level, pos); + return; + } + + Container container = containerAt(level, pos); + + if (container == null) { + RetoldVillageStorageKnowledge.forget(level, pos); + } else { + RetoldVillageStorageKnowledge.observe(level, pos, container); + } + } + + private static boolean isReusableNegativeTarget( + StorageTarget cached, + BlockPos center, + BlockPos villageAnchor, + long gameTime + ) { + return cached != null + && cached.target() == null + && gameTime < cached.expiresAt() + && center.distSqr(cached.center()) <= MAX_CENTER_DRIFT_SQUARED + && villageAnchor.equals(cached.villageAnchor()); + } + + private static long cacheExpiry( + Villager villager, + long gameTime, + int cacheTicks + ) { + return gameTime + Math.max( + MIN_CACHE_TICKS, + RetoldAiLod.cacheTicks(villager, cacheTicks) + ); + } + private static boolean isValidForPurpose( ServerLevel level, Villager villager, @@ -579,6 +820,7 @@ private static boolean isValidForFood( } if (level.isOutsideBuildHeight(pos) + || !level.hasChunkAt(pos) || pos.distSqr(context.anchor()) > VILLAGE_RADIUS * VILLAGE_RADIUS || !isSupportedStorage(level, pos) || !hasVillagerFood(containerAt(level, pos))) { @@ -602,6 +844,7 @@ private static boolean isValidForDeposit( if (offered == null || offered.isEmpty() || level.isOutsideBuildHeight(pos) + || !level.hasChunkAt(pos) || pos.distSqr(context.anchor()) > VILLAGE_RADIUS * VILLAGE_RADIUS || !isSupportedStorage(level, pos) @@ -648,6 +891,7 @@ private static boolean isValidForItem( if (wanted == null || wanted.isEmpty() || level.isOutsideBuildHeight(pos) + || !level.hasChunkAt(pos) || pos.distSqr(context.anchor()) > VILLAGE_RADIUS * VILLAGE_RADIUS || !isSupportedStorage(level, pos) @@ -676,6 +920,11 @@ private static boolean isSupportedStorage(ServerLevel level, BlockPos pos) { return state.getBlock() instanceof BarrelBlock; } + private static boolean isStorageBlock(ServerLevel level, BlockPos pos) { + var block = level.getBlockState(pos).getBlock(); + return block instanceof ChestBlock || block instanceof BarrelBlock; + } + static Container containerAt(ServerLevel level, BlockPos pos) { var state = level.getBlockState(pos); diff --git a/src/main/resources/retold.mixins.json b/src/main/resources/retold.mixins.json index ac13dc60..eaff952f 100644 --- a/src/main/resources/retold.mixins.json +++ b/src/main/resources/retold.mixins.json @@ -20,6 +20,7 @@ "CompoundContainerAccessor", "HarvestFarmlandMixin", "RandomizableContainerMixin", + "BlockEntityVillageStorageKnowledgeMixin", "VillagerInvoker", "VillagerGolemConstructionMixin", "DelayedStructurePlacementMixin", diff --git a/src/test/java/cz/xefensor/retold/ambient/RetoldHorizonScheduleTest.java b/src/test/java/cz/xefensor/retold/ambient/RetoldHorizonScheduleTest.java new file mode 100644 index 00000000..d8de2370 --- /dev/null +++ b/src/test/java/cz/xefensor/retold/ambient/RetoldHorizonScheduleTest.java @@ -0,0 +1,90 @@ +package cz.xefensor.retold.ambient; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RetoldHorizonScheduleTest { + private static final UUID PLAYER_ID = + UUID.fromString("11111111-2222-3333-4444-555555555555"); + + @Test + void scheduleWaitsUntilDueAndReschedulesFromCurrentTime() { + RetoldHorizonSchedule schedule = new RetoldHorizonSchedule(); + + assertTrue(schedule.scheduleIfAbsent(PLAYER_ID, 1_000L, 240_000L)); + assertFalse(schedule.scheduleIfAbsent(PLAYER_ID, 2_000L, 480_000L)); + assertFalse(schedule.isDue(PLAYER_ID, 240_999L)); + assertTrue(schedule.isDue(PLAYER_ID, 241_000L)); + + schedule.reschedule(PLAYER_ID, 250_000L, 480_000L); + + assertEquals(730_000L, schedule.nextCue(PLAYER_ID)); + assertFalse(schedule.isDue(PLAYER_ID, 729_999L)); + assertTrue(schedule.isDue(PLAYER_ID, 730_000L)); + } + + @Test + void dayIntervalsConvertToExactWorldTicks() { + assertEquals(48_000L, RetoldHorizonSchedule.intervalTicksForDays(2)); + assertEquals(168_000L, RetoldHorizonSchedule.intervalTicksForDays(7)); + assertEquals(120_000L, RetoldHorizonSchedule.intervalTicksForDays(5)); + assertEquals(480_000L, RetoldHorizonSchedule.intervalTicksForDays(20)); + assertThrows( + IllegalArgumentException.class, + () -> RetoldHorizonSchedule.intervalTicksForDays(0) + ); + } + + @Test + void oldLongDelayIsCappedWithoutPostponingSoonerCue() { + RetoldHorizonSchedule schedule = new RetoldHorizonSchedule(); + schedule.scheduleIfAbsent(PLAYER_ID, 1_000L, 2_400_000L); + + assertTrue(schedule.capDelay(PLAYER_ID, 2_000L, 480_000L)); + assertEquals(482_000L, schedule.nextCue(PLAYER_ID)); + assertFalse(schedule.capDelay(PLAYER_ID, 3_000L, 480_000L)); + assertEquals(482_000L, schedule.nextCue(PLAYER_ID)); + } + + @Test + void serializedStateRoundTripPreservesNextCue() { + RetoldHorizonSchedule schedule = new RetoldHorizonSchedule(); + schedule.scheduleIfAbsent(PLAYER_ID, 1_000L, 240_000L); + + RetoldHorizonSchedule restored = RetoldHorizonSchedule.fromSerializedState( + schedule.serialize() + ); + + assertEquals(241_000L, restored.nextCue(PLAYER_ID)); + } + + @Test + void invalidStateIsRejected() { + RetoldHorizonSchedule.PlayerEntry entry = + new RetoldHorizonSchedule.PlayerEntry(PLAYER_ID, 240_000L); + + assertThrows( + IllegalArgumentException.class, + () -> RetoldHorizonSchedule.fromSerializedState( + new RetoldHorizonSchedule.SerializedState(0, List.of()) + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> RetoldHorizonSchedule.fromSerializedState( + new RetoldHorizonSchedule.SerializedState(1, List.of(entry, entry)) + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> new RetoldHorizonSchedule().scheduleIfAbsent(PLAYER_ID, 0L, 0L) + ); + } +}