diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index 11fd0b7f..984747a1 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -1,6 +1,6 @@ # BannerMod Multiplayer Guide -Last updated: 2026-04-30. +Last updated: 2026-05-04. BannerMod adds settlements, workers, armies, political states, and wars. This guide is written for regular server players, not for developers. @@ -40,7 +40,7 @@ During normal play in the Overworld, the small top-right claim HUD shows whether 2. Pick a place for your base in the Overworld. If the map already offers a valid claim action, open it with `M`, right-click the chunk you want, choose `Claim chunk`, then expand by claiming nearby chunks one at a time so the server validates each edit. If the first claim action is unavailable, a successful starter-fort validation now creates the anchor chunk claim automatically for the leader/co-leader state that founded it. Claim cost and currency are server-defined (`AllowClaiming` must be true) and shown in the menu itself. Claim protection is Overworld-only; matching Nether or End X/Z chunks are not protected by Overworld claims. 3. Craft `Settlement Surveyor Tool` and `Building Placement Wand`. Both items are added by the mod and are how you validate your starter fort and register buildings. The surveyor recipe is 2 sticks, 2 oak planks and 1 iron ingot in a stairs-shaped pattern. The wand recipe is one gold block over one stick. 4. Place a `Starter Fort` — the keystone building, no settlement spawns without one. The Surveyor hologram is now a 21x21 U-shaped wooden fort plan: anchor in the courtyard center, medium palisade, four corner towers, an open gate arch with no gate leaves, and 5-block-wide side/back wings reserved for storage and barracks. Build it manually from the hologram, then hold the `Settlement Surveyor Tool`: it draws the gold 3D fort preview, an anchor flag, the required authority box, captured zone outlines, and a compact HUD checklist. Right-click in the air to open the survey board, or shift+right-click any block to reopen it while aiming at your build. In `BOOTSTRAP_FORT`, the tool starts on `AUTHORITY_POINT`; normal right-click the anchor block, then mark two opposite corners around the anchor. After that zone is captured the tool switches to `INTERIOR`; capture one large INTERIOR zone for the whole usable fort, courtyard, and side/back wings. Split fort interior zones are not supported yet, so do not mark only one brown wing or one small room if you expect starter-fort validation to pass. Color key: gold/wood lines show walls and towers, orange marks the authority anchor, blue marks usable interior/courtyard space, and brown marks wings, storage, or barracks space. If you make a mistake, use the board's `Actions` menu instead of hunting hidden gestures: `Cancel Corner A`, `Clear Current Role`, `Reset All Marks`, and `Pin Hologram` are there with explicit disabled reasons. The board and HUD now spell out the selected zone's job, what kind of blocks belong inside it, and role labels float over the hologram itself so you can tell which highlighted volume is authority, interior, sleeping space, storage, or work area. `Pin Hologram` stores a client-side copy of the current anchored preview so the fort skeleton keeps rendering even after you put the surveyor away; re-equip the tool when you want to move zones or validate again. Global blockers are: no selected zone, a missing required role, zone volume `<= 0` or `> 262,144`, anchor outside every selected zone, or for non-fort modes no settlement at the anchor/claim. The starter fort requires `AUTHORITY_POINT` and `INTERIOR`; keep the anchor inside at least one selected zone and ideally inside the authority zone too. The current validator warns, instead of blocking, when the authority zone exists but does not cover the anchor. `INTERIOR` means the usable air volume: air at feet, air at head, solid floor below; roof coverage checks for any roof block 2-8 blocks above those walkable cells. The hologram is a teaching plan, not a strict shape lock: the server validates the marked areas and the actual usable build, not whether every wall matches the preview block-for-block. When the HUD checklist is green, use the board's `Validate` button. -5. After a successful starter-fort validation the settlement bootstraps automatically: the existing claim is bound to a new `SettlementRecord`, or if no claim existed yet the server creates the anchor chunk claim for the founding state first. Starter workers then spawn near the anchor (farmer, miner, lumberjack, builder), and four free citizens spawn for vacancy jobs. Claim growth no longer auto-creates settlements on its own; founding stays gated behind a real validated fort. The farmer is ready immediately because bootstrap seeds a starter crop area. The miner, lumberjack, and builder are intentionally waiting until you create/register a mine, lumber camp, and architect workshop/build area; the bootstrap message and follow-up onboarding prompts now point you at those exact next actions. Free citizens are a separate population source, not the same thing as these starter workers. +5. After a successful starter-fort validation the settlement bootstraps automatically: the existing claim is bound to a new `SettlementRecord`, or if no claim existed yet the server creates the anchor chunk claim for the founding state first. Starter workers then spawn near the anchor (farmer, miner, lumberjack, builder), and four free citizens spawn for vacancy jobs. Claim growth no longer auto-creates settlements on its own; founding stays gated behind a real validated fort. Starter workers now wait for player-marked or validated work areas instead of bootstrap ploughing a field on its own. The farmer needs a crop area, the miner needs a mine, the lumberjack needs a lumber camp, and the builder needs an architect workshop/build area; the bootstrap message and follow-up onboarding prompts point you at those exact next actions. Free citizens are a separate population source, not the same thing as these starter workers. 6. For the other manual buildings keep using the `Settlement Surveyor Tool`: switch modes to `Storage`, `Farm`, `House`, `Mine`, `Lumber Camp`, `Smithy`, `Architect Builder`, or `Barracks`. Each mode now keeps its own hologram visible after you mark zones, highlights the recommended storage/interior/sleeping/work area, auto-advances to the next required role for multi-zone buildings, and shows role-specific build hints directly in the board/HUD so you know whether a volume wants beds, chests, crops, furnaces, drafting space, or walkable interior air. Supported post-anchor modes also expose `Actions -> Suggest Draft`: it scans for likely beds, chests/barrels, farmland plus water, ore or stone faces, logs plus saplings, and furnace/anvil work clusters, then drafts only the still-missing zones into your current survey session. Those suggestions are a helper, not a guaranteed-correct validator: keep them, edit them, clear them, or overwrite them with normal manual marking before pressing `Validate`. Suggested zones never register a building on their own; validation still goes through the same surveyor registration/runtime path as a manual building of that mode. The surveyor still never places blocks; it only previews and validates what the player built. If you loaded or built an imported structure first, open `Actions` on the survey board and turn off the canned guide preview before marking zones. In that imported/post-build flow the overlay keeps only the anchor, your pending corner, and your captured zones visible, so odd imported footprints do not fight the teaching hologram. Validation still goes through the same surveyor registration/runtime path as a manual building of that mode, and it does not need any extra metadata file beside the built structure itself. The `Building Placement Wand` still exists for prefab placement, validation, and registration workflows, but its picker is now paged and less cluttered: fewer entries per page, shorter labels, and most details moved into hover tooltips. It now includes a `Gatehouse` prefab: select it while the wand is in `PLACE` mode and right-click the target block to spawn a roofed twin-tower entrance. This is a prefab-placement workflow, not a starter-fort surveyor mode. The surveyor modes are now enough to build and validate the core starter settlement chain by hand. 7. Nearby unassigned citizens only convert after they physically reach a registered building anchor with an open vacancy. Watch the surveyor/wand feedback after founding or validating buildings: it now tells you which vacancy opened and which building type is the next safe expansion step. 8. Configure workers: right-click a worker to open its inventory and assign a profession or task, or open the worker command screen with `X` and issue group orders. @@ -108,7 +108,7 @@ Important checks: Civilian work-area editors now show a sync state in the top-right corner, an explicit owner reminder when nobody is assigned yet, and per-screen hints for missing seeds/saplings or tunnel settings. Market and storage editors also spell out the next step directly in the settings panel: an open market without a merchant, a closed stall with a merchant assigned, a missing storage route destination, or a blocked courier route now show visible guidance instead of relying on guesswork. Hold `Shift` while moving an area to nudge it by five blocks instead of one. -Right-clicking a worker now opens a compact worker ledger instead of dumping chat lines. The ledger shows owner, authority token, claim relation, assignment, current problem, and transport state in one place. If it shows `Ownership mismatch` or `Foreign claim`, fix claim/state/work-area ownership first; workers only run inside friendly authority and on the correct political side. +Right-clicking a worker now opens a compact worker ledger instead of dumping chat lines. The ledger shows owner, authority token, claim relation, assignment, current problem, and transport state in one place. If it shows `Ownership mismatch` or `Foreign claim`, fix claim/state/work-area ownership first; workers only run inside friendly authority and on the correct political side. The worker ledger, Citizen Profile, and `AI` trace also now phrase routine recovery in one short sentence: what the resident is doing now, what last broke, and whether it is regrouping at home, food, supplies, or cover. The same ledger also has an `Actions` menu. `To Citizen` converts that worker into a free citizen on the server and applies a short auto-assignment pause so the citizen does not instantly snap back into the same vacancy before you can move or repurpose it. `Dismiss` is the explicit removal path: it asks the server to release the worker's current work area and remove the worker entity. Only the worker owner or an admin can dismiss; other players get denied feedback and no worker state changes. @@ -136,6 +136,26 @@ New worker spawns are now gated on free housing. If a claim has no home with fre Citizen-on-citizen birth runs as a separate optional pass (off by default — enable via `CitizenBirthEnabled`). When active, each claim is scanned every `CitizenBirthCooldownTicks` (default ~1 in-game day) for an opposite-gender adult pair; if there is at least one pair, the claim has free housing capacity, the baby cap (`CitizenBirthMaxBabiesPerClaim`) is not yet reached, and the claim's StorageArea containers together hold at least `CitizenBirthFoodMinUnits` vanilla food items (default 8; set to 0 to disable the food precondition), the server spawns a baby citizen at the mother's position. If a settlement runs out of food, births pause (rule emits `NO_FOOD`) until food is restocked into a registered StorageArea inside the claim. The baby becomes an adult after `CitizenBirthGrowUpTicks` (default ~7 days), after which auto-staffing can assign it a profession through the normal flow. Citizen gender is randomized at first spawn and persisted in NBT. +Housing pressure is no longer silently auto-approved. When a homeless or overcrowded household petitions for a house, the settlement ruler now gets a clickable server-side notice and can also review open petitions with `/bannermod society housing list`. The simple first slice supports approve and deny directly from chat, and approved petitions are the ones that proceed into the housing-project path. + +Housing petitions now reserve an explicit family lot inside the claim as soon as the petition is raised. The ruler's notice and `/bannermod society housing list` both show that reserved plot, approved house builds prefer that exact lot, and when the finished home is validated the assignment pass gives it back to the same requesting household before general free-home assignment can take it. + +Settlements can now also raise basic livelihood building requests on their own when workers are idle and key survival infrastructure is missing. The first slice covers `lumber camp`, `mine`, and `animal pen`, and the ruler can review them with `/bannermod society livelihood list` or approve/deny them from clickable chat notices. Only approved requests enter the prefab project pipeline. + +Settlement-spawned workers now start with basic profession tools and try to bind themselves to existing friendly claim work areas of the matching type instead of idling as often after bootstrap. In practice this means a miner, lumberjack, fisherman, or animal farmer can begin working sooner when the settlement already has a registered mine, lumber camp, fishing area, or pen, and their gathered goods still go through settlement storage. + +Claim-grown workers now follow the same cheap work-area rule as starter workers. They can immediately bind to an existing friendly `Crop Area`, `Fishing Area`, `Mine`, `Lumber Area`, or `Animal Pen` inside the claim, but if no suitable player-marked or validated zone exists yet they stay idle and report that missing assignment instead of creating a starter field or fishing area on their own. + +Workers can now also craft replacement basic stone tools for themselves when a nearby crafting table is available and they can get the needed materials. This first slice covers the common survival tools for farmers, lumberjacks, miners, animal farmers, and builders; it is not yet a full workshop economy, but it reduces cases where a worker stalls forever after losing a tool. + +New worker spawns are now gated on free housing. If a claim has no home with free `residentCapacity`, worker birth and claim/settlement spawns are frozen (the rule emits `NO_FREE_HOUSING`). To unfreeze population growth, place another validated house — the next spawn is automatically bound to a free slot and counted against the housing ledger. When a new worker is spawned the settlement now picks the profession by deficit: the allowed profession with the lowest current headcount in the claim wins (ties resolve by declaration order in `allowedProfessions`), so a lost smith is refilled first instead of being skipped by a round-robin cycle. + +Citizen-on-citizen birth runs as a separate optional pass (off by default — enable via `CitizenBirthEnabled`). When active, each claim is scanned every `CitizenBirthCooldownTicks` (default ~1 in-game day) for an opposite-gender adult pair; if there is at least one pair, the claim has free housing capacity, the baby cap (`CitizenBirthMaxBabiesPerClaim`) is not yet reached, and the claim's StorageArea containers together hold at least `CitizenBirthFoodMinUnits` vanilla food items (default 8; set to 0 to disable the food precondition), the server spawns a baby citizen at the mother's position. If a settlement runs out of food, births pause (rule emits `NO_FOOD`) until food is restocked into a registered StorageArea inside the claim. The baby becomes an adult after `CitizenBirthGrowUpTicks` (default ~7 days), after which auto-staffing can assign it a profession through the normal flow. Citizen gender is randomized at first spawn and persisted in NBT. + +Starter-fort bootstrap now seeds 2-4 family households instead of only a flat pile of identical free adults. In practice that means the first settlement population can include married couples, young families, adolescents, and children. Children and adolescents stay with their households and do not auto-convert into worker vacancies until they grow into the adult path. + +Citizen behavior is intentionally cheap and readable rather than memory-driven: daily decisions are centered on work, food, home, rest, and safety. Use the `Kinlot Staff` (`Родовая межа`) near a claimed house lot to inspect which household reserved it; while held it shows the nearest reserved family lot in the action bar, and right-clicking the lot prints the household summary, petition state, and current build-area marker. + The worker command screen (`X`) supports simple group orders: follow, guard, move to position, stop. Governors expose the settlement's mirrored server snapshot: loading/stale/fresh state, citizen count, taxes, incidents, treasury data, policy buttons, and a read-only logistics panel. If the mirror says loading or stale, wait for the server refresh before trusting the panel; policy buttons explain why they are disabled until the server can validate the change. War, claim, governor, and work-area screens now use distinct waiting, empty, stale, and ready labels so you can tell whether to wait, select something, or fix authority. Promote an eligible owned recruit from its inventory when it has enough experience and is tied to a friendly claimed settlement. diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index b311ccbf..458c1dee 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -1,6 +1,6 @@ # BannerMod: мультиплеерный гайд -Обновлено: 2026-04-30. +Обновлено: 2026-05-04. BannerMod добавляет поселения, рабочих, армии, государства и войны. Этот гайд написан для обычных игроков: без кода, без файлов и без технических терминов. @@ -40,7 +40,7 @@ BannerMod добавляет поселения, рабочих, армии, г 2. Выбери место для базы в обычном мире. Если карта уже дает валидное действие клейма, открой ее по `M`, ПКМ по нужному чанку, выбери `Claim chunk`, затем расширяйся соседними чанками по одному, чтобы сервер проверял каждое изменение. Если первый клейм через карту недоступен, успешная валидация стартового форта теперь сама создает якорный чанк-клейм для государства лидера или со-лидера, который основал поселение. Карта прямо показывает причины отключения: ожидание синхронизации, устаревшее редактирование, недоступный чанк или отсутствие власти над соседним клеймом. Стоимость и валюта клейма задаются на сервере (`AllowClaiming` должен быть включен) и видны в самом меню. Защита клеймов работает только в обычном мире; совпадающие X/Z чанки в Nether или End не защищаются клеймами обычного мира. 3. Скрафти `Settlement Surveyor Tool` и `Building Placement Wand`. Оба предмета добавляются модом и нужны, чтобы валидировать стартовый форт и регистрировать здания. Surveyor крафтится из 2 палок, 2 досок и 1 железного слитка по форме лестницы. Wand крафтится из золотого блока над палкой. 4. Поставь стартовый форт (`Starter Fort`) — это ключевое здание, без которого поселение не появится. Голограмма землемера теперь показывает практичный U-план 21x21: якорь в центре подворья, средний деревянный палисад, четыре угловые вышки, открытая арка ворот без створок и боковые/заднее крылья шириной 5 блоков под склад и барак. Строй форт вручную по голограмме, затем возьми `Settlement Surveyor Tool`: он рисует золотую 3D-голограмму форта, флаг якоря, обязательную область власти, контуры захваченных зон и компактный HUD-чеклист. ПКМ в воздухе открывает планшет землемера, а Shift+ПКМ по любому блоку снова открывает его прямо во время разметки. В режиме `BOOTSTRAP_FORT` инструмент начинает с `AUTHORITY_POINT`; обычным ПКМ поставь якорь, затем отметь два противоположных угла вокруг него. После захвата этой зоны инструмент сам переключится на `INTERIOR`; дальше захвати одну большую INTERIOR-зону на весь пригодный для прохода форт, двор и боковые/задние крылья. Несколько отдельных INTERIOR-зон для стартового форта пока не поддерживаются, поэтому не размечай только одно коричневое крыло или одну маленькую комнату, если хочешь успешно пройти валидацию форта. Цвета читаются так: золотые/деревянные линии показывают стены и башни, оранжевый показывает якорь и власть, синий показывает пригодный для прохода интерьер/двор, а коричневый показывает крылья, склад или казарменное пространство. Если ошибся, не ищи скрытые жесты: открой меню `Действия`, там явно лежат `Отменить угол A`, `Очистить текущую роль`, `Сбросить все отметки` и `Закрепить голограмму` с понятными причинами, когда кнопка недоступна. Планшет и HUD теперь прямо пишут, за что отвечает выбранная зона, какие блоки в ней ожидаются, а над самими guide-box голограммы висят подписи ролей, так что видно, какой объем отвечает за власть, интерьер, кровати, склад или работу. `Закрепить голограмму` сохраняет клиентскую копию текущего заякоренного превью, поэтому скелет форта остается видимым даже после того, как ты убрал землемер; чтобы двигать зоны или валидировать их, снова возьми инструмент в руки. Глобальные блокеры такие: нет ни одной зоны, нет обязательной роли, объем зоны `<= 0` или `> 262 144`, якорь вне всех выбранных зон, либо для нефортовых режимов у якоря/клейма нет поселения. Для стартового форта нужны `AUTHORITY_POINT` и `INTERIOR`; держи якорь хотя бы внутри одной выбранной зоны и по возможности внутри зоны власти. Текущий валидатор не блокирует, а только предупреждает, если зона власти существует, но не покрывает якорь. `INTERIOR` означает полезный воздушный объем: воздух в ногах, воздух над головой и твердый пол снизу; крыша проверяется по любому не-air блоку на высоте 2-8 блоков над проходимыми ячейками. Голограмма здесь — учебный план, а не жесткий шаблон: сервер проверяет отмеченные зоны и реально пригодную постройку, а не совпадение каждой стены с превью блок-в-блок. Когда HUD-чеклист зеленый — жми кнопку `Проверить` в GUI. -5. После успешной валидации стартового форта поселение бутстрапится автоматически: существующий клейм привязывается к новому `SettlementRecord`, а если клейма еще не было, сервер сначала создает якорный чанк-клейм для государства основателя. После этого около якоря спавнятся стартовые рабочие (фермер, шахтер, лесоруб, строитель) и четыре свободных жителя для вакансий. Сам по себе рост рабочих больше не создает поселение автоматически; основание по-прежнему закрыто за реальным валидированным фортом. Фермер сразу готов к работе, потому что бутстрап создает стартовую грядку. Шахтер, лесоруб и строитель намеренно ждут, пока ты создашь/зарегистрируешь шахту, лесной лагерь и мастерскую архитектора/стройплощадку; сообщение бутстрапа и новые подсказки после основания прямо называют эти следующие действия. Свободные жители — отдельный источник населения, это не то же самое, что стартовые рабочие. +5. После успешной валидации стартового форта поселение бутстрапится автоматически: существующий клейм привязывается к новому `SettlementRecord`, а если клейма еще не было, сервер сначала создает якорный чанк-клейм для государства основателя. После этого около якоря спавнятся стартовые рабочие (фермер, шахтер, лесоруб, строитель) и четыре свободных жителя для вакансий. Сам по себе рост рабочих больше не создает поселение автоматически; основание по-прежнему закрыто за реальным валидированным фортом. Стартовые рабочие теперь ждут, пока ты сам разметишь или валидируешь рабочие зоны, а бутстрап больше не распахивает стартовую грядку сам по себе. Фермеру нужна `Crop Area`, шахтеру нужна шахта, лесорубу нужен лесной лагерь, а строителю нужна мастерская архитектора/стройплощадка; сообщение бутстрапа и новые подсказки после основания прямо называют эти следующие действия. Свободные жители — отдельный источник населения, это не то же самое, что стартовые рабочие. 6. Для остальных ручных построек продолжай использовать `Settlement Surveyor Tool`: переключай режимы на `Storage`, `Farm`, `House`, `Mine`, `Lumber Camp`, `Smithy`, `Architect Builder` или `Barracks`. У каждого режима теперь своя голограмма, она не исчезает после захвата зон, показывает рекомендуемую область склада/интерьера/спален/работы, сама переключает следующую обязательную роль у многозонных построек и прямо в планшете/HUD объясняет, чего эта зона ждет: кровати, сундуки, посевы, печи, место для чертежей или просто проходимый воздушный интерьер. Поддерживаемые режимы после постановки якоря также получают `Actions -> Черновик по подсказкам`: помощник сканирует рядом кровати, сундуки и бочки, грядки с водой, открытый камень или руду, бревна с саженцами и кластеры печей или наковален, а затем добавляет только недостающие зоны в текущую сессию. Это именно помощник, а не гарантированно точный валидатор: черновик можно оставить, поправить, очистить или просто перерисовать обычной ручной разметкой до нажатия `Проверить`. Сами подсказки никогда не регистрируют здание без обычной валидации. Землемер по-прежнему никогда не ставит блоки сам: он только показывает план и валидирует то, что реально построил игрок. Если ты сначала загрузил или уже построил импортированную структуру, открой `Actions` у планшета землемера и отключи шаблонную голограмму перед разметкой зон. В этом импортированном/post-build потоке остаются видимыми только якорь, ожидаемый угол и отмеченные игроком зоны, поэтому странный силуэт схемы не спорит с учебной голограммой. Проверка все равно идет по тому же пути регистрации и runtime, что и у ручного здания того же режима, и не требует отдельного metadata-файла. `Building Placement Wand` все еще существует для префабов, проверки и регистрации шаблонов, но его селектор теперь разбит на страницы и меньше перегружает экран: меньше вариантов за раз, короче подписи и больше подробностей в ховере. В нем теперь есть префаб `Gatehouse`: выбери его, пока wand стоит в режиме `PLACE`, и ПКМ по целевому блоку, чтобы поставить крытый вход с двумя башнями. Это путь размещения префаба, а не режим surveyor для стартового форта. Цепочку стартовых зданий теперь можно полностью строить и валидировать вручную через режимы землемера. 7. Свободные жители превращаются только когда физически доходят до якоря зарегистрированного здания с открытой вакансией. Следи за сообщениями землемера и жезла после основания или валидации: они теперь пишут, какая вакансия открылась и какой тип здания лучше ставить следующим. 8. Настрой воркеров: открой их инвентарь (ПКМ по воркеру), назначь профессию/задачу, либо открой их командный экран по `X` и раздай приказы группе. @@ -108,7 +108,7 @@ BannerMod добавляет поселения, рабочих, армии, г Гражданские экраны рабочих зон теперь показывают состояние синхронизации в правом верхнем углу, явное напоминание о владельце, если он не назначен, и подсказки про отсутствующие семена/саженцы или настройки шахты. Удерживай `Shift` при перемещении зоны, чтобы сдвигать ее сразу на пять блоков вместо одного. -Правая кнопка по работнику теперь открывает компактную книгу работника вместо россыпи строк в чат. В ней сразу видно владельца, токен власти, отношение к клейму, текущее назначение, проблему и состояние транспорта. Если там написано `Несовпадение владения` или `Чужое владение`, сначала выровняй владение клейма, государства и рабочей зоны: работники работают только внутри дружественной власти и на своей политической стороне. +Правая кнопка по работнику теперь открывает компактную книгу работника вместо россыпи строк в чат. В ней сразу видно владельца, токен власти, отношение к клейму, текущее назначение, проблему и состояние транспорта. Если там написано `Несовпадение владения` или `Чужое владение`, сначала выровняй владение клейма, государства и рабочей зоны: работники работают только внутри дружественной власти и на своей политической стороне. Книга работника, профиль жителя и экран `ИИ` теперь также коротко и по-человечески объясняют восстановление распорядка: что житель делает сейчас, что у него в прошлый раз сорвалось и почему он уходит домой, к еде, к припасам или в укрытие. В той же книге есть меню `Действия`. `В гражданина` серверно превращает работника в свободного жителя и даёт короткую паузу на автоназначение, чтобы житель не прыгнул мгновенно обратно в ту же вакансию до того, как ты его переместишь или переназначишь. `Уволить` — явный путь удаления: он просит сервер освободить текущую рабочую зону работника и убрать сущность работника. Уволить может только владелец работника или администратор; остальные игроки получают отказ, а состояние работника не меняется. @@ -132,6 +132,26 @@ BannerMod добавляет поселения, рабочих, армии, г Гражданин-на-гражданине рождается отдельным циклом (опционально, по умолчанию выключен — включается флагом `CitizenBirthEnabled`). При активном режиме каждый клейм раз в `CitizenBirthCooldownTicks` (по умолчанию ~1 мин-день) проверяется на пару взрослых разных полов; если пара есть, в клейме осталось свободное жилье, не превышен лимит детей (`CitizenBirthMaxBabiesPerClaim`), а суммарный запас ванильной еды в складских зонах (StorageArea) клейма не ниже `CitizenBirthFoodMinUnits` (по умолчанию 8; 0 отключает порог), сервер спавнит ребёнка-гражданина у точки матери. Если в поселении кончилась еда, рождение приостанавливается (правило выдаёт `NO_FOOD`) до тех пор, пока в зарегистрированный склад внутри клейма не положат еду. Ребенок становится взрослым через `CitizenBirthGrowUpTicks` (по умолчанию ~7 дней) — после этого автостаффинг сможет назначить ему профессию обычным путём. Пол гражданина определяется при первом спавне случайно и сохраняется в NBT. +Жилищные прошения больше не проходят скрытым автоодобрением. Если хозяйство оказалось без дома или в тесноте, правитель поселения получает кликабельное серверное уведомление и может также открыть список текущих прошений командой `/bannermod society housing list`. В этом первом срезе решение простое: одобрить или отклонить прямо из чата; дальше в строительный пайплайн уходят только одобренные прошения. + +Теперь такое прошение сразу резервирует конкретный семейный участок внутри клейма. Координаты участка видны в уведомлении правителю и в `/bannermod society housing list`, одобренный дом старается встать именно на этот участок, а после валидации готового дома заселение сначала возвращает его тому самому хозяйству, которое подало прошение. + +Теперь поселение может и само просить базовые хозяйственные постройки, если рабочие простаивают, а ключевой инфраструктуры для выживания не хватает. В первом срезе это `лесной лагерь`, `шахта` и `загон для скота`; правитель смотрит их через `/bannermod society livelihood list` или прямо из кликабельного сообщения в чате. В существующий prefab/project pipeline уходят только одобренные просьбы. + +Работники, которые спавнятся от поселения, теперь стартуют с базовыми инструментами своей профессии и стараются сразу привязаться к уже существующим дружественным рабочим зонам подходящего типа, а не так часто стоять без дела после бутстрапа. На практике шахтёр, лесоруб, рыбак или животновод быстрее начинают работу, если в клейме уже зарегистрированы шахта, лесной лагерь, рыболовная зона или загон, а вся добыча по-прежнему уходит через склад поселения. + +Рабочие, которые появляются через claim-growth, теперь подчиняются тому же дешевому правилу рабочих зон, что и стартовые рабочие. Они могут сразу привязаться к уже существующей дружественной `Crop Area`, `Fishing Area`, шахте, `Lumber Area` или загону внутри клейма, но если подходящую размеченную или валидированную зону игрок еще не создал, рабочий будет ждать и явно показывать, какой именно зоны ему не хватает, вместо того чтобы сам рисовать стартовое поле или рыболовную зону. + +Работники теперь могут и сами крафтить себе замену базовым каменным инструментам, если рядом есть верстак и можно достать нужные материалы. Этот первый срез покрывает обычные survival-инструменты фермера, лесоруба, шахтёра, животновода и строителя; это ещё не полноценная ремесленная экономика, но теперь работник реже застревает навсегда просто потому, что потерял инструмент. + +Появление новых рабочих ограничено свободной вместимостью домов. Если в клейме нет дома со свободным `residentCapacity`, рождение рабочих и спавн через клейм/поселение замораживаются (правило выдает `NO_FREE_HOUSING`). Чтобы возобновить рост населения, поставь дополнительный валидированный дом — следующий спавненный рабочий будет автоматически закреплен за свободным слотом и учтется в учете жилья. При выборе профессии для нового жителя поселение теперь смотрит на дефицит: профессия с самым низким текущим количеством рабочих в клейме выигрывает (равенство — порядок в списке allowedProfessions), поэтому потерянный кузнец восполняется первым, не цикломатически. + +Гражданин-на-гражданине рождается отдельным циклом (опционально, по умолчанию выключен — включается флагом `CitizenBirthEnabled`). При активном режиме каждый клейм раз в `CitizenBirthCooldownTicks` (по умолчанию ~1 мин-день) проверяется на пару взрослых разных полов; если пара есть, в клейме осталось свободное жилье, не превышен лимит детей (`CitizenBirthMaxBabiesPerClaim`), а суммарный запас ванильной еды в складских зонах (StorageArea) клейма не ниже `CitizenBirthFoodMinUnits` (по умолчанию 8; 0 отключает порог), сервер спавнит ребёнка-гражданина у точки матери. Если в поселении кончилась еда, рождение приостанавливается (правило выдаёт `NO_FOOD`) до тех пор, пока в зарегистрированный склад внутри клейма не положат еду. Ребенок становится взрослым через `CitizenBirthGrowUpTicks` (по умолчанию ~7 дней) — после этого автостаффинг сможет назначить ему профессию обычным путём. Пол гражданина определяется при первом спавне случайно и сохраняется в NBT. + +Бутстрап стартового форта теперь засеивает не просто одинаковых взрослых, а 2-4 семейных хозяйства: могут появиться молодожёны, молодые семьи, подростки и дети. Дети и подростки остаются в составе семьи и не уходят автоматически в рабочие вакансии, пока не дойдут до взрослой ветки. + +Поведение жителей намеренно держится на дешёвой и понятной модели, а не на глубокой памяти отношений: решения завязаны на труд, еду, дом, отдых и безопасность. Для просмотра семейных участков используй `Родовую межу` (`Kinlot Staff`). Рядом с отмеченным участком она пишет в action bar, какому хозяйству он принадлежит; ПКМ по участку выводит представителя семьи, статус прошения и текущую стройку. + Командный экран рабочих (`X`) дает простые групповые приказы: следовать, охранять, идти в точку, остановиться. Экраны посланника, губернатора, благородной торговли, патруля и разведчика теперь держат состояние основного действия прямо на экране, а не прячут его в молчаливом сером кнопочном состоянии. Если у курьера не выбран получатель, у дворянина нет доступного контракта или у командира не задан маршрут, экран прямо пишет, какого шага не хватает; после принятия приказа там же появится подтверждение отправки. diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index a11e1c92..49f929a1 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -101,12 +101,11 @@

Taxes and strategy

7. Workers And Citizens

Workers

-

Workers execute registered work areas, storage requests, and settlement work orders. Right-click a worker to open a ledger with owner, authority token, claim relation, assignment, problem text, and transport status. The ledger's Actions menu contains To Citizen and Dismiss: To Citizen turns the worker into a free citizen with a short auto-assignment pause, while Dismiss releases the current work area and removes the worker entity. Dismiss is accepted only from the worker owner or an admin; denied attempts leave worker state unchanged. If the ledger says Ownership mismatch or Foreign claim, fix claim/state/work-area ownership first. Use X for group worker commands such as follow, guard, move, and stop. Civilian work-area editors now show sync state in the top-right corner, warn when no owner is assigned yet, and call out missing seeds, saplings, or tunnel settings directly in the screen. While a work-area screen is open its zone box is visible again, and B toggles a culled overlay of nearby work areas you are allowed to control.

-

Assign Home: citizen profiles, worker ledgers, and recruit inventories have an Assign Home button. It closes the screen and gives you 30 seconds to right-click a bed. The HUD shows remaining time; Esc cancels, and timeout cancels automatically. Cancelled selectors do not change home, and the server only accepts valid beds from the owner or an admin.

+

Workers execute registered work areas, storage requests, and settlement work orders. Right-click a worker to open a ledger with owner, authority token, claim relation, assignment, problem text, and transport status. If the ledger says Ownership mismatch or Foreign claim, fix claim/state/work-area ownership first. Use X for group worker commands such as follow, guard, move, and stop. Civilian work-area editors now show sync state in the top-right corner, warn when no owner is assigned yet, and call out missing seeds, saplings, or tunnel settings directly in the screen. While a work-area screen is open its zone box is visible again, and B toggles a culled overlay of nearby work areas you are allowed to control. Settlement-spawned workers now also start with basic profession tools and try to bind themselves to existing friendly claim work areas of the matching type, but starter-fort bootstrap no longer auto-ploughs a field on its own: starter workers wait for player-marked or validated work areas first. Claim-grown farmers can still seed a starter field for themselves, and claim-grown fishermen can seed a fishing area from nearby water, instead of idling immediately after spawn. When a nearby crafting table and materials are available, workers can also craft replacement basic stone tools for themselves instead of waiting forever for a manual resupply. The worker ledger, Citizen Profile, and AI trace also explain recovery in a short readable line: what the resident is doing now, what last failed, and whether it is regrouping at home, food, supplies, or cover.

Why a worker idles

  1. The worker is not owned by the correct player or political side.
  2. The target work area is outside a friendly claim.
  3. The building was never validated or registered.
  4. The settlement has no matching vacancy or no free citizen.
  5. The required item is missing from storage.
  6. The worker already has another active claim or its previous claim has not been released yet.

Citizens

-

Citizens are unassigned population. Starter bootstrap adds four free citizens. They are consumed by vacancies over time, so housing, safety, and valid building records matter before trying to grow professions. The worker ledger can also convert a worker back into a free citizen and applies a short auto-assignment pause so the same vacancy does not reclaim that citizen instantly.

+

Citizens are unassigned population. Starter bootstrap now seeds 2-4 family households instead of only a flat pile of identical adults, so the first settlement can include newlyweds, young families, adolescents, and children. They are consumed by vacancies over time, so housing, safety, and valid building records matter before trying to grow professions; children and adolescents stay with their households instead of auto-converting into worker vacancies. The worker ledger can also convert a worker back into a free citizen and applies a short auto-assignment pause so the same vacancy does not reclaim that citizen instantly. Housing petitions from homeless or overcrowded households are no longer silently auto-approved: the ruler now gets a clickable chat notice and can review open petitions with /bannermod society housing list; the petition also reserves a concrete family lot inside the claim, approved house builds prefer that lot, and the finished house is handed back to the same requesting household before general free-home assignment can take it. Settlements can now also raise ruler-approved livelihood building requests for a lumber camp, mine, or animal pen through /bannermod society livelihood list when idle workers and missing infrastructure suggest that the village needs to fend for itself. Citizen behavior now stays on the cheap readable model: work, food, home, rest, and safety win over any deeper social simulation. Use the Kinlot Staff (Rodovaya Mezha) near a claimed lot to inspect which household reserved it and which build marker is active there.

Population growth: births and the food gate

An optional server pass grows population from existing citizens. It is off by default and turns on when the server sets CitizenBirthEnabled = true. When active, every claim is checked once per CitizenBirthCooldownTicks (default ~1 in-game day) against five preconditions, and only spawns a baby citizen when all five hold:

  1. Pair: at least one adult male and one adult female citizen are alive in the claim.
  2. Baby cap: current babies in the claim are below CitizenBirthMaxBabiesPerClaim (default 2).
  3. Cooldown: the configured cooldown has elapsed since the last birth in this claim.
  4. Free housing: the claim has a validated home with free residentCapacity.
  5. Food in storage: the claim's StorageArea containers together hold at least CitizenBirthFoodMinUnits vanilla food items (default 8). Setting it to 0 disables the food gate.
@@ -119,7 +118,6 @@

8. Recruits, Orders, And Combat

Recruits obey owner and group authority. All movement, facing, attack, aggression, ranged fire, stance, mount, and siege-machine commands flow through a server command intent pipeline. This preserves selection narrowing, queued orders, priorities, and audit logging.

The recruit command, recruit inventory, hiring, rename, promotion, and group-management screens now keep a visible status line. When an action is disabled, that line or the tooltip tells you the missing next step: choose a company or player, aim at ground or a unit, type a name, or save the company first.

If a crossbowman is holding a musketmod firearm, inspect that recruit's inventory screen for explicit firearm feedback. It now tells you whether the gun is recruit-supported, whether cartridges are present, or whether the weapon is unsupported and should not be expected to fire.

-

Recruit and player perks are server-side: level-ups grant perk points, player kill credit can add perk progress, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and recruit archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry. Open recruit perks from the recruit inventory Perks button; open player skills with K by default. Unlock and respec actions are validated on the server before the refreshed tree returns to the client.

Movement states

StateOrder
0Hold position.
1Follow owner.
2Regroup.
3Wander.
4Come to me.
5Patrol.
6Move to position.
7 / 8Formation forward / backward.

Stances and combat rules

@@ -130,7 +128,7 @@

Stances and combat rules

9. States And Government

-

A state is the political actor behind settlements, claims, wars, and allies. Create one with /bannermod state create <name>, inspect with list and info, set capital with setcapital, and change status with status. War Room state screens now show a visible ledger line for waiting-sync, select-first, read-only authority, and next server-checked steps. If you only want another player to share claim and settlement access, use the claim editor's Trusted Members list instead of making them a co-leader.

+

A state is the political actor behind settlements, claims, wars, and allies. Create one with /bannermod state create <name>, inspect with list and info, set capital with setcapital, and change status with status. War Room state screens now show a visible ledger line for waiting-sync, select-first, read-only authority, and next server-checked steps. Charter opens a dedicated editor that explains where the text appears, and Add co-leader now opens an online player picker while keeping manual nickname or UUID entry for offline targets. If you only want another player to share claim and settlement access, use the claim editor's Trusted Members list instead of making them a co-leader.

Government forms

Monarchy keeps core political authority leader-only. Republic lets co-leaders share authority for status, capital, colors, charter, claim edits, ally invites, siege placement, and legal war outcomes. Operators can perform explicitly admin-only outcomes. Trusted Members are different: they may build and manage local work areas in the claim, but they do not get claim-edit or war/state authority.

Promotion

@@ -246,12 +244,11 @@

Налоги и стратегические роли

7. Жители и работники

Работники

-

Работники выполняют работу в зарегистрированных зонах, запросы складов и поручения поселения. Правая кнопка по работнику открывает книгу со владельцем, токеном власти, отношением к клейму, назначением, проблемой и транспортом. В меню Действия есть В гражданина и Уволить: первое превращает работника в свободного жителя с короткой паузой автоназначения, второе освобождает текущую рабочую зону и удаляет сущность работника. Увольнение принимает только серверный запрос владельца или администратора; при отказе состояние работника не меняется. Если в книге видно Несовпадение владения или Чужое владение, сначала выровняй владение клейма, государства и рабочей зоны. Клавиша X открывает групповые приказы работникам: следовать, охранять, идти в точку, остановиться. Гражданские экраны рабочих зон теперь показывают состояние синхронизации в правом верхнем углу, предупреждают об отсутствии владельца и прямо в экране подсказывают про семена, саженцы и настройки шахты. Пока экран рабочей зоны открыт, её короб снова виден, а клавиша B включает отсечённую по видимости подсветку ближайших рабочих зон, которыми тебе разрешено управлять.

-

Назначить дом: профиль жителя, книга работника и инвентарь рекрута имеют кнопку назначения дома. Она закрывает экран и даёт 30 секунд, чтобы нажать ПКМ по кровати. HUD показывает остаток времени; Esc отменяет выбор, а тайм-аут отменяет его автоматически. Отмена не меняет дом, а сервер принимает только настоящую кровать от владельца или администратора.

+

Работники выполняют работу в зарегистрированных зонах, запросы складов и поручения поселения. Правая кнопка по работнику открывает книгу со владельцем, токеном власти, отношением к клейму, назначением, проблемой и транспортом. Если в книге видно Несовпадение владения или Чужое владение, сначала выровняй владение клейма, государства и рабочей зоны. Клавиша X открывает групповые приказы работникам: следовать, охранять, идти в точку, остановиться. Гражданские экраны рабочих зон теперь показывают состояние синхронизации в правом верхнем углу, предупреждают об отсутствии владельца и прямо в экране подсказывают про семена, саженцы и настройки шахты. Пока экран рабочей зоны открыт, её короб снова виден, а клавиша B включает отсечённую по видимости подсветку ближайших рабочих зон, которыми тебе разрешено управлять. Работники, порождённые поселением, теперь также стартуют с базовыми инструментами профессии и пытаются сразу привязаться к уже существующей дружественной рабочей зоне подходящего типа, но бутстрап стартового форта больше не распахивает стартовую грядку сам по себе: стартовые рабочие сначала ждут, пока игрок разметит или провалидирует рабочие зоны. У farmers из claim-growth по-прежнему остается самоходный стартовый участок, а у рыбаков — самоходная рыболовная зона на ближайшей воде. А если рядом есть верстак и материалы, работники теперь могут и сами делать себе замену базовым каменным инструментам, вместо того чтобы бесконечно ждать ручного подвоза. Книга работника, профиль жителя и экран ИИ теперь также объясняют восстановление короткой понятной строкой: что житель делает сейчас, что у него сорвалось в прошлый раз и почему он уходит домой, к еде, к припасам или в укрытие.

Почему работник стоит без дела

  1. Работник принадлежит не тому игроку или не той стороне.
  2. Нужная зона вне своего защищённого участка.
  3. Здание не проверено или не зарегистрировано.
  4. Нет подходящей вакансии или свободного жителя.
  5. Нужного предмета нет на складе.
  6. У работника уже есть другое активное поручение или старое поручение ещё не освобождено.

Жители

-

Жители без профессии — запас населения. Начальный запуск даёт четырёх свободных жителей. Они расходуются на вакансии, поэтому перед расширением профессий нужны жильё, безопасность и действительные записи зданий. Книга работника также умеет превращать работника обратно в свободного жителя и даёт короткую паузу на автоназначение, чтобы та же вакансия не забрала его мгновенно обратно.

+

Жители без профессии — запас населения. Начальный запуск теперь засеивает 2-4 семейных хозяйства вместо просто одинаковой кучки взрослых, поэтому в первом поселении могут появиться молодожёны, молодые семьи, подростки и дети. Они расходуются на вакансии, поэтому перед расширением профессий нужны жильё, безопасность и действительные записи зданий; дети и подростки остаются в составе семьи и не уходят автоматически в рабочие вакансии. Книга работника также умеет превращать работника обратно в свободного жителя и даёт короткую паузу на автоназначение, чтобы та же вакансия не забрала его мгновенно обратно. Жилищные прошения от бездомных или тесно живущих хозяйств больше не одобряются скрыто сами собой: правитель получает кликабельное сообщение в чате и может просмотреть открытые прошения через /bannermod society housing list; прошение сразу резервирует конкретный семейный участок внутри клейма, одобренный дом старается встать именно туда, а после завершения сначала заселяет то хозяйство, которое просило жильё. Поселение теперь может и само просить у правителя лесной лагерь, шахту или загон для скота через /bannermod society livelihood list, если рабочие простаивают, а нужной хозяйственной базы ещё нет. Поведение жителей теперь держится на дешёвой понятной модели: труд, еда, дом, отдых и безопасность важнее глубокой социальной симуляции. Для просмотра таких участков используй Родовую межу (Kinlot Staff): она показывает, какое хозяйство закрепило участок и какая стройка там активна.

Рост населения: рождения и порог еды

Серверный цикл может выращивать население из уже живущих жителей. По умолчанию он выключен и включается на сервере флагом CitizenBirthEnabled = true. При активном режиме каждый клейм раз в CitizenBirthCooldownTicks (по умолчанию ~1 мин-день) проверяется по пяти условиям, и ребёнок-гражданин спавнится только тогда, когда выполнены все пять:

  1. Пара: в клейме жив хотя бы один взрослый мужчина и одна взрослая женщина.
  2. Лимит детей: текущее число младенцев в клейме ниже CitizenBirthMaxBabiesPerClaim (по умолчанию 2).
  3. Кулдаун: с момента последнего рождения в этом клейме прошло достаточно тиков.
  4. Свободное жильё: в клейме есть валидированный дом со свободным residentCapacity.
  5. Еда на складе: в складских зонах (StorageArea) клейма суммарно лежит хотя бы CitizenBirthFoodMinUnits ванильных съедобных предметов (по умолчанию 8). Значение 0 отключает порог.
@@ -262,7 +259,6 @@

Рост населения: рождения и порог еды

8. Рекруты, приказы и бой

Рекруты подчиняются владельцу и группе. Все приказы движения, поворота, атаки, поведения, дальнего огня, строя, посадки и осадных машин проходят через единый серверный путь приказов. Это сохраняет выбор бойцов, очередь, приоритет и журнал команд.

-

Перки рекрутов и игроков считаются на сервере: уровни дают очки перков, kill-credit игрока может добавлять прогресс, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки рекрутов работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии. Дерево рекрута открывается кнопкой Перки в инвентаре, дерево игрока — клавишей K по умолчанию. Изучение и сброс проверяются сервером, затем клиент получает свежий снимок дерева.

Состояния движения

КодПриказ
0Держать место.
1Следовать за владельцем.
2Собраться.
3Бродить.
4Ко мне.
5Патруль.
6Идти в точку.
7 / 8Строй вперёд / назад.

Стойки и правила боя

@@ -273,7 +269,7 @@

Стойки и правила боя

9. Государство и правление

-

Государство — политическое лицо поселений, участков, войн и союзов. Создание: /bannermod state create <название>. Просмотр: list и info. Столица: setcapital. Статус: status. Если нужно просто поделиться доступом к клейму и поселению, используй список Доверенные игроки в редакторе клейма, а не со-лидера.

+

Государство — политическое лицо поселений, участков, войн и союзов. Создание: /bannermod state create <название>. Просмотр: list и info. Столица: setcapital. Статус: status. Кнопка Хартия теперь открывает отдельный редактор и сразу объясняет, где будет виден текст, а Добавить со-лидера открывает список онлайн-игроков, но сохраняет ручной ввод ника или UUID для оффлайн-целей. Если нужно просто поделиться доступом к клейму и поселению, используй список Доверенные игроки в редакторе клейма, а не со-лидера.

Формы правления

Монархия оставляет основные решения лидеру. Республика даёт соправителям общие полномочия: статус, столица, цвета, устав, правки участков, приглашение союзников, постановка осады и законные итоги войны. Операторы сервера могут выполнять действия, явно отмеченные как административные. Доверенные игроки работают иначе: они могут строить и управлять местными рабочими зонами в клейме, но не получают власти над государством, дипломатией или войной.

Повышение

diff --git a/docs/NPC_SOCIETY_SIMULATION_PLAN.md b/docs/NPC_SOCIETY_SIMULATION_PLAN.md new file mode 100644 index 00000000..4ccdcd3c --- /dev/null +++ b/docs/NPC_SOCIETY_SIMULATION_PLAN.md @@ -0,0 +1,622 @@ +# BannerMod NPC Society Plan + +## Purpose + +This document replaces the older "very smart society" plan. + +The new goal is simple: + +- allow many NPCs to exist at once +- keep MSPT stable on a real server +- make NPCs look believable enough in play +- avoid deep simulation that Minecraft cannot carry cheaply + +This is now an optimization-first design document, not a wishlist for a city-sim brain. + +## Core Decision + +BannerMod will not try to simulate fully social, memory-driven, highly autonomous people. + +BannerMod will instead simulate cheap, readable, useful residents with: + +- a home +- a workplace +- a small set of daily states +- simple danger fallback +- simple hunger and fatigue handling +- clear player-facing UI + +If a feature makes NPCs feel smarter but significantly increases constant per-tick cost, it should be rejected. + +## Hard Constraints + +Minecraft server performance is the first constraint. + +The system must assume: + +- pathfinding is expensive +- repeated world scans are expensive +- NPC-to-NPC reasoning scales badly +- cross-chunk simulation is dangerous +- per-tick decision trees become unstable and costly fast + +Therefore the target is not "smart NPCs". + +The target is: + +- low-cost state machines +- infrequent decisions +- cached world knowledge +- readable behavior +- stable behavior under load + +## Final Scope + +### Keep + +- basic resident identity +- home assignment +- workplace assignment +- day schedule +- coarse needs: hunger, fatigue, danger +- simple state selection +- simple worker usefulness +- clear debug and profile UI +- ruler approval flows for explicit petitions + +### Reduce + +- social behavior +- family behavior +- recovery logic +- anchored micro-movement polish +- autonomous infrastructure behavior + +### Remove From Target Scope + +- deep social memory simulation +- relationship graph simulation +- emotional chain reactions through families and households +- partner-based social staging +- dense household clustering logic +- autonomous hamlet life simulation +- autonomous remote settlement expansion +- high-detail recovery and retry theory across many intent families +- any feature that requires frequent NPC-to-NPC evaluation to look correct + +## Cheap NPC Model + +Every NPC should be understandable as one of a few states. + +### Main States + +- `WORK` +- `EAT` +- `GO_HOME` +- `REST` +- `HIDE` +- `IDLE` + +Optional states are allowed only if they are cheap and clearly useful. + +Examples: + +- `SEEK_SUPPLIES` may stay if it is really just a work or food fallback +- `DEFEND` may stay only for restricted roles + +### State Priority Philosophy + +The logic should be obvious: + +- if it is work time and the NPC has a real assignment, work wins +- if hunger is high, food wins +- if it is night or fatigue is high, home or rest wins +- if danger is high, hide wins +- if nothing else is needed, idle or light wander wins + +That is enough. + +The system should not require dozens of micro-rules to produce a believable day. + +## Update Budget Rules + +NPCs must not think at full resolution every tick. + +### Rules + +- expensive intent selection should run on a heartbeat, not every tick +- current tasks should keep running between decision updates +- path recalculation should happen only when the target meaningfully changed or navigation clearly failed +- world lookups must prefer cached settlement data over live scans +- social reasoning must never require broad nearby-entity analysis for large crowds + +### Forbidden Patterns + +- scanning many nearby NPCs every tick to find the best social partner +- recomputing household, family, memory, and emotion effects every tick +- repeatedly trying alternate route families in the same short window +- spawning new work areas or new social structures as ordinary NPC behavior + +## Data Model To Keep + +These data are cheap enough and useful enough to keep: + +- resident UUID +- home building UUID +- work building UUID +- coarse role +- current coarse state +- hunger +- fatigue +- danger or safety pressure + +These data may remain only as light metadata, not as heavy runtime drivers: + +- household ID +- spouse or parent or child links +- housing request state +- simple housing pressure label + +## Data Model To Stop Treating As Runtime AI Drivers + +The following can exist for flavor, UI, or save compatibility, but should not continuously drive behavior for large populations: + +- trust +- fear as a social-memory network value +- anger as a relationship network value +- gratitude +- loyalty +- durable social memory chains +- multi-step remembered grievance propagation + +If these remain in code, they should be demoted to lightweight labels or occasional modifiers, not constant simulation inputs. + +## Social Behavior Policy + +Social behavior is no longer a major system. + +It becomes a cheap presentation layer. + +### Allowed + +- idle at home in evening +- idle near a public spot sometimes +- look at nearby NPCs occasionally +- small random variation in where an NPC stands + +### Not Allowed As Core Runtime + +- active partner selection loops +- companion stickiness systems +- household gathering choreography +- social pressure trying to outrank real work during the labor window +- fine-grained social scene management near the player + +In short: + +NPCs may appear social. + +They should not be powered by expensive social simulation. + +## Work Behavior Policy + +Work is one of the main reasons these NPCs exist, so work behavior should be much simpler and stronger than social behavior. + +### Rules + +- assigned workers should prefer work during work time +- unassigned residents may idle or wander +- work should lose only to strong survival pressure such as hunger, sleep, or danger +- post-shift social or idle behavior is fine after work time ends + +### Anti-Goal + +We do not want a system where workers constantly look psychologically rich but fail to do useful work. + +Useful and predictable work is more important than expressive social nuance. + +## Home Behavior Policy + +Home is still important, but home logic must stay cheap. + +### Keep + +- sleep at home at night +- go home when tired enough +- go home when danger is high enough +- simple homeless handling + +### Remove + +- large stacks of home-recovery hysteresis rules +- fine family-gravity tuning +- companion-based indoor clustering as a requirement +- repeated home-route reinterpretation for tiny state changes + +The player only needs to understand that an NPC has a home and tends to return there. + +That is enough for the illusion. + +## Family And Household Policy + +Family and household data may stay only where they help with: + +- housing assignment +- family-tree UI +- identity flavor + +They should not stay as a deep behavior engine. + +### Keep + +- household membership as data +- family-tree inspection +- home capacity and housing pressure + +### Remove As Simulation Drivers + +- dependent-aware behavior in many intent branches +- family-linked fear propagation as a central runtime system +- household social gravity as a major scheduling factor +- family-based companion clustering as a standard behavior requirement + +## Hamlets And Remote Expansion Policy + +Hamlets are the clearest part of the old plan that must be demoted. + +### Keep + +- ruler-visible records +- manual naming or registration if already created by controlled gameplay +- simple lot or housing metadata if needed + +### Remove From Main AI Target + +- autonomous remote family settlement growth +- routine remote plot reservation far from the core settlement +- hamlet maturation as a broad live simulation loop +- hamlet-driven pressure systems that constantly feed back into AI + +Hamlets should be event-driven content, not a core always-on simulation layer. + +## Autonomous World Mutation Policy + +NPCs should rarely create new world structure on their own. + +### Keep + +- player-approved project execution +- explicit ruler-approved petitions +- bounded project pipelines + +### Remove + +- workers auto-creating starter fields during bootstrap +- widespread self-starting infrastructure behavior as a normal expectation +- NPC-led expansion as a core living-world loop + +If NPCs change the world, it should be rare, explicit, and bounded. + +## UI Policy + +UI is worth keeping because it is cheap compared to simulation. + +### Keep + +- citizen profile +- worker profile +- simple AI state screen +- family tree screen +- petition ledger screens + +### UI Content Goal + +Show only: + +- what the NPC is doing +- where it is going +- what blocked it +- what building or home it belongs to + +Do not build UI that depends on an overcomplicated hidden AI just to justify itself. + +## What Is Explicitly Deleted From The Old Direction + +The following old direction is no longer the target: + +- "smart society" as a major feature pillar +- memory-and-relationships as a core behavior driver +- deep social states for most residents +- near-player social staging as a major polish target +- many layered AI refinement slices that mostly exist to stabilize an overly complex model +- autonomous hamlet life and remote expansion as standard simulation + +These ideas are not banned forever. + +They are simply not acceptable under the current optimization goal. + +## New Target Architecture + +The intended architecture is: + +1. settlement snapshot provides cached world facts +2. resident state machine chooses one coarse state on a heartbeat +3. resident executes that state cheaply between heartbeats +4. pathfinding is only refreshed on meaningful change +5. UI explains the current state clearly + +That is the full loop. + +If a future addition does not fit inside that loop cheaply, it should be rejected. + +## New Phased Plan + +### Phase A: Stabilize The Cheap Core + +- keep work, home, rest, eat, hide, idle +- make work reliable during labor hours +- make home and rest reliable at night +- make danger interruption simple and stable +- remove obvious behavior thrash + +### Phase B: Reduce Runtime Cost + +- move decision updates to heartbeats where still needed +- reduce path refresh frequency +- cut broad entity scans +- remove hidden social complexity from ordinary residents + +### Phase C: Simplify Data Usage + +- keep family and household as metadata +- stop using memory and relationship depth as broad runtime scoring inputs +- preserve save compatibility where practical + +### Phase D: Keep Only Cheap Observability + +- maintain simple readable UI +- keep blocked reason and current task text +- keep ruler petition visibility + +### Phase E: Reintroduce Optional Flavor Carefully + +- only add flavor that is local, cheap, and optional +- never reintroduce full graph-based society simulation +- never make social polish outrank server performance + +## Current Implementation Status + +The cleanup described in this plan is already underway in the live root `src/**` code. + +### Already Removed Or Heavily Cut Back + +- deep social-memory runtime +- memory ledger UI +- trust / fear / anger / gratitude / loyalty as active runtime profile state +- socialise goal and dead socialise intent path +- hamlet runtime, hamlet UI, hamlet packets, hamlet commands, and hamlet saved-data classes +- hamlet-specific prefab and related remote-expansion code +- autonomous claim-growth starter field / fishing-area creation +- autonomous livelihood project enqueueing during ordinary settlement claim ticks +- recovery-heavy scheduler refresh behavior that kept rearming timed-out tasks + +### Already Simplified + +- resident scheduling now stays centered on coarse work / eat / go-home / rest / hide / idle behavior +- hide routing and UI wording now describe simple shelter seeking rather than fear-memory logic +- housing plot inspection tooling was reduced to a simple housing / household inspector instead of hamlet-aware logic +- claim-grown workers now reuse only existing work areas and otherwise wait with explicit missing-zone feedback +- citizen / worker UI and docs were updated to reflect the cheap-NPC direction instead of the older smart-society direction + +### Intentionally Still Kept + +- home assignment +- workplace assignment +- family-tree and household metadata where it helps housing and identity +- housing requests and ruler approval flows +- readable AI / citizen profile screens for current task, route, blocked reason, and needs + +### Verification Already Run + +- repository-wide cleanup removed code references to hamlet, social-memory, and socialise systems in active Java sources +- `compileJava` and `testClasses` pass when Gradle is run with a local JDK 21 toolchain + +This means the repository is no longer merely planning this direction. + +It is already being converted toward the cheap resident model described above. + +## Acceptance Criteria + +The system is successful when: + +- many NPCs can exist without severe MSPT spikes +- assigned workers mostly work when they should +- residents go home or rest predictably +- danger causes clear fallback behavior +- the player can understand NPC state from the UI +- NPCs feel alive enough without requiring deep cognition + +The system is not successful when: + +- NPCs look psychologically rich but tank server performance +- workers constantly choose chatter over labor +- behavior depends on fragile chains of micro-rules +- pathfinding and social logic dominate tick cost +- each new refinement slice mostly exists to compensate for the complexity of earlier ones + +## Final Summary + +BannerMod should aim for believable, useful, low-cost residents. + +It should not aim for a full social simulator inside Minecraft. + +That older direction is the part we are now explicitly abandoning. + +## Code Removal Targets + +This section is intentionally blunt. + +The items below are not just "low priority". They are the first things that should be cut back, removed, or demoted to metadata/UI if the goal is to support many NPCs safely. + +### Remove Or Demote First + +1. Deep memory-and-relationships runtime as a behavior driver + +What to remove or demote: + +- trust / fear / anger / gratitude / loyalty as constantly active scoring inputs +- durable social-memory propagation across family and household links +- remembered-event chains that alter everyday behavior for ordinary residents + +Preferred replacement: + +- keep these only as flavor labels, save compatibility data, or UI text + +Commits that introduced or expanded this direction: + +- `61252670` `feat(society): add phase three social memory runtime` + +2. Partner and household-companion social choreography + +What to remove or demote: + +- social partner preference systems +- household companion preference systems +- family clustering and household clustering as required runtime behavior +- home-social stickiness whose purpose is mostly scene polish + +Preferred replacement: + +- cheap public-idle or home-idle behavior with only occasional look-at-nearby-entity flavor + +Commits that expanded this direction: + +- `6f16d5e4` `update society AI home recovery and social staging` +- `1ea18c9b` `update society AI recovery fallback and household readability` +- `de1fd465` `update society AI recovery lock and home regrouping` +- `b97242a2` `update society AI near-player stability and home regrouping` +- `47f555e9` `update society AI home calm and route recovery` + +3. Heavy recovery and anti-thrashing refinement layers + +What to remove or simplify: + +- failure-family penalties +- sibling retry suppression +- recovery locks +- route-recovery windows +- threat-settle bridge windows +- many overlapping hysteresis rules whose purpose is to stabilize a too-complex state machine + +Preferred replacement: + +- one short cooldown after failed tasks +- one simple rule for danger interruption +- one simple rule for work-vs-home-vs-food precedence + +Commits that mark this heavy direction: + +- `2806fdc1` `update society AI stability and explainability` +- `030b60d9` `update NPC society daily routine stability` +- `b6391bca` `update society AI recovery and explainability` +- `6f16d5e4` `update society AI home recovery and social staging` +- `1ea18c9b` `update society AI recovery fallback and household readability` +- `de1fd465` `update society AI recovery lock and home regrouping` +- `b97242a2` `update society AI near-player stability and home regrouping` +- `9391c41b` `update society AI calm recovery and readable routines` +- `4f3e9937` `update society AI runtime consistency and test hardening` +- `57b186f9` `update society AI invalidation recovery hardening` +- `f0feeff3` `update society AI calm recovery and readable routines` +- `47f555e9` `update society AI home calm and route recovery` + +4. Autonomous livelihood world mutation as normal NPC behavior + +What to remove or restrict: + +- workers auto-creating starter fields as ordinary expected behavior +- workers auto-seeding new fishing areas as ordinary expected behavior +- any broad expectation that NPCs should create the infrastructure they need by themselves during routine play + +Preferred replacement: + +- player-marked or ruler-approved work areas only +- if autonomy remains at all, keep it rare, bounded, and event-driven + +Commits that introduced or expanded this direction: + +- `c05e0c9a` `feat(society): add ruler-approved livelihood requests and worker self-sufficiency` +- `a01b6b28` `feat(society): bootstrap autonomous claim livelihoods` + +5. Remote hamlet autonomy and remote-family expansion as live simulation + +What to remove or demote: + +- remote-family autonomous expansion as a general system +- hamlet maturation as a broad live runtime loop +- hamlet pressure feeding back into normal resident AI +- remote settlement-style world mutation as routine behavior + +Preferred replacement: + +- hamlets may exist as manual or event-driven content records +- hamlet UI and naming can stay if runtime autonomy is cut down + +Commits that introduced or expanded this direction: + +- `001878bf` `feat(society): seed remote hamlet zemlyanka housing` +- `78aa0430` `feat(society): add hamlet runtime and war room ledger` +- `b0f76211` `docs(society): record hamlet ledger flow` + +6. Family and household as major scheduling pressure on everyday behavior + +What to remove or demote: + +- dependent-aware branching across many intents +- family-linked fear and recovery pull as a major scoring driver +- household-gravity tuning as an ordinary scheduling requirement + +Preferred replacement: + +- keep family-tree UI and housing metadata +- do not let family logic dominate ordinary worker schedules + +Commits that introduced or expanded this direction: + +- `4952abe4` `feat(society): add household family runtime and gui` +- `622543dd` `feat(society): reserve and highlight family lots` + +### Keep Even If The Above Is Cut Back + +The following slices are still worth keeping after simplification: + +- `dea21a23` `feat(society): add npc phase one-two foundations` + - keep as the cheap base layer +- `10b9974e` `feat(society): complete phase two daily intent loop` + - keep only the coarse work/eat/home/rest/hide structure, not every later refinement rule +- `58ff4776` `feat(society): rank housing petitions and clarify household pressure` + - keep because petitions are cheap compared to AI + +### Practical Cleanup Order + +If runtime performance becomes the main priority, cleanup should happen in this order: + +1. demote memory-and-relationship runtime effects +2. remove partner/companion social choreography +3. collapse recovery logic into a much simpler scheduler +4. stop autonomous livelihood world mutation from ordinary NPC behavior +5. demote hamlet autonomy into records/UI only +6. keep only the cheap core state machine plus readable UI + +### Important Note On Commits + +The commit list above is not a blind revert script. + +Many later commits are mixed stabilization slices and may also contain useful bug fixes. + +Use them as: + +- history markers for where complexity was added +- search anchors for code removal +- grouping hints for future cleanup PRs + +Do not assume the correct cleanup is always a raw revert. In many cases the right move will be selective removal or demotion of the expensive runtime logic while keeping harmless UI or persistence pieces. diff --git a/docs/STATUS.md b/docs/STATUS.md index 381c648b..559cea46 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -15,6 +15,14 @@ Last updated: 2026-05-10. - Compact Phase 25 settlement runtime is live enough to publish and persist work-order claims, growth/project hints, job scheduling gates, market/stockpile snapshots, and targeted mutation refreshes. - Compact Phase 26 combat AI is live: stance control, shield-wall behavior, reach weapons, second-rank poke, flank/cohesion/brace rules, unit counters, and Better Combat metadata/attack-presentation integration. +- NPC housing requests now require explicit ruler approval in the first shipped slice: new petitions stay pending, rulers get clickable chat actions plus `/bannermod society housing list`, denied state is persisted, and only approved petitions enter the house-project path. +- Housing petitions now also run through a shared fairness queue: homelessness, overcrowding, household size, wait age, and request state feed one server-side priority scorer, `/bannermod society housing list` uses that order, and the `U` War Room now includes a dedicated `Housing` ledger screen for ruler-side approval/denial. +- Settlements can now raise ruler-approved livelihood requests for `lumber camp`, `mine`, and `animal pen`; the new requests stay pending until approved via clickable chat or `/bannermod society livelihood list`, then flow into the prefab project path with exact prefab ids. +- Kinlot Staff remains the family-lot inspector for reserved household plots and active build markers, while housing and livelihood petitions continue to flow through ruler-approved ledgers and commands. +- Workers now craft first-slice replacement stone tools for themselves at nearby crafting tables when they can obtain the needed materials, reducing permanent idle states after tool loss. +- Settlement-spawned workers now start with basic profession tools and auto-bind to existing friendly claim work areas for farmer/lumberjack/miner/fisherman/animal-farmer paths when those zones already exist. +- Claim-grown workers now follow the same cheap work-area rule as starter workers: they bind to existing friendly claim work areas when those zones already exist, otherwise they wait and report the missing assignment instead of auto-creating starter fields or fishing areas. +- NPC society currently stays on the cheap resident model from the simulation plan: homes, workplaces, coarse day states, hunger/fatigue/danger pressure, housing metadata, and readable citizen/AI screens without deep social-memory or hamlet runtime. - War Room and political UI now cover political entity list/detail actions, siege-standard placement, siege-zone HUD status, government form toggles, cooldown-backed war spam protection, a synced battle-window phase banner with humanized open/close countdown, and a consent-based ally invite flow (leader-or-op invite, leader accept/decline/cancel, picker filtered by the shared `WarAllyPolicy`). - War runtime is partially live beyond declarations: outcome actions can create occupations/annexations/tribute/vassalization/demilitarization, occupation tax accrues from a server ticker, due revolts auto-resolve from objective presence during battle windows, and recruits can attack enemy siege standards or escort same-side standards. - Worker/settlement claim binding is being normalized away from legacy faction IDs toward political-entity UUIDs and scoreboard team names. diff --git a/src/gametest/java/com/talhanation/bannermod/BannerModClaimWorkerGrowthGameTests.java b/src/gametest/java/com/talhanation/bannermod/BannerModClaimWorkerGrowthGameTests.java index d1aab7b0..30f3a4d2 100644 --- a/src/gametest/java/com/talhanation/bannermod/BannerModClaimWorkerGrowthGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/BannerModClaimWorkerGrowthGameTests.java @@ -6,9 +6,14 @@ import com.talhanation.bannermod.events.WorkersVillagerEvents; import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; import com.talhanation.bannermod.entity.civilian.FarmerEntity; +import com.talhanation.bannermod.entity.civilian.FishermanEntity; import com.talhanation.bannermod.entity.civilian.workarea.CropArea; +import com.talhanation.bannermod.entity.civilian.workarea.FishingArea; +import com.talhanation.bannermod.entity.civilian.workarea.WorkAreaIndex; +import com.talhanation.bannermod.registry.civilian.ModEntityTypes; import com.talhanation.bannermod.settlement.civilian.WorkerSettlementSpawnRules; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.server.level.ServerLevel; @@ -118,7 +123,7 @@ public static void hostileOrUnclaimedTerritoryNeverSpawnsClaimWorkers(GameTestHe @PrefixGameTestTemplate(false) @GameTest(template = "harness_empty") - public static void claimGrownFarmerSeedsCropAreaFromPreparedField(GameTestHelper helper) { + public static void claimGrownFarmerWaitsWithoutMarkedCropAreaEvenOnPreparedField(GameTestHelper helper) { WorkerSettlementSpawnRules.ClaimGrowthConfig config = claimGrowthConfig(20L, 4); ServerLevel level = helper.getLevel(); @@ -130,15 +135,67 @@ public static void claimGrownFarmerSeedsCropAreaFromPreparedField(GameTestHelper AbstractWorkerEntity worker = WorkersVillagerEvents.attemptClaimWorkerGrowth(level, claim, teamId, 20L, config); + helper.assertTrue(worker instanceof FarmerEntity, "Expected the configured claim-growth profession pool to spawn a farmer."); + FarmerEntity farmer = (FarmerEntity) worker; + helper.runAfterDelay(20, () -> { + helper.assertTrue(farmer.getCurrentCropArea() == null, + "Expected prepared farmland alone not to auto-bind or create a crop area for a claim-grown farmer."); + List cropAreas = level.getEntitiesOfClass(CropArea.class, new AABB(fieldCenter).inflate(12.0D)); + helper.assertTrue(cropAreas.isEmpty(), + "Expected claim growth to leave prepared farmland without a synthetic CropArea until a player-marked or validated zone exists."); + helper.succeed(); + }); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void claimGrownFarmerBindsExistingCropArea(GameTestHelper helper) { + WorkerSettlementSpawnRules.ClaimGrowthConfig config = claimGrowthConfig(20L, 4, List.of(WorkerSettlementSpawnRules.WorkerProfession.FARMER)); + + ServerLevel level = helper.getLevel(); + String teamId = "phase31_claim_autofield"; + ServerPlayer leader = createLeader(helper, level, UUID.fromString("00000000-0000-0000-0000-000000003116"), "phase31-autofield-leader", teamId); + BlockPos claimPos = helper.absolutePos(new BlockPos(8, 2, 8)); + RecruitsClaim claim = BannerModDedicatedServerGameTestSupport.seedClaim(level, claimPos, teamId, leader.getUUID(), leader.getScoreboardName()); + prepareField(level, claimPos); + CropArea existingArea = placeCropArea(level, leader, claimPos); + + AbstractWorkerEntity worker = WorkersVillagerEvents.attemptClaimWorkerGrowth(level, claim, teamId, 20L, config); + helper.assertTrue(worker instanceof FarmerEntity, "Expected the configured claim-growth profession pool to spawn a farmer."); FarmerEntity farmer = (FarmerEntity) worker; helper.succeedWhen(() -> { CropArea currentArea = farmer.getCurrentCropArea(); - List cropAreas = level.getEntitiesOfClass(CropArea.class, new AABB(fieldCenter).inflate(12.0D)); - helper.assertTrue(currentArea != null || !cropAreas.isEmpty(), - "Expected a claim-grown farmer on a prepared field to receive or seed a crop area instead of idling without work."); - helper.assertTrue((currentArea != null && currentArea.isAlive()) || cropAreas.stream().anyMatch(CropArea::isAlive), - "Expected claim-grown farmer field seeding to create a live crop area entity."); + helper.assertTrue(currentArea != null && currentArea.isAlive(), + "Expected claim-grown farmers to bind an existing claim crop area instead of waiting idle."); + helper.assertTrue(existingArea.getUUID().equals(currentArea.getUUID()), + "Expected claim-grown farmer to reuse the existing player-marked crop area instead of spawning a new one."); + }); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void claimGrownFishermanWaitsWithoutMarkedFishingArea(GameTestHelper helper) { + WorkerSettlementSpawnRules.ClaimGrowthConfig config = claimGrowthConfig(20L, 4, List.of(WorkerSettlementSpawnRules.WorkerProfession.FISHERMAN)); + + ServerLevel level = helper.getLevel(); + String teamId = "phase31_claim_autofish"; + ServerPlayer leader = createLeader(helper, level, UUID.fromString("00000000-0000-0000-0000-000000003117"), "phase31-autofish-leader", teamId); + BlockPos claimPos = helper.absolutePos(new BlockPos(8, 2, 8)); + RecruitsClaim claim = BannerModDedicatedServerGameTestSupport.seedClaim(level, claimPos, teamId, leader.getUUID(), leader.getScoreboardName()); + prepareFishingWater(level, claimPos.east(3)); + + AbstractWorkerEntity worker = WorkersVillagerEvents.attemptClaimWorkerGrowth(level, claim, teamId, 20L, config); + + helper.assertTrue(worker instanceof FishermanEntity, "Expected the configured claim-growth profession pool to spawn a fisherman."); + FishermanEntity fisherman = (FishermanEntity) worker; + helper.runAfterDelay(20, () -> { + helper.assertTrue(fisherman.getCurrentFishingArea() == null, + "Expected nearby water alone not to auto-create or bind a fishing area for a claim-grown fisherman."); + List fishingAreas = level.getEntitiesOfClass(FishingArea.class, new AABB(claimPos).inflate(24.0D)); + helper.assertTrue(fishingAreas.isEmpty(), + "Expected claim growth to leave nearby water without a synthetic FishingArea until a player-marked or validated zone exists."); + helper.succeed(); }); } @@ -174,12 +231,35 @@ private static List getClaimWorkers(ServerLevel level, Rec }); } + private static CropArea placeCropArea(ServerLevel level, ServerPlayer leader, BlockPos center) { + CropArea cropArea = new CropArea(ModEntityTypes.CROPAREA.get(), level); + cropArea.setWidthSize(9); + cropArea.setHeightSize(2); + cropArea.setDepthSize(9); + cropArea.setFacing(Direction.NORTH); + cropArea.moveTo(center.getX() - 4, center.getY(), center.getZ() + 4, 0.0F, 0.0F); + cropArea.createArea(); + cropArea.setDone(false); + cropArea.setTeamStringID(leader.getTeam() == null ? "" : leader.getTeam().getName()); + cropArea.setPlayerUUID(leader.getUUID()); + cropArea.setPlayerName(leader.getScoreboardName()); + level.addFreshEntity(cropArea); + WorkAreaIndex.instance().onEntityJoin(cropArea); + return cropArea; + } + private static WorkerSettlementSpawnRules.ClaimGrowthConfig claimGrowthConfig(long baseCooldownTicks, int workerCap) { + return claimGrowthConfig(baseCooldownTicks, workerCap, List.of(WorkerSettlementSpawnRules.WorkerProfession.FARMER)); + } + + private static WorkerSettlementSpawnRules.ClaimGrowthConfig claimGrowthConfig(long baseCooldownTicks, + int workerCap, + List professions) { return new WorkerSettlementSpawnRules.ClaimGrowthConfig( true, baseCooldownTicks, workerCap, - List.of(WorkerSettlementSpawnRules.WorkerProfession.FARMER) + professions ); } @@ -196,4 +276,14 @@ private static void prepareField(ServerLevel level, BlockPos center) { } } + private static void prepareFishingWater(ServerLevel level, BlockPos center) { + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + level.setBlockAndUpdate(center.offset(dx, -1, dz), Blocks.DIRT.defaultBlockState()); + level.setBlockAndUpdate(center.offset(dx, 0, dz), Blocks.WATER.defaultBlockState()); + level.setBlockAndUpdate(center.offset(dx, 1, dz), Blocks.AIR.defaultBlockState()); + } + } + } + } diff --git a/src/gametest/java/com/talhanation/bannermod/BannerModFormationDimensionGuardGameTests.java b/src/gametest/java/com/talhanation/bannermod/BannerModFormationDimensionGuardGameTests.java index 8dcf040e..3c4019a7 100644 --- a/src/gametest/java/com/talhanation/bannermod/BannerModFormationDimensionGuardGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/BannerModFormationDimensionGuardGameTests.java @@ -246,7 +246,7 @@ public static void fiveRecruitFormationHoldsAcrossDimensionTeleport(GameTestHelp Vec3 now = recruit.position(); Vec3 then = startingPositions.get(i); double horizontalDelta = Math.hypot(now.x - then.x, now.z - then.z); - helper.assertTrue(horizontalDelta < 1.0D, + helper.assertTrue(horizontalDelta < 2.0D, "TESTDIM-001: recruit " + i + " must hold horizontal position (delta=" + horizontalDelta + ")"); } diff --git a/src/gametest/java/com/talhanation/bannermod/BannerModSettlementFactionEnforcementGameTests.java b/src/gametest/java/com/talhanation/bannermod/BannerModSettlementFactionEnforcementGameTests.java index 8580432a..09643599 100644 --- a/src/gametest/java/com/talhanation/bannermod/BannerModSettlementFactionEnforcementGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/BannerModSettlementFactionEnforcementGameTests.java @@ -10,6 +10,7 @@ import com.talhanation.bannermod.network.messages.civilian.WorkAreaAuthoringRules; import com.talhanation.bannermod.persistence.military.RecruitsClaim; import com.talhanation.bannermod.registry.civilian.ModEntityTypes; +import com.talhanation.bannermod.shared.settlement.BannerModSettlementBinding; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; diff --git a/src/gametest/java/com/talhanation/bannermod/BannerModSettlementProjectGameTests.java b/src/gametest/java/com/talhanation/bannermod/BannerModSettlementProjectGameTests.java index 92639f5d..b328b88c 100644 --- a/src/gametest/java/com/talhanation/bannermod/BannerModSettlementProjectGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/BannerModSettlementProjectGameTests.java @@ -4,14 +4,14 @@ import com.talhanation.bannermod.entity.civilian.workarea.BuildArea; import com.talhanation.bannermod.entity.civilian.workarea.WorkAreaIndex; import com.talhanation.bannermod.persistence.military.RecruitsClaim; -import com.talhanation.bannermod.settlement.SettlementBuildingCategory; -import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed; +import com.talhanation.bannermod.settlement.BannerModSettlementBuildingCategory; +import com.talhanation.bannermod.settlement.BannerModSettlementBuildingProfileSeed; import com.talhanation.bannermod.settlement.growth.PendingProject; import com.talhanation.bannermod.settlement.growth.ProjectBlocker; import com.talhanation.bannermod.settlement.growth.ProjectKind; import com.talhanation.bannermod.settlement.project.AssignmentPhase; import com.talhanation.bannermod.settlement.project.BannerModBuildAreaProjectBridge; -import com.talhanation.bannermod.settlement.project.SettlementProjectRuntime; +import com.talhanation.bannermod.settlement.project.BannerModSettlementProjectRuntime; import com.talhanation.bannermod.settlement.project.ProjectAssignment; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTest; @@ -65,16 +65,17 @@ private static void runAssertSettlementProjectCreatesExecutableBuildAreaInWorld( UUID.randomUUID(), ProjectKind.NEW_BUILDING, null, - SettlementBuildingCategory.GENERAL, - SettlementBuildingProfileSeed.GENERAL, + null, + BannerModSettlementBuildingCategory.GENERAL, + BannerModSettlementBuildingProfileSeed.GENERAL, 100, level.getGameTime(), 20, ProjectBlocker.NONE ); - SettlementProjectRuntime runtime = SettlementProjectRuntime.forServer(level); - ProjectAssignment assignment = SettlementProjectRuntime.tickClaim( + BannerModSettlementProjectRuntime runtime = BannerModSettlementProjectRuntime.forServer(level); + ProjectAssignment assignment = BannerModSettlementProjectRuntime.tickClaim( level, claim.getUUID(), List.of(project) @@ -118,15 +119,16 @@ static void assertSettlementProjectBindsToExecutableBuildAreaTarget(GameTestHelp UUID.randomUUID(), ProjectKind.NEW_BUILDING, null, - SettlementBuildingCategory.GENERAL, - SettlementBuildingProfileSeed.GENERAL, + null, + BannerModSettlementBuildingCategory.GENERAL, + BannerModSettlementBuildingProfileSeed.GENERAL, 100, level.getGameTime(), 20, ProjectBlocker.NONE ); - Optional assignment = SettlementProjectRuntime.detachedForTests().tickClaim( + Optional assignment = BannerModSettlementProjectRuntime.detachedForTests().tickClaim( level, claim.getUUID(), List.of(project), @@ -161,15 +163,16 @@ static void assertSettlementProjectProgressesFromBuildExecutionEvents(GameTestHe UUID.randomUUID(), ProjectKind.NEW_BUILDING, null, - SettlementBuildingCategory.GENERAL, - SettlementBuildingProfileSeed.GENERAL, + null, + BannerModSettlementBuildingCategory.GENERAL, + BannerModSettlementBuildingProfileSeed.GENERAL, 100, level.getGameTime(), 20, ProjectBlocker.NONE ); - SettlementProjectRuntime runtime = SettlementProjectRuntime.forServer(level); + BannerModSettlementProjectRuntime runtime = BannerModSettlementProjectRuntime.forServer(level); ProjectAssignment assignment = runtime.tickClaim( level, claim.getUUID(), diff --git a/src/gametest/java/com/talhanation/bannermod/entity/civilian/BannerModStarterWorkerReadinessGameTests.java b/src/gametest/java/com/talhanation/bannermod/entity/civilian/BannerModStarterWorkerReadinessGameTests.java index 5e2a84c6..7a804306 100644 --- a/src/gametest/java/com/talhanation/bannermod/entity/civilian/BannerModStarterWorkerReadinessGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/entity/civilian/BannerModStarterWorkerReadinessGameTests.java @@ -19,6 +19,10 @@ import net.minecraft.gametest.framework.GameTestHelper; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.AxeItem; +import net.minecraft.world.item.HoeItem; +import net.minecraft.world.item.PickaxeItem; +import net.minecraft.world.item.ShovelItem; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.phys.AABB; import net.neoforged.neoforge.gametest.GameTestHolder; @@ -60,10 +64,24 @@ public static void starterBootstrapSeedsRealWorkerAssignmentsAndWaitingReasons(G BuilderEntity builder = singleWorker(helper, level, BuilderEntity.class, anchor); assertAssignmentOrIdleReason(helper, farmer, "farmer_no_area"); + assertHasItem(helper, farmer, stack -> stack.getItem() instanceof HoeItem, + "Expected starter farmer to carry a hoe."); assertIdleReason(helper, miner, "miner_no_area"); + assertHasItem(helper, miner, stack -> stack.getItem() instanceof PickaxeItem, + "Expected starter miner to carry a pickaxe."); + assertHasItem(helper, miner, stack -> stack.getItem() instanceof ShovelItem, + "Expected starter miner to carry a shovel."); assertIdleReason(helper, lumberjack, "lumberjack_no_area"); + assertHasItem(helper, lumberjack, stack -> stack.getItem() instanceof AxeItem, + "Expected starter lumberjack to carry an axe."); assertAssignmentOrIdleReason(helper, builder, "builder_no_area"); + assertHasItem(helper, builder, stack -> stack.getItem() instanceof AxeItem, + "Expected starter builder to carry an axe."); + assertHasItem(helper, builder, stack -> stack.getItem() instanceof PickaxeItem, + "Expected starter builder to carry a pickaxe."); + assertHasItem(helper, builder, stack -> stack.getItem() instanceof ShovelItem, + "Expected starter builder to carry a shovel."); WorkersServerConfig.clearAllTestOverrides(); }); } @@ -115,6 +133,13 @@ private static void assertAssignmentOrIdleReason(GameTestHelper helper, Abstract assertIdleReason(helper, worker, expectedIdleReason); } + private static void assertHasItem(GameTestHelper helper, + AbstractWorkerEntity worker, + java.util.function.Predicate predicate, + String message) { + helper.assertTrue(worker.getMatchingItem(predicate) != null && !worker.getMatchingItem(predicate).isEmpty(), message); + } + private static void buildStarterField(ServerLevel level, BlockPos waterCenter) { for (int dx = -4; dx <= 4; dx++) { for (int dz = -4; dz <= 4; dz++) { diff --git a/src/gametest/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoGameTests.java b/src/gametest/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoGameTests.java new file mode 100644 index 00000000..5f946a7d --- /dev/null +++ b/src/gametest/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoGameTests.java @@ -0,0 +1,771 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.BannerModDedicatedServerGameTestSupport; +import com.talhanation.bannermod.BannerModGameTestSupport; +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.entity.citizen.CitizenEntity; +import com.talhanation.bannermod.entity.civilian.FarmerEntity; +import com.talhanation.bannermod.registry.citizen.ModCitizenEntityTypes; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementManager; +import com.talhanation.bannermod.settlement.SettlementMarketRecord; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentMode; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.SettlementResidentRuntimeRoleState; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleSeed; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; +import com.talhanation.bannermod.settlement.SettlementResidentServiceContract; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import com.talhanation.bannermod.settlement.goal.BannerModResidentGoalScheduler; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentStopReason; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import com.talhanation.bannermod.settlement.goal.ResidentTaskOutcome; +import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; +import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; +import com.talhanation.bannermod.settlement.household.HomePreference; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@GameTestHolder(BannerModMain.MOD_ID) +public final class NpcSocietyPhaseTwoGameTests { + private static final long ACTIVE_TIME = 6000L; + private static final long NIGHT_TIME = 15000L; + + private NpcSocietyPhaseTwoGameTests() { + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void hungerPressureSelectsEatAndPublishesMarketAnchor(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042001"); + UUID marketUuid = UUID.fromString("00000000-0000-0000-0000-000000042011"); + SettlementBuildingRecord market = building(marketUuid, "bannermod:market_stall", helper.absolutePos(new BlockPos(10, 2, 10)), 0); + SettlementSnapshot snapshot = snapshot( + ACTIVE_TIME, + List.of(villagerResident(residentId)), + List.of(market), + marketState(marketUuid) + ); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(95, 10, 10, 10, ACTIVE_TIME); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(villagerResident(residentId), snapshot, ACTIVE_TIME, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, EatResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcSocietyProfile stored = NpcSocietyAccess.profileFor(level, residentId).orElseThrow(); + helper.assertTrue(stored.currentIntent() == NpcIntent.EAT, + "Expected hunger pressure to publish EAT intent."); + helper.assertTrue(stored.currentAnchor() == NpcAnchorType.MARKET, + "Expected hungry resident without a home to publish MARKET as the current anchor."); + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("eat".equals(aiSnapshot.aiCurrentGoalLabel()), + "Expected AI observability to publish the selected eat goal."); + helper.assertTrue("hunger_pressure".equals(aiSnapshot.aiChoiceReasonTag().toLowerCase()) + || "severe_hunger".equals(aiSnapshot.aiChoiceReasonTag().toLowerCase()), + "Expected AI observability to explain EAT via hunger pressure."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void heavyFatigueWithHomeSelectsGoHomeBeforeRest(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042002"); + UUID homeUuid = UUID.fromString("00000000-0000-0000-0000-000000042012"); + SettlementBuildingRecord home = building(homeUuid, "bannermod:house", helper.absolutePos(new BlockPos(12, 2, 12)), 4); + SettlementSnapshot snapshot = snapshot( + NIGHT_TIME, + List.of(workerResident(residentId, null, null)), + List.of(home), + SettlementMarketState.empty() + ); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + homeRuntime.assign(residentId, homeUuid, HomePreference.ASSIGNED, NIGHT_TIME); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals( + homeRuntime, + SettlementMarketState::empty, + new com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime() + ); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, NIGHT_TIME) + .withPhaseOneState(null, homeUuid, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), NIGHT_TIME) + .withNeedState(10, 95, 10, 10, NIGHT_TIME); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(workerResident(residentId, null, null), snapshot, NIGHT_TIME, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, GoHomeResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcSocietyProfile stored = NpcSocietyAccess.profileFor(level, residentId).orElseThrow(); + helper.assertTrue(stored.currentIntent() == NpcIntent.GO_HOME, + "Expected a heavily fatigued resident with a home to choose GO_HOME first."); + helper.assertTrue(stored.currentAnchor() == NpcAnchorType.HOME, + "Expected GO_HOME to publish the home anchor."); + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("go_home".equals(aiSnapshot.aiCurrentGoalLabel()), + "Expected AI observability to publish the go-home goal."); + helper.assertTrue("rest_window".equals(aiSnapshot.aiChoiceReasonTag().toLowerCase()) + || "fatigue_spike".equals(aiSnapshot.aiChoiceReasonTag().toLowerCase()), + "Expected AI observability to explain why GO_HOME won."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void socialNeedNowFallsBackToIdleWithoutMarketAnchor(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042003"); + UUID marketUuid = UUID.fromString("00000000-0000-0000-0000-000000042013"); + SettlementBuildingRecord market = building(marketUuid, "bannermod:market_stall", helper.absolutePos(new BlockPos(8, 2, 8)), 0); + SettlementSnapshot snapshot = snapshot( + ACTIVE_TIME, + List.of(villagerResident(residentId)), + List.of(market), + marketState(marketUuid) + ); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(5, 5, 95, 5, ACTIVE_TIME); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(villagerResident(residentId), snapshot, ACTIVE_TIME, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, IdleResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcSocietyProfile stored = NpcSocietyAccess.profileFor(level, residentId).orElseThrow(); + helper.assertTrue(stored.currentIntent() == NpcIntent.IDLE, + "Expected high social pressure to fall back to the cheap idle intent instead of selecting a dedicated social slice."); + helper.assertTrue(stored.currentAnchor() == NpcAnchorType.STREET, + "Expected the cheap idle fallback to avoid publishing a special market social anchor."); + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("idle".equals(aiSnapshot.aiCurrentGoalLabel()), + "Expected AI observability to publish the idle fallback once social scheduling is removed."); + helper.assertTrue("no_higher_priority_goal".equals(aiSnapshot.aiChoiceReasonTag().toLowerCase()), + "Expected AI observability to explain that no higher-priority goal beat the fallback."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void hungryHomelessResidentWithoutMarketPublishesBlockedEatReason(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042021"); + SettlementSnapshot snapshot = snapshot( + ACTIVE_TIME, + List.of(villagerResident(residentId)), + List.of(), + SettlementMarketState.empty() + ); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(96, 15, 10, 10, ACTIVE_TIME); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(villagerResident(residentId), snapshot, ACTIVE_TIME, profile); + scheduler.tick(ctx); + + ResidentTask task = scheduler.currentTask(residentId).orElseThrow(); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("eat".equals(aiSnapshot.aiBlockedGoalLabel()), + "Expected blocked-goal observability to report eat when hunger is high but no food access exists."); + helper.assertTrue("no_food_access".equals(aiSnapshot.aiBlockedReasonTag().toLowerCase()), + "Expected blocked-goal observability to explain the denial as missing food access."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void villagerThreatPublishesBlockedDefendReasonWhileHiding(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042022"); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(5, 5, 5, 92, ACTIVE_TIME); + ResidentGoalContext ctx = new ResidentGoalContext(villagerResident(residentId), null, ACTIVE_TIME, profile); + + seedProfile(level, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, HideResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, Map.of()); + + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("hide".equals(aiSnapshot.aiCurrentGoalLabel()), + "Expected AI observability to publish the current hide goal for threatened villagers."); + helper.assertTrue("defend".equals(aiSnapshot.aiBlockedGoalLabel()), + "Expected AI observability to show defend as the refused alternative for villagers under threat."); + helper.assertTrue("role_cannot_defend".equals(aiSnapshot.aiBlockedReasonTag().toLowerCase()), + "Expected AI observability to explain that villagers cannot take the defend path."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void threatPressureHidesVillagersButDefendsGovernors(GameTestHelper helper) { + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + UUID villagerId = UUID.fromString("00000000-0000-0000-0000-000000042004"); + UUID governorId = UUID.fromString("00000000-0000-0000-0000-000000042005"); + + ResidentGoalContext villagerCtx = new ResidentGoalContext( + villagerResident(villagerId), + null, + ACTIVE_TIME, + NpcSocietyProfile.createDefault(villagerId, ACTIVE_TIME).withNeedState(5, 5, 5, 92, ACTIVE_TIME) + ); + ResidentGoalContext governorCtx = new ResidentGoalContext( + governorResident(governorId), + null, + ACTIVE_TIME, + NpcSocietyProfile.createDefault(governorId, ACTIVE_TIME).withNeedState(5, 5, 5, 92, ACTIVE_TIME) + ); + + scheduler.tick(villagerCtx); + requireTask(helper, scheduler, villagerId, HideResidentGoal.ID.toString()); + scheduler.reset(); + + scheduler.tick(governorCtx); + requireTask(helper, scheduler, governorId, DefendResidentGoal.ID.toString()); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void workerLaborPausesWhenSocietyIntentIsNotWork(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + Player owner = helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL); + FarmerEntity worker = BannerModGameTestSupport.spawnOwnedFarmer(helper, owner, new BlockPos(2, 1, 2)); + com.talhanation.bannermod.entity.civilian.workarea.CropArea area = BannerModGameTestSupport.spawnOwnedCropArea(helper, owner, new BlockPos(5, 1, 2)); + worker.setFollowState(0); + worker.setCurrentWorkArea(area); + + NpcSocietyAccess.reconcilePhaseOneState( + level, + worker.getUUID(), + null, + null, + worker.getBoundWorkAreaUUID(), + NpcDailyPhase.ACTIVE, + NpcIntent.WORK, + NpcAnchorType.WORKPLACE, + NpcSocietyDecisionSnapshot.empty(), + ACTIVE_TIME + ); + helper.assertTrue(worker.shouldWork(), + "Expected worker labor to remain enabled while society intent is WORK."); + + NpcSocietyAccess.reconcilePhaseOneState( + level, + worker.getUUID(), + null, + null, + worker.getBoundWorkAreaUUID(), + NpcDailyPhase.ACTIVE, + NpcIntent.IDLE, + NpcAnchorType.STREET, + NpcSocietyDecisionSnapshot.empty(), + ACTIVE_TIME + 1L + ); + helper.assertFalse(worker.shouldWork(), + "Expected non-work society intent to pause worker labor selection."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void assignedMissingBuildingWorkerStillChoosesWorkDuringActivePhase(GameTestHelper helper) { + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042006"); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(10, 18, 72, 5, ACTIVE_TIME); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId, null, null, SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING), + null, + ACTIVE_TIME, + profile + ); + + scheduler.tick(ctx); + + requireTask(helper, scheduler, residentId, WorkResidentGoal.ID.toString()); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void workerFallsBackToIdleDuringLeisureGapAfterShift(GameTestHelper helper) { + long leisureTime = 10000L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042007"); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, leisureTime) + .withNeedState(10, 10, 95, 5, leisureTime); + ResidentGoalContext ctx = new ResidentGoalContext(workerResident(residentId, null, null), null, leisureTime, profile); + + scheduler.tick(ctx); + + requireTask(helper, scheduler, residentId, IdleResidentGoal.ID.toString()); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void lateDayHomeboundFallbackPublishesHomeAnchor(GameTestHelper helper) { + long leisureTime = 11550L; + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042008"); + UUID homeUuid = UUID.fromString("00000000-0000-0000-0000-000000042018"); + SettlementBuildingRecord home = building(homeUuid, "bannermod:house", helper.absolutePos(new BlockPos(12, 2, 12)), 4); + SettlementSnapshot snapshot = snapshot( + leisureTime, + List.of(villagerResident(residentId)), + List.of(home), + SettlementMarketState.empty() + ); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + homeRuntime.assign(residentId, homeUuid, HomePreference.ASSIGNED, leisureTime); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals( + homeRuntime, + SettlementMarketState::empty, + new com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime() + ); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, leisureTime) + .withPhaseOneState(null, homeUuid, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), leisureTime) + .withNeedState(10, 10, 95, 5, leisureTime); + ResidentGoalContext ctx = new ResidentGoalContext( + villagerResident(residentId), + snapshot, + leisureTime, + leisureTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, GoHomeResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcSocietyProfile stored = NpcSocietyAccess.profileFor(level, residentId).orElseThrow(); + helper.assertTrue(stored.currentAnchor() == NpcAnchorType.HOME, + "Expected late-day fallback to publish the home anchor once dedicated leisure social slices are gone."); + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("soon_night_homebound".equals(aiSnapshot.aiRouteReasonTag().toLowerCase()), + "Expected observability to explain that evening residents are now simply heading home."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void nightGoHomeChainSettlesIntoRestAfterReturnWindow(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042009"); + UUID homeUuid = UUID.fromString("00000000-0000-0000-0000-000000042019"); + SettlementBuildingRecord home = building(homeUuid, "bannermod:house", helper.absolutePos(new BlockPos(12, 2, 12)), 4); + SettlementSnapshot snapshot = snapshot( + NIGHT_TIME, + List.of(workerResident(residentId, null, null)), + List.of(home), + SettlementMarketState.empty() + ); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + homeRuntime.assign(residentId, homeUuid, HomePreference.ASSIGNED, NIGHT_TIME - 200L); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals( + homeRuntime, + SettlementMarketState::empty, + new com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime() + ); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, NIGHT_TIME) + .withPhaseOneState(null, homeUuid, null, NpcDailyPhase.RETURNING_HOME, NpcIntent.GO_HOME, NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("EXECUTING", GoHomeResidentGoal.ID.toString(), "REST_WINDOW", "SOON_NIGHT_HOMEBOUND", null, "NONE", NpcIntent.WORK.name(), NIGHT_TIME - 120L), NIGHT_TIME) + .withNeedState(10, 92, 10, 10, NIGHT_TIME); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(workerResident(residentId, null, null), snapshot, NIGHT_TIME, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("settling_at_home_for_rest".equals(aiSnapshot.aiRouteReasonTag().toLowerCase()), + "Expected the return-home chain to publish a clear settle-into-rest route reason once night rest takes over."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void morningLeaveHomeChainFansOutIntoWork(GameTestHelper helper) { + long morningTick = 1080L; + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042010"); + UUID homeUuid = UUID.fromString("00000000-0000-0000-0000-000000042020"); + SettlementBuildingRecord home = building(homeUuid, "bannermod:house", helper.absolutePos(new BlockPos(6, 2, 6)), 4); + SettlementSnapshot snapshot = snapshot( + morningTick, + List.of(workerResident(residentId, null, null)), + List.of(home), + SettlementMarketState.empty() + ); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + homeRuntime.assign(residentId, homeUuid, HomePreference.ASSIGNED, morningTick - 200L); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals( + homeRuntime, + SettlementMarketState::empty, + new com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime() + ); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, morningTick) + .withPhaseOneState(null, homeUuid, UUID.fromString("00000000-0000-0000-0000-000000042099"), NpcDailyPhase.DEPARTING_HOME, NpcIntent.LEAVE_HOME, NpcAnchorType.STREET, + new NpcSocietyDecisionSnapshot("EXECUTING", com.talhanation.bannermod.settlement.household.LeaveHomeResidentGoal.ID.toString(), "EARLY_ACTIVE_WINDOW", "LEAVING_HOME_FOR_WORK", null, "NONE", NpcIntent.REST.name(), morningTick - 70L), morningTick) + .withNeedState(10, 18, 18, 5, morningTick); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + ResidentGoalContext ctx = new ResidentGoalContext(workerResident(residentId, null, null), snapshot, morningTick, profile); + scheduler.tick(ctx); + + ResidentTask task = requireTask(helper, scheduler, residentId, WorkResidentGoal.ID.toString()); + NpcSocietyPhaseOneRuntime.updateResidentProfile(level, homeRuntime, ctx, task, byBuilding(snapshot)); + + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("starting_workday_after_home".equals(aiSnapshot.aiRouteReasonTag().toLowerCase()), + "Expected the morning bridge goal to resolve into a readable heading-to-work route once the worker has stepped out of the house."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void invalidatedWorkRecoveryPublishesReadableHomeRegroup(GameTestHelper helper) { + long gameTime = 9200L; + ServerLevel level = helper.getLevel(); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000042011"); + UUID homeUuid = UUID.fromString("00000000-0000-0000-0000-000000042031"); + SettlementBuildingRecord home = building(homeUuid, "bannermod:house", helper.absolutePos(new BlockPos(8, 2, 8)), 4); + SettlementSnapshot snapshot = snapshot( + gameTime, + List.of(workerResident(residentId, null, null)), + List.of(home), + SettlementMarketState.empty() + ); + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + homeRuntime.assign(residentId, homeUuid, HomePreference.ASSIGNED, gameTime - 120L); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState(null, homeUuid, UUID.fromString("00000000-0000-0000-0000-000000042099"), NpcDailyPhase.ACTIVE, + NpcIntent.WORK, NpcAnchorType.WORKPLACE, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", + WorkResidentGoal.ID.toString(), NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED, + NpcIntent.WORK.name(), gameTime - 40L), + gameTime) + .withNeedState(16, 56, 18, 18, gameTime); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId, null, null), + snapshot, + gameTime, + gameTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + seedProfile(level, profile); + SettlementManager.get(level).putSnapshot(snapshot); + + NpcSocietyPhaseOneRuntime.updateResidentProfile( + level, + homeRuntime, + ctx, + new ResidentTask(GoHomeResidentGoal.ID, gameTime, 40), + new ResidentTaskOutcome(WorkResidentGoal.ID, ResidentStopReason.CONTEXT_INVALID, gameTime - 20L), + byBuilding(snapshot) + ); + + NpcPhaseOneSnapshot aiSnapshot = NpcSocietyAccess.phaseOneSnapshot(level, residentId, null); + helper.assertTrue("RECOVERING".equals(aiSnapshot.aiStateTag()), + "Expected a broken work route to publish the recovering AI state."); + helper.assertTrue("work".equals(aiSnapshot.aiBlockedGoalLabel()), + "Expected observability to remember which work goal just broke."); + helper.assertTrue("context_invalidated".equals(aiSnapshot.aiBlockedReasonTag().toLowerCase()), + "Expected observability to distinguish an invalidated context from a generic timeout."); + helper.assertTrue("regrouping_at_home".equals(aiSnapshot.aiRouteReasonTag().toLowerCase()), + "Expected recovery routing to explain that the worker is regrouping at home after the broken path."); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty", timeoutTicks = 160) + public static void idleIntentDoesNotChaseSquareSpotWithoutMarket(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + CitizenEntity citizen = BannerModGameTestSupport.spawnEntity(helper, ModCitizenEntityTypes.CITIZEN.get(), new BlockPos(1, 1, 1)); + UUID squareUuid = UUID.fromString("00000000-0000-0000-0000-000000042021"); + BlockPos squarePos = helper.absolutePos(new BlockPos(12, 1, 1)); + SettlementBuildingRecord square = building(squareUuid, "bannermod:village_square", squarePos, 0); + SettlementSnapshot snapshot = snapshot( + ACTIVE_TIME, + List.of(villagerResident(citizen.getUUID())), + List.of(square), + SettlementMarketState.empty() + ); + + SettlementManager.get(level).putSnapshot(snapshot); + NpcSocietyAccess.reconcilePhaseOneState( + level, + citizen.getUUID(), + null, + null, + null, + NpcDailyPhase.ACTIVE, + NpcIntent.IDLE, + NpcAnchorType.STREET, + NpcSocietyDecisionSnapshot.empty(), + ACTIVE_TIME + ); + double startDistance = citizen.distanceToSqr(Vec3.atCenterOf(squarePos)); + + helper.runAfterDelay(20, () -> { + helper.assertTrue( + citizen.distanceToSqr(Vec3.atCenterOf(squarePos)) >= startDistance - 4.0D, + "Expected idle intent to stop chasing named social gathering anchors." + ); + helper.succeed(); + }); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty", timeoutTicks = 120) + public static void idleIntentDoesNotChaseSettlementAnchor(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + CitizenEntity citizen = BannerModGameTestSupport.spawnEntity(helper, ModCitizenEntityTypes.CITIZEN.get(), new BlockPos(1, 1, 1)); + UUID marketUuid = UUID.fromString("00000000-0000-0000-0000-000000042016"); + BlockPos marketPos = helper.absolutePos(new BlockPos(6, 1, 1)); + SettlementBuildingRecord market = building(marketUuid, "bannermod:market_stall", marketPos, 0); + SettlementSnapshot snapshot = snapshot( + ACTIVE_TIME, + List.of(villagerResident(citizen.getUUID())), + List.of(market), + marketState(marketUuid) + ); + + SettlementManager.get(level).putSnapshot(snapshot); + NpcSocietyAccess.reconcilePhaseOneState( + level, + citizen.getUUID(), + null, + null, + null, + NpcDailyPhase.ACTIVE, + NpcIntent.IDLE, + NpcAnchorType.STREET, + NpcSocietyDecisionSnapshot.empty(), + ACTIVE_TIME + ); + double startDistance = citizen.distanceToSqr(Vec3.atCenterOf(marketPos)); + + helper.runAfterDelay(20, () -> { + NpcSocietyProfile stored = NpcSocietyAccess.profileFor(level, citizen.getUUID()).orElseThrow(); + helper.assertTrue(stored.currentIntent() == NpcIntent.IDLE, + "Expected the citizen to stay on the cheap idle intent when no other coarse goal wins."); + helper.assertTrue(stored.currentAnchor() == NpcAnchorType.STREET, + "Expected idle intent to avoid keeping a dedicated market social anchor."); + helper.assertTrue(citizen.distanceToSqr(Vec3.atCenterOf(marketPos)) >= startDistance - 4.0D, + "Expected idle intent not to pull the citizen toward the market anchor."); + helper.succeed(); + }); + } + + private static ResidentTask requireTask(GameTestHelper helper, + BannerModResidentGoalScheduler scheduler, + UUID residentId, + String expectedGoalId) { + Optional task = scheduler.currentTask(residentId); + helper.assertTrue(task.isPresent(), "Expected resident scheduler to publish a task."); + helper.assertTrue(expectedGoalId.equals(task.get().goalId().toString()), + "Expected task " + expectedGoalId + " but got " + task.get().goalId() + "."); + return task.get(); + } + + private static void seedProfile(ServerLevel level, NpcSocietyProfile profile) { + NpcSocietySavedData.get(level).runtime().seedResident(profile); + } + + private static SettlementResidentRecord villagerResident(UUID residentId) { + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.VILLAGER, + SettlementResidentScheduleSeed.SETTLEMENT_IDLE, + SettlementResidentScheduleWindowSeed.DAYLIGHT_FLEX, + SettlementResidentRuntimeRoleState.VILLAGE_LIFE, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.SETTLEMENT_RESIDENT, + null, + null, + null, + SettlementResidentAssignmentState.NOT_APPLICABLE + ); + } + + private static SettlementResidentRecord workerResident(UUID residentId, UUID ownerUuid, String teamId) { + return workerResident(residentId, ownerUuid, teamId, SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING); + } + + private static SettlementResidentRecord workerResident(UUID residentId, + UUID ownerUuid, + String teamId, + SettlementResidentAssignmentState assignmentState) { + UUID workAreaUuid = UUID.fromString("00000000-0000-0000-0000-000000042099"); + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.defaultFor( + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentRuntimeRoleState.LOCAL_LABOR + ), + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + ownerUuid, + teamId, + workAreaUuid, + assignmentState + ); + } + + private static SettlementResidentRecord governorResident(UUID residentId) { + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.GOVERNOR_RECRUIT, + SettlementResidentScheduleSeed.GOVERNING, + SettlementResidentScheduleWindowSeed.CIVIC_DAY, + SettlementResidentRuntimeRoleState.GOVERNANCE, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.SETTLEMENT_RESIDENT, + null, + null, + null, + SettlementResidentAssignmentState.NOT_APPLICABLE + ); + } + + private static SettlementBuildingRecord building(UUID buildingUuid, String typeId, BlockPos originPos, int residentCapacity) { + return new SettlementBuildingRecord( + buildingUuid, + typeId, + originPos, + null, + null, + residentCapacity, + 0, + 0, + List.of() + ); + } + + private static SettlementSnapshot snapshot(long gameTime, + List residents, + List buildings, + SettlementMarketState marketState) { + return new SettlementSnapshot( + UUID.fromString("00000000-0000-0000-0000-000000042777"), + 0, + 0, + null, + gameTime, + 0, + 0, + 0, + residents.size(), + 0, + 0, + SettlementStockpileSummary.empty(), + marketState, + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + residents, + buildings + ); + } + + private static SettlementMarketState marketState(UUID marketBuildingUuid) { + return new SettlementMarketState( + 1, + 1, + 16, + 8, + 0, + 0, + List.of(new SettlementMarketRecord(marketBuildingUuid, "market", true, 16, 8)), + List.of() + ); + } + + private static Map byBuilding(SettlementSnapshot snapshot) { + Map indexed = new LinkedHashMap<>(); + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && building.buildingUuid() != null) { + indexed.put(building.buildingUuid(), building); + } + } + return indexed; + } +} diff --git a/src/main/java/com/talhanation/bannermod/ai/civilian/DepositItemsToStorage.java b/src/main/java/com/talhanation/bannermod/ai/civilian/DepositItemsToStorage.java index 86429e45..cdfb3dd6 100644 --- a/src/main/java/com/talhanation/bannermod/ai/civilian/DepositItemsToStorage.java +++ b/src/main/java/com/talhanation/bannermod/ai/civilian/DepositItemsToStorage.java @@ -17,7 +17,11 @@ public DepositItemsToStorage(AbstractWorkerEntity worker){ } @Override public boolean canUse() { - return worker.needsToDeposit() && super.canUse(); + boolean courierOverride = worker.hasActiveCourierTask(); + return (courierOverride || worker.shouldWork()) + && (courierOverride || !worker.needsToSleep()) + && worker.needsToDeposit() + && super.canUse(); } @Override diff --git a/src/main/java/com/talhanation/bannermod/ai/civilian/GetNeededItemsFromStorage.java b/src/main/java/com/talhanation/bannermod/ai/civilian/GetNeededItemsFromStorage.java index 7810401b..1bddfa5e 100644 --- a/src/main/java/com/talhanation/bannermod/ai/civilian/GetNeededItemsFromStorage.java +++ b/src/main/java/com/talhanation/bannermod/ai/civilian/GetNeededItemsFromStorage.java @@ -4,9 +4,11 @@ import com.talhanation.bannermod.entity.civilian.WorkerStorageRequestState; import com.talhanation.bannermod.entity.civilian.workarea.StorageArea; import com.talhanation.bannermod.persistence.civilian.NeededItem; +import com.talhanation.bannermod.shared.logistics.BannerModCourierTask; import com.talhanation.bannermod.shared.logistics.BannerModLogisticsBlockedReason; import net.minecraft.core.BlockPos; import net.minecraft.network.chat.Component; +import net.minecraft.world.Container; import net.minecraft.world.SimpleContainer; import net.minecraft.world.item.ItemStack; @@ -25,7 +27,26 @@ public GetNeededItemsFromStorage(AbstractWorkerEntity worker) { @Override public boolean canUse() { - return !worker.needsToDeposit() && worker.needsToGetItems() && super.canUse(); + boolean courierOverride = worker.hasActiveCourierTask(); + return (courierOverride || worker.shouldWork()) + && (courierOverride || !worker.needsToSleep()) + && !worker.needsToDeposit() + && worker.needsToGetItems() + && super.canUse(); + } + + @Override + public boolean canContinueToUse() { + if (!worker.hasActiveCourierTask()) { + return super.canContinueToUse(); + } + if (!super.canUse() || this.state == null) { + return false; + } + return switch (this.state) { + case DONE, ERROR_NO_STORAGE_FOUND, ERROR_ITEM_NOT_IN_STORAGE, ERROR_STORAGE_NO_CONTAINERS, ERROR_OWN_INVENTORY_FULL -> false; + default -> true; + }; } @Override @@ -79,6 +100,13 @@ public void tick(){ return; } + if (worker.hasActiveCourierTask()) { + this.chestPos = null; + this.container = null; + setState(State.TAKE_NEEDED_ITEMS); + return; + } + setState(State.SELECT_CHEST); } @@ -128,7 +156,18 @@ public void tick(){ } case TAKE_NEEDED_ITEMS -> { - worker.getLookControl().setLookAt(chestPos.getCenter()); + if (chestPos != null) { + worker.getLookControl().setLookAt(chestPos.getCenter()); + } + + if (worker.hasActiveCourierTask()) { + if (takeNeededItems()) { + setState(State.DONE); + } else { + setState(State.ERROR_ITEM_NOT_IN_STORAGE); + } + return; + } if(takeNeededItems()){ setState(State.CLOSE_CHEST_DONE); @@ -238,12 +277,37 @@ public void tick(){ } private boolean takeNeededItems() { - SimpleContainer inventory = worker.getInventory(); - if (container == null || container.isEmpty()) { return false; } + BannerModCourierTask activeCourierTask = worker.getActiveCourierTask(); + if (activeCourierTask != null) { + int missingCount = worker.getActiveCourierPickupMissingCount(); + if (missingCount <= 0) { + return true; + } + int moved = 0; + Collection sources = this.storageArea == null + ? List.of(container) + : this.storageArea.storageMap.values(); + for (Container source : sources) { + if (source == null || moved >= missingCount) { + continue; + } + moved += TransportContainerExchange.withdrawInto( + source, + worker.getInventory(), + activeCourierTask.reservation().filter(), + missingCount - moved + ); + } + if (moved <= 0) { + return false; + } + return !worker.hasActiveCourierPickupPending(); + } + List neededItems = worker.neededItems; if (neededItems.isEmpty()) return false; @@ -261,17 +325,30 @@ private boolean takeNeededItems() { int toExtract = Math.min(neededCount, availableCount); ItemStack extracted = itemInChest.split(toExtract); - ItemStack leftover = inventory.addItem(extracted); + ItemStack applied = extracted.copy(); + ItemStack leftover = worker.addItem(extracted); + int movedCount = Math.max(0, applied.getCount() - leftover.getCount()); if (!leftover.isEmpty()) { - itemInChest.grow(toExtract); + itemInChest.grow(leftover.getCount()); + } + container.setItem(i, itemInChest); + if (movedCount <= 0) { + container.setChanged(); return false; } - - NeededItem.applyToNeededItems(extracted, neededItems); + if (movedCount < applied.getCount()) { + applied.setCount(movedCount); + } + NeededItem.applyToNeededItems(applied, neededItems); + container.setChanged(); anyTaken = true; + if (!leftover.isEmpty()) { + return false; + } + if (itemInChest.isEmpty()) { break; } diff --git a/src/main/java/com/talhanation/bannermod/ai/civilian/SettlementOrderWorkGoal.java b/src/main/java/com/talhanation/bannermod/ai/civilian/SettlementOrderWorkGoal.java index 40664404..ef7040df 100644 --- a/src/main/java/com/talhanation/bannermod/ai/civilian/SettlementOrderWorkGoal.java +++ b/src/main/java/com/talhanation/bannermod/ai/civilian/SettlementOrderWorkGoal.java @@ -102,6 +102,9 @@ public boolean canUse() { if (worker.getCommandSenderWorld().isClientSide()) { return false; } + if (!worker.shouldWork() || worker.needsToSleep()) { + return false; + } if (!(worker.getCommandSenderWorld() instanceof ServerLevel level)) { return false; } @@ -118,6 +121,9 @@ public boolean canContinueToUse() { if (activeOrder == null) { return false; } + if (!worker.shouldWork() || worker.needsToSleep()) { + return false; + } if (!(worker.getCommandSenderWorld() instanceof ServerLevel level)) { return false; } @@ -166,6 +172,10 @@ public void tick() { if (activeOrder == null) { return; } + if (!worker.shouldWork() || worker.needsToSleep()) { + worker.getNavigation().stop(); + return; + } if (!(worker.getCommandSenderWorld() instanceof ServerLevel level)) { return; } diff --git a/src/main/java/com/talhanation/bannermod/ai/civilian/WorkerToolCraftingGoal.java b/src/main/java/com/talhanation/bannermod/ai/civilian/WorkerToolCraftingGoal.java new file mode 100644 index 00000000..f88708f4 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/ai/civilian/WorkerToolCraftingGoal.java @@ -0,0 +1,301 @@ +package com.talhanation.bannermod.ai.civilian; + +import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.entity.civilian.AnimalFarmerEntity; +import com.talhanation.bannermod.entity.civilian.BuilderEntity; +import com.talhanation.bannermod.entity.civilian.FarmerEntity; +import com.talhanation.bannermod.entity.civilian.LumberjackEntity; +import com.talhanation.bannermod.entity.civilian.MinerEntity; +import com.talhanation.bannermod.persistence.civilian.NeededItem; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.ItemTags; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.item.AxeItem; +import net.minecraft.world.item.HoeItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.PickaxeItem; +import net.minecraft.world.item.ShovelItem; +import net.minecraft.world.level.block.Blocks; + +import javax.annotation.Nullable; +import java.util.EnumSet; + +public class WorkerToolCraftingGoal extends Goal { + private static final int SEARCH_RADIUS = 24; + private static final int REQUEST_COOLDOWN_TICKS = 20 * 10; + private static final double REACH_SQR = 2.5D * 2.5D; + + private final AbstractWorkerEntity worker; + @Nullable + private ToolRecipe recipe; + @Nullable + private BlockPos workbenchPos; + private int lastRequestTick = -REQUEST_COOLDOWN_TICKS; + + public WorkerToolCraftingGoal(AbstractWorkerEntity worker) { + this.worker = worker; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (this.worker.level().isClientSide() || this.worker.hasActiveCourierTask()) { + return false; + } + if (!this.worker.isOwned() || !this.worker.isWorking() && !this.worker.getCommandSenderWorld().isDay()) { + return false; + } + if (!this.worker.shouldWork() || this.worker.needsToSleep()) { + return false; + } + this.recipe = nextMissingRecipe(); + if (this.recipe == null) { + return false; + } + this.workbenchPos = findNearbyWorkbench(); + if (this.workbenchPos == null) { + return false; + } + if (canCraftNow(this.recipe)) { + return true; + } + maybeRequestMaterials(this.recipe); + return false; + } + + @Override + public boolean canContinueToUse() { + return this.recipe != null + && this.workbenchPos != null + && this.worker.shouldWork() + && !this.worker.needsToSleep() + && canCraftNow(this.recipe); + } + + @Override + public void stop() { + super.stop(); + this.recipe = null; + this.workbenchPos = null; + this.worker.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.recipe == null || this.workbenchPos == null) { + return; + } + this.worker.getLookControl().setLookAt(this.workbenchPos.getCenter()); + if (this.worker.distanceToSqr(this.workbenchPos.getX() + 0.5D, this.workbenchPos.getY() + 0.5D, this.workbenchPos.getZ() + 0.5D) > REACH_SQR) { + this.worker.getNavigation().moveTo(this.workbenchPos.getX() + 0.5D, this.workbenchPos.getY(), this.workbenchPos.getZ() + 0.5D, 0.9D); + return; + } + this.worker.getNavigation().stop(); + if (!canCraftNow(this.recipe)) { + maybeRequestMaterials(this.recipe); + this.stop(); + return; + } + consumeInputs(this.recipe); + this.worker.addItem(this.recipe.output().copy()); + this.worker.onWorkerItemAdded(this.recipe.output()); + this.worker.switchMainHandItem(stack -> stack.is(this.recipe.output().getItem())); + this.worker.swing(net.minecraft.world.InteractionHand.MAIN_HAND); + this.worker.clearWorkStatus(); + this.stop(); + } + + @Nullable + private ToolRecipe nextMissingRecipe() { + if (this.worker instanceof FarmerEntity && this.worker.getMatchingItem(stack -> stack.getItem() instanceof HoeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_HOE), 2); + } + if (this.worker instanceof LumberjackEntity && this.worker.getMatchingItem(stack -> stack.getItem() instanceof AxeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_AXE), 3); + } + if (this.worker instanceof AnimalFarmerEntity && this.worker.getMatchingItem(stack -> stack.getItem() instanceof AxeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_AXE), 3); + } + if (this.worker instanceof MinerEntity) { + if (this.worker.getMatchingItem(stack -> stack.getItem() instanceof PickaxeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_PICKAXE), 3); + } + if (this.worker.getMatchingItem(stack -> stack.getItem() instanceof ShovelItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_SHOVEL), 1); + } + } + if (this.worker instanceof BuilderEntity) { + if (this.worker.getMatchingItem(stack -> stack.getItem() instanceof AxeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_AXE), 3); + } + if (this.worker.getMatchingItem(stack -> stack.getItem() instanceof PickaxeItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_PICKAXE), 3); + } + if (this.worker.getMatchingItem(stack -> stack.getItem() instanceof ShovelItem) == null) { + return new ToolRecipe(new ItemStack(Items.STONE_SHOVEL), 1); + } + } + return null; + } + + private boolean canCraftNow(ToolRecipe recipe) { + return countItem(Items.COBBLESTONE) >= recipe.cobblestoneCost() && availableStickCount() >= 2; + } + + private void maybeRequestMaterials(ToolRecipe recipe) { + if (this.worker.tickCount - this.lastRequestTick < REQUEST_COOLDOWN_TICKS) { + return; + } + this.lastRequestTick = this.worker.tickCount; + int missingCobblestone = Math.max(0, recipe.cobblestoneCost() - countItem(Items.COBBLESTONE)); + if (missingCobblestone > 0) { + this.worker.requestRequiredItem(new NeededItem(stack -> stack.is(Items.COBBLESTONE), missingCobblestone, true), + "worker_crafting_missing_cobblestone", + Component.literal(this.worker.getName().getString() + ": I need cobblestone to craft a tool.")); + } + if (availableStickCount() >= 2) { + return; + } + if (countTagged(ItemTags.PLANKS) >= 2 || countTagged(ItemTags.LOGS) >= 1) { + return; + } + this.worker.requestRequiredItem(new NeededItem(stack -> stack.is(ItemTags.PLANKS) || stack.is(Items.STICK), 2, true), + "worker_crafting_missing_wood", + Component.literal(this.worker.getName().getString() + ": I need wood to craft a tool.")); + } + + private void consumeInputs(ToolRecipe recipe) { + removeItems(Items.COBBLESTONE, recipe.cobblestoneCost()); + ensureTwoSticks(); + removeItems(Items.STICK, 2); + } + + private void ensureTwoSticks() { + if (countItem(Items.STICK) >= 2) { + return; + } + if (countTagged(ItemTags.PLANKS) < 2) { + convertOneLogToPlanks(); + } + if (countItem(Items.STICK) < 2 && countTagged(ItemTags.PLANKS) >= 2) { + removeTaggedItems(ItemTags.PLANKS, 2); + this.worker.addItem(new ItemStack(Items.STICK, 4)); + this.worker.onWorkerItemAdded(new ItemStack(Items.STICK, 4)); + } + } + + private void convertOneLogToPlanks() { + net.minecraft.world.SimpleContainer inventory = this.worker.getInventory(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.is(ItemTags.LOGS)) { + continue; + } + Item plankItem = plankOutputForLog(stack); + stack.shrink(1); + if (stack.isEmpty()) { + inventory.setItem(i, ItemStack.EMPTY); + } + ItemStack planks = new ItemStack(plankItem, 4); + this.worker.addItem(planks.copy()); + this.worker.onWorkerItemAdded(planks); + return; + } + } + + private Item plankOutputForLog(ItemStack logStack) { + ResourceLocation id = BuiltInRegistries.ITEM.getKey(logStack.getItem()); + if (id == null) { + return Items.OAK_PLANKS; + } + String path = id.getPath(); + String plankPath = path + .replace("_log", "_planks") + .replace("_wood", "_planks") + .replace("stem", "planks") + .replace("hyphae", "planks"); + Item resolved = BuiltInRegistries.ITEM.get(ResourceLocation.fromNamespaceAndPath(id.getNamespace(), plankPath)); + return resolved == Items.AIR ? Items.OAK_PLANKS : resolved; + } + + @Nullable + private BlockPos findNearbyWorkbench() { + BlockPos center = this.worker.blockPosition(); + BlockPos best = null; + double bestDist = Double.MAX_VALUE; + for (BlockPos pos : BlockPos.betweenClosed(center.offset(-SEARCH_RADIUS, -3, -SEARCH_RADIUS), center.offset(SEARCH_RADIUS, 3, SEARCH_RADIUS))) { + if (!this.worker.level().getBlockState(pos).is(Blocks.CRAFTING_TABLE)) { + continue; + } + double dist = pos.distSqr(center); + if (dist < bestDist) { + bestDist = dist; + best = pos.immutable(); + } + } + return best; + } + + private int availableStickCount() { + int sticks = countItem(Items.STICK); + int planks = countTagged(ItemTags.PLANKS); + int logs = countTagged(ItemTags.LOGS); + return sticks + (planks / 2) * 4 + logs * 8; + } + + private int countItem(Item item) { + return this.worker.countMatchingItems(stack -> stack.is(item)); + } + + private int countTagged(net.minecraft.tags.TagKey tag) { + return this.worker.countMatchingItems(stack -> stack.is(tag)); + } + + private void removeItems(Item item, int count) { + if (count <= 0) { + return; + } + net.minecraft.world.SimpleContainer inventory = this.worker.getInventory(); + int remaining = count; + for (int i = 0; i < inventory.getContainerSize() && remaining > 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.is(item)) { + continue; + } + int taken = Math.min(stack.getCount(), remaining); + stack.shrink(taken); + remaining -= taken; + if (stack.isEmpty()) { + inventory.setItem(i, ItemStack.EMPTY); + } + } + } + + private void removeTaggedItems(net.minecraft.tags.TagKey tag, int count) { + if (count <= 0) { + return; + } + net.minecraft.world.SimpleContainer inventory = this.worker.getInventory(); + int remaining = count; + for (int i = 0; i < inventory.getContainerSize() && remaining > 0; i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.is(tag)) { + continue; + } + int taken = Math.min(stack.getCount(), remaining); + stack.shrink(taken); + remaining -= taken; + if (stack.isEmpty()) { + inventory.setItem(i, ItemStack.EMPTY); + } + } + } + + private record ToolRecipe(ItemStack output, int cobblestoneCost) { + } +} diff --git a/src/main/java/com/talhanation/bannermod/ai/military/RecruitFollowOwnerGoal.java b/src/main/java/com/talhanation/bannermod/ai/military/RecruitFollowOwnerGoal.java index a7806fe4..81d393e0 100644 --- a/src/main/java/com/talhanation/bannermod/ai/military/RecruitFollowOwnerGoal.java +++ b/src/main/java/com/talhanation/bannermod/ai/military/RecruitFollowOwnerGoal.java @@ -58,6 +58,11 @@ public boolean canContinueToUse() { if (this.recruit.getNavigation().isDone()) { return false; } + LivingEntity liveOwner = this.recruit.getOwner(); + if (liveOwner == null || !liveOwner.isAlive() || liveOwner.isRemoved()) { + return false; + } + this.owner = liveOwner; // FORMATIONDIM-001: stop the follow as soon as the leader crosses dimensions. if (FormationDimensionGuard.shouldHoldDueToDimensionMismatch(this.recruit, this.owner)) { return false; @@ -78,6 +83,12 @@ public void stop() { } public void tick() { + LivingEntity liveOwner = this.recruit.getOwner(); + if (liveOwner == null || !liveOwner.isAlive() || liveOwner.isRemoved()) { + this.recruit.getNavigation().stop(); + return; + } + this.owner = liveOwner; // FORMATIONDIM-001: belt-and-braces — bail out if leader crossed dimensions mid-tick. if (FormationDimensionGuard.shouldHoldDueToDimensionMismatch(this.recruit, this.owner)) { this.recruit.getNavigation().stop(); @@ -92,4 +103,4 @@ public void tick() { } } } -} \ No newline at end of file +} diff --git a/src/main/java/com/talhanation/bannermod/client/citizen/render/CitizenRenderer.java b/src/main/java/com/talhanation/bannermod/client/citizen/render/CitizenRenderer.java index 39b6bbdf..a8b0cdf2 100644 --- a/src/main/java/com/talhanation/bannermod/client/citizen/render/CitizenRenderer.java +++ b/src/main/java/com/talhanation/bannermod/client/citizen/render/CitizenRenderer.java @@ -27,6 +27,7 @@ public ResourceLocation getTextureLocation(CitizenEntity entity) { @Override protected void scale(CitizenEntity entity, PoseStack poseStack, float partialTickTime) { - poseStack.scale(0.9375F, 0.9375F, 0.9375F); + float scale = 0.9375F * entity.renderScaleFactor(); + poseStack.scale(scale, scale, scale); } } diff --git a/src/main/java/com/talhanation/bannermod/client/civilian/gui/CitizenProfileScreen.java b/src/main/java/com/talhanation/bannermod/client/civilian/gui/CitizenProfileScreen.java index 39b21c9e..9c55d4aa 100644 --- a/src/main/java/com/talhanation/bannermod/client/civilian/gui/CitizenProfileScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/civilian/gui/CitizenProfileScreen.java @@ -1,19 +1,20 @@ package com.talhanation.bannermod.client.civilian.gui; import com.talhanation.bannermod.client.military.ClientManager; -import com.talhanation.bannermod.client.civilian.input.AssignHomeTargetSelector; import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; import com.talhanation.bannermod.citizen.CitizenProfession; import com.talhanation.bannermod.entity.citizen.CitizenEntity; import com.talhanation.bannermod.inventory.civilian.CitizenProfileMenu; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.InventoryScreen; import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Inventory; +import net.neoforged.neoforge.client.gui.widget.ExtendedButton; import javax.annotation.Nullable; import java.util.UUID; @@ -23,10 +24,14 @@ public class CitizenProfileScreen extends AbstractContainerScreen { + if (this.minecraft != null) { + this.minecraft.setScreen(new NpcAiDecisionScreen(this, this.phaseOneSnapshot)); + } + } + )); + this.addRenderableWidget(new LedgerButton( + this.leftPos + this.imageWidth - 62, + this.topPos + 10, + 48, + 16, + MilitaryGuiStyle.clampLabel(this.font, Component.translatable("gui.bannermod.family_tree.open"), 42), button -> { - AssignHomeTargetSelector.start(this.citizen.getUUID()); - this.onClose(); + if (this.minecraft != null) { + this.minecraft.setScreen(new NpcFamilyTreeScreen(this, this.familyTreeSnapshot)); + } } - ).bounds(this.leftPos + 14, this.topPos + 140, 70, 16).build(); - assignHome.setTooltip(Tooltip.create(Component.translatable("bannermod.assign_home.tooltip"))); - this.addRenderableWidget(assignHome); + )); } @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { - this.renderBackground(graphics, mouseX, mouseY, partialTick); + super.renderBackground(graphics, mouseX, mouseY, partialTick); super.render(graphics, mouseX, mouseY, partialTick); this.renderTooltip(graphics, mouseX, mouseY); } + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + } + @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { graphics.fill(0, 0, this.width, this.height, 0x54160E08); @@ -91,6 +114,7 @@ protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int (float) (this.leftPos + 48) - mouseX, (float) (this.topPos + 56) - mouseY, this.citizen); + MilitaryGuiStyle.drawBadge(graphics, this.font, professionLabel(this.citizen.activeProfession()), this.leftPos + 96, this.topPos + 30, 134, MilitaryGuiStyle.TEXT_WARN); } @Override @@ -98,14 +122,20 @@ protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, 0, 10, this.imageWidth); // Inset starts at x=92 with width=142, so we have 138px of usable text space. final int textBoxWidth = 134; - drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.profession", - professionLabel(this.citizen.activeProfession()).getString()), 92, 32, textBoxWidth, MilitaryGuiStyle.TEXT_DARK); drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.owner", - ownerLabel().getString()), 92, 46, textBoxWidth, MilitaryGuiStyle.TEXT_DARK); + ownerLabel().getString()), 92, 44, textBoxWidth, MilitaryGuiStyle.TEXT_DARK); drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.assignment", - assignmentLabel().getString()), 92, 60, textBoxWidth, 0xFF6E5535); - drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.state", - stateLabel().getString()), 92, 74, textBoxWidth, 0xFF6E5535); + assignmentLabel().getString()), 92, 53, textBoxWidth, 0xFF6E5535); + drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.home", + homeSummary().getString()), 92, 62, textBoxWidth, 0xFF6E5535); + drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.household", + householdSummary().getString()), 92, 71, textBoxWidth, 0xFF6E5535); + drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.routine", + routineSummary().getString()), 92, 80, textBoxWidth, 0xFF6E5535); + drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.housing", + housingSummary().getString()), 92, 89, textBoxWidth, 0xFF6E5535); + drawClamped(graphics, Component.translatable("gui.bannermod.citizen_profile.needs", + needsSummary().getString()), 92, 98, textBoxWidth, MilitaryGuiStyle.TEXT_DARK); graphics.drawString(this.font, Component.translatable("gui.bannermod.citizen_profile.inventory"), 96, 108, MilitaryGuiStyle.TEXT_DARK, false); graphics.drawString(this.font, Component.translatable("gui.bannermod.citizen_profile.player_inventory"), 14, 166, MilitaryGuiStyle.TEXT_DARK, false); } @@ -157,7 +187,7 @@ private Component ownerLabel() { } private Component assignmentLabel() { - @Nullable UUID boundArea = this.citizen.getBoundWorkAreaUUID(); + @Nullable UUID boundArea = this.phaseOneSnapshot.workBuildingUuid(); if (boundArea == null) { return Component.translatable("gui.bannermod.citizen_profile.assignment.none"); } @@ -167,10 +197,53 @@ private Component assignmentLabel() { boundArea.toString().substring(0, 8)); } - private Component stateLabel() { - return this.citizen.isWorking() - ? Component.translatable("gui.bannermod.citizen_profile.state.working") - : Component.translatable("gui.bannermod.citizen_profile.state.idle"); + private Component homeSummary() { + return Component.translatable( + "gui.bannermod.citizen_profile.home.summary", + NpcPhaseOneSnapshot.shortId(this.phaseOneSnapshot.homeBuildingUuid()), + Component.translatable(this.phaseOneSnapshot.lifeStageTranslationKey()).getString(), + Component.translatable(this.phaseOneSnapshot.sexTranslationKey()).getString() + ); + } + + private Component householdSummary() { + return Component.translatable( + "gui.bannermod.citizen_profile.household.summary", + this.phaseOneSnapshot.householdSize(), + Component.translatable(this.phaseOneSnapshot.householdHousingStateTranslationKey()).getString() + ); + } + + private Component routineSummary() { + String phaseLabel = this.phaseOneSnapshot.isBlockedState() + ? Component.translatable(this.phaseOneSnapshot.aiStateTranslationKey()).getString() + : Component.translatable(this.phaseOneSnapshot.dailyPhaseTranslationKey()).getString(); + return Component.translatable( + "gui.bannermod.citizen_profile.routine.summary", + phaseLabel, + Component.translatable(this.phaseOneSnapshot.currentIntentTranslationKey()).getString(), + Component.translatable(this.phaseOneSnapshot.currentAnchorTranslationKey()).getString(), + this.phaseOneSnapshot.aiReadableRoutineReasonComponent() + ); + } + + private Component housingSummary() { + return Component.translatable( + "gui.bannermod.citizen_profile.housing.summary", + Component.translatable(this.phaseOneSnapshot.housingRequestTranslationKey()).getString(), + Component.translatable(this.phaseOneSnapshot.housingUrgencyTranslationKey()).getString(), + Component.translatable(this.phaseOneSnapshot.housingReasonTranslationKey()).getString(), + this.phaseOneSnapshot.housingWaitingDays() + ); + } + + private Component needsSummary() { + return Component.translatable( + "gui.bannermod.citizen_profile.needs.summary", + this.phaseOneSnapshot.hungerNeed(), + this.phaseOneSnapshot.fatigueNeed(), + this.phaseOneSnapshot.safetyNeed() + ); } private Component professionLabel(CitizenProfession profession) { @@ -193,4 +266,16 @@ private Component professionLabel(CitizenProfession profession) { case NOBLE -> Component.translatable("gui.bannermod.citizen_profile.profession.noble"); }; } + + private static class LedgerButton extends ExtendedButton { + LedgerButton(int x, int y, int width, int height, Component label, OnPress handler) { + super(x, y, width, height, label, handler); + } + + @Override + public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + MilitaryGuiStyle.commandButton(graphics, Minecraft.getInstance().font, mouseX, mouseY, + getX(), getY(), width, height, getMessage(), active, false); + } + } } diff --git a/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcAiDecisionScreen.java b/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcAiDecisionScreen.java new file mode 100644 index 00000000..15d8336e --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcAiDecisionScreen.java @@ -0,0 +1,167 @@ +package com.talhanation.bannermod.client.civilian.gui; + +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.neoforged.neoforge.client.gui.widget.ExtendedButton; + +public class NpcAiDecisionScreen extends Screen { + private static final int WIDTH = 320; + private static final int HEIGHT = 314; + + private final Screen parent; + private final NpcPhaseOneSnapshot snapshot; + private int left; + private int top; + + public NpcAiDecisionScreen(Screen parent, NpcPhaseOneSnapshot snapshot) { + super(Component.translatable("gui.bannermod.society.ai.title")); + this.parent = parent; + this.snapshot = snapshot == null ? NpcPhaseOneSnapshot.empty() : snapshot; + } + + @Override + protected void init() { + super.init(); + this.left = (this.width - WIDTH) / 2; + this.top = (this.height - HEIGHT) / 2; + this.addRenderableWidget(new DecisionButton( + this.left + WIDTH - 62, + this.top + HEIGHT - 26, + 48, + 16, + MilitaryGuiStyle.clampLabel(this.font, Component.translatable("gui.bannermod.common.back"), 42), + button -> onClose() + )); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.renderBackground(graphics, mouseX, mouseY, partialTick); + graphics.fill(0, 0, this.width, this.height, 0x54160E08); + MilitaryGuiStyle.parchmentPanel(graphics, this.left, this.top, WIDTH, HEIGHT); + MilitaryGuiStyle.titleStrip(graphics, this.left + 8, this.top + 8, WIDTH - 16, 16); + MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, this.left + 8, this.top + 12, WIDTH - 16); + + renderSmallField(graphics, this.left + 14, this.top + 36, 140, + Component.translatable("gui.bannermod.society.ai.state"), + Component.translatable(this.snapshot.aiStateTranslationKey()).getString(), + this.snapshot.isBlockedState() + ? MilitaryGuiStyle.TEXT_DENIED + : MilitaryGuiStyle.TEXT_DARK); + renderSmallField(graphics, this.left + 166, this.top + 36, 140, + Component.translatable("gui.bannermod.society.ai.phase"), + Component.translatable(this.snapshot.dailyPhaseTranslationKey()).getString(), + MilitaryGuiStyle.TEXT_DARK); + renderSmallField(graphics, this.left + 14, this.top + 66, 140, + Component.translatable("gui.bannermod.society.ai.intent"), + Component.translatable(this.snapshot.currentIntentTranslationKey()).getString(), + MilitaryGuiStyle.TEXT_DARK); + renderSmallField(graphics, this.left + 166, this.top + 66, 140, + Component.translatable("gui.bannermod.society.ai.anchor"), + Component.translatable(this.snapshot.currentAnchorTranslationKey()).getString(), + MilitaryGuiStyle.TEXT_DARK); + + renderLargeField(graphics, this.left + 14, this.top + 96, WIDTH - 28, 44, + Component.translatable("gui.bannermod.society.ai.route"), + Component.translatable(this.snapshot.aiRouteReasonTranslationKey()), + this.snapshot.aiRouteSecondaryComponent(), + MilitaryGuiStyle.TEXT_DARK); + + renderLargeField(graphics, this.left + 14, this.top + 146, WIDTH - 28, 44, + Component.translatable("gui.bannermod.society.ai.goal"), + this.snapshot.aiCurrentGoalComponent(), + Component.translatable(this.snapshot.aiChoiceReasonTranslationKey()), + MilitaryGuiStyle.TEXT_WARN); + renderLargeField(graphics, this.left + 14, this.top + 196, WIDTH - 28, 44, + Component.translatable("gui.bannermod.society.ai.blocked_goal"), + this.snapshot.aiBlockedGoalComponent(), + Component.translatable(this.snapshot.aiBlockedReasonTranslationKey()), + this.snapshot.hasAiBlockedGoal() ? MilitaryGuiStyle.TEXT_DENIED : MilitaryGuiStyle.TEXT_DARK); + + renderStackedField(graphics, this.left + 14, this.top + 250, WIDTH - 28, 34, + Component.translatable("gui.bannermod.society.ai.pressures"), + shortNeedLineLeft(), + shortNeedLineRight(), + MilitaryGuiStyle.TEXT_DARK); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + } + + private void renderSmallField(GuiGraphics graphics, int x, int y, int width, Component label, String value, int color) { + MilitaryGuiStyle.parchmentInset(graphics, x, y, width, 24); + graphics.drawString(this.font, label, x + 6, y + 4, MilitaryGuiStyle.TEXT_MUTED, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(value, width - 12), x + 6, y + 14, color, false); + } + + private void renderLargeField(GuiGraphics graphics, + int x, + int y, + int width, + int height, + Component label, + Component primary, + Component secondary, + int primaryColor) { + MilitaryGuiStyle.parchmentInset(graphics, x, y, width, height); + graphics.drawString(this.font, label, x + 6, y + 4, MilitaryGuiStyle.TEXT_MUTED, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(primary.getString(), width - 12), x + 6, y + 16, primaryColor, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(secondary.getString(), width - 12), x + 6, y + 28, MilitaryGuiStyle.TEXT_DARK, false); + } + + private void renderStackedField(GuiGraphics graphics, + int x, + int y, + int width, + int height, + Component label, + String lineOne, + String lineTwo, + int valueColor) { + MilitaryGuiStyle.parchmentInset(graphics, x, y, width, height); + graphics.drawString(this.font, label, x + 6, y + 4, MilitaryGuiStyle.TEXT_MUTED, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(lineOne, width - 12), x + 6, y + 14, valueColor, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(lineTwo, width - 12), x + 6, y + 24, valueColor, false); + } + + private String shortNeedLineLeft() { + return Component.translatable("gui.bannermod.society.ai.pressures.line_one", + this.snapshot.hungerNeed(), this.snapshot.fatigueNeed()).getString(); + } + + private String shortNeedLineRight() { + return Component.translatable("gui.bannermod.society.ai.pressures.line_two", + this.snapshot.safetyNeed(), + NpcPhaseOneSnapshot.shortId(this.snapshot.homeBuildingUuid()), + NpcPhaseOneSnapshot.shortId(this.snapshot.workBuildingUuid())).getString(); + } + + @Override + public void onClose() { + Minecraft.getInstance().setScreen(this.parent); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private static class DecisionButton extends ExtendedButton { + DecisionButton(int x, int y, int width, int height, Component label, OnPress handler) { + super(x, y, width, height, label, handler); + } + + @Override + public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + MilitaryGuiStyle.commandButton(graphics, Minecraft.getInstance().font, mouseX, mouseY, + getX(), getY(), width, height, getMessage(), active, false); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcFamilyTreeScreen.java b/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcFamilyTreeScreen.java new file mode 100644 index 00000000..c23605c3 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/civilian/gui/NpcFamilyTreeScreen.java @@ -0,0 +1,207 @@ +package com.talhanation.bannermod.client.civilian.gui; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.network.messages.civilian.MessageOpenNpcProfile; +import com.talhanation.bannermod.society.NpcFamilyMemberSnapshot; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.neoforged.neoforge.client.gui.widget.ExtendedButton; + +import java.util.ArrayList; +import java.util.List; + +public class NpcFamilyTreeScreen extends Screen { + private static final int WIDTH = 320; + private static final int HEIGHT = 276; + private static final int CARD_W = 88; + private static final int CARD_H = 84; + private static final int CHILD_CARD_H = 34; + + private final Screen parent; + private final NpcFamilyTreeSnapshot snapshot; + private final List clickableCards = new ArrayList<>(); + private int left; + private int top; + + public NpcFamilyTreeScreen(Screen parent, NpcFamilyTreeSnapshot snapshot) { + super(Component.translatable("gui.bannermod.family_tree.title")); + this.parent = parent; + this.snapshot = snapshot == null ? NpcFamilyTreeSnapshot.empty() : snapshot; + } + + @Override + protected void init() { + super.init(); + this.left = (this.width - WIDTH) / 2; + this.top = (this.height - HEIGHT) / 2; + this.addRenderableWidget(new FamilyButton( + this.left + WIDTH - 62, + this.top + HEIGHT - 26, + 48, + 16, + MilitaryGuiStyle.clampLabel(this.font, Component.translatable("gui.bannermod.common.back"), 42), + button -> onClose() + )); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + this.clickableCards.clear(); + super.renderBackground(graphics, mouseX, mouseY, partialTick); + graphics.fill(0, 0, this.width, this.height, 0x54160E08); + MilitaryGuiStyle.parchmentPanel(graphics, this.left, this.top, WIDTH, HEIGHT); + MilitaryGuiStyle.titleStrip(graphics, this.left + 8, this.top + 8, WIDTH - 16, 16); + MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, this.left + 8, this.top + 12, WIDTH - 16); + graphics.drawString(this.font, Component.translatable("gui.bannermod.family_tree.click_hint"), this.left + 14, this.top + 28, MilitaryGuiStyle.TEXT_MUTED, false); + + renderMemberCard(graphics, this.left + 14, this.top + 34, CARD_W, CARD_H, this.snapshot.mother(), "mother", true); + renderMemberCard(graphics, this.left + 107, this.top + 26, 106, 96, this.snapshot.self(), "self", true); + renderMemberCard(graphics, this.left + 218, this.top + 34, CARD_W, CARD_H, this.snapshot.father(), "father", true); + renderMemberCard(graphics, this.left + 118, this.top + 128, 84, 44, this.snapshot.spouse(), "spouse", true); + + MilitaryGuiStyle.parchmentInset(graphics, this.left + 14, this.top + 182, WIDTH - 28, 60); + graphics.drawString(this.font, Component.translatable("gui.bannermod.family_tree.children"), this.left + 20, this.top + 188, MilitaryGuiStyle.TEXT_MUTED, false); + if (this.snapshot.children().isEmpty()) { + graphics.drawString(this.font, Component.translatable("gui.bannermod.family_tree.children.none"), this.left + 20, this.top + 202, MilitaryGuiStyle.TEXT_DARK, false); + } else { + int columns = 3; + int startX = this.left + 18; + int startY = this.top + 198; + int shown = Math.min(6, this.snapshot.children().size()); + for (int i = 0; i < shown; i++) { + int row = i / columns; + int col = i % columns; + renderMemberCard( + graphics, + startX + col * 92, + startY + row * 20, + 86, + CHILD_CARD_H, + this.snapshot.children().get(i), + "child", + false + ); + } + if (this.snapshot.children().size() > shown) { + graphics.drawString( + this.font, + Component.translatable("gui.bannermod.family_tree.children.more", this.snapshot.children().size() - shown), + this.left + WIDTH - 94, + this.top + 228, + MilitaryGuiStyle.TEXT_MUTED, + false + ); + } + } + + super.render(graphics, mouseX, mouseY, partialTick); + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + for (ClickableCard card : this.clickableCards) { + if (card.contains(mouseX, mouseY)) { + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageOpenNpcProfile(card.member().residentUuid())); + return true; + } + } + return super.mouseClicked(mouseX, mouseY, button); + } + + private void renderMemberCard(GuiGraphics graphics, + int x, + int y, + int width, + int height, + NpcFamilyMemberSnapshot member, + String fallbackRelation, + boolean renderEntity) { + MilitaryGuiStyle.parchmentInset(graphics, x, y, width, height); + Component relation = member == null + ? Component.translatable("gui.bannermod.society.family_relation." + fallbackRelation) + : Component.translatable(member.relationTranslationKey()); + graphics.drawString(this.font, relation, x + 6, y + 4, MilitaryGuiStyle.TEXT_MUTED, false); + if (member == null) { + graphics.drawString(this.font, "-", x + 6, y + 18, MilitaryGuiStyle.TEXT_DARK, false); + return; + } + if (renderEntity) { + LivingEntity entity = resolveEntity(member); + if (entity != null) { + InventoryScreen.renderEntityInInventoryFollowsMouse( + graphics, + x + 8, + y + 18, + x + width - 8, + y + Math.min(height - 8, 70), + height <= 48 ? 14 : 22, + 0.0F, + 0.0F, + 0.0F, + entity + ); + } + } + int textY = height <= 40 ? y + 18 : y + 66; + graphics.drawString(this.font, this.font.plainSubstrByWidth(member.displayName(), width - 12), x + 6, textY, MilitaryGuiStyle.TEXT_DARK, false); + graphics.drawString( + this.font, + this.font.plainSubstrByWidth(Component.translatable(member.lifeStageTranslationKey()).getString(), width - 12), + x + 6, + textY + 10, + 0xFF6E5535, + false + ); + this.clickableCards.add(new ClickableCard(x, y, width, height, member)); + } + + private LivingEntity resolveEntity(NpcFamilyMemberSnapshot member) { + if (member == null || this.minecraft == null || this.minecraft.level == null || member.entityId() < 0) { + return null; + } + Entity entity = this.minecraft.level.getEntity(member.entityId()); + if (!(entity instanceof LivingEntity living) || !member.residentUuid().equals(entity.getUUID())) { + return null; + } + return living; + } + + @Override + public void onClose() { + Minecraft.getInstance().setScreen(this.parent); + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private record ClickableCard(int x, int y, int width, int height, NpcFamilyMemberSnapshot member) { + private boolean contains(double mouseX, double mouseY) { + return mouseX >= this.x && mouseX < this.x + this.width && mouseY >= this.y && mouseY < this.y + this.height; + } + } + + private static class FamilyButton extends ExtendedButton { + FamilyButton(int x, int y, int width, int height, Component label, OnPress handler) { + super(x, y, width, height, label, handler); + } + + @Override + public void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + MilitaryGuiStyle.commandButton(graphics, Minecraft.getInstance().font, mouseX, mouseY, + getX(), getY(), width, height, getMessage(), active, false); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/civilian/gui/WorkerStatusScreen.java b/src/main/java/com/talhanation/bannermod/client/civilian/gui/WorkerStatusScreen.java index 4c888225..21de9770 100644 --- a/src/main/java/com/talhanation/bannermod/client/civilian/gui/WorkerStatusScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/civilian/gui/WorkerStatusScreen.java @@ -3,13 +3,12 @@ import com.talhanation.bannermod.bootstrap.BannerModMain; import com.talhanation.bannermod.citizen.CitizenProfession; import com.talhanation.bannermod.citizen.CitizenRole; -import com.talhanation.bannermod.client.civilian.input.AssignHomeTargetSelector; import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; import com.talhanation.bannermod.client.military.gui.widgets.ActionMenuButton; import com.talhanation.bannermod.client.military.gui.widgets.ContextMenuEntry; import com.talhanation.bannermod.entity.civilian.WorkerInspectionSnapshot; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; import com.talhanation.bannermod.network.messages.civilian.MessageConvertWorkerToCitizen; -import com.talhanation.bannermod.network.messages.civilian.MessageDismissWorker; import com.talhanation.bannermod.network.messages.civilian.MessageOpenWorkerScreen; import com.talhanation.bannermod.network.messages.civilian.MessageReassignWorkerProfession; import net.minecraft.client.Minecraft; @@ -24,11 +23,9 @@ import java.util.Locale; public class WorkerStatusScreen extends Screen { - private static final int WIDTH = 300; - // 210 gives 22 px clearance between the bottom transport text box (ends at top+168) - // and the action button row (starts at top + HEIGHT - 26 = top+184) so they never overlap. - private static final int HEIGHT = 210; - private static final int BTN_W = 54; + private static final int WIDTH = 252; + private static final int HEIGHT = 280; + private static final int BTN_W = 58; private static final int BTN_H = 20; private final WorkerInspectionSnapshot snapshot; @@ -46,10 +43,38 @@ protected void init() { this.left = (this.width - WIDTH) / 2; this.top = (this.height - HEIGHT) / 2; - // Bottom action row: 5 evenly spaced buttons inside WIDTH. - // Stride between centers = (WIDTH - 16) / 5 = 56 -> stays inside parchment frame. + SmallCommandButton family = this.addRenderableWidget(new SmallCommandButton( + this.left + WIDTH - 72, + this.top + 46, + 56, + 18, + MilitaryGuiStyle.clampLabel(this.font, text("gui.bannermod.family_tree.open"), 50), + button -> { + if (this.minecraft != null) { + this.minecraft.setScreen(new NpcFamilyTreeScreen(this, this.snapshot.familyTree())); + } + } + )); + family.setTooltip(Tooltip.create(text("gui.bannermod.family_tree.open.tooltip"))); + + SmallCommandButton ai = this.addRenderableWidget(new SmallCommandButton( + this.left + WIDTH - 72, + this.top + 68, + 56, + 18, + MilitaryGuiStyle.clampLabel(this.font, text("gui.bannermod.society.ai.button"), 50), + button -> { + if (this.minecraft != null) { + this.minecraft.setScreen(new NpcAiDecisionScreen(this, this.snapshot.phaseOne())); + } + } + )); + ai.setTooltip(Tooltip.create(text("gui.bannermod.society.ai.tooltip"))); + + // Bottom action row: 4 evenly spaced buttons inside WIDTH. + // Stride between centers = (WIDTH - 16) / 4 = 59 -> stays inside parchment frame. int rowY = this.top + HEIGHT - 26; - int strideX = (WIDTH - 16) / 5; + int strideX = (WIDTH - 16) / 4; int firstCenter = this.left + 8 + strideX / 2; SmallCommandButton refresh = this.addRenderableWidget(new SmallCommandButton( @@ -59,35 +84,20 @@ protected void init() { )); refresh.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.refresh.tooltip"))); - SmallCommandButton assignHome = this.addRenderableWidget(new SmallCommandButton( - firstCenter + 3 * strideX - BTN_W / 2, rowY, BTN_W, BTN_H, - clamped("bannermod.assign_home.button"), + SmallCommandButton convert = this.addRenderableWidget(new SmallCommandButton( + firstCenter + strideX - BTN_W / 2, rowY, BTN_W, BTN_H, + clamped("gui.bannermod.worker_screen.convert"), button -> { - AssignHomeTargetSelector.start(this.snapshot.workerUuid()); + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageConvertWorkerToCitizen(this.snapshot.workerUuid())); this.onClose(); } )); - assignHome.setTooltip(Tooltip.create(text("bannermod.assign_home.tooltip"))); - - SmallCommandButton close = this.addRenderableWidget(new SmallCommandButton( - firstCenter + 4 * strideX - BTN_W / 2, rowY, BTN_W, BTN_H, - clamped("gui.bannermod.worker_screen.close"), - button -> this.onClose() - )); - close.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.close.tooltip"))); - - ActionMenuButton workerActions = new ActionMenuButton( - firstCenter + strideX - BTN_W / 2, rowY, BTN_W, BTN_H, - clamped("gui.bannermod.worker_screen.actions"), - buildWorkerActionEntries() - ); - workerActions.setOpenUpward(true); + convert.active = this.snapshot.canConvert(); if (this.snapshot.convertBlockedReasonKey() != null) { - workerActions.setTooltip(Tooltip.create(text(this.snapshot.convertBlockedReasonKey()))); + convert.setTooltip(Tooltip.create(text(this.snapshot.convertBlockedReasonKey()))); } else { - workerActions.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.actions.tooltip"))); + convert.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.convert.tooltip.dismiss_path"))); } - this.addRenderableWidget(workerActions); // Reassign — opens an ActionMenuButton with one row per available // CONTROLLED_WORKER profession (current one filtered out). Server is @@ -101,6 +111,13 @@ protected void init() { reassign.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.reassign.tooltip"))); reassign.active = this.snapshot.canConvert(); this.addRenderableWidget(reassign); + + SmallCommandButton close = this.addRenderableWidget(new SmallCommandButton( + firstCenter + 3 * strideX - BTN_W / 2, rowY, BTN_W, BTN_H, + clamped("gui.bannermod.worker_screen.close"), + button -> this.onClose() + )); + close.setTooltip(Tooltip.create(text("gui.bannermod.worker_screen.close.tooltip"))); } private List buildReassignEntries() { @@ -126,52 +143,84 @@ private List buildReassignEntries() { return entries; } - private List buildWorkerActionEntries() { - List entries = new ArrayList<>(); - entries.add(new ContextMenuEntry( - Component.translatable("gui.bannermod.worker_screen.convert").getString(), - () -> { - BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageConvertWorkerToCitizen(this.snapshot.workerUuid())); - this.onClose(); - }, - this.snapshot.canConvert() - )); - entries.add(new ContextMenuEntry( - Component.translatable("gui.bannermod.worker_screen.dismiss").getString(), - () -> { - BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageDismissWorker(this.snapshot.workerUuid())); - this.onClose(); - }, - true - )); - return entries; - } - @Override public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { super.renderBackground(graphics, mouseX, mouseY, partialTick); + graphics.fill(0, 0, this.width, this.height, 0x54160E08); MilitaryGuiStyle.parchmentPanel(graphics, this.left, this.top, WIDTH, HEIGHT); MilitaryGuiStyle.titleStrip(graphics, this.left + 8, this.top + 8, WIDTH - 16, 16); MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, this.left + 8, this.top + 12, WIDTH - 16); MilitaryGuiStyle.drawBadge(graphics, this.font, Component.translatable(this.snapshot.claimRelationKey()), this.left + 16, this.top + 32, 110, MilitaryGuiStyle.TEXT_WARN); - graphics.drawString(this.font, this.snapshot.workerName(), this.left + 132, this.top + 35, MilitaryGuiStyle.TEXT_DARK, false); + MilitaryGuiStyle.drawBadge(graphics, this.font, Component.translatable(this.snapshot.professionKey()), this.left + 132, this.top + 32, 104, MilitaryGuiStyle.TEXT_WARN); + graphics.drawString(this.font, this.snapshot.workerName(), this.left + 132, this.top + 47, MilitaryGuiStyle.TEXT_DARK, false); renderInfoBlock(graphics, this.left + 14, this.top + 52, WIDTH - 28, 58); - drawLabelValue(graphics, text("gui.bannermod.worker_screen.profession"), Component.translatable(this.snapshot.professionKey()), this.left + 20, this.top + 58); - drawLabelValue(graphics, text("gui.bannermod.worker_screen.owner"), Component.literal(this.snapshot.ownerLabel()), this.left + 20, this.top + 72); - drawLabelValue(graphics, text("gui.bannermod.worker_screen.political"), Component.literal(this.snapshot.politicalLabel()), this.left + 20, this.top + 86); - drawLabelValue(graphics, text("gui.bannermod.worker_screen.assignment"), Component.literal(this.snapshot.assignmentLabel()), this.left + 20, this.top + 100); + drawLabelValue(graphics, text("gui.bannermod.worker_screen.owner"), Component.literal(this.snapshot.ownerLabel()), this.left + 20, this.top + 58); + drawLabelValue(graphics, text("gui.bannermod.worker_screen.political"), Component.literal(this.snapshot.politicalLabel()), this.left + 20, this.top + 72); + drawLabelValue(graphics, text("gui.bannermod.worker_screen.assignment"), Component.literal(this.snapshot.assignmentLabel()), this.left + 20, this.top + 86); + drawLabelValue(graphics, text("gui.bannermod.worker_screen.profession"), Component.translatable(this.snapshot.professionKey()), this.left + 20, this.top + 100); renderTextBox(graphics, this.left + 14, this.top + 116, WIDTH - 28, 24, + text("gui.bannermod.worker_screen.identity"), + identitySummary(), + MilitaryGuiStyle.TEXT_DARK); + renderTextBox(graphics, this.left + 14, this.top + 144, WIDTH - 28, 24, + text("gui.bannermod.worker_screen.routine"), + routineSummary(), + MilitaryGuiStyle.TEXT_DARK); + renderTextBox(graphics, this.left + 14, this.top + 172, WIDTH - 28, 24, + text("gui.bannermod.worker_screen.needs"), + needsSummary(), + MilitaryGuiStyle.TEXT_DARK); + renderTextBox(graphics, this.left + 14, this.top + 200, WIDTH - 28, 24, text("gui.bannermod.worker_screen.problem"), Component.literal(this.snapshot.problemLabel()), isClearState(this.snapshot.problemLabel()) ? MilitaryGuiStyle.TEXT_GOOD : MilitaryGuiStyle.TEXT_DENIED); - renderTextBox(graphics, this.left + 14, this.top + 144, WIDTH - 28, 24, + renderTextBox(graphics, this.left + 14, this.top + 228, WIDTH - 28, 24, text("gui.bannermod.worker_screen.transport"), Component.literal(this.snapshot.transportLabel()), MilitaryGuiStyle.TEXT_DARK); } + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + renderBackground(graphics, mouseX, mouseY, partialTick); + super.render(graphics, mouseX, mouseY, partialTick); + } + + private Component identitySummary() { + NpcPhaseOneSnapshot phaseOne = this.snapshot.phaseOne(); + return Component.translatable( + "gui.bannermod.worker_screen.identity.summary", + Component.translatable(phaseOne.lifeStageTranslationKey()).getString(), + Component.translatable(phaseOne.sexTranslationKey()).getString(), + NpcPhaseOneSnapshot.shortId(phaseOne.homeBuildingUuid()), + Component.translatable(phaseOne.householdHousingStateTranslationKey()).getString() + ); + } + + private Component routineSummary() { + NpcPhaseOneSnapshot phaseOne = this.snapshot.phaseOne(); + String phaseLabel = Component.translatable(phaseOne.dailyPhaseTranslationKey()).getString(); + return Component.translatable( + "gui.bannermod.worker_screen.routine.summary", + phaseLabel, + Component.translatable(phaseOne.currentIntentTranslationKey()).getString(), + Component.translatable(phaseOne.currentAnchorTranslationKey()).getString(), + phaseOne.aiReadableRoutineReasonComponent() + ); + } + + private Component needsSummary() { + NpcPhaseOneSnapshot phaseOne = this.snapshot.phaseOne(); + return Component.translatable( + "gui.bannermod.worker_screen.needs.summary", + phaseOne.hungerNeed(), + phaseOne.fatigueNeed(), + phaseOne.safetyNeed() + ); + } + private void renderInfoBlock(GuiGraphics graphics, int x, int y, int width, int height) { MilitaryGuiStyle.insetPanel(graphics, x, y, width, height); } @@ -210,7 +259,7 @@ public boolean isPauseScreen() { /** * Narrow command button styled with the same parchment-iron palette as * {@link com.talhanation.bannermod.client.military.gui.group.RecruitsCommandButton} - * but width-configurable so the compact action row fits inside the ledger. + * but width-configurable so four widgets fit inside WIDTH=252. */ private static class SmallCommandButton extends ExtendedButton { SmallCommandButton(int x, int y, int width, int height, Component label, OnPress handler) { diff --git a/src/main/java/com/talhanation/bannermod/client/civilian/render/KinlotStaffRenderEvents.java b/src/main/java/com/talhanation/bannermod/client/civilian/render/KinlotStaffRenderEvents.java new file mode 100644 index 00000000..7f081cb8 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/civilian/render/KinlotStaffRenderEvents.java @@ -0,0 +1,144 @@ +package com.talhanation.bannermod.client.civilian.render; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.render.ClientRenderPrimitives; +import com.talhanation.bannermod.items.civilian.KinlotStaffItem; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.LightTexture; +import net.minecraft.client.renderer.MultiBufferSource; +import net.minecraft.client.renderer.RenderType; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.RenderLevelStageEvent; + +@EventBusSubscriber(modid = BannerModMain.MOD_ID, bus = EventBusSubscriber.Bus.GAME, value = Dist.CLIENT) +public final class KinlotStaffRenderEvents { + private static final int LOT_COLOR = 0xFFB98542; + + private KinlotStaffRenderEvents() { + } + + @SubscribeEvent + public static void onRenderLevel(RenderLevelStageEvent event) { + if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_ENTITIES) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + LocalPlayer player = minecraft.player; + if (player == null || minecraft.level == null) { + return; + } + ItemStack stack = kinlotStaffStack(player); + if (stack.isEmpty()) { + return; + } + BlockPos plotPos = KinlotStaffItem.renderPlotPos(stack); + if (plotPos == null) { + return; + } + + Camera camera = event.getCamera(); + Vec3 cameraPos = camera.getPosition(); + PoseStack poseStack = event.getPoseStack(); + MultiBufferSource.BufferSource bufferSource = minecraft.renderBuffers().bufferSource(); + AABB lotBox = lotBox(plotPos); + + poseStack.pushPose(); + poseStack.translate(-cameraPos.x, -cameraPos.y, -cameraPos.z); + RenderSystem.disableDepthTest(); + float[] rgb = rgb(LOT_COLOR); + ClientRenderPrimitives.lineBox(poseStack, bufferSource.getBuffer(RenderType.lines()), lotBox, rgb[0], rgb[1], rgb[2], 1.0F); + renderLabel(poseStack, bufferSource, lotBox, KinlotStaffItem.renderLabel(stack), KinlotStaffItem.renderHouseholdId(stack)); + RenderSystem.enableDepthTest(); + poseStack.popPose(); + bufferSource.endBatch(); + } + + private static ItemStack kinlotStaffStack(LocalPlayer player) { + ItemStack main = player.getItemInHand(InteractionHand.MAIN_HAND); + if (main.getItem() instanceof KinlotStaffItem) { + return main; + } + ItemStack off = player.getItemInHand(InteractionHand.OFF_HAND); + return off.getItem() instanceof KinlotStaffItem ? off : ItemStack.EMPTY; + } + + private static AABB lotBox(BlockPos plotPos) { + int half = KinlotStaffItem.LOT_HALF_SPAN; + return new AABB( + plotPos.getX() - half, + plotPos.getY(), + plotPos.getZ() - half, + plotPos.getX() + half + 1.0D, + plotPos.getY() + 4.0D, + plotPos.getZ() + half + 1.0D + ).inflate(0.03D); + } + + private static void renderLabel(PoseStack poseStack, + MultiBufferSource buffers, + AABB lotBox, + String label, + String householdId) { + Minecraft minecraft = Minecraft.getInstance(); + Font font = minecraft.font; + String main = label == null || label.isBlank() ? "Household" : label; + String sub = householdId == null || householdId.isBlank() ? "" : "Household " + householdId; + Vec3 center = lotBox.getCenter(); + + drawFloatingText(poseStack, buffers, font, Component.literal(main).getVisualOrderText(), center.x, lotBox.maxY + 0.85D, center.z, 0xFFF7E3B2); + if (!sub.isBlank()) { + drawFloatingText(poseStack, buffers, font, Component.literal(sub).getVisualOrderText(), center.x, lotBox.maxY + 0.52D, center.z, 0xFFD8B36B); + } + } + + private static void drawFloatingText(PoseStack poseStack, + MultiBufferSource buffers, + Font font, + FormattedCharSequence text, + double x, + double y, + double z, + int color) { + Minecraft minecraft = Minecraft.getInstance(); + poseStack.pushPose(); + poseStack.translate(x, y, z); + poseStack.mulPose(minecraft.getEntityRenderDispatcher().cameraOrientation()); + poseStack.scale(-0.025F, -0.025F, 0.025F); + float textX = -font.width(text) / 2.0F; + font.drawInBatch( + text, + textX, + 0.0F, + color, + false, + poseStack.last().pose(), + buffers, + Font.DisplayMode.SEE_THROUGH, + 0x66000000, + LightTexture.FULL_BRIGHT + ); + poseStack.popPose(); + } + + private static float[] rgb(int color) { + return new float[]{ + ((color >> 16) & 0xFF) / 255.0F, + ((color >> 8) & 0xFF) / 255.0F, + (color & 0xFF) / 255.0F + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/events/ClientSyncLifecycleEvents.java b/src/main/java/com/talhanation/bannermod/client/military/events/ClientSyncLifecycleEvents.java index b83af50a..4bd97bae 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/events/ClientSyncLifecycleEvents.java +++ b/src/main/java/com/talhanation/bannermod/client/military/events/ClientSyncLifecycleEvents.java @@ -2,6 +2,7 @@ import com.talhanation.bannermod.bootstrap.BannerModMain; import com.talhanation.bannermod.client.military.ClientManager; +import com.talhanation.bannermod.society.client.NpcHousingClientState; import com.talhanation.bannermod.war.client.WarClientState; import net.neoforged.api.distmarker.Dist; import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; @@ -16,11 +17,13 @@ public class ClientSyncLifecycleEvents { public static void onClientLogin(ClientPlayerNetworkEvent.LoggingIn event) { ClientManager.resetSynchronizedState(); WarClientState.clear(); + NpcHousingClientState.clear(); } @SubscribeEvent public static void onClientLogout(ClientPlayerNetworkEvent.LoggingOut event) { ClientManager.resetSynchronizedState(); WarClientState.clear(); + NpcHousingClientState.clear(); } } diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/HousingLedgerScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/HousingLedgerScreen.java new file mode 100644 index 00000000..16b1d4e8 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/HousingLedgerScreen.java @@ -0,0 +1,547 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.network.messages.civilian.MessageApproveHousingRequest; +import com.talhanation.bannermod.network.messages.civilian.MessageDenyHousingRequest; +import com.talhanation.bannermod.network.messages.civilian.MessageRequestHousingSnapshot; +import com.talhanation.bannermod.society.NpcHousingLedgerEntry; +import com.talhanation.bannermod.society.NpcHousingPriorityService; +import com.talhanation.bannermod.society.client.NpcHousingClientState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public class HousingLedgerScreen extends Screen { + private static final int MIN_BOOK_W = 392; + private static final int MAX_BOOK_W = 760; + private static final int MIN_BOOK_H = 220; + private static final int MAX_BOOK_H = 520; + private static final int ROW_H = 18; + private static final int BUTTON_H = 18; + private static final int BOOK_BORDER = 10; + private static final int PAGE_SHADE = 0xFFE0BC78; + private static final int LEATHER = 0xFF4A2D18; + private static final int LEATHER_DARK = 0xFF24150D; + private static final int INK = 0xFF2D1B0F; + private static final int INK_MUTED = 0xFF6C5030; + private static final int GOLD = 0xFFFFD36A; + + private final Screen parent; + private int guiLeft; + private int guiTop; + private int guiW; + private int guiH; + private int listVisible = 8; + private int scrollOffset; + private int observedVersion = -1; + private List requests = List.of(); + @Nullable + private NpcHousingLedgerEntry selected; + + private Button approveBtn; + private Button denyBtn; + private Button refreshBtn; + private Button backBtn; + + public HousingLedgerScreen(@Nullable Screen parent) { + super(text("gui.bannermod.housing_ledger.title")); + this.parent = parent; + } + + @Override + protected void init() { + super.init(); + updateGeometry(); + this.approveBtn = actionButton(0, text("gui.bannermod.housing_ledger.action.approve"), btn -> approveSelected()); + this.denyBtn = actionButton(1, text("gui.bannermod.housing_ledger.action.deny"), btn -> denySelected()); + this.refreshBtn = actionButton(2, text("gui.bannermod.common.refresh"), btn -> requestSnapshot()); + this.backBtn = actionButton(3, text("gui.bannermod.common.back"), btn -> onClose()); + addRenderableWidget(this.approveBtn); + addRenderableWidget(this.denyBtn); + addRenderableWidget(this.refreshBtn); + addRenderableWidget(this.backBtn); + requestSnapshot(); + } + + private void requestSnapshot() { + NpcHousingClientState.beginSync(); + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageRequestHousingSnapshot()); + refreshLocal(); + } + + private void refreshLocal() { + this.requests = new ArrayList<>(NpcHousingClientState.requests()); + if (this.selected != null) { + UUID selectedHouseholdId = this.selected.householdId(); + this.selected = this.requests.stream() + .filter(entry -> entry.householdId().equals(selectedHouseholdId)) + .findFirst() + .orElse(null); + } + this.scrollOffset = clamp(this.scrollOffset, 0, Math.max(0, this.requests.size() - this.listVisible)); + this.observedVersion = NpcHousingClientState.version(); + updateButtons(); + } + + private void approveSelected() { + if (this.selected == null) { + return; + } + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageApproveHousingRequest(this.selected.householdId())); + } + + private void denySelected() { + if (this.selected == null) { + return; + } + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageDenyHousingRequest(this.selected.householdId())); + } + + private Button actionButton(int index, Component label, Button.OnPress onPress) { + return new MedievalButton(actionButtonX(index), actionButtonY(index), actionButtonW(), BUTTON_H, label, onPress); + } + + private void updateButtons() { + boolean hasSelection = this.selected != null; + boolean canManage = NpcHousingClientState.canManage(); + this.approveBtn.active = hasSelection && canManage && NpcHousingPriorityService.canApprove(this.selected); + this.denyBtn.active = hasSelection && canManage && NpcHousingPriorityService.canDeny(this.selected); + this.approveBtn.setTooltip(approveTooltip(hasSelection, canManage)); + this.denyBtn.setTooltip(denyTooltip(hasSelection, canManage)); + } + + private @Nullable Tooltip approveTooltip(boolean hasSelection, boolean canManage) { + if (this.approveBtn.active) { + return null; + } + if (!hasSelection) { + return Tooltip.create(text("gui.bannermod.housing_ledger.tooltip.select_request")); + } + if (!canManage) { + return Tooltip.create(readOnlyReason()); + } + return Tooltip.create(text("gui.bannermod.housing_ledger.tooltip.approve_unavailable")); + } + + private @Nullable Tooltip denyTooltip(boolean hasSelection, boolean canManage) { + if (this.denyBtn.active) { + return null; + } + if (!hasSelection) { + return Tooltip.create(text("gui.bannermod.housing_ledger.tooltip.select_request")); + } + if (!canManage) { + return Tooltip.create(readOnlyReason()); + } + return Tooltip.create(text("gui.bannermod.housing_ledger.tooltip.deny_unavailable")); + } + + @Override + public void tick() { + super.tick(); + if (this.observedVersion != NpcHousingClientState.version()) { + refreshLocal(); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + super.renderBackground(graphics, mouseX, mouseY, partialTick); + graphics.fill(0, 0, this.width, this.height, 0x66000000); + renderBookFrame(graphics); + renderHeader(graphics); + renderList(graphics, mouseX, mouseY); + renderDetails(graphics); + renderActionLedger(graphics); + super.render(graphics, mouseX, mouseY, partialTick); + } + + @Override + public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + } + + private void renderBookFrame(GuiGraphics graphics) { + graphics.fill(this.guiLeft + 4, this.guiTop + 5, this.guiLeft + this.guiW + 4, this.guiTop + this.guiH + 5, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, this.guiLeft, this.guiTop, this.guiW, this.guiH); + + int pageY = contentTop(); + int pageH = Math.max(36, contentBottom() - pageY); + MilitaryGuiStyle.parchmentInset(graphics, leftPageX(), pageY, leftPageW(), pageH); + MilitaryGuiStyle.parchmentInset(graphics, rightPageX(), pageY, rightPageW(), pageH); + + int spineX = leftPageX() + leftPageW() + pageGap() / 2 - 1; + graphics.fill(spineX, pageY + 3, spineX + 2, pageY + pageH - 3, PAGE_SHADE); + graphics.fill(spineX + 2, pageY + 3, spineX + 3, pageY + pageH - 3, 0x88FFF3C5); + + MilitaryGuiStyle.parchmentInset(graphics, actionLedgerX(), actionLedgerTop(), actionLedgerW(), actionLedgerH()); + } + + private void renderHeader(GuiGraphics graphics) { + graphics.drawCenteredString(this.font, text("gui.bannermod.housing_ledger.heading").getString(), this.guiLeft + this.guiW / 2, this.guiTop + 9, GOLD); + graphics.drawString(this.font, + this.font.plainSubstrByWidth(this.title.getString(), Math.max(40, innerW() / 2 - 8)), + innerX() + 4, this.guiTop + 25, INK_MUTED, false); + graphics.drawString(this.font, + text("gui.bannermod.housing_ledger.ledger_title").getString(), + actionLedgerX() + 8, actionLedgerTop() + 5, INK_MUTED, false); + } + + private void renderList(GuiGraphics graphics, int mouseX, int mouseY) { + int listX = listX(); + int listY = listY(); + int listW = listW(); + int listH = this.listVisible * ROW_H; + graphics.drawString(this.font, text("gui.bannermod.housing_ledger.list_title").getString(), listX, contentTop() + 8, INK, false); + graphics.fill(listX, listY, listX + listW, listY + listH, 0x22FFFFFF); + graphics.renderOutline(listX, listY, listW, listH, PAGE_SHADE); + int rendered = Math.min(this.listVisible, Math.max(0, this.requests.size() - this.scrollOffset)); + for (int i = 0; i < rendered; i++) { + NpcHousingLedgerEntry entry = this.requests.get(this.scrollOffset + i); + int rowY = listY + i * ROW_H; + boolean hovered = mouseX >= listX && mouseX < listX + listW && mouseY >= rowY && mouseY < rowY + ROW_H; + boolean picked = this.selected != null && this.selected.householdId().equals(entry.householdId()); + if (picked || hovered) { + graphics.fill(listX + 1, rowY + 1, listX + listW - 1, rowY + ROW_H - 1, picked ? 0x669E3A23 : 0x33FFFFFF); + } + String badge = "#" + entry.queueRank() + " [" + Component.translatable(entry.urgencyTranslationKey()).getString().toUpperCase(Locale.ROOT) + "]"; + graphics.drawString(this.font, badge, listX + 4, rowY + 4, urgencyColor(entry.urgencyTag()), false); + String label = Component.translatable("gui.bannermod.housing_ledger.list_row", shortId(entry.householdId()), entry.householdSize()).getString(); + graphics.drawString(this.font, + this.font.plainSubstrByWidth(" " + label, Math.max(20, listW - 126)), + listX + 98, rowY + 4, INK, false); + graphics.drawString(this.font, + this.font.plainSubstrByWidth(Component.translatable(entry.statusTranslationKey()).getString(), 58), + listX + listW - 60, rowY + 4, statusColor(entry.statusTag()), false); + } + if (showEmptyPanel()) { + String empty = emptyListMessage().getString(); + graphics.renderOutline(listX + 8, listY + listH / 2 - 14, Math.max(20, listW - 16), 28, INK_MUTED); + graphics.drawCenteredString(this.font, this.font.plainSubstrByWidth(empty, Math.max(20, listW - 20)), listX + listW / 2, listY + listH / 2 - 4, INK_MUTED); + } + } + + private boolean showEmptyPanel() { + return this.requests.isEmpty() || NpcHousingClientState.syncPending(); + } + + private Component emptyListMessage() { + if (NpcHousingClientState.syncPending() || !NpcHousingClientState.hasSnapshot()) { + return text("gui.bannermod.housing_ledger.waiting_sync"); + } + if (!NpcHousingClientState.hasClaim()) { + return text("gui.bannermod.housing_ledger.no_claim"); + } + if (this.requests.isEmpty()) { + return text("gui.bannermod.housing_ledger.empty"); + } + return text("gui.bannermod.housing_ledger.select_request"); + } + + private void renderDetails(GuiGraphics graphics) { + int x = rightPageX() + 8; + int y = contentTop() + 8; + int w = Math.max(40, rightPageW() - 16); + graphics.drawString(this.font, text("gui.bannermod.housing_ledger.detail").getString(), x, y, INK, false); + if (this.selected == null) { + graphics.drawString(this.font, this.font.plainSubstrByWidth(text("gui.bannermod.housing_ledger.select_request").getString(), w), x, y + 14, INK_MUTED, false); + graphics.drawString(this.font, this.font.plainSubstrByWidth(text("gui.bannermod.housing_ledger.help").getString(), w), x, y + 28, INK_MUTED, false); + return; + } + List lines = new ArrayList<>(); + lines.add(text("gui.bannermod.housing_ledger.detail.rank", this.selected.queueRank(), this.selected.priorityScore()).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.urgency", Component.translatable(this.selected.urgencyTranslationKey())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.reason", Component.translatable(this.selected.reasonTranslationKey())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.status", Component.translatable(this.selected.statusTranslationKey())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.household", shortId(this.selected.householdId()), shortId(this.selected.headResidentUuid())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.members", this.selected.householdSize(), Component.translatable(this.selected.housingStateTranslationKey())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.wait", this.selected.waitingDays(), this.selected.requestedAtGameTime()).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.resident", shortId(this.selected.residentUuid())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.claim", shortId(this.selected.claimUuid())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.home", shortId(this.selected.homeBuildingUuid())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.build_area", shortId(this.selected.buildAreaUuid())).getString()); + lines.add(text("gui.bannermod.housing_ledger.detail.plot", plotLabel(this.selected)).getString()); + int maxLines = maxDetailLines(y); + for (int i = 0; i < lines.size() && i < maxLines; i++) { + graphics.drawString(this.font, this.font.plainSubstrByWidth(lines.get(i), w), x, y + 14 + i * 12, i >= 8 ? INK_MUTED : INK, false); + } + } + + private void renderActionLedger(GuiGraphics graphics) { + int x = actionLedgerX() + 8; + int y = actionLedgerTop() + 18; + int w = Math.max(40, actionLedgerW() - 16); + Component status = visibleActionStatus(); + graphics.drawString(this.font, this.font.plainSubstrByWidth(status.getString(), w), x, y, INK, false); + } + + private Component visibleActionStatus() { + if (NpcHousingClientState.syncPending() || !NpcHousingClientState.hasSnapshot()) { + return text("gui.bannermod.housing_ledger.waiting_sync"); + } + if (!NpcHousingClientState.hasClaim()) { + return text("gui.bannermod.housing_ledger.no_claim"); + } + if (!NpcHousingClientState.canManage()) { + return readOnlyReason(); + } + if (this.selected == null) { + return text("gui.bannermod.housing_ledger.select_request"); + } + if (NpcHousingPriorityService.canApprove(this.selected)) { + return text("gui.bannermod.housing_ledger.action.approve_ready"); + } + if (NpcHousingPriorityService.canDeny(this.selected)) { + return text("gui.bannermod.housing_ledger.action.deny_ready"); + } + return text("gui.bannermod.housing_ledger.action.authorized"); + } + + private Component readOnlyReason() { + String denialKey = NpcHousingClientState.denialKey(); + return denialKey == null || denialKey.isBlank() + ? text("gui.bannermod.housing_ledger.action.read_only") + : Component.translatable(denialKey); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0) { + int listX = listX(); + int listY = listY(); + int listW = listW(); + int listH = this.listVisible * ROW_H; + if (mouseX >= listX && mouseX < listX + listW && mouseY >= listY && mouseY < listY + listH) { + int idx = this.scrollOffset + (int) ((mouseY - listY) / ROW_H); + if (idx >= 0 && idx < this.requests.size()) { + this.selected = this.requests.get(idx); + updateButtons(); + return true; + } + } + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double delta) { + int listX = listX(); + int listY = listY(); + int listW = listW(); + int listH = this.listVisible * ROW_H; + if (mouseX < listX || mouseX >= listX + listW || mouseY < listY || mouseY >= listY + listH) { + return super.mouseScrolled(mouseX, mouseY, scrollX, delta); + } + int max = Math.max(0, this.requests.size() - this.listVisible); + this.scrollOffset = clamp(this.scrollOffset - (int) Math.signum(delta), 0, max); + return true; + } + + private void updateGeometry() { + int viewportW = Math.max(1, this.width - 12); + int viewportH = Math.max(1, this.height - 12); + int minW = Math.min(MIN_BOOK_W, viewportW); + int minH = Math.min(MIN_BOOK_H, viewportH); + this.guiW = Math.min(MAX_BOOK_W, Math.max(minW, this.width - 28)); + this.guiH = Math.min(MAX_BOOK_H, Math.max(minH, this.height - 24)); + this.guiLeft = (this.width - this.guiW) / 2; + this.guiTop = (this.height - this.guiH) / 2; + this.listVisible = Math.max(1, listH() / ROW_H); + } + + private int innerX() { + return this.guiLeft + BOOK_BORDER + 8; + } + + private int innerW() { + return this.guiW - (BOOK_BORDER + 8) * 2; + } + + private int pageGap() { + return Math.max(12, this.guiW / 54); + } + + private int contentTop() { + return this.guiTop + 38; + } + + private int contentBottom() { + return actionLedgerTop() - 8; + } + + private int leftPageX() { + return innerX(); + } + + private int leftPageW() { + int available = innerW() - pageGap(); + return clamp(available * 2 / 5, 136, Math.max(136, available - 148)); + } + + private int rightPageX() { + return leftPageX() + leftPageW() + pageGap(); + } + + private int rightPageW() { + return innerW() - leftPageW() - pageGap(); + } + + private int listX() { + return leftPageX() + 8; + } + + private int listY() { + return contentTop() + 24; + } + + private int listW() { + return Math.max(80, leftPageW() - 16); + } + + private int listH() { + return Math.max(ROW_H, contentBottom() - listY() - 8); + } + + private int actionLedgerTop() { + return this.guiTop + this.guiH - actionLedgerH() - 8; + } + + private int actionLedgerH() { + return 32 + (BUTTON_H + 4); + } + + private int actionLedgerX() { + return innerX(); + } + + private int actionLedgerW() { + return innerW(); + } + + private int actionButtonW() { + return Math.max(64, (actionLedgerW() - 16 - 3 * 6) / 4); + } + + private int actionButtonX(int index) { + return actionLedgerX() + 8 + index * (actionButtonW() + 6); + } + + private int actionButtonY(int index) { + return actionLedgerTop() + 30; + } + + private int maxDetailLines(int titleY) { + int firstLineY = titleY + 14; + int detailBottom = contentBottom() - 8; + if (detailBottom < firstLineY) { + return 0; + } + return ((detailBottom - firstLineY) / 12) + 1; + } + + private static int urgencyColor(String urgencyTag) { + return switch (safeTag(urgencyTag)) { + case "CRITICAL" -> 0xFFD75B4E; + case "HIGH" -> 0xFFD9A441; + case "MEDIUM" -> 0xFF79B15A; + default -> 0xFFAAAAAA; + }; + } + + private static int statusColor(String statusTag) { + return switch (safeTag(statusTag)) { + case "REQUESTED" -> 0xFFD9A441; + case "DENIED" -> 0xFFD75B4E; + case "APPROVED" -> 0xFF79B15A; + default -> 0xFFAAAAAA; + }; + } + + private static String plotLabel(NpcHousingLedgerEntry entry) { + if (entry == null || entry.reservedPlotPos() == null) { + return "-"; + } + return entry.reservedPlotPos().getX() + " " + entry.reservedPlotPos().getY() + " " + entry.reservedPlotPos().getZ(); + } + + private static String shortId(@Nullable UUID uuid) { + if (uuid == null) { + return "-"; + } + String raw = uuid.toString(); + return raw.length() > 8 ? raw.substring(0, 8) : raw; + } + + private static String safeTag(@Nullable String value) { + return value == null || value.isBlank() ? "UNSPECIFIED" : value.toUpperCase(Locale.ROOT); + } + + private static Component text(String key, Object... args) { + return Component.translatable(key, args); + } + + private static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(max, v)); + } + + @Override + public void onClose() { + if (this.parent != null) { + this.minecraft.setScreen(this.parent); + } else { + super.onClose(); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private static class MedievalButton extends Button { + MedievalButton(int x, int y, int width, int height, Component message, OnPress onPress) { + super(x, y, width, height, message, onPress, DEFAULT_NARRATION); + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + int x = getX(); + int y = getY(); + int w = getWidth(); + int h = getHeight(); + boolean hovered = isHoveredOrFocused(); + int border = active ? (hovered ? GOLD : PAGE_SHADE) : 0xFF7C6C55; + int fill = active ? (hovered ? 0xFF6A3D1F : LEATHER) : 0xFF4C3A28; + + graphics.fill(x, y, x + w, y + h, LEATHER_DARK); + graphics.fill(x + 1, y + 1, x + w - 1, y + h - 1, fill); + graphics.fill(x + 2, y + 2, x + w - 2, y + 4, 0x557A4C24); + graphics.renderOutline(x, y, w, h, border); + graphics.renderOutline(x + 1, y + 1, w - 2, h - 2, 0x661A100A); + + Font font = Minecraft.getInstance().font; + String label = clippedLabel(font, getMessage().getString(), Math.max(4, w - 10)); + int textColor = active ? GOLD : 0xFFB8A17A; + graphics.drawCenteredString(font, label, x + w / 2, y + (h - 8) / 2, textColor); + } + + private static String clippedLabel(Font font, String label, int maxWidth) { + if (font.width(label) <= maxWidth) { + return label; + } + String ellipsis = "..."; + int textWidth = Math.max(1, maxWidth - font.width(ellipsis)); + return font.plainSubstrByWidth(label, textWidth) + ellipsis; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java new file mode 100644 index 00000000..b08f60f0 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java @@ -0,0 +1,131 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.client.military.gui.component.RecruitsMultiLineEditBox; +import com.talhanation.bannermod.war.registry.PoliticalRegistryValidation; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.List; +import java.util.function.Consumer; + +public class PoliticalEntityCharterScreen extends Screen { + private static final int W = 320; + private static final int H = 224; + + private final Screen parent; + private final Consumer onSubmit; + private final String initialValue; + private int guiLeft; + private int guiTop; + private RecruitsMultiLineEditBox charterBox; + + public PoliticalEntityCharterScreen(Screen parent, String initialValue, Consumer onSubmit) { + super(Component.translatable("gui.bannermod.states.dialog.charter.title")); + this.parent = parent; + this.onSubmit = onSubmit; + this.initialValue = initialValue == null ? "" : initialValue; + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - W) / 2; + this.guiTop = Math.max(8, (this.height - H) / 2); + + this.charterBox = new RecruitsMultiLineEditBox(font, guiLeft + 14, guiTop + 68, W - 28, 100, Component.empty(), Component.empty()); + this.charterBox.setValue(this.initialValue); + this.charterBox.setEnableEditing(true); + this.charterBox.setCharacterLimit(PoliticalRegistryValidation.MAX_CHARTER_LENGTH); + this.charterBox.setFocused(true); + addRenderableWidget(this.charterBox); + setInitialFocus(this.charterBox); + + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.submit"), button -> submit()) + .bounds(guiLeft + 14, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.charter.clear"), button -> clearDraft()) + .bounds(guiLeft + 116, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(guiLeft + W - 102, guiTop + H - 28, 88, 20) + .build()); + } + + private void submit() { + this.onSubmit.accept(this.charterBox.getValue().trim()); + onClose(); + } + + private void clearDraft() { + this.charterBox.setValue(""); + this.charterBox.setFocused(true); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, width, height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, guiLeft, guiTop, W, H); + MilitaryGuiStyle.titleStrip(graphics, guiLeft + 8, guiTop + 8, W - 16, 14); + MilitaryGuiStyle.parchmentInset(graphics, guiLeft + 10, guiTop + 24, W - 20, 36); + MilitaryGuiStyle.insetPanel(graphics, guiLeft + 12, guiTop + 66, W - 24, 104); + + MilitaryGuiStyle.drawCenteredTitle(graphics, font, title, guiLeft, guiTop + 11, W); + drawWrapped(graphics, + Component.translatable("gui.bannermod.states.dialog.charter.subtitle"), + guiLeft + 16, + guiTop + 30, + W - 32, + MilitaryGuiStyle.TEXT_DARK); + graphics.drawString(font, + Component.translatable("gui.bannermod.states.dialog.charter.prompt", PoliticalRegistryValidation.MAX_CHARTER_LENGTH), + guiLeft + 14, + guiTop + 58, + MilitaryGuiStyle.TEXT_MUTED, + false); + + if (this.charterBox != null) { + String count = Component.translatable( + "gui.bannermod.states.dialog.charter.count", + this.charterBox.getValue().length(), + PoliticalRegistryValidation.MAX_CHARTER_LENGTH).getString(); + graphics.drawString(font, count, guiLeft + 14, guiTop + 176, MilitaryGuiStyle.TEXT_MUTED, false); + Component preview = this.charterBox.getValue().isBlank() + ? Component.translatable("gui.bannermod.states.dialog.charter.empty") + : Component.translatable("gui.bannermod.states.dialog.charter.preview", this.charterBox.getValue().replaceAll("\\s+", " ").trim()); + graphics.drawString(font, + font.plainSubstrByWidth(preview.getString(), W - 32), + guiLeft + 14, + guiTop + 188, + this.charterBox.getValue().isBlank() ? MilitaryGuiStyle.TEXT_MUTED : MilitaryGuiStyle.TEXT_DARK, + false); + } + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private int drawWrapped(GuiGraphics graphics, Component text, int x, int y, int width, int color) { + List lines = this.font.split(text, width); + for (net.minecraft.util.FormattedCharSequence line : lines) { + graphics.drawString(this.font, line, x, y, color, false); + y += 10; + } + return y; + } + + @Override + public void onClose() { + if (parent != null) { + this.minecraft.setScreen(parent); + } else { + super.onClose(); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java new file mode 100644 index 00000000..dbee15a6 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java @@ -0,0 +1,226 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.military.ClientManager; +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.client.military.gui.widgets.ScrollDropDownMenu; +import com.talhanation.bannermod.network.messages.war.MessageUpdateCoLeader; +import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FastColor; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +public class PoliticalEntityCoLeaderPickerScreen extends Screen { + private static final int W = 292; + private static final int H = 172; + + private final Screen parent; + private final UUID entityId; + private final UUID leaderUuid; + private final List existingCoLeaders; + private final List candidates = new ArrayList<>(); + private int guiLeft; + private int guiTop; + private int lastOnlinePlayersVersion = -1; + private ScrollDropDownMenu playerDropdown; + private Button grantButton; + @Nullable + private RecruitsPlayerInfo selectedPlayer; + + public PoliticalEntityCoLeaderPickerScreen(Screen parent, UUID entityId, @Nullable UUID leaderUuid, List existingCoLeaders) { + super(Component.translatable("gui.bannermod.states.dialog.co_leader_add.title")); + this.parent = parent; + this.entityId = entityId; + this.leaderUuid = leaderUuid; + this.existingCoLeaders = existingCoLeaders == null ? List.of() : List.copyOf(existingCoLeaders); + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - W) / 2; + this.guiTop = Math.max(8, (this.height - H) / 2); + rebuildCandidates(); + + this.playerDropdown = new ScrollDropDownMenu<>(this.selectedPlayer, guiLeft + 16, guiTop + 72, W - 32, 20, this.candidates, + this::displayName, + selected -> { + this.selectedPlayer = selected; + updateButtons(); + }); + this.playerDropdown.setBgFill(FastColor.ARGB32.color(255, 66, 50, 34)); + this.playerDropdown.setBgFillHovered(FastColor.ARGB32.color(255, 104, 79, 54)); + this.playerDropdown.setBgFillSelected(FastColor.ARGB32.color(255, 50, 37, 24)); + this.playerDropdown.setDisplayColor(MilitaryGuiStyle.TEXT); + this.playerDropdown.setOptionTextColor(MilitaryGuiStyle.TEXT); + addRenderableWidget(this.playerDropdown); + + this.grantButton = addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.co_leader.select"), button -> submitSelected()) + .bounds(guiLeft + 16, guiTop + H - 28, 82, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.dialog.co_leader.manual"), button -> openManualEntry()) + .bounds(guiLeft + 105, guiTop + H - 28, 88, 20) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(guiLeft + W - 98, guiTop + H - 28, 82, 20) + .build()); + updateButtons(); + } + + private void rebuildCandidates() { + this.candidates.clear(); + this.lastOnlinePlayersVersion = ClientManager.onlinePlayersVersion; + for (RecruitsPlayerInfo playerInfo : ClientManager.onlinePlayers) { + if (playerInfo == null || playerInfo.getUUID() == null) { + continue; + } + if (playerInfo.getUUID().equals(this.leaderUuid) || this.existingCoLeaders.contains(playerInfo.getUUID())) { + continue; + } + this.candidates.add(copyInfo(playerInfo)); + } + this.candidates.sort(Comparator.comparing(this::displayName, String.CASE_INSENSITIVE_ORDER)); + if (this.selectedPlayer != null && this.selectedPlayer.getUUID() != null) { + this.selectedPlayer = this.candidates.stream() + .filter(candidate -> candidate.getUUID().equals(this.selectedPlayer.getUUID())) + .findFirst() + .orElse(null); + } + } + + @Override + public void tick() { + super.tick(); + if (this.lastOnlinePlayersVersion != ClientManager.onlinePlayersVersion) { + this.init(); + } + } + + private void submitSelected() { + if (this.selectedPlayer == null || this.selectedPlayer.getUUID() == null) { + return; + } + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUpdateCoLeader(this.entityId, this.selectedPlayer.getUUID().toString(), true)); + this.minecraft.setScreen(this.parent); + } + + private void openManualEntry() { + this.minecraft.setScreen(new PoliticalEntityNameInputScreen( + this.parent, + Component.translatable("gui.bannermod.states.dialog.co_leader_add.title"), + Component.translatable("gui.bannermod.states.dialog.co_leader.prompt"), + "", + value -> BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUpdateCoLeader(this.entityId, value, true)), + 36, + false + )); + } + + private void updateButtons() { + if (this.grantButton != null) { + boolean hasSelection = this.selectedPlayer != null && this.selectedPlayer.getUUID() != null; + this.grantButton.active = hasSelection; + this.grantButton.setTooltip(hasSelection ? net.minecraft.client.gui.components.Tooltip.create(Component.translatable("gui.bannermod.states.dialog.co_leader.select.tooltip")) + : net.minecraft.client.gui.components.Tooltip.create(Component.translatable("gui.bannermod.states.dialog.co_leader.empty"))); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, width, height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, guiLeft, guiTop, W, H); + MilitaryGuiStyle.titleStrip(graphics, guiLeft + 8, guiTop + 8, W - 16, 14); + MilitaryGuiStyle.parchmentInset(graphics, guiLeft + 10, guiTop + 24, W - 20, 34); + MilitaryGuiStyle.insetPanel(graphics, guiLeft + 14, guiTop + 70, W - 28, 24); + + MilitaryGuiStyle.drawCenteredTitle(graphics, font, title, guiLeft, guiTop + 11, W); + drawWrapped(graphics, Component.translatable("gui.bannermod.states.dialog.co_leader.subtitle"), guiLeft + 16, guiTop + 32, W - 32, MilitaryGuiStyle.TEXT_DARK); + graphics.drawString(font, Component.translatable("gui.bannermod.states.dialog.co_leader.online"), guiLeft + 16, guiTop + 60, MilitaryGuiStyle.TEXT_MUTED, false); + drawWrapped(graphics, + candidates.isEmpty() + ? Component.translatable("gui.bannermod.states.dialog.co_leader.empty") + : Component.translatable("gui.bannermod.states.dialog.co_leader.selected", displayName(this.selectedPlayer)), + guiLeft + 16, + guiTop + 104, + W - 32, + MilitaryGuiStyle.TEXT_MUTED); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private int drawWrapped(GuiGraphics graphics, Component text, int x, int y, int width, int color) { + List lines = this.font.split(text, width); + for (net.minecraft.util.FormattedCharSequence line : lines) { + graphics.drawString(this.font, line, x, y, color, false); + y += 10; + } + return y; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button == 0 && this.playerDropdown != null && this.playerDropdown.isMouseOver(mouseX, mouseY)) { + this.playerDropdown.onMouseClick(mouseX, mouseY); + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + super.mouseMoved(mouseX, mouseY); + if (this.playerDropdown != null) { + this.playerDropdown.onMouseMove(mouseX, mouseY); + } + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double delta) { + if (this.playerDropdown != null && this.playerDropdown.isMouseOver(mouseX, mouseY) && this.playerDropdown.mouseScrolled(mouseX, mouseY, scrollX, delta)) { + return true; + } + return super.mouseScrolled(mouseX, mouseY, scrollX, delta); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (this.playerDropdown != null && this.playerDropdown.mouseReleased(mouseX, mouseY, button)) { + return true; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + private String displayName(@Nullable RecruitsPlayerInfo playerInfo) { + if (playerInfo == null) { + return Component.translatable("gui.bannermod.common.none").getString(); + } + if (playerInfo.getName() != null && !playerInfo.getName().isBlank()) { + return playerInfo.getName(); + } + return playerInfo.getUUID() == null ? Component.translatable("gui.bannermod.common.none").getString() : playerInfo.getUUID().toString(); + } + + private RecruitsPlayerInfo copyInfo(RecruitsPlayerInfo playerInfo) { + RecruitsPlayerInfo copy = new RecruitsPlayerInfo(playerInfo.getUUID(), playerInfo.getName()); + copy.setOnline(playerInfo.isOnline()); + return copy; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parent); + } + + @Override + public boolean isPauseScreen() { + return false; + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java new file mode 100644 index 00000000..c5fe994e --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java @@ -0,0 +1,179 @@ +package com.talhanation.bannermod.client.military.gui.war; + +import com.talhanation.bannermod.client.military.gui.MilitaryGuiStyle; +import com.talhanation.bannermod.war.registry.PoliticalColorParser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.util.function.Consumer; + +public class PoliticalEntityColorPaletteScreen extends Screen { + private static final int PANEL_W = 252; + private static final int PANEL_H = 194; + private static final String[] PALETTE = { + "F9FFFE", "F9801D", "C74EBD", "3AB3DA", + "FED83D", "80C71F", "F38BAA", "474F52", + "9D9D97", "169C9C", "8932B8", "3C44AA", + "835432", "5E7C16", "B02E26", "1D1D21" + }; + + private final Screen parent; + private final Consumer onSubmit; + private String currentColor; + private int guiLeft; + private int guiTop; + + public PoliticalEntityColorPaletteScreen(Screen parent, String currentColor, Consumer onSubmit) { + super(Component.translatable("gui.bannermod.states.dialog.color.title")); + this.parent = parent; + this.currentColor = currentColor == null ? "" : currentColor.trim(); + this.onSubmit = onSubmit; + } + + @Override + protected void init() { + super.init(); + this.guiLeft = (this.width - PANEL_W) / 2; + this.guiTop = (this.height - PANEL_H) / 2; + + int swatchSize = 28; + int gap = 6; + int gridLeft = this.guiLeft + 22; + int gridTop = this.guiTop + 72; + for (int i = 0; i < PALETTE.length; i++) { + String hex = PALETTE[i]; + int column = i % 4; + int row = i / 4; + addRenderableWidget(new SwatchButton( + gridLeft + column * (swatchSize + gap), + gridTop + row * (swatchSize + gap), + swatchSize, + hex, + button -> choose(hex))); + } + + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.palette.clear"), button -> choose("")) + .bounds(this.guiLeft + 150, this.guiTop + 82, 80, 20) + .tooltip(Tooltip.create(Component.translatable("gui.bannermod.states.palette.clear.tooltip"))) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.states.palette.custom"), button -> openCustomDialog()) + .bounds(this.guiLeft + 150, this.guiTop + 108, 80, 20) + .tooltip(Tooltip.create(Component.translatable("gui.bannermod.states.palette.custom.tooltip"))) + .build()); + addRenderableWidget(Button.builder(Component.translatable("gui.bannermod.common.back"), button -> onClose()) + .bounds(this.guiLeft + 150, this.guiTop + 134, 80, 20) + .build()); + } + + private void choose(String color) { + this.currentColor = color == null ? "" : color; + this.onSubmit.accept(this.currentColor); + this.minecraft.setScreen(this.parent); + } + + private void applyCustomColor(String color) { + this.currentColor = color == null ? "" : color; + this.onSubmit.accept(this.currentColor); + } + + private void openCustomDialog() { + Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + this, + Component.translatable("gui.bannermod.states.dialog.color.title"), + Component.translatable("gui.bannermod.states.dialog.color.prompt"), + this.currentColor, + this::applyCustomColor, + 9, + true)); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, this.width, this.height, 0x66000000); + MilitaryGuiStyle.parchmentPanel(graphics, this.guiLeft, this.guiTop, PANEL_W, PANEL_H); + MilitaryGuiStyle.titleStrip(graphics, this.guiLeft + 6, this.guiTop + 6, PANEL_W - 12, 14); + MilitaryGuiStyle.drawCenteredTitle(graphics, this.font, this.title, this.guiLeft, this.guiTop + 9, PANEL_W); + + graphics.drawString(this.font, + Component.translatable("gui.bannermod.states.palette.current", currentLabel()), + this.guiLeft + 18, + this.guiTop + 28, + MilitaryGuiStyle.TEXT_DARK, + false); + graphics.drawString(this.font, + Component.translatable("gui.bannermod.states.palette.hint"), + this.guiLeft + 18, + this.guiTop + 44, + MilitaryGuiStyle.TEXT_MUTED, + false); + + int previewColor = PoliticalColorParser.parseArgb(this.currentColor, 0xFFB8A17A); + graphics.fill(this.guiLeft + 18, this.guiTop + 58, this.guiLeft + 230, this.guiTop + 60, 0x665A4025); + graphics.fill(this.guiLeft + 150, this.guiTop + 30, this.guiLeft + 230, this.guiTop + 72, 0xFF201810); + graphics.fill(this.guiLeft + 154, this.guiTop + 34, this.guiLeft + 226, this.guiTop + 68, previewColor); + graphics.renderOutline(this.guiLeft + 150, this.guiTop + 30, 80, 42, 0xFF8A6A3A); + + super.render(graphics, mouseX, mouseY, partialTick); + } + + private Component currentLabel() { + if (this.currentColor.isBlank()) { + return Component.translatable("gui.bannermod.common.none"); + } + return Component.literal(this.currentColor); + } + + private static boolean sameColor(String left, String right) { + return PoliticalColorParser.parseArgb(left, Integer.MIN_VALUE) + == PoliticalColorParser.parseArgb(right, Integer.MAX_VALUE); + } + + @Override + public void onClose() { + if (this.parent != null) { + this.minecraft.setScreen(this.parent); + } else { + super.onClose(); + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + private final class SwatchButton extends Button { + private final String hexColor; + private final int argbColor; + + private SwatchButton(int x, int y, int size, String hexColor, OnPress onPress) { + super(x, y, size, size, Component.empty(), onPress, DEFAULT_NARRATION); + this.hexColor = hexColor; + this.argbColor = PoliticalColorParser.parseArgb(hexColor); + setTooltip(Tooltip.create(Component.literal("#" + hexColor))); + } + + @Override + protected void renderWidget(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + int x = getX(); + int y = getY(); + int size = getWidth(); + boolean hovered = isHoveredOrFocused(); + boolean selected = sameColor(currentColor, this.hexColor); + int border = selected ? 0xFFE0B86A : hovered ? 0xFFB8A17A : 0xFF5A4025; + + graphics.fill(x, y, x + size, y + size, 0xFF201810); + graphics.fill(x + 2, y + 2, x + size - 2, y + size - 2, this.argbColor); + graphics.renderOutline(x, y, size, size, border); + if (selected) { + Font font = Minecraft.getInstance().font; + graphics.drawCenteredString(font, "*", x + size / 2, y + (size - 8) / 2, 0xFFFFFFFF); + } + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java index f4b7acf5..4bd98571 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java @@ -260,41 +260,42 @@ private void promoteToState() { private void openColorDialog() { if (this.selected == null) return; UUID id = this.selected.id(); - // 9 chars: optional '#' + up to 8 hex digits (covers AARRGGBB). - Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + Minecraft.getInstance().setScreen(new PoliticalEntityColorPaletteScreen( this, - text("gui.bannermod.states.dialog.color.title"), - text("gui.bannermod.states.dialog.color.prompt"), this.selected.color(), - value -> sendColor(id, value), - 9, - /* allowEmpty */ true + value -> sendColor(id, value) )); } private void openCharterDialog() { if (this.selected == null) return; UUID id = this.selected.id(); - Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( + Minecraft.getInstance().setScreen(new PoliticalEntityCharterScreen( this, - text("gui.bannermod.states.dialog.charter.title"), - text("gui.bannermod.states.dialog.charter.prompt", PoliticalRegistryValidation.MAX_CHARTER_LENGTH), this.selected.charter(), - value -> sendCharter(id, value), - PoliticalRegistryValidation.MAX_CHARTER_LENGTH, - /* allowEmpty */ true + value -> sendCharter(id, value) )); } - private void openCoLeaderDialog(boolean add) { + private void openAddCoLeaderDialog() { + if (this.selected == null) return; + Minecraft.getInstance().setScreen(new PoliticalEntityCoLeaderPickerScreen( + this, + this.selected.id(), + this.selected.leaderUuid(), + this.selected.coLeaderUuids() + )); + } + + private void openRemoveCoLeaderDialog() { if (this.selected == null) return; UUID id = this.selected.id(); Minecraft.getInstance().setScreen(new PoliticalEntityNameInputScreen( this, - text(add ? "gui.bannermod.states.dialog.co_leader_add.title" : "gui.bannermod.states.dialog.co_leader_remove.title"), + text("gui.bannermod.states.dialog.co_leader_remove.title"), text("gui.bannermod.states.dialog.co_leader.prompt"), "", - value -> sendCoLeader(id, value, add), + value -> sendCoLeader(id, value, false), 36, false )); @@ -351,9 +352,9 @@ private List buildManageEntries() { entries.add(new ContextMenuEntry(text("gui.bannermod.states.charter").getString(), this::openCharterDialog, canAct)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.add_co_leader").getString(), - () -> openCoLeaderDialog(true), leader)); + this::openAddCoLeaderDialog, leader)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.remove_co_leader").getString(), - () -> openCoLeaderDialog(false), canRemoveCoLeader)); + this::openRemoveCoLeaderDialog, canRemoveCoLeader)); entries.add(new ContextMenuEntry(text("gui.bannermod.states.promote_state").getString(), this::promoteToState, canPromote)); return entries; @@ -438,6 +439,7 @@ private void renderDetails(GuiGraphics graphics) { text("gui.bannermod.states.detail.co_leader_authority", text(selected.governmentForm().coLeadersShareAuthority() ? "gui.bannermod.states.co_authority.active" : "gui.bannermod.states.co_authority.locked").getString()).getString(), text("gui.bannermod.states.detail.capital", selected.capitalPos() == null ? text("gui.bannermod.common.none").getString() : selected.capitalPos().toShortString()).getString(), text("gui.bannermod.states.detail.color", selected.color().isBlank() ? text("gui.bannermod.common.none").getString() : selected.color()).getString(), + text("gui.bannermod.states.detail.charter", charterSummary(selected)).getString(), text("gui.bannermod.states.detail.region", selected.homeRegion().isBlank() ? text("gui.bannermod.common.none").getString() : selected.homeRegion()).getString(), text("gui.bannermod.states.detail.wars", involvedWarCount(selected)).getString() }; @@ -553,6 +555,13 @@ private String coLeaderSummary(PoliticalEntityRecord entity) { return String.join(", ", names) + suffix; } + private String charterSummary(PoliticalEntityRecord entity) { + if (entity.charter().isBlank()) { + return text("gui.bannermod.common.none").getString(); + } + return entity.charter().replaceAll("\\s+", " ").trim(); + } + private static int involvedWarCount(PoliticalEntityRecord entity) { int count = 0; for (var war : WarClientState.wars()) { diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/war/WarListScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/war/WarListScreen.java index 705750a5..8ec2ebee 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/gui/war/WarListScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/war/WarListScreen.java @@ -83,6 +83,7 @@ public class WarListScreen extends Screen { private Button alliesBtn; private Button declareBtn; private Button statesBtn; + private Button housingBtn; private Button refreshBtn; private Button closeBtn; private Button adminRecruitSpawnBtn; @@ -100,22 +101,24 @@ protected void init() { // Nav buttons remain plain (different tier from outcome resolution). statesBtn = actionButton(0, text("gui.bannermod.war_list.states"), btn -> this.minecraft.setScreen(new PoliticalEntityListScreen(this))); - refreshBtn = actionButton(1, text("gui.bannermod.common.refresh"), btn -> refresh()); - declareBtn = actionButton(2, text("gui.bannermod.war_list.declare"), btn -> this.minecraft.setScreen(new WarDeclareScreen(this))); - alliesBtn = actionButton(3, text("gui.bannermod.war_list.allies"), btn -> openAllies()); - openAttackerBtn = actionButton(4, text("gui.bannermod.war_list.attacker_info"), btn -> openEntity(selected != null ? selected.attackerPoliticalEntityId() : null)); - openDefenderBtn = actionButton(5, text("gui.bannermod.war_list.defender_info"), btn -> openEntity(selected != null ? selected.defenderPoliticalEntityId() : null)); - closeBtn = actionButton(6, text("gui.bannermod.common.close"), btn -> onClose()); - adminRecruitSpawnBtn = actionButton(7, text("gui.bannermod.war_list.admin_recruit_spawn"), btn -> openAdminRecruitSpawner()); + housingBtn = actionButton(1, text("gui.bannermod.war_list.housing"), btn -> this.minecraft.setScreen(new HousingLedgerScreen(this))); + refreshBtn = actionButton(2, text("gui.bannermod.common.refresh"), btn -> refresh()); + declareBtn = actionButton(3, text("gui.bannermod.war_list.declare"), btn -> this.minecraft.setScreen(new WarDeclareScreen(this))); + alliesBtn = actionButton(4, text("gui.bannermod.war_list.allies"), btn -> openAllies()); + openAttackerBtn = actionButton(5, text("gui.bannermod.war_list.attacker_info"), btn -> openEntity(selected != null ? selected.attackerPoliticalEntityId() : null)); + openDefenderBtn = actionButton(6, text("gui.bannermod.war_list.defender_info"), btn -> openEntity(selected != null ? selected.defenderPoliticalEntityId() : null)); + closeBtn = actionButton(7, text("gui.bannermod.common.close"), btn -> onClose()); + adminRecruitSpawnBtn = actionButton(8, text("gui.bannermod.war_list.admin_recruit_spawn"), btn -> openAdminRecruitSpawner()); // Resolve-outcome ledger collapses 7 same-tier outcome buttons under one menu. resolveOutcomeMenu = new ActionMenuButton( - actionButtonX(8), actionButtonY(8), actionButtonW(), BUTTON_H, + actionButtonX(9), actionButtonY(9), actionButtonW(), BUTTON_H, text("gui.bannermod.war_list.menu.resolve_outcome"), buildResolveOutcomeEntries()); resolveOutcomeMenu.setOpenUpward(true); addRenderableWidget(statesBtn); + addRenderableWidget(housingBtn); addRenderableWidget(refreshBtn); addRenderableWidget(declareBtn); addRenderableWidget(alliesBtn); @@ -223,8 +226,8 @@ private int actionRows() { } private int actionButtonCount() { - // 8 nav buttons + 1 resolve-outcome dropdown trigger. - return 9; + // 8 nav buttons + 1 admin button + 1 resolve-outcome dropdown trigger. + return 10; } private int actionLedgerX() { diff --git a/src/main/java/com/talhanation/bannermod/commands/society/BannerModSocietyCommands.java b/src/main/java/com/talhanation/bannermod/commands/society/BannerModSocietyCommands.java new file mode 100644 index 00000000..89eba010 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/commands/society/BannerModSocietyCommands.java @@ -0,0 +1,371 @@ +package com.talhanation.bannermod.commands.society; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.society.NpcHousingRequestAccess; +import com.talhanation.bannermod.society.NpcHousingLedgerEntry; +import com.talhanation.bannermod.society.NpcHousingPriorityService; +import com.talhanation.bannermod.society.NpcHousingRequestRecord; +import com.talhanation.bannermod.society.NpcHousingRequestStatus; +import com.talhanation.bannermod.society.NpcLivelihoodRequestAccess; +import com.talhanation.bannermod.society.NpcLivelihoodRequestRecord; +import com.talhanation.bannermod.society.NpcLivelihoodRequestSavedData; +import com.talhanation.bannermod.society.NpcLivelihoodRequestStatus; +import com.talhanation.bannermod.society.NpcLivelihoodRequestType; +import com.talhanation.bannermod.war.WarRuntimeContext; +import com.talhanation.bannermod.war.registry.PoliticalEntityAuthority; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public final class BannerModSocietyCommands { + private BannerModSocietyCommands() { + } + + public static LiteralArgumentBuilder build() { + return Commands.literal("society") + .then(Commands.literal("housing") + .then(Commands.literal("list") + .executes(BannerModSocietyCommands::listCurrentClaimRequests)) + .then(Commands.literal("approve") + .then(Commands.argument("householdId", StringArgumentType.word()) + .executes(ctx -> updateRequestStatus(ctx, true)))) + .then(Commands.literal("deny") + .then(Commands.argument("householdId", StringArgumentType.word()) + .executes(ctx -> updateRequestStatus(ctx, false))))) + .then(Commands.literal("livelihood") + .then(Commands.literal("list") + .executes(BannerModSocietyCommands::listCurrentClaimLivelihoodRequests)) + .then(Commands.literal("approve") + .then(Commands.argument("claimId", StringArgumentType.word()) + .then(Commands.argument("type", StringArgumentType.word()) + .executes(ctx -> updateLivelihoodRequestStatus(ctx, true))))) + .then(Commands.literal("deny") + .then(Commands.argument("claimId", StringArgumentType.word()) + .then(Commands.argument("type", StringArgumentType.word()) + .executes(ctx -> updateLivelihoodRequestStatus(ctx, false)))))); + } + + private static int listCurrentClaimRequests(CommandContext ctx) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = ctx.getSource().getPlayerOrException(); + ServerLevel level = player.serverLevel(); + RecruitsClaim claim = currentClaim(player); + if (claim == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.housing_request.command.no_claim")); + return 0; + } + PoliticalEntityRecord owner = ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + ctx.getSource().sendFailure(PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + return 0; + } + + List requests = NpcHousingPriorityService.activeEntriesForClaim(level, claim.getUUID(), level.getGameTime()); + + if (requests.isEmpty()) { + ctx.getSource().sendSuccess(() -> Component.translatable("gui.bannermod.society.housing_request.command.empty"), false); + return 1; + } + + ctx.getSource().sendSuccess(() -> Component.translatable("gui.bannermod.society.housing_request.command.header", requests.size()), false); + for (NpcHousingLedgerEntry request : requests) { + Component state = Component.translatable(request.housingStateTranslationKey()); + Component status = Component.translatable(request.statusTranslationKey()); + Component urgency = Component.translatable(request.urgencyTranslationKey()); + Component reason = Component.translatable(request.reasonTranslationKey()); + Component plot = request.reservedPlotPos() == null + ? Component.literal("-") + : Component.literal(request.reservedPlotPos().getX() + " " + + request.reservedPlotPos().getY() + " " + + request.reservedPlotPos().getZ()); + MutableComponent line = Component.translatable( + "gui.bannermod.society.housing_request.command.entry", + request.queueRank(), + shortId(request.residentUuid()), + state, + request.householdSize(), + status, + urgency, + reason, + plot + ); + if (NpcHousingPriorityService.canApprove(request)) { + line.append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.housing_request.action.approve", + "/bannermod society housing approve " + request.householdId(), + ChatFormatting.GREEN, + "gui.bannermod.society.housing_request.action.approve.tooltip" + )); + } + if (NpcHousingPriorityService.canDeny(request)) { + line.append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.housing_request.action.deny", + "/bannermod society housing deny " + request.householdId(), + ChatFormatting.RED, + "gui.bannermod.society.housing_request.action.deny.tooltip" + )); + } + ctx.getSource().sendSuccess(() -> line, false); + } + return 1; + } + + private static int updateRequestStatus(CommandContext ctx, boolean approve) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = ctx.getSource().getPlayerOrException(); + ServerLevel level = player.serverLevel(); + UUID householdId = parseUuid(ctx.getSource(), StringArgumentType.getString(ctx, "householdId")); + if (householdId == null) { + return 0; + } + NpcHousingRequestRecord request = NpcHousingRequestAccess.requestForHousehold(level, householdId); + if (request == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.housing_request.command.not_found")); + return 0; + } + RecruitsClaim claim = claimForRequest(request); + if (claim == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.housing_request.command.no_claim")); + return 0; + } + PoliticalEntityRecord owner = ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + ctx.getSource().sendFailure(PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + return 0; + } + if (!approve && request.status() == NpcHousingRequestStatus.APPROVED) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.housing_request.command.approved_locked")); + return 0; + } + if (request.status() == NpcHousingRequestStatus.FULFILLED) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.housing_request.command.fulfilled_locked")); + return 0; + } + + NpcHousingRequestRecord updated = approve + ? NpcHousingRequestAccess.approveHousehold(level, householdId, level.getGameTime()) + : NpcHousingRequestAccess.denyHousehold(level, householdId, level.getGameTime()); + Component plot = updated.reservedPlotPos() == null + ? Component.literal("-") + : Component.literal(updated.reservedPlotPos().getX() + " " + + updated.reservedPlotPos().getY() + " " + + updated.reservedPlotPos().getZ()); + Component result = approve + ? Component.translatable("gui.bannermod.society.housing_request.command.approved", shortId(updated.residentUuid()), plot) + : Component.translatable("gui.bannermod.society.housing_request.command.denied", shortId(updated.residentUuid()), plot); + ctx.getSource().sendSuccess(() -> result, false); + return 1; + } + + private static int listCurrentClaimLivelihoodRequests(CommandContext ctx) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = ctx.getSource().getPlayerOrException(); + ServerLevel level = player.serverLevel(); + RecruitsClaim claim = currentClaim(player); + if (claim == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.no_claim")); + return 0; + } + PoliticalEntityRecord owner = ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + ctx.getSource().sendFailure(PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + return 0; + } + List requests = new ArrayList<>(NpcLivelihoodRequestSavedData.get(level).runtime().requestsForClaim(claim.getUUID())); + requests.removeIf(request -> request == null + || request.status() == NpcLivelihoodRequestStatus.NONE + || request.status() == NpcLivelihoodRequestStatus.FULFILLED); + requests.sort(Comparator.comparingInt((NpcLivelihoodRequestRecord request) -> livelihoodSeverity(request.type())) + .thenComparingLong(NpcLivelihoodRequestRecord::requestedAtGameTime)); + if (requests.isEmpty()) { + ctx.getSource().sendSuccess(() -> Component.translatable("gui.bannermod.society.livelihood_request.command.empty"), false); + return 1; + } + ctx.getSource().sendSuccess(() -> Component.translatable("gui.bannermod.society.livelihood_request.command.header", requests.size()), false); + for (NpcLivelihoodRequestRecord request : requests) { + Component type = Component.translatable("gui.bannermod.society.livelihood_request.type." + request.type().translationSuffix()); + Component status = Component.translatable("gui.bannermod.society.livelihood_request.status." + request.status().name().toLowerCase(Locale.ROOT)); + MutableComponent line = Component.translatable( + "gui.bannermod.society.livelihood_request.command.entry", + type, + shortId(request.representativeResidentUuid()), + status + ); + if (request.status() == NpcLivelihoodRequestStatus.REQUESTED || request.status() == NpcLivelihoodRequestStatus.DENIED) { + line.append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.livelihood_request.action.approve", + "/bannermod society livelihood approve " + request.claimUuid() + " " + request.type().name(), + ChatFormatting.GREEN, + "gui.bannermod.society.livelihood_request.action.approve.tooltip" + )); + } + if (request.status() == NpcLivelihoodRequestStatus.REQUESTED) { + line.append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.livelihood_request.action.deny", + "/bannermod society livelihood deny " + request.claimUuid() + " " + request.type().name(), + ChatFormatting.RED, + "gui.bannermod.society.livelihood_request.action.deny.tooltip" + )); + } + ctx.getSource().sendSuccess(() -> line, false); + } + return 1; + } + + private static int updateLivelihoodRequestStatus(CommandContext ctx, boolean approve) throws com.mojang.brigadier.exceptions.CommandSyntaxException { + ServerPlayer player = ctx.getSource().getPlayerOrException(); + ServerLevel level = player.serverLevel(); + UUID claimId = parseUuid(ctx.getSource(), StringArgumentType.getString(ctx, "claimId"), "gui.bannermod.society.livelihood_request.command.invalid_id"); + if (claimId == null) { + return 0; + } + NpcLivelihoodRequestType type = NpcLivelihoodRequestType.fromName(StringArgumentType.getString(ctx, "type")); + if (type == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.invalid_type")); + return 0; + } + NpcLivelihoodRequestRecord request = NpcLivelihoodRequestAccess.requestFor(level, claimId, type); + if (request == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.not_found")); + return 0; + } + RecruitsClaim claim = claimForRequest(request); + if (claim == null) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.no_claim")); + return 0; + } + PoliticalEntityRecord owner = ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + ctx.getSource().sendFailure(PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + return 0; + } + if (!approve && request.status() == NpcLivelihoodRequestStatus.APPROVED) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.approved_locked")); + return 0; + } + if (request.status() == NpcLivelihoodRequestStatus.FULFILLED) { + ctx.getSource().sendFailure(Component.translatable("gui.bannermod.society.livelihood_request.command.fulfilled_locked")); + return 0; + } + NpcLivelihoodRequestRecord updated = approve + ? NpcLivelihoodRequestAccess.approve(level, claimId, type, level.getGameTime()) + : NpcLivelihoodRequestAccess.deny(level, claimId, type, level.getGameTime()); + Component result = approve + ? Component.translatable("gui.bannermod.society.livelihood_request.command.approved", + Component.translatable("gui.bannermod.society.livelihood_request.type." + updated.type().translationSuffix())) + : Component.translatable("gui.bannermod.society.livelihood_request.command.denied", + Component.translatable("gui.bannermod.society.livelihood_request.type." + updated.type().translationSuffix())); + ctx.getSource().sendSuccess(() -> result, false); + return 1; + } + + @Nullable + private static UUID parseUuid(CommandSourceStack source, String raw) { + return parseUuid(source, raw, "gui.bannermod.society.housing_request.command.invalid_id"); + } + + @Nullable + private static UUID parseUuid(CommandSourceStack source, String raw, String invalidKey) { + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ignored) { + source.sendFailure(Component.translatable(invalidKey)); + return null; + } + } + + @Nullable + private static RecruitsClaim currentClaim(ServerPlayer player) { + if (player == null || ClaimEvents.claimManager() == null) { + return null; + } + return ClaimEvents.claimManager().getClaim(new ChunkPos(player.blockPosition())); + } + + @Nullable + private static RecruitsClaim claimForRequest(NpcHousingRequestRecord request) { + if (request == null || request.claimUuid() == null || ClaimEvents.claimManager() == null) { + return null; + } + return claimById(request.claimUuid()); + } + + @Nullable + private static RecruitsClaim claimForRequest(NpcLivelihoodRequestRecord request) { + if (request == null || request.claimUuid() == null || ClaimEvents.claimManager() == null) { + return null; + } + return claimById(request.claimUuid()); + } + + @Nullable + private static RecruitsClaim claimById(UUID claimUuid) { + if (claimUuid == null || ClaimEvents.claimManager() == null) { + return null; + } + for (RecruitsClaim claim : ClaimEvents.claimManager().getAllClaims()) { + if (claim != null && claimUuid.equals(claim.getUUID())) { + return claim; + } + } + return null; + } + + @Nullable + private static PoliticalEntityRecord ownerRecord(ServerLevel level, RecruitsClaim claim) { + if (level == null || claim == null || claim.getOwnerPoliticalEntityId() == null) { + return null; + } + return WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null); + } + + private static int livelihoodSeverity(NpcLivelihoodRequestType type) { + if (type == null) { + return 99; + } + return switch (type) { + case LUMBER_CAMP -> 0; + case MINE -> 1; + case ANIMAL_PEN -> 2; + }; + } + + private static String shortId(@Nullable UUID uuid) { + if (uuid == null) { + return "?"; + } + String raw = uuid.toString(); + return raw.length() > 8 ? raw.substring(0, 8) : raw; + } + + private static MutableComponent actionButton(String labelKey, + String command, + ChatFormatting color, + String tooltipKey) { + return Component.translatable(labelKey) + .withStyle(style -> style.withColor(color) + .withUnderlined(true) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, command)) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(tooltipKey)))); + } +} diff --git a/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java b/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java index 8c8c42d5..9c3acb04 100644 --- a/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java @@ -2,6 +2,7 @@ import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.talhanation.bannermod.commands.society.BannerModSocietyCommands; import com.talhanation.bannermod.commands.admin.AdminDebugCommands; import com.talhanation.bannermod.commands.admin.AdminRecoveryCommands; import com.talhanation.bannermod.commands.admin.VisualScenarioCommands; @@ -18,6 +19,7 @@ public static void register(CommandDispatcher dispatcher) { private static LiteralArgumentBuilder root() { return Commands.literal("bannermod") + .then(BannerModSocietyCommands.build()) .then(AdminDebugCommands.debug()) .then(AdminRecoveryCommands.settlement()) .then(AdminRecoveryCommands.treasury()) diff --git a/src/main/java/com/talhanation/bannermod/entity/citizen/CitizenEntity.java b/src/main/java/com/talhanation/bannermod/entity/citizen/CitizenEntity.java index 7a0b7632..6725c5f7 100644 --- a/src/main/java/com/talhanation/bannermod/entity/citizen/CitizenEntity.java +++ b/src/main/java/com/talhanation/bannermod/entity/citizen/CitizenEntity.java @@ -18,6 +18,11 @@ import com.talhanation.bannermod.network.compat.BannerModNetworkHooks; import com.talhanation.bannermod.registry.civilian.ModEntityTypes; import com.talhanation.bannermod.settlement.prefab.staffing.PrefabAutoStaffingRuntime; +import com.talhanation.bannermod.society.NpcLifeStage; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; +import com.talhanation.bannermod.society.NpcSocietyAnchorGoal; +import com.talhanation.bannermod.society.NpcSocietyAccess; import com.talhanation.bannermod.util.BannerModCurrencyHelper; import com.talhanation.bannermod.util.BannerModNpcNamePool; import net.minecraft.core.BlockPos; @@ -75,6 +80,8 @@ public class CitizenEntity extends PathfinderMob implements CitizenCore { SynchedEntityData.defineId(CitizenEntity.class, EntityDataSerializers.BOOLEAN); private static final EntityDataAccessor DATA_BABY = SynchedEntityData.defineId(CitizenEntity.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_LIFE_STAGE = + SynchedEntityData.defineId(CitizenEntity.class, EntityDataSerializers.INT); private static final CitizenProfessionRegistry DEFAULT_REGISTRY = CitizenProfessionRegistry.defaults(); @@ -125,6 +132,7 @@ protected void defineSynchedData(net.minecraft.network.syncher.SynchedEntityData builder.define(DATA_PROFESSION, CitizenProfession.NONE.name()); builder.define(DATA_FEMALE, false); builder.define(DATA_BABY, false); + builder.define(DATA_LIFE_STAGE, NpcLifeStage.ADULT.ordinal()); } public boolean isFemale() { @@ -168,6 +176,7 @@ protected void registerGoals() { // goal is dormant. this.goalSelector.addGoal(3, new com.talhanation.bannermod.ai.home.PathfindHomeGoal( this, this::getHomePos)); + this.goalSelector.addGoal(7, new NpcSocietyAnchorGoal(this)); this.goalSelector.addGoal(8, new WaterAvoidingRandomStrollGoal(this, 0.6D)); this.goalSelector.addGoal(9, new LookAtPlayerGoal(this, Player.class, 8.0F)); this.goalSelector.addGoal(10, new RandomLookAroundGoal(this)); @@ -181,6 +190,7 @@ public void aiStep() { tickGrowUp(); if (this.tickCount % 20 == 0) { BannerModNpcNamePool.ensureNamed(this); + syncLifeStageFromSociety(); PrefabAutoStaffingRuntime.assignCitizenToNearestVacancy((net.minecraft.server.level.ServerLevel) this.level(), this); tryConvertIntoPendingWorker(); } @@ -198,6 +208,29 @@ private void tickGrowUp() { this.entityData.set(DATA_BABY, false); } + private void syncLifeStageFromSociety() { + if (!(this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel)) { + return; + } + NpcLifeStage stage = NpcSocietyAccess.ensureResident(serverLevel, this.getUUID(), serverLevel.getGameTime()).lifeStage(); + this.entityData.set(DATA_LIFE_STAGE, stage.ordinal()); + } + + public NpcLifeStage renderLifeStage() { + int ordinal = this.entityData.get(DATA_LIFE_STAGE); + NpcLifeStage[] values = NpcLifeStage.values(); + return ordinal >= 0 && ordinal < values.length ? values[ordinal] : NpcLifeStage.ADULT; + } + + public float renderScaleFactor() { + return switch (renderLifeStage()) { + case CHILD -> 0.72F; + case ADOLESCENT -> 0.84F; + case ELDER -> 0.92F; + default -> 1.0F; + }; + } + @Override public InteractionResult mobInteract(Player player, InteractionHand hand) { if (hand != InteractionHand.MAIN_HAND) { @@ -214,13 +247,13 @@ public InteractionResult mobInteract(Player player, InteractionHand hand) { return InteractionResult.SUCCESS; } - private boolean canOpenProfile(Player player) { + public boolean canOpenProfile(Player player) { return player.hasPermissions(2) || !this.isOwned() || this.getOwnerUUID() != null && this.getOwnerUUID().equals(player.getUUID()); } - private void openProfileGui(Player player) { + public void openProfileGui(Player player) { if (player instanceof net.minecraft.server.level.ServerPlayer serverPlayer) { BannerModNetworkHooks.openScreen(serverPlayer, new MenuProvider() { @Override @@ -230,9 +263,24 @@ public Component getDisplayName() { @Override public AbstractContainerMenu createMenu(int id, Inventory playerInventory, Player menuPlayer) { - return new CitizenProfileMenu(id, CitizenEntity.this, playerInventory); + NpcPhaseOneSnapshot phaseOneSnapshot = CitizenEntity.this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel + ? NpcSocietyAccess.phaseOneSnapshot(serverLevel, CitizenEntity.this.getUUID(), CitizenEntity.this.getBoundWorkAreaUUID()) + : NpcPhaseOneSnapshot.empty(); + NpcFamilyTreeSnapshot familyTreeSnapshot = CitizenEntity.this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel + ? NpcSocietyAccess.familyTreeSnapshot(serverLevel, CitizenEntity.this.getUUID()) + : NpcFamilyTreeSnapshot.empty(); + return new CitizenProfileMenu(id, CitizenEntity.this, playerInventory, phaseOneSnapshot, familyTreeSnapshot); } - }, buffer -> buffer.writeUUID(this.getUUID())); + }, buffer -> { + buffer.writeUUID(this.getUUID()); + if (this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel) { + NpcSocietyAccess.phaseOneSnapshot(serverLevel, this.getUUID(), this.getBoundWorkAreaUUID()).toBytes(buffer); + NpcSocietyAccess.familyTreeSnapshot(serverLevel, this.getUUID()).toBytes(buffer); + } else { + NpcPhaseOneSnapshot.empty().toBytes(buffer); + NpcFamilyTreeSnapshot.empty().toBytes(buffer); + } + }); } } @@ -240,6 +288,9 @@ private void tryConvertIntoPendingWorker() { if (this.activeProfession() != CitizenProfession.NONE) { return; } + if (renderLifeStage() != NpcLifeStage.ADULT && renderLifeStage() != NpcLifeStage.ELDER) { + return; + } if (!this.getPersistentData().contains(PrefabAutoStaffingRuntime.TAG_PENDING_WORKER_PROFESSION, Tag.TAG_STRING)) { return; } @@ -286,6 +337,9 @@ private void tryConvertIntoPendingWorker() { } worker.getCitizenCore().setBoundWorkAreaUUID(boundWorkAreaUuid); this.level().addFreshEntity(worker); + if (this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel) { + NpcSocietyAccess.moveResidentProfile(serverLevel, this.getUUID(), worker.getUUID(), serverLevel.getGameTime()); + } this.discard(); return; } @@ -304,6 +358,9 @@ private void tryConvertIntoPendingWorker() { } recruit.getCitizenCore().setBoundWorkAreaUUID(boundWorkAreaUuid); this.level().addFreshEntity(recruit); + if (this.level() instanceof net.minecraft.server.level.ServerLevel serverLevel) { + NpcSocietyAccess.moveResidentProfile(serverLevel, this.getUUID(), recruit.getUUID(), serverLevel.getGameTime()); + } this.discard(); } diff --git a/src/main/java/com/talhanation/bannermod/entity/civilian/AbstractWorkerEntity.java b/src/main/java/com/talhanation/bannermod/entity/civilian/AbstractWorkerEntity.java index ab8284e2..8a9b1beb 100644 --- a/src/main/java/com/talhanation/bannermod/entity/civilian/AbstractWorkerEntity.java +++ b/src/main/java/com/talhanation/bannermod/entity/civilian/AbstractWorkerEntity.java @@ -7,17 +7,22 @@ import com.talhanation.bannermod.entity.military.RecruitPoliticalContext; import com.talhanation.bannermod.shared.logistics.BannerModLogisticsRuntime; import com.talhanation.bannermod.shared.settlement.BannerModSettlementBinding; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; +import com.talhanation.bannermod.society.NpcSocietyAnchorGoal; +import com.talhanation.bannermod.society.NpcSocietyAccess; import com.talhanation.bannermod.config.RecruitsClientConfig; import com.talhanation.bannermod.events.ClaimEvents; import com.talhanation.bannermod.entity.military.AbstractChunkLoaderEntity; import com.talhanation.bannermod.ai.civilian.DepositItemsToStorage; import com.talhanation.bannermod.ai.civilian.GetNeededItemsFromStorage; import com.talhanation.bannermod.ai.civilian.SettlementOrderWorkGoal; +import com.talhanation.bannermod.ai.civilian.WorkerToolCraftingGoal; import com.talhanation.bannermod.entity.civilian.workarea.AbstractWorkAreaEntity; import com.talhanation.bannermod.network.compat.BannerModPacketDistributor; import com.talhanation.bannermod.network.messages.civilian.MessageToClientOpenWorkerScreen; import com.talhanation.bannermod.persistence.civilian.NeededItem; import com.talhanation.bannermod.util.BannerModNpcNamePool; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; import com.talhanation.bannermod.war.WarRuntimeContext; import net.minecraft.core.BlockPos; import net.minecraft.nbt.CompoundTag; @@ -34,6 +39,7 @@ import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; import net.minecraft.world.level.ServerLevelAccessor; import net.minecraft.world.level.block.Block; @@ -90,9 +96,11 @@ public boolean removeWhenFarAway(double sqDistanceToClosestPlayer) { @Override protected void registerGoals() { super.registerGoals(); + this.goalSelector.addGoal(0, new WorkerToolCraftingGoal(this)); this.goalSelector.addGoal(0, new SettlementOrderWorkGoal(this)); this.goalSelector.addGoal(0, new DepositItemsToStorage(this)); this.goalSelector.addGoal(0, new GetNeededItemsFromStorage(this)); + this.goalSelector.addGoal(1, new NpcSocietyAnchorGoal(this)); this.goalSelector.removeGoal(new MoveTowardsTargetGoal(this, 0.9D, 32.0F)); } @@ -167,6 +175,12 @@ public InteractionResult mobInteract(@NotNull Player player, @NotNull Interactio private WorkerInspectionSnapshot inspectionSnapshot(@Nullable Player viewer) { ServerPlayer serverPlayer = viewer instanceof ServerPlayer sp ? sp : null; String convertBlockedReasonKey = WorkerCitizenConversionService.convertDeniedReasonKey(serverPlayer, this); + NpcPhaseOneSnapshot phaseOneSnapshot = this.level() instanceof ServerLevel serverLevel + ? NpcSocietyAccess.phaseOneSnapshot(serverLevel, this.getUUID(), this.getBoundWorkAreaUUID()) + : NpcPhaseOneSnapshot.empty(); + NpcFamilyTreeSnapshot familyTreeSnapshot = this.level() instanceof ServerLevel serverLevel + ? NpcSocietyAccess.familyTreeSnapshot(serverLevel, this.getUUID()) + : NpcFamilyTreeSnapshot.empty(); return new WorkerInspectionSnapshot( this.getId(), this.getUUID(), @@ -175,9 +189,11 @@ private WorkerInspectionSnapshot inspectionSnapshot(@Nullable Player viewer) { workerOwnerLabel(), workerPoliticalLabel(), workerClaimRelationKey(), - workerAssignmentLabel(), + workerAssignmentLabel(phaseOneSnapshot), workerProblemLabel(), this.transportService.inspectionMessage().getString(), + phaseOneSnapshot, + familyTreeSnapshot, convertBlockedReasonKey == null, convertBlockedReasonKey, WorkerCitizenConversionService.workerProfessionTag(this) @@ -190,19 +206,23 @@ private String workerOwnerLabel() { return owner.getName().getString(); } UUID ownerUuid = this.getOwnerUUID(); - return ownerUuid == null ? "none" : ownerUuid.toString(); + return ownerUuid == null ? "none" : NpcPhaseOneSnapshot.shortId(ownerUuid); } private String workerPoliticalLabel() { return this.getTeam() == null ? "none" : this.getTeam().getName(); } - private String workerAssignmentLabel() { + private String workerAssignmentLabel(NpcPhaseOneSnapshot phaseOneSnapshot) { + UUID assignedWorkUuid = phaseOneSnapshot == null ? null : phaseOneSnapshot.workBuildingUuid(); + if (assignedWorkUuid == null) { + return "unassigned"; + } AbstractWorkAreaEntity workArea = this.getCurrentWorkArea(); if (workArea != null) { return workArea.getType().getDescription().getString(); } - return this.getBoundWorkAreaUUID() == null ? "unassigned" : "missing work area"; + return NpcPhaseOneSnapshot.shortId(assignedWorkUuid); } private String workerClaimRelationKey() { @@ -348,6 +368,55 @@ protected float getSoundVolume() { //////////////////////////////////// SET//////////////////////////////////// public void setEquipment() { + if (this instanceof LumberjackEntity) { + ensureStarterItem(new ItemStack(Items.STONE_AXE), true); + return; + } + if (this instanceof MinerEntity) { + ensureStarterItem(new ItemStack(Items.STONE_PICKAXE), true); + ensureStarterItem(new ItemStack(Items.STONE_SHOVEL), false); + ensureStarterItem(new ItemStack(Items.TORCH, 16), false); + ensureStarterItem(new ItemStack(Items.COBBLESTONE, 16), false); + return; + } + if (this instanceof FarmerEntity) { + ensureStarterItem(new ItemStack(Items.STONE_HOE), true); + return; + } + if (this instanceof AnimalFarmerEntity) { + ensureStarterItem(new ItemStack(Items.STONE_AXE), true); + return; + } + if (this instanceof BuilderEntity) { + ensureStarterItem(new ItemStack(Items.STONE_AXE), true); + ensureStarterItem(new ItemStack(Items.STONE_PICKAXE), false); + ensureStarterItem(new ItemStack(Items.STONE_SHOVEL), false); + return; + } + if (this instanceof FishermanEntity) { + ensureStarterItem(new ItemStack(Items.FISHING_ROD), true); + } + } + + private void ensureStarterItem(ItemStack stack, boolean preferMainHand) { + if (stack.isEmpty()) { + return; + } + if (this.getMainHandItem().is(stack.getItem()) || this.getOffhandItem().is(stack.getItem())) { + return; + } + ItemStack existing = this.getMatchingItem(candidate -> candidate.is(stack.getItem())); + if (existing != null && !existing.isEmpty()) { + if (preferMainHand && !this.getMainHandItem().is(stack.getItem())) { + this.setItemInHand(InteractionHand.MAIN_HAND, existing.copy()); + } + return; + } + ItemStack toStore = stack.copy(); + if (preferMainHand) { + this.setItemInHand(InteractionHand.MAIN_HAND, toStore.copy()); + } + this.getInventory().addItem(toStore); } public boolean needsToSleep() { @@ -381,6 +450,10 @@ public void mineBlock(BlockPos pos) { this.blockBreakService.mineBlock(pos); } + public void onWorkerItemAdded(ItemStack itemStack) { + this.supplyRuntime.onItemStackAdded(itemStack); + } + public void switchMainHandItem(Predicate predicate) { this.stateAccess.switchMainHandItem(predicate); diff --git a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerCitizenConversionService.java b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerCitizenConversionService.java index bd90e5e9..38197eb2 100644 --- a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerCitizenConversionService.java +++ b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerCitizenConversionService.java @@ -7,6 +7,7 @@ import com.talhanation.bannermod.registry.civilian.ModEntityTypes; import com.talhanation.bannermod.settlement.prefab.staffing.PrefabAutoStaffingRuntime; import com.talhanation.bannermod.shared.settlement.BannerModSettlementRefreshSupport; +import com.talhanation.bannermod.society.NpcSocietyAccess; import com.talhanation.bannermod.war.WarRuntimeContext; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; @@ -90,6 +91,7 @@ public static boolean convert(@Nullable ServerPlayer player, @Nullable AbstractW } serverLevel.addFreshEntity(citizen); + NpcSocietyAccess.moveResidentProfile(serverLevel, worker.getUUID(), citizen.getUUID(), serverLevel.getGameTime()); if (worker.getTeam() instanceof PlayerTeam team) { serverLevel.getScoreboard().addPlayerToTeam(citizen.getScoreboardName(), team); } @@ -225,6 +227,7 @@ public static String reassignProfession(@Nullable ServerPlayer player, if (!serverLevel.addFreshEntity(replacement)) { return "chat.bannermod.workerui.reassign.denied.convert_failed"; } + NpcSocietyAccess.moveResidentProfile(serverLevel, worker.getUUID(), replacement.getUUID(), serverLevel.getGameTime()); if (team != null) { serverLevel.getScoreboard().addPlayerToTeam(replacement.getScoreboardName(), team); } diff --git a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerInspectionSnapshot.java b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerInspectionSnapshot.java index 384981be..ba5a3b49 100644 --- a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerInspectionSnapshot.java +++ b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerInspectionSnapshot.java @@ -1,5 +1,7 @@ package com.talhanation.bannermod.entity.civilian; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; import net.minecraft.network.FriendlyByteBuf; import javax.annotation.Nullable; @@ -16,6 +18,8 @@ public record WorkerInspectionSnapshot( String assignmentLabel, String problemLabel, String transportLabel, + NpcPhaseOneSnapshot phaseOne, + NpcFamilyTreeSnapshot familyTree, boolean canConvert, @Nullable String convertBlockedReasonKey, String currentProfessionTag @@ -31,6 +35,8 @@ public void toBytes(FriendlyByteBuf buf) { buf.writeUtf(assignmentLabel); buf.writeUtf(problemLabel); buf.writeUtf(transportLabel); + (phaseOne == null ? NpcPhaseOneSnapshot.empty() : phaseOne).toBytes(buf); + (familyTree == null ? NpcFamilyTreeSnapshot.empty() : familyTree).toBytes(buf); buf.writeBoolean(canConvert); buf.writeBoolean(convertBlockedReasonKey != null); if (convertBlockedReasonKey != null) { @@ -51,6 +57,8 @@ public static WorkerInspectionSnapshot fromBytes(FriendlyByteBuf buf) { buf.readUtf(), buf.readUtf(), buf.readUtf(), + NpcPhaseOneSnapshot.fromBytes(buf), + NpcFamilyTreeSnapshot.fromBytes(buf), buf.readBoolean(), buf.readBoolean() ? buf.readUtf() : null, buf.readUtf() diff --git a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerStateAccess.java b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerStateAccess.java index a45b6157..d3eadd8b 100644 --- a/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerStateAccess.java +++ b/src/main/java/com/talhanation/bannermod/entity/civilian/WorkerStateAccess.java @@ -1,5 +1,8 @@ package com.talhanation.bannermod.entity.civilian; +import com.talhanation.bannermod.society.NpcSocietyAccess; +import com.talhanation.bannermod.society.NpcSocietyIntentRules; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.InteractionHand; import net.minecraft.world.SimpleContainer; import net.minecraft.world.item.ItemStack; @@ -21,6 +24,12 @@ boolean isWorking() { } boolean needsToSleep() { + if (this.worker.getCurrentWorkArea() == null) { + return !this.worker.getCommandSenderWorld().isDay(); + } + if (currentIntentIsRestLike()) { + return true; + } return !this.worker.getCommandSenderWorld().isDay(); } @@ -57,6 +66,29 @@ void switchMainHandItem(Predicate predicate) { } boolean shouldWork() { - return this.worker.isOwned() && (this.worker.getFollowState() == 0 || this.worker.getFollowState() == 6); + return this.worker.isOwned() + && (this.worker.getFollowState() == 0 || this.worker.getFollowState() == 6) + && currentIntentAllowsWork(); + } + + private boolean currentIntentAllowsWork() { + if (this.worker.getCurrentWorkArea() == null) { + return true; + } + if (!(this.worker.level() instanceof ServerLevel serverLevel)) { + return true; + } + return NpcSocietyAccess.profileFor(serverLevel, this.worker.getUUID()) + .map(profile -> NpcSocietyIntentRules.isWorkerLaborIntent(profile.currentIntent())) + .orElse(true); + } + + private boolean currentIntentIsRestLike() { + if (!(this.worker.level() instanceof ServerLevel serverLevel)) { + return false; + } + return NpcSocietyAccess.profileFor(serverLevel, this.worker.getUUID()) + .map(profile -> NpcSocietyIntentRules.isRestLikeIntent(profile.currentIntent())) + .orElse(false); } } diff --git a/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java b/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java index 018350d1..204ae071 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java @@ -681,6 +681,10 @@ public boolean hire(Player player, RecruitsGroup group, boolean message) { return RecruitLifecycleService.hire(this, player, group, message, INFO_RECRUITING_MAX(name), List.of(TEXT_RECRUITED1(name), TEXT_RECRUITED2(name), TEXT_RECRUITED3(name))); } + public void assignSpawnedToPlayer(Player player, @Nullable RecruitsGroup group) { + RecruitLifecycleService.assignSpawnedToPlayer(this, player, group); + } + public void dialogue(String name, Player player) { int i = this.random.nextInt(4); switch (i) { diff --git a/src/main/java/com/talhanation/bannermod/entity/military/RecruitLifecycleService.java b/src/main/java/com/talhanation/bannermod/entity/military/RecruitLifecycleService.java index bef9c6c3..5c94d071 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/RecruitLifecycleService.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/RecruitLifecycleService.java @@ -74,6 +74,27 @@ static boolean hire(AbstractRecruitEntity recruit, Player player, @Nullable Recr return true; } + static void assignSpawnedToPlayer(AbstractRecruitEntity recruit, Player player, @Nullable RecruitsGroup group) { + RecruitUpkeepService.resetPaymentTimer(recruit); + recruit.setOwnerUUID(Optional.of(player.getUUID())); + recruit.setIsOwned(true); + recruit.stopNavigation(); + recruit.setTarget(null); + recruit.setFollowState(2); + recruit.setAggroState(0); + if (group != null) { + recruit.setGroupUUID(group.getUUID()); + } + recruit.despawnTimer = -1; + if (!recruit.getCommandSenderWorld().isClientSide()) { + RecruitEvents.playerUnitManager().addRecruits(player.getUUID(), 1); + if (group != null) { + RecruitEvents.groupsManager().addMember(group.getUUID(), recruit.getUUID(), (ServerLevel) recruit.getCommandSenderWorld()); + RecruitEvents.groupsManager().broadCastGroupsToPlayer(player); + } + } + } + static void onDeath(AbstractRecruitEntity recruit, DamageSource dmg, Component deathMessage) { recruit.superDie(dmg); if (!recruit.isDeadOrDying() || recruit.getCommandSenderWorld().isClientSide()) return; diff --git a/src/main/java/com/talhanation/bannermod/entity/military/RecruitSpawnService.java b/src/main/java/com/talhanation/bannermod/entity/military/RecruitSpawnService.java index ff90f911..b3017496 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/RecruitSpawnService.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/RecruitSpawnService.java @@ -7,6 +7,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.world.DifficultyInstance; import net.minecraft.world.entity.SpawnGroupData; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; import net.minecraft.world.entity.ai.attributes.AttributeModifier; import net.minecraft.world.entity.ai.attributes.Attributes; import net.minecraft.world.level.ServerLevelAccessor; @@ -53,10 +54,18 @@ static void initPersistentNamedSpawn(AbstractRecruitEntity recruit, String defau } static void setRandomSpawnBonus(AbstractRecruitEntity recruit) { - recruit.getAttribute(Attributes.MAX_HEALTH).addPermanentModifier(new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "heath_bonus"), recruit.getRandom().nextDouble() * 0.5D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); - recruit.getAttribute(Attributes.ATTACK_DAMAGE).addPermanentModifier(new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "attack_bonus"), recruit.getRandom().nextDouble() * 0.5D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); - recruit.getAttribute(Attributes.KNOCKBACK_RESISTANCE).addPermanentModifier(new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "knockback_bonus"), recruit.getRandom().nextDouble() * 0.1D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); - recruit.getAttribute(Attributes.MOVEMENT_SPEED).addPermanentModifier(new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "speed_bonus"), recruit.getRandom().nextDouble() * 0.1D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); + applyPermanentModifier(recruit.getAttribute(Attributes.MAX_HEALTH), new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "heath_bonus"), recruit.getRandom().nextDouble() * 0.5D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); + applyPermanentModifier(recruit.getAttribute(Attributes.ATTACK_DAMAGE), new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "attack_bonus"), recruit.getRandom().nextDouble() * 0.5D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); + applyPermanentModifier(recruit.getAttribute(Attributes.KNOCKBACK_RESISTANCE), new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "knockback_bonus"), recruit.getRandom().nextDouble() * 0.1D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); + applyPermanentModifier(recruit.getAttribute(Attributes.MOVEMENT_SPEED), new AttributeModifier(ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "speed_bonus"), recruit.getRandom().nextDouble() * 0.1D, AttributeModifier.Operation.ADD_MULTIPLIED_BASE)); + } + + private static void applyPermanentModifier(AttributeInstance attribute, AttributeModifier modifier) { + if (attribute == null) { + return; + } + attribute.removeModifier(modifier.id()); + attribute.addPermanentModifier(modifier); } static void applySpawnValues(AbstractRecruitEntity recruit) { diff --git a/src/main/java/com/talhanation/bannermod/inventory/civilian/CitizenProfileMenu.java b/src/main/java/com/talhanation/bannermod/inventory/civilian/CitizenProfileMenu.java index a8b0c438..4a874445 100644 --- a/src/main/java/com/talhanation/bannermod/inventory/civilian/CitizenProfileMenu.java +++ b/src/main/java/com/talhanation/bannermod/inventory/civilian/CitizenProfileMenu.java @@ -2,6 +2,8 @@ import com.talhanation.bannermod.entity.citizen.CitizenEntity; import com.talhanation.bannermod.registry.civilian.ModMenuTypes; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; import de.maxhenkel.corelib.inventory.ContainerBase; import net.minecraft.world.Container; import net.minecraft.world.entity.player.Inventory; @@ -11,11 +13,27 @@ public class CitizenProfileMenu extends ContainerBase { private final CitizenEntity citizen; private final Container citizenInventory; + private final NpcPhaseOneSnapshot phaseOneSnapshot; + private final NpcFamilyTreeSnapshot familyTreeSnapshot; public CitizenProfileMenu(int id, CitizenEntity citizen, Inventory playerInventory) { + this(id, citizen, playerInventory, NpcPhaseOneSnapshot.empty(), NpcFamilyTreeSnapshot.empty()); + } + + public CitizenProfileMenu(int id, CitizenEntity citizen, Inventory playerInventory, NpcPhaseOneSnapshot phaseOneSnapshot) { + this(id, citizen, playerInventory, phaseOneSnapshot, NpcFamilyTreeSnapshot.empty()); + } + + public CitizenProfileMenu(int id, + CitizenEntity citizen, + Inventory playerInventory, + NpcPhaseOneSnapshot phaseOneSnapshot, + NpcFamilyTreeSnapshot familyTreeSnapshot) { super(ModMenuTypes.CITIZEN_PROFILE_CONTAINER_TYPE.get(), id, playerInventory, citizen.getInventory()); this.citizen = citizen; this.citizenInventory = citizen.getInventory(); + this.phaseOneSnapshot = phaseOneSnapshot == null ? NpcPhaseOneSnapshot.empty() : phaseOneSnapshot; + this.familyTreeSnapshot = familyTreeSnapshot == null ? NpcFamilyTreeSnapshot.empty() : familyTreeSnapshot; addCitizenInventorySlots(); addPlayerInventorySlots(playerInventory); } @@ -24,6 +42,14 @@ public CitizenEntity getCitizen() { return citizen; } + public NpcPhaseOneSnapshot getPhaseOneSnapshot() { + return this.phaseOneSnapshot; + } + + public NpcFamilyTreeSnapshot getFamilyTreeSnapshot() { + return this.familyTreeSnapshot; + } + @Override public boolean stillValid(Player player) { return citizen.isAlive() && player.distanceToSqr(citizen) < 64.0D; diff --git a/src/main/java/com/talhanation/bannermod/items/civilian/KinlotStaffItem.java b/src/main/java/com/talhanation/bannermod/items/civilian/KinlotStaffItem.java new file mode 100644 index 00000000..3ea0d771 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/items/civilian/KinlotStaffItem.java @@ -0,0 +1,197 @@ +package com.talhanation.bannermod.items.civilian; + +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.society.NpcHousingPlotPlanner; +import com.talhanation.bannermod.society.NpcHousingRequestRecord; +import com.talhanation.bannermod.util.ItemStackComponentData; +import net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.context.UseOnContext; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; + +import java.util.List; +import java.util.Locale; + +public class KinlotStaffItem extends Item { + private static final double ACTIONBAR_RADIUS = 10.0D; + private static final double DETAIL_RADIUS = 12.0D; + public static final int LOT_HALF_SPAN = 4; + private static final String TAG_RENDER_PLOT = "bannermod:kinlot_plot"; + private static final String TAG_RENDER_LABEL = "bannermod:kinlot_label"; + private static final String TAG_RENDER_HOUSEHOLD = "bannermod:kinlot_household"; + + public KinlotStaffItem(Properties properties) { + super(properties); + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Player player = context.getPlayer(); + Level level = context.getLevel(); + if (player == null) { + return InteractionResult.PASS; + } + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (!(player instanceof ServerPlayer serverPlayer)) { + return InteractionResult.SUCCESS; + } + RecruitsClaim claim = claimAt(context.getClickedPos()); + if (claim == null) { + serverPlayer.sendSystemMessage(Component.translatable("item.bannermod.kinlot_staff.no_claim").withStyle(ChatFormatting.RED)); + return InteractionResult.SUCCESS; + } + NpcHousingPlotPlanner.HousingPlotInfo info = NpcHousingPlotPlanner.nearestPlotInfo( + serverPlayer.serverLevel(), + claim.getUUID(), + context.getClickedPos(), + DETAIL_RADIUS + ); + if (info == null) { + clearRenderData(context.getItemInHand()); + serverPlayer.sendSystemMessage(Component.translatable("item.bannermod.kinlot_staff.no_plot").withStyle(ChatFormatting.YELLOW)); + return InteractionResult.SUCCESS; + } + writeRenderData(context.getItemInHand(), info, serverPlayer); + sendDetails(serverPlayer, info); + return InteractionResult.SUCCESS; + } + + @Override + public void inventoryTick(ItemStack stack, Level level, Entity entity, int slotId, boolean isSelected) { + if (level.isClientSide() || !isSelected || !(entity instanceof ServerPlayer player) || player.tickCount % 20 != 0) { + return; + } + RecruitsClaim claim = claimAt(player.blockPosition()); + if (claim == null) { + clearRenderData(stack); + return; + } + NpcHousingPlotPlanner.HousingPlotInfo info = NpcHousingPlotPlanner.nearestPlotInfo( + player.serverLevel(), + claim.getUUID(), + player.blockPosition(), + ACTIONBAR_RADIUS + ); + if (info == null) { + clearRenderData(stack); + return; + } + writeRenderData(stack, info, player); + player.displayClientMessage(Component.translatable( + "item.bannermod.kinlot_staff.actionbar", + shortId(info.request().householdId()), + info.household() == null + ? Component.literal("-") + : Component.translatable("gui.bannermod.society.household_housing." + + info.household().housingState().name().toLowerCase(Locale.ROOT)), + residentDisplayName(player, info.request()), + info.plotPos().getX(), + info.plotPos().getZ() + ).withStyle(ChatFormatting.GOLD), true); + } + + @Override + public void appendHoverText(ItemStack stack, Item.TooltipContext context, List tooltip, TooltipFlag flag) { + tooltip.add(Component.translatable("item.bannermod.kinlot_staff.tooltip.1").withStyle(ChatFormatting.GRAY)); + tooltip.add(Component.translatable("item.bannermod.kinlot_staff.tooltip.2").withStyle(ChatFormatting.GRAY)); + } + + public static BlockPos renderPlotPos(ItemStack stack) { + CompoundTag tag = ItemStackComponentData.read(stack); + return tag != null && tag.contains(TAG_RENDER_PLOT) ? BlockPos.of(tag.getLong(TAG_RENDER_PLOT)) : null; + } + + public static String renderLabel(ItemStack stack) { + CompoundTag tag = ItemStackComponentData.read(stack); + return tag != null && tag.contains(TAG_RENDER_LABEL) ? tag.getString(TAG_RENDER_LABEL) : null; + } + + public static String renderHouseholdId(ItemStack stack) { + CompoundTag tag = ItemStackComponentData.read(stack); + return tag != null && tag.contains(TAG_RENDER_HOUSEHOLD) ? tag.getString(TAG_RENDER_HOUSEHOLD) : null; + } + + private static void sendDetails(ServerPlayer player, NpcHousingPlotPlanner.HousingPlotInfo info) { + NpcHousingRequestRecord request = info.request(); + Component housingState = info.household() == null + ? Component.literal("-") + : Component.translatable("gui.bannermod.society.household_housing." + + info.household().housingState().name().toLowerCase(Locale.ROOT)); + int members = info.household() == null ? 0 : info.household().memberResidentUuids().size(); + Component status = Component.translatable("gui.bannermod.society.housing_request." + + request.status().name().toLowerCase(Locale.ROOT)); + player.sendSystemMessage(Component.translatable( + "item.bannermod.kinlot_staff.detail.header", + shortId(request.householdId()), + info.plotPos().getX(), + info.plotPos().getY(), + info.plotPos().getZ() + ).withStyle(ChatFormatting.AQUA)); + player.sendSystemMessage(Component.translatable( + "item.bannermod.kinlot_staff.detail.line", + shortId(request.residentUuid()), + members, + housingState, + status, + request.buildAreaUuid() == null ? "-" : shortId(request.buildAreaUuid()) + ).withStyle(ChatFormatting.GRAY)); + } + + private static RecruitsClaim claimAt(BlockPos pos) { + if (pos == null || ClaimEvents.claimManager() == null) { + return null; + } + return ClaimEvents.claimManager().getClaim(new ChunkPos(pos)); + } + + private static void writeRenderData(ItemStack stack, NpcHousingPlotPlanner.HousingPlotInfo info, ServerPlayer player) { + if (stack == null || info == null) { + return; + } + String label = residentDisplayName(player, info.request()); + ItemStackComponentData.update(stack, tag -> { + tag.putLong(TAG_RENDER_PLOT, info.plotPos().asLong()); + tag.putString(TAG_RENDER_LABEL, label); + tag.putString(TAG_RENDER_HOUSEHOLD, shortId(info.request().householdId())); + }); + } + + private static void clearRenderData(ItemStack stack) { + if (stack == null) { + return; + } + ItemStackComponentData.update(stack, tag -> { + tag.remove(TAG_RENDER_PLOT); + tag.remove(TAG_RENDER_LABEL); + tag.remove(TAG_RENDER_HOUSEHOLD); + }); + } + + private static String residentDisplayName(ServerPlayer player, NpcHousingRequestRecord request) { + if (player == null || request == null) { + return "Household"; + } + Entity resident = player.serverLevel().getEntity(request.residentUuid()); + if (resident != null) { + return resident.getName().getString(); + } + return "House of " + shortId(request.residentUuid()); + } + + private static String shortId(java.util.UUID id) { + return id == null ? "-" : id.toString().substring(0, 8); + } +} diff --git a/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java b/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java index 37d04dcd..b6af3ef8 100644 --- a/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java +++ b/src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.items.civilian; +import com.talhanation.bannermod.bootstrap.BannerModMain; import com.talhanation.bannermod.client.civilian.gui.SettlementSurveyorScreen; +import com.talhanation.bannermod.network.messages.civilian.MessageUseSurveyorBlock; import com.talhanation.bannermod.settlement.building.ZoneRole; import com.talhanation.bannermod.settlement.building.ZoneSelection; import com.talhanation.bannermod.settlement.validation.SurveyorDraftSuggestionService; @@ -65,21 +67,32 @@ public InteractionResult useOn(UseOnContext context) { } if (level.isClientSide) { + // Keep surveyor mode changes and block clicks on the same packet stream so a + // fresh farm click cannot race an older fort session on the server. + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUseSurveyorBlock(context.getHand(), clicked)); return InteractionResult.SUCCESS; } + return InteractionResult.SUCCESS; + } + + public static void handleBlockClick(ServerPlayer player, ItemStack stack, BlockPos clicked) { + if (player == null || stack == null || clicked == null) { + return; + } + ValidationSession session = getOrCreateSession(player, stack); if (session.anchorPos().equals(BlockPos.ZERO)) { SurveyorSessionCodec.write(stack, session.withAnchor(clicked)); player.sendSystemMessage(Component.translatable("bannermod.surveyor.anchor_set", clicked.toShortString()).withStyle(ChatFormatting.AQUA)); - return InteractionResult.SUCCESS; + return; } CompoundTag tag = ItemStackComponentData.read(stack); if (!tag.contains(TAG_PENDING_CORNER)) { ItemStackComponentData.update(stack, data -> data.putLong(TAG_PENDING_CORNER, clicked.asLong())); player.sendSystemMessage(Component.translatable("bannermod.surveyor.corner_a", clicked.toShortString()).withStyle(ChatFormatting.AQUA)); - return InteractionResult.SUCCESS; + return; } BlockPos cornerA = BlockPos.of(tag.getLong(TAG_PENDING_CORNER)); @@ -90,7 +103,6 @@ public InteractionResult useOn(UseOnContext context) { SurveyorSessionCodec.write(stack, updated); player.sendSystemMessage(Component.translatable("bannermod.surveyor.zone_captured", roleLabel(role)).withStyle(ChatFormatting.GREEN)); maybeAdvanceRoleAfterCapture(player, stack, updated, role); - return InteractionResult.SUCCESS; } @Override @@ -153,7 +165,11 @@ public static void setMode(Player player, ItemStack stack, SurveyorMode mode) { return; } ValidationSession session = getOrCreateSession(player, stack); - SurveyorSessionCodec.write(stack, session.withMode(mode)); + ItemStackComponentData.update(stack, data -> data.remove(TAG_PENDING_CORNER)); + ValidationSession updated = session.mode() == mode + ? session.withMode(mode) + : new ValidationSession(player.getUUID(), mode, BlockPos.ZERO, List.of(), session.showGuidePreview()); + SurveyorSessionCodec.write(stack, updated); setSelectedRole(stack, defaultRoleForMode(mode)); } @@ -265,12 +281,6 @@ public static boolean hasAnyMarks(ItemStack stack) { || session != null && (!session.anchorPos().equals(BlockPos.ZERO) || !session.selections().isEmpty()); } - private static SurveyorMode nextMode(SurveyorMode mode) { - SurveyorMode[] modes = SurveyorMode.values(); - int idx = mode.ordinal(); - return modes[(idx + 1) % modes.length]; - } - public static void setSelectedRole(ItemStack stack, ZoneRole role) { if (stack == null || role == null) { return; diff --git a/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java b/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java index a203b36d..05298d96 100644 --- a/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java +++ b/src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java @@ -32,15 +32,20 @@ public final class CivilianPacketCatalog { MessageRequestRegisterBuilding.class, MessageSetSurveyorMode.class, MessageSetSurveyorRole.class, + MessageUseSurveyorBlock.class, MessageModifySurveyorSession.class, MessageValidateSurveyorSession.class, MessageToClientOpenWorkerScreen.class, MessageOpenWorkerScreen.class, + MessageOpenNpcProfile.class, MessageConvertWorkerToCitizen.class, MessageReassignWorkerProfession.class, MessageAssignCitizenVacancy.class, MessageAssignHome.class, - MessageDismissWorker.class, + MessageToClientUpdateHousingState.class, + MessageRequestHousingSnapshot.class, + MessageApproveHousingRequest.class, + MessageDenyHousingRequest.class, }; public static final PacketCatalog CATALOG = new PacketCatalog(MESSAGES); diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageApproveHousingRequest.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageApproveHousingRequest.java new file mode 100644 index 00000000..d1e8a461 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageApproveHousingRequest.java @@ -0,0 +1,93 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.society.NpcHousingRequestAccess; +import com.talhanation.bannermod.society.NpcHousingRequestRecord; +import com.talhanation.bannermod.society.NpcHousingRequestStatus; +import com.talhanation.bannermod.war.registry.PoliticalEntityAuthority; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public class MessageApproveHousingRequest implements BannerModMessage { + private UUID householdId; + + public MessageApproveHousingRequest() { + } + + public MessageApproveHousingRequest(UUID householdId) { + this.householdId = householdId; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer player = context.getSender(); + ServerLevel level = MessageRequestHousingSnapshot.serverLevel(player); + if (player == null || level == null || this.householdId == null) { + return; + } + NpcHousingRequestRecord request = NpcHousingRequestAccess.requestForHousehold(level, this.householdId); + if (request == null) { + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable("gui.bannermod.society.housing_request.command.not_found")); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + RecruitsClaim claim = MessageRequestHousingSnapshot.claimById(request.claimUuid()); + PoliticalEntityRecord owner = MessageRequestHousingSnapshot.ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + MessageRequestHousingSnapshot.sendSystemMessage(player, PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + if (request.status() == NpcHousingRequestStatus.FULFILLED) { + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable("gui.bannermod.society.housing_request.command.fulfilled_locked")); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + NpcHousingRequestRecord updated = NpcHousingRequestAccess.approveHousehold(level, this.householdId, level.getGameTime()); + Component plot = updated.reservedPlotPos() == null + ? Component.literal("-") + : Component.literal(updated.reservedPlotPos().getX() + " " + + updated.reservedPlotPos().getY() + " " + + updated.reservedPlotPos().getZ()); + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable( + "gui.bannermod.society.housing_request.command.approved", + shortId(updated.residentUuid()), + plot + )); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + }); + } + + @Override + public MessageApproveHousingRequest fromBytes(FriendlyByteBuf buf) { + this.householdId = buf.readUUID(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeUUID(this.householdId); + } + + private static String shortId(UUID uuid) { + if (uuid == null) { + return "?"; + } + String raw = uuid.toString(); + return raw.length() > 8 ? raw.substring(0, 8) : raw; + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageDenyHousingRequest.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageDenyHousingRequest.java new file mode 100644 index 00000000..1008b52b --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageDenyHousingRequest.java @@ -0,0 +1,98 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.society.NpcHousingRequestAccess; +import com.talhanation.bannermod.society.NpcHousingRequestRecord; +import com.talhanation.bannermod.society.NpcHousingRequestStatus; +import com.talhanation.bannermod.war.registry.PoliticalEntityAuthority; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public class MessageDenyHousingRequest implements BannerModMessage { + private UUID householdId; + + public MessageDenyHousingRequest() { + } + + public MessageDenyHousingRequest(UUID householdId) { + this.householdId = householdId; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer player = context.getSender(); + ServerLevel level = MessageRequestHousingSnapshot.serverLevel(player); + if (player == null || level == null || this.householdId == null) { + return; + } + NpcHousingRequestRecord request = NpcHousingRequestAccess.requestForHousehold(level, this.householdId); + if (request == null) { + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable("gui.bannermod.society.housing_request.command.not_found")); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + RecruitsClaim claim = MessageRequestHousingSnapshot.claimById(request.claimUuid()); + PoliticalEntityRecord owner = MessageRequestHousingSnapshot.ownerRecord(level, claim); + if (!PoliticalEntityAuthority.canAct(player, owner)) { + MessageRequestHousingSnapshot.sendSystemMessage(player, PoliticalEntityAuthority.denialReason(player.getUUID(), player.hasPermissions(2), owner)); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + if (request.status() == NpcHousingRequestStatus.APPROVED) { + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable("gui.bannermod.society.housing_request.command.approved_locked")); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + if (request.status() == NpcHousingRequestStatus.FULFILLED) { + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable("gui.bannermod.society.housing_request.command.fulfilled_locked")); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + return; + } + NpcHousingRequestRecord updated = NpcHousingRequestAccess.denyHousehold(level, this.householdId, level.getGameTime()); + Component plot = updated.reservedPlotPos() == null + ? Component.literal("-") + : Component.literal(updated.reservedPlotPos().getX() + " " + + updated.reservedPlotPos().getY() + " " + + updated.reservedPlotPos().getZ()); + MessageRequestHousingSnapshot.sendSystemMessage(player, Component.translatable( + "gui.bannermod.society.housing_request.command.denied", + shortId(updated.residentUuid()), + plot + )); + MessageRequestHousingSnapshot.sendSnapshot(player, MessageRequestHousingSnapshot.buildSnapshot(player)); + }); + } + + @Override + public MessageDenyHousingRequest fromBytes(FriendlyByteBuf buf) { + this.householdId = buf.readUUID(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeUUID(this.householdId); + } + + private static String shortId(UUID uuid) { + if (uuid == null) { + return "?"; + } + String raw = uuid.toString(); + return raw.length() > 8 ? raw.substring(0, 8) : raw; + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageOpenNpcProfile.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageOpenNpcProfile.java new file mode 100644 index 00000000..0c6d16b9 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageOpenNpcProfile.java @@ -0,0 +1,58 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.entity.citizen.CitizenEntity; +import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public class MessageOpenNpcProfile implements BannerModMessage { + private UUID targetUuid; + + public MessageOpenNpcProfile() { + } + + public MessageOpenNpcProfile(UUID targetUuid) { + this.targetUuid = targetUuid; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + ServerPlayer player = context.getSender(); + if (player == null || this.targetUuid == null) { + return; + } + if (player.serverLevel().getEntity(this.targetUuid) instanceof CitizenEntity citizen + && citizen.isAlive() + && citizen.canOpenProfile(player) + && player.distanceToSqr(citizen) <= 16.0D * 16.0D) { + citizen.openProfileGui(player); + return; + } + if (player.serverLevel().getEntity(this.targetUuid) instanceof AbstractWorkerEntity worker + && worker.isAlive() + && player.distanceToSqr(worker) <= 16.0D * 16.0D) { + worker.openDepositsGUI(player); + } + } + + @Override + public MessageOpenNpcProfile fromBytes(FriendlyByteBuf buf) { + this.targetUuid = buf.readUUID(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeUUID(this.targetUuid); + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageRequestHousingSnapshot.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageRequestHousingSnapshot.java new file mode 100644 index 00000000..6698fe83 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageRequestHousingSnapshot.java @@ -0,0 +1,115 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.compat.BannerModPacketDistributor; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.society.NpcHousingPriorityService; +import com.talhanation.bannermod.society.NpcHousingSnapshotContract; +import com.talhanation.bannermod.war.WarRuntimeContext; +import com.talhanation.bannermod.war.registry.PoliticalEntityAuthority; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.UUID; + +public class MessageRequestHousingSnapshot implements BannerModMessage { + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + ServerPlayer player = context.getSender(); + if (player == null) { + return; + } + sendSnapshot(player, buildSnapshot(player)); + } + + static CompoundTag buildSnapshot(ServerPlayer player) { + ServerLevel level = serverLevel(player); + if (level == null) { + return NpcHousingSnapshotContract.encode(null, false, + "gui.bannermod.society.housing_request.command.no_claim", List.of()); + } + RecruitsClaim claim = currentClaim(player); + if (claim == null) { + return NpcHousingSnapshotContract.encode(null, false, + "gui.bannermod.society.housing_request.command.no_claim", List.of()); + } + PoliticalEntityRecord owner = ownerRecord(level, claim); + boolean canManage = PoliticalEntityAuthority.canAct(player, owner); + String denialKey = canManage ? "" : PoliticalEntityAuthority.denialReasonKey(player.getUUID(), player.hasPermissions(2), owner); + return NpcHousingSnapshotContract.encode( + claim.getUUID(), + canManage, + denialKey, + NpcHousingPriorityService.activeEntriesForClaim(level, claim.getUUID(), level.getGameTime()) + ); + } + + static void sendSnapshot(ServerPlayer player, CompoundTag payload) { + BannerModMain.SIMPLE_CHANNEL.send( + BannerModPacketDistributor.PLAYER.with(() -> player), + new MessageToClientUpdateHousingState(payload) + ); + } + + static @Nullable RecruitsClaim currentClaim(ServerPlayer player) { + if (player == null || ClaimEvents.claimManager() == null || player.level().dimension() != Level.OVERWORLD) { + return null; + } + return ClaimEvents.claimManager().getClaim(new ChunkPos(player.blockPosition())); + } + + static @Nullable ServerLevel serverLevel(ServerPlayer player) { + return player == null || player.server == null ? null : player.server.overworld(); + } + + static @Nullable PoliticalEntityRecord ownerRecord(ServerLevel level, RecruitsClaim claim) { + if (level == null || claim == null || claim.getOwnerPoliticalEntityId() == null) { + return null; + } + return WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null); + } + + static @Nullable RecruitsClaim claimById(@Nullable UUID claimUuid) { + if (claimUuid == null || ClaimEvents.claimManager() == null) { + return null; + } + for (RecruitsClaim claim : ClaimEvents.claimManager().getAllClaims()) { + if (claim != null && claimUuid.equals(claim.getUUID())) { + return claim; + } + } + return null; + } + + static void sendSystemMessage(ServerPlayer player, Component message) { + if (player != null && message != null) { + player.sendSystemMessage(message); + } + } + + @Override + public MessageRequestHousingSnapshot fromBytes(FriendlyByteBuf buf) { + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageToClientUpdateHousingState.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageToClientUpdateHousingState.java new file mode 100644 index 00000000..84e74e48 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageToClientUpdateHousingState.java @@ -0,0 +1,43 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import com.talhanation.bannermod.society.client.NpcHousingClientState; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +public class MessageToClientUpdateHousingState implements BannerModMessage { + private CompoundTag payload; + + public MessageToClientUpdateHousingState() { + } + + public MessageToClientUpdateHousingState(CompoundTag payload) { + this.payload = payload; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.clientbound(); + } + + @Override + @OnlyIn(Dist.CLIENT) + public void executeClientSide(BannerModNetworkContext context) { + NpcHousingClientState.applyFromNbt(this.payload); + } + + @Override + public MessageToClientUpdateHousingState fromBytes(FriendlyByteBuf buf) { + this.payload = buf.readNbt(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeNbt(this.payload); + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java new file mode 100644 index 00000000..b3a80e6a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java @@ -0,0 +1,64 @@ +package com.talhanation.bannermod.network.messages.civilian; + +import com.talhanation.bannermod.items.civilian.SettlementSurveyorToolItem; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.core.BlockPos; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; + +public class MessageUseSurveyorBlock implements BannerModMessage { + private static final double MAX_CLICK_DISTANCE_SQR = 49.0D; + + public int handIndex; + public BlockPos clickedPos; + + public MessageUseSurveyorBlock() { + this.clickedPos = BlockPos.ZERO; + } + + public MessageUseSurveyorBlock(InteractionHand hand, BlockPos clickedPos) { + this.handIndex = hand == InteractionHand.OFF_HAND ? 1 : 0; + this.clickedPos = clickedPos == null ? BlockPos.ZERO : clickedPos.immutable(); + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer player = context.getSender(); + if (player == null) { + return; + } + if (player.getEyePosition().distanceToSqr(Vec3.atCenterOf(this.clickedPos)) > MAX_CLICK_DISTANCE_SQR) { + return; + } + ItemStack stack = player.getItemInHand(handIndex == 1 ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND); + if (!(stack.getItem() instanceof SettlementSurveyorToolItem)) { + return; + } + SettlementSurveyorToolItem.handleBlockClick(player, stack, this.clickedPos); + }); + } + + @Override + public MessageUseSurveyorBlock fromBytes(FriendlyByteBuf buf) { + this.handIndex = buf.readVarInt(); + this.clickedPos = buf.readBlockPos(); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeVarInt(this.handIndex); + buf.writeBlockPos(this.clickedPos == null ? BlockPos.ZERO : this.clickedPos); + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageAdminRecruitSpawn.java b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageAdminRecruitSpawn.java index da17f75f..52cab437 100644 --- a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageAdminRecruitSpawn.java +++ b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageAdminRecruitSpawn.java @@ -1,8 +1,13 @@ package com.talhanation.bannermod.network.messages.military; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.HorsemanEntity; +import com.talhanation.bannermod.entity.military.IRangedRecruit; +import com.talhanation.bannermod.entity.military.ScoutEntity; +import com.talhanation.bannermod.entity.military.runtime.RecruitEvents; import com.talhanation.bannermod.network.compat.BannerModNetworkContext; import com.talhanation.bannermod.network.payload.BannerModMessage; +import com.talhanation.bannermod.persistence.military.RecruitsGroup; import net.minecraft.ChatFormatting; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; @@ -54,6 +59,7 @@ public void executeServerSide(BannerModNetworkContext context) { } int spawnCount = Math.max(1, Math.min(16, this.count)); int spawned = 0; + RecruitsGroup defaultGroup = defaultGroupFor(player); for (int i = 0; i < spawnCount; i++) { if (!(resolvedType.create(level) instanceof AbstractRecruitEntity recruit)) { continue; @@ -63,6 +69,7 @@ public void executeServerSide(BannerModNetworkContext context) { recruit.finalizeSpawn(level, level.getCurrentDifficultyAt(spawnPos), MobSpawnType.COMMAND, null, null); recruit.setPersistenceRequired(); level.addFreshEntity(recruit); + recruit.assignSpawnedToPlayer(player, groupForRecruit(player, recruit, defaultGroup)); spawned++; } player.sendSystemMessage(Component.translatable("gui.bannermod.admin_recruit_spawn.feedback.spawned", spawned, rawType.getDescription()) @@ -97,4 +104,21 @@ private static BlockPos spawnPos(ServerLevel level, BlockPos playerPos, Directio } return base.above(); } + + private static RecruitsGroup defaultGroupFor(ServerPlayer player) { + return RecruitEvents.groupsManager().getPlayersGroupByName(player, "Infantry"); + } + + private static RecruitsGroup groupForRecruit(ServerPlayer player, AbstractRecruitEntity recruit, RecruitsGroup fallbackGroup) { + String targetGroupName = "Infantry"; + if (recruit instanceof ScoutEntity) { + targetGroupName = "Ranged Cavalry"; + } else if (recruit instanceof HorsemanEntity) { + targetGroupName = "Cavalry"; + } else if (recruit instanceof IRangedRecruit) { + targetGroupName = "Ranged"; + } + RecruitsGroup group = RecruitEvents.groupsManager().getPlayersGroupByName(player, targetGroupName); + return group != null ? group : fallbackGroup; + } } diff --git a/src/main/java/com/talhanation/bannermod/registry/civilian/ModItems.java b/src/main/java/com/talhanation/bannermod/registry/civilian/ModItems.java index 663d2b10..a2354a0b 100644 --- a/src/main/java/com/talhanation/bannermod/registry/civilian/ModItems.java +++ b/src/main/java/com/talhanation/bannermod/registry/civilian/ModItems.java @@ -5,6 +5,7 @@ import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import com.talhanation.bannermod.items.civilian.BannerModAlmanacItem; import com.talhanation.bannermod.items.civilian.BuildingPlacementWandItem; +import com.talhanation.bannermod.items.civilian.KinlotStaffItem; import com.talhanation.bannermod.items.civilian.SettlementSurveyorToolItem; import com.talhanation.bannermod.items.civilian.WorkersSpawnEgg; import net.minecraft.core.registries.Registries; @@ -35,6 +36,7 @@ public class ModItems { public static final DeferredHolder BANNERMOD_ALMANAC = ITEMS.register("banner_almanac", () -> new BannerModAlmanacItem(new Item.Properties().stacksTo(1))); public static final DeferredHolder BUILDING_PLACEMENT_WAND = ITEMS.register("building_placement_wand", () -> new BuildingPlacementWandItem(new Item.Properties().stacksTo(1))); public static final DeferredHolder SETTLEMENT_SURVEYOR_TOOL = ITEMS.register("settlement_surveyor_tool", () -> new SettlementSurveyorToolItem(new Item.Properties().stacksTo(1))); + public static final DeferredHolder KINLOT_STAFF = ITEMS.register("kinlot_staff", () -> new KinlotStaffItem(new Item.Properties().stacksTo(1))); public static DeferredHolder createSpawnEggItem(String entityName, Supplier> supplier, int primaryColor, int secondaryColor) { DeferredHolder spawnEgg = ModItems.ITEMS.register(entityName + "_spawn_egg", () -> { diff --git a/src/main/java/com/talhanation/bannermod/registry/civilian/ModMenuTypes.java b/src/main/java/com/talhanation/bannermod/registry/civilian/ModMenuTypes.java index 8b7b958c..29834225 100644 --- a/src/main/java/com/talhanation/bannermod/registry/civilian/ModMenuTypes.java +++ b/src/main/java/com/talhanation/bannermod/registry/civilian/ModMenuTypes.java @@ -14,6 +14,8 @@ import com.talhanation.bannermod.inventory.civilian.MerchantAddEditTradeContainer; import com.talhanation.bannermod.inventory.civilian.MerchantTradeContainer; import com.talhanation.bannermod.persistence.civilian.WorkersMerchantTrade; +import com.talhanation.bannermod.society.NpcFamilyTreeSnapshot; +import com.talhanation.bannermod.society.NpcPhaseOneSnapshot; import net.minecraft.nbt.CompoundTag; import net.minecraft.core.registries.Registries; import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent; @@ -69,7 +71,13 @@ public static void registerMenuScreens(RegisterMenuScreensEvent event) { if (citizen == null) { return null; } - return new CitizenProfileMenu(windowId, citizen, inv); + return new CitizenProfileMenu( + windowId, + citizen, + inv, + NpcPhaseOneSnapshot.fromBytes(data), + NpcFamilyTreeSnapshot.fromBytes(data) + ); })); @Nullable diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java index 1423d8ba..278009ed 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java @@ -1,12 +1,21 @@ package com.talhanation.bannermod.settlement; import com.talhanation.bannermod.governance.BannerModGovernorSnapshot; +import com.talhanation.bannermod.society.NpcHousingProjectPlanner; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcLivelihoodProjectPlanner; +import com.talhanation.bannermod.society.NpcSocietyAnchorGoal; +import com.talhanation.bannermod.society.NpcSocietyNeedRuntime; import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime; import com.talhanation.bannermod.settlement.dispatch.SellerPhase; import com.talhanation.bannermod.settlement.dispatch.SellerPhaseRecord; +import com.talhanation.bannermod.settlement.goal.ResidentStopReason; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; import com.talhanation.bannermod.settlement.goal.ResidentTask; import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.society.NpcSocietyAccess; +import com.talhanation.bannermod.society.NpcSocietyPhaseOneRuntime; +import com.talhanation.bannermod.society.NpcSocietyProfile; import com.talhanation.bannermod.settlement.growth.SettlementGrowthContext; import com.talhanation.bannermod.settlement.growth.SettlementGrowthManager; import com.talhanation.bannermod.settlement.growth.PendingProject; @@ -16,11 +25,17 @@ import com.talhanation.bannermod.settlement.job.JobExecutionContext; import com.talhanation.bannermod.settlement.project.SettlementProjectRuntime; import com.talhanation.bannermod.settlement.workorder.SettlementWorkOrderPublishContext; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; import net.minecraft.server.level.ServerLevel; import javax.annotation.Nullable; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -46,21 +61,31 @@ static void tickSnapshot(SettlementOrchestrator.LevelRuntimeState state, governorSnapshot, gameTime ); + assignHomes(state.homeRuntime, snapshot, level, gameTime); + Map buildingsByUuid = indexBuildings(snapshot); List growthQueue = SettlementGrowthManager.evaluateGrowthQueue( growthContext, MAX_GROWTH_QUEUE_SIZE ); + List citizenHousingProjects = level == null + ? List.of() + : NpcHousingProjectPlanner.collectApprovedHouseProjects(level, snapshot, state.homeRuntime, gameTime); + List approvedLivelihoodProjects = level == null + ? List.of() + : NpcLivelihoodProjectPlanner.collectApprovedProjects(level, snapshot, gameTime); + List combinedGrowthQueue = new java.util.ArrayList<>(growthQueue); + combinedGrowthQueue.addAll(citizenHousingProjects); + combinedGrowthQueue.addAll(approvedLivelihoodProjects); // Keep settlement founding/player progression manual: passive claim ticks may bind // existing BuildAreas, but must not auto-spawn prefab-backed ones on their own. state.projectRuntime.tickClaim( null, snapshot.claimUuid(), - growthQueue, + combinedGrowthQueue, SettlementProjectRuntime.buildAreaResolver(level), gameTime ); - assignHomes(state.homeRuntime, snapshot, gameTime); state.marketStateSupplier.set(snapshot.marketState()); tickSellerDispatches(state.sellerRuntime, snapshot.marketState(), gameTime); publishBuildingWorkOrders(state, snapshot, level, gameTime); @@ -70,9 +95,36 @@ static void tickSnapshot(SettlementOrchestrator.LevelRuntimeState state, if (resident == null || resident.residentUuid() == null) { continue; } - ResidentGoalContext goalContext = new ResidentGoalContext(resident, snapshot, gameTime); + Entity residentEntity = level == null ? null : level.getEntity(resident.residentUuid()); + ResidentTask previousTask = state.goalScheduler.currentTask(resident.residentUuid()) + .filter(task -> !task.isDone()) + .orElse(null); + NpcSocietyProfile profile = preScheduleSocietyTick(level, state.homeRuntime, resident, gameTime, residentEntity, previousTask); + long worldDayTime = level == null ? gameTime : level.getDayTime(); + ResidentGoalContext goalContext = buildGoalContext(resident, snapshot, gameTime, worldDayTime, profile, + residentEntity == null ? null : residentEntity.position()); state.goalScheduler.tick(goalContext); + applyRouteInvalidationIfNeeded(state, goalContext); runResidentJobStep(state, goalContext); + syncResidentSocietyProfile(state, goalContext, level, buildingsByUuid); + } + } + + static void applyRouteInvalidationIfNeeded(SettlementOrchestrator.LevelRuntimeState state, + ResidentGoalContext goalContext) { + if (state == null || goalContext == null) { + return; + } + ResidentTask activeTask = state.goalScheduler.currentTask(goalContext.residentId()).orElse(null); + if (activeTask == null || activeTask.isDone()) { + return; + } + NpcIntent currentIntent = NpcSocietyPhaseOneRuntime.publishedIntentForGoal(activeTask.goalId()); + if (currentIntent == null || currentIntent == NpcIntent.UNSPECIFIED) { + return; + } + if (NpcSocietyAnchorGoal.consumeRouteInvalidation(goalContext.residentId(), currentIntent, goalContext.gameTime())) { + state.goalScheduler.forceStop(goalContext.residentId(), ResidentStopReason.CONTEXT_INVALID); } } @@ -101,19 +153,243 @@ private static void publishBuildingWorkOrders(SettlementOrchestrator.LevelRuntim private static void assignHomes(BannerModHomeAssignmentRuntime homeRuntime, SettlementSnapshot snapshot, + @Nullable ServerLevel level, long gameTime) { - for (SettlementResidentRecord resident : snapshot.residents()) { - if (resident == null || resident.residentUuid() == null || homeRuntime.homeFor(resident.residentUuid()).isPresent()) { + if (level != null) { + assignReservedHomes(homeRuntime, snapshot, level, gameTime); + } + java.util.Set prioritizedResidents = level == null + ? java.util.Set.of() + : NpcHousingProjectPlanner.approvedRequesterIdsForClaim(level, snapshot.claimUuid()); + Map buildingsByUuid = indexBuildings(snapshot); + List orderedResidents; + if (prioritizedResidents.isEmpty()) { + orderedResidents = snapshot.residents(); + } else { + orderedResidents = new java.util.ArrayList<>(snapshot.residents()); + orderedResidents.sort((left, right) -> { + boolean leftPriority = left != null && left.residentUuid() != null && prioritizedResidents.contains(left.residentUuid()); + boolean rightPriority = right != null && right.residentUuid() != null && prioritizedResidents.contains(right.residentUuid()); + return Boolean.compare(rightPriority, leftPriority); + }); + } + for (SettlementResidentRecord resident : orderedResidents) { + if (resident == null || resident.residentUuid() == null) { continue; } - BannerModHomeAssignmentAdvisor.pickHomeBuilding(resident.residentUuid(), snapshot, homeRuntime) - .ifPresent(homeBuildingUuid -> homeRuntime.assign( - resident.residentUuid(), - homeBuildingUuid, - HomePreference.ASSIGNED, - gameTime - )); + UUID residentUuid = resident.residentUuid(); + Optional homeBuildingUuid = homeRuntime.homeFor(residentUuid).map(home -> home.homeBuildingUuid()); + if (homeBuildingUuid.isEmpty()) { + homeBuildingUuid = BannerModHomeAssignmentAdvisor.pickHomeBuilding(residentUuid, snapshot, homeRuntime); + homeBuildingUuid.ifPresent(homeUuid -> homeRuntime.assign( + residentUuid, + homeUuid, + HomePreference.ASSIGNED, + gameTime + )); + } + if (level != null) { + NpcSocietyProfile profile = NpcSocietyAccess.ensureResident(level, residentUuid, gameTime); + if (homeBuildingUuid.isPresent()) { + com.talhanation.bannermod.society.NpcHousingRequestAccess.markFulfilled(level, residentUuid, gameTime); + } + UUID householdId = reconcileHouseholdMetadataIfNeeded( + level, + residentUuid, + homeBuildingUuid.orElse(null), + homeBuildingUuid.map(buildingsByUuid::get) + .map(SettlementBuildingRecord::residentCapacity) + .orElse(0), + profile, + gameTime + ); + NpcSocietyAccess.reconcilePhaseOneState( + level, + residentUuid, + householdId, + homeBuildingUuid.orElse(null), + resident.effectiveWorkBuildingUuid(), + profile.dailyPhase(), + profile.currentIntent(), + profile.currentAnchor(), + profile.decisionSnapshot(), + gameTime + ); + } + } + } + + private static @Nullable UUID reconcileHouseholdMetadataIfNeeded(ServerLevel level, + UUID residentUuid, + @Nullable UUID homeBuildingUuid, + int residentCapacity, + NpcSocietyProfile profile, + long gameTime) { + if (level == null || residentUuid == null || profile == null) { + return profile == null ? null : profile.householdId(); } + UUID householdIdFromRuntime = com.talhanation.bannermod.society.NpcHouseholdAccess.householdForResident(level, residentUuid) + .map(com.talhanation.bannermod.society.NpcHouseholdRecord::householdId) + .orElse(null); + UUID previousHouseholdId = householdIdFromRuntime == null ? profile.householdId() : householdIdFromRuntime; + UUID previousHomeBuildingUuid = profile.homeBuildingUuid(); + boolean needsReconcile = householdIdFromRuntime == null || !Objects.equals(previousHomeBuildingUuid, homeBuildingUuid); + if (!needsReconcile) { + return previousHouseholdId; + } + UUID nextHouseholdId = com.talhanation.bannermod.society.NpcHouseholdAccess.reconcileResidentHome( + level, + residentUuid, + homeBuildingUuid, + residentCapacity, + gameTime + ); + if (nextHouseholdId != null) { + com.talhanation.bannermod.society.NpcFamilyAccess.reconcileHousehold(level, nextHouseholdId, gameTime); + } + if (previousHouseholdId != null && !previousHouseholdId.equals(nextHouseholdId)) { + com.talhanation.bannermod.society.NpcFamilyAccess.reconcileHousehold(level, previousHouseholdId, gameTime); + } + return nextHouseholdId == null ? previousHouseholdId : nextHouseholdId; + } + + private static void assignReservedHomes(BannerModHomeAssignmentRuntime homeRuntime, + SettlementSnapshot snapshot, + ServerLevel level, + long gameTime) { + for (com.talhanation.bannermod.society.NpcHousingRequestRecord request + : com.talhanation.bannermod.society.NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(snapshot.claimUuid())) { + if (request == null || request.status() == com.talhanation.bannermod.society.NpcHousingRequestStatus.DENIED) { + continue; + } + com.talhanation.bannermod.society.NpcHouseholdRecord household = com.talhanation.bannermod.society.NpcHouseholdAccess.householdFor(level, request.householdId()).orElse(null); + if (household == null || household.homeBuildingUuid() != null || household.memberResidentUuids().isEmpty()) { + continue; + } + UUID reservedHome = com.talhanation.bannermod.society.NpcHousingPlotPlanner.findReservedHomeBuilding(snapshot, homeRuntime, request); + if (reservedHome == null) { + continue; + } + int capacity = snapshot.buildings().stream() + .filter(building -> building != null && reservedHome.equals(building.buildingUuid())) + .mapToInt(com.talhanation.bannermod.settlement.SettlementBuildingRecord::residentCapacity) + .findFirst() + .orElse(0); + if (capacity <= 0) { + continue; + } + int assigned = homeRuntime.assignmentsForBuilding(reservedHome).size(); + for (UUID memberResidentUuid : household.memberResidentUuids()) { + if (memberResidentUuid == null || assigned >= capacity) { + continue; + } + if (homeRuntime.homeFor(memberResidentUuid).isPresent()) { + continue; + } + homeRuntime.assign(memberResidentUuid, reservedHome, HomePreference.SHARED, gameTime); + assigned++; + } + } + } + + private static NpcSocietyProfile preScheduleSocietyTick(@Nullable ServerLevel level, + BannerModHomeAssignmentRuntime homeRuntime, + SettlementResidentRecord resident, + long gameTime, + @Nullable Entity residentEntity, + @Nullable ResidentTask previousTask) { + if (level == null || resident == null || resident.residentUuid() == null) { + return null; + } + UUID residentUuid = resident.residentUuid(); + NpcSocietyProfile profile = NpcSocietyAccess.ensureResident(level, residentUuid, gameTime); + UUID homeBuildingUuid = homeRuntime.homeFor(residentUuid).map(home -> home.homeBuildingUuid()).orElse(null); + ResidentGoalContext previewContext = buildGoalContext( + resident, + null, + gameTime, + level.getDayTime(), + profile, + residentEntity == null ? null : residentEntity.position() + ); + NpcSocietyProfile updatedProfile = NpcSocietyNeedRuntime.tickNeeds( + profile, + homeBuildingUuid, + previewContext.isActivePhase(), + previewContext.isRestPhase(), + previousTask, + isThreatened(residentEntity), + resident.role() == SettlementResidentRole.GOVERNOR_RECRUIT, + gameTime + ); + NpcSocietyProfile needProfile = NpcSocietyAccess.reconcileNeedState( + level, + residentUuid, + updatedProfile.hungerNeed(), + updatedProfile.fatigueNeed(), + updatedProfile.safetyNeed(), + gameTime + ); + return needProfile; + } + + private static boolean isThreatened(@Nullable Entity entity) { + if (!(entity instanceof LivingEntity living)) { + return false; + } + return living.hurtTime > 0 || living instanceof Mob mob && mob.getTarget() != null; + } + + private static ResidentGoalContext buildGoalContext(@Nullable ServerLevel level, + SettlementResidentRecord resident, + @Nullable SettlementSnapshot snapshot, + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile profile) { + if (level == null || resident == null || resident.residentUuid() == null) { + return new ResidentGoalContext(resident, snapshot, gameTime, worldDayTime, profile); + } + Entity residentEntity = level.getEntity(resident.residentUuid()); + return buildGoalContext(resident, snapshot, gameTime, worldDayTime, profile, + residentEntity == null ? null : residentEntity.position()); + } + + private static ResidentGoalContext buildGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot snapshot, + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile profile, + @Nullable net.minecraft.world.phys.Vec3 currentPosition) { + return new ResidentGoalContext(resident, snapshot, gameTime, worldDayTime, profile, currentPosition); + } + + private static Map indexBuildings(SettlementSnapshot snapshot) { + Map buildingsByUuid = new LinkedHashMap<>(); + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && building.buildingUuid() != null) { + buildingsByUuid.put(building.buildingUuid(), building); + } + } + return buildingsByUuid; + } + + private static void syncResidentSocietyProfile(SettlementOrchestrator.LevelRuntimeState state, + ResidentGoalContext goalContext, + @Nullable ServerLevel level, + Map buildingsByUuid) { + if (state == null || goalContext == null || level == null) { + return; + } + Optional activeTask = state.goalScheduler.currentTask(goalContext.residentId()) + .filter(task -> !task.isDone()); + NpcSocietyPhaseOneRuntime.updateResidentProfile( + level, + state.homeRuntime, + goalContext, + activeTask.orElse(null), + state.goalScheduler.lastOutcome(goalContext.residentId()).orElse(null), + buildingsByUuid + ); } private static void tickSellerDispatches(BannerModSellerDispatchRuntime sellerRuntime, @@ -145,22 +421,6 @@ private static void tickSellerDispatches(BannerModSellerDispatchRuntime sellerRu } } - for (SettlementSellerDispatchRecord seed : marketState.sellerDispatches()) { - if (seed == null - || seed.dispatchState() != SettlementSellerDispatchState.READY - || seed.residentUuid() == null - || seed.marketUuid() == null - || !openMarkets.contains(seed.marketUuid()) - || sellerRuntime.isActive(seed.residentUuid())) { - continue; - } - try { - sellerRuntime.beginDispatch(seed.residentUuid(), seed.marketUuid(), gameTime); - } catch (IllegalStateException ignored) { - // Another claim tick may have started the dispatch already; keep this seam additive. - } - } - for (SellerPhaseRecord dispatch : sellerRuntime.activeDispatches()) { if (dispatch != null && dispatch.sellerResidentUuid() != null) { sellerRuntime.tickPhase(dispatch.sellerResidentUuid(), gameTime); @@ -197,14 +457,11 @@ private static void runResidentJobStep(SettlementOrchestrator.LevelRuntimeState private static JobExecutionContext jobContext(SettlementOrchestrator.LevelRuntimeState state, SettlementResidentRecord resident, long gameTime) { - UUID workplaceUuid = resident.jobDefinition() == null || resident.jobDefinition().targetBuildingUuid() == null - ? resident.boundWorkAreaUuid() - : resident.jobDefinition().targetBuildingUuid(); return new JobExecutionContext( resident, gameTime, resident.residentUuid(), - workplaceUuid, + resident.effectiveWorkBuildingUuid(), state.workOrderRuntime ); } diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementResidentRecord.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementResidentRecord.java index 31a71582..3a687143 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementResidentRecord.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementResidentRecord.java @@ -264,8 +264,18 @@ public static SettlementResidentRecord fromTag(CompoundTag tag) { ); } + public @Nullable UUID effectiveWorkBuildingUuid() { + if (this.serviceContract != null && this.serviceContract.serviceBuildingUuid() != null) { + return this.serviceContract.serviceBuildingUuid(); + } + if (this.jobDefinition != null && this.jobDefinition.targetBuildingUuid() != null) { + return this.jobDefinition.targetBuildingUuid(); + } + return this.boundWorkAreaUuid; + } + private static SettlementResidentAssignmentState defaultAssignmentState(SettlementResidentRole role, - @Nullable UUID boundWorkAreaUuid) { + @Nullable UUID boundWorkAreaUuid) { if (role != SettlementResidentRole.CONTROLLED_WORKER) { return SettlementResidentAssignmentState.NOT_APPLICABLE; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementService.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementService.java index 64f9c95f..8df983b5 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementService.java @@ -64,10 +64,15 @@ public static List workersInClaim(ServerLevel level, Recru } public static Map buildCanonicalWorkAreaBindings(Collection validatedBuildings, - List workAreas) { + List workAreas) { return SettlementSnapshotRuntime.buildCanonicalWorkAreaBindings(validatedBuildings, workAreas); } + public static Map buildAuthoritativeWorkBuildingBindings(Collection validatedBuildings, + List workAreas) { + return SettlementSnapshotRuntime.buildAuthoritativeWorkBuildingBindings(validatedBuildings, workAreas); + } + public static AABB claimBounds(ServerLevel level, RecruitsClaim claim) { return SettlementSnapshotRuntime.claimBounds(level, claim); } diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotBuilder.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotBuilder.java index b0a6bd1f..59ca3fb3 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotBuilder.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotBuilder.java @@ -39,10 +39,11 @@ static SettlementSnapshot buildSnapshot(ServerLevel level, List workAreas = SettlementSnapshotRuntime.collectWorkAreas(level, claim, AbstractWorkAreaEntity.class); SettlementRecord settlementRecord = SettlementSnapshotRuntime.settlementRecordForClaim(level, claim); List validatedBuildings = SettlementSnapshotRuntime.collectValidatedBuildings(level, settlementRecord); + java.util.Map authoritativeBindings = SettlementSnapshotRuntime.buildAuthoritativeWorkBuildingBindings(validatedBuildings, workAreas); SettlementSnapshotRuntime.repairClaimState(level, claim, workAreas, validatedBuildings); - List residents = SettlementSnapshotRuntime.collectResidents(level, claim, governorSnapshot, settlementFactionId); - List buildings = SettlementSnapshotRuntime.collectBuildings(level, claim); + List residents = SettlementSnapshotRuntime.collectResidents(level, claim, governorSnapshot, settlementFactionId, authoritativeBindings); + List buildings = SettlementSnapshotRuntime.collectBuildings(level, claim, workAreas, validatedBuildings); SettlementMarketState marketState = SettlementSnapshotRuntime.collectMarketState(level, claim); List storageAreas = SettlementSnapshotRuntime.collectStorageAreas(level, claim); List liveSeaTradeEntrypoints = SettlementSnapshotRuntime.collectLiveSeaTradeEntrypoints(storageAreas); diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotRuntime.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotRuntime.java index e82bb3d7..41bc2e1d 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotRuntime.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementSnapshotRuntime.java @@ -61,9 +61,10 @@ static void repairClaimState(ServerLevel level, } static List collectResidents(ServerLevel level, - RecruitsClaim claim, - @Nullable BannerModGovernorSnapshot governorSnapshot, - @Nullable String settlementFactionId) { + RecruitsClaim claim, + @Nullable BannerModGovernorSnapshot governorSnapshot, + @Nullable String settlementFactionId, + Map authoritativeBindings) { Map residents = new LinkedHashMap<>(); for (Villager villager : level.getEntitiesOfClass(Villager.class, claimBounds(level, claim), entity -> entity.isAlive() && claim.containsChunk(entity.chunkPosition()))) { residents.put(villager.getUUID(), new SettlementResidentRecord( @@ -87,9 +88,10 @@ static List collectResidents(ServerLevel level, )); } for (AbstractWorkerEntity worker : workersInClaim(level, claim)) { - SettlementResidentScheduleSeed scheduleSeed = SettlementResidentScheduleSeed.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, worker.getBoundWorkAreaUUID()); + UUID authoritativeWorkBuildingUuid = authoritativeWorkBuildingBinding(worker.getBoundWorkAreaUUID(), authoritativeBindings); + SettlementResidentScheduleSeed scheduleSeed = SettlementResidentScheduleSeed.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, authoritativeWorkBuildingUuid); SettlementResidentMode residentMode = SettlementResidentMode.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, worker.getOwnerUUID()); - SettlementResidentAssignmentState assignmentState = worker.getBoundWorkAreaUUID() == null + SettlementResidentAssignmentState assignmentState = authoritativeWorkBuildingUuid == null ? SettlementResidentAssignmentState.UNASSIGNED : SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING; SettlementResidentRuntimeRoleState runtimeRoleState = SettlementResidentRuntimeRoleState.defaultFor( @@ -104,17 +106,17 @@ static List collectResidents(ServerLevel level, scheduleSeed, SettlementResidentScheduleWindowSeed.defaultFor(scheduleSeed, runtimeRoleState), runtimeRoleState, - SettlementResidentServiceContract.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, residentMode, assignmentState, worker.getBoundWorkAreaUUID(), null), + SettlementResidentServiceContract.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, residentMode, assignmentState, authoritativeWorkBuildingUuid, null), SettlementResidentJobDefinition.defaultFor( SettlementResidentRole.CONTROLLED_WORKER, runtimeRoleState, - SettlementResidentServiceContract.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, residentMode, assignmentState, worker.getBoundWorkAreaUUID(), null), + SettlementResidentServiceContract.defaultFor(SettlementResidentRole.CONTROLLED_WORKER, residentMode, assignmentState, authoritativeWorkBuildingUuid, null), null ), residentMode, worker.getOwnerUUID(), worker.getTeam() == null ? null : worker.getTeam().getName(), - worker.getBoundWorkAreaUUID(), + authoritativeWorkBuildingUuid, assignmentState )); } @@ -142,6 +144,14 @@ static List collectResidents(ServerLevel level, return new ArrayList<>(residents.values()); } + static @Nullable UUID authoritativeWorkBuildingBinding(@Nullable UUID boundWorkAreaUuid, + Map authoritativeBindings) { + if (boundWorkAreaUuid == null || authoritativeBindings == null || authoritativeBindings.isEmpty()) { + return boundWorkAreaUuid; + } + return authoritativeBindings.getOrDefault(boundWorkAreaUuid, boundWorkAreaUuid); + } + public static List workersInClaim(ServerLevel level, RecruitsClaim claim) { return WorkerIndex.instance() .queryInClaim(level, claim) @@ -164,10 +174,11 @@ static List applyResidentAssignmentSemantics(List applyResidentAssignmentSemantics(List applyResidentServiceContracts(List updatedResidents = new ArrayList<>(residents.size()); for (SettlementResidentRecord resident : residents) { - SettlementBuildingRecord serviceBuilding = resident.boundWorkAreaUuid() == null + UUID workBuildingUuid = resident.effectiveWorkBuildingUuid(); + SettlementBuildingRecord serviceBuilding = workBuildingUuid == null ? null - : buildingsByUuid.get(resident.boundWorkAreaUuid()); + : buildingsByUuid.get(workBuildingUuid); SettlementResidentServiceContract serviceContract = SettlementResidentServiceContract.defaultFor( resident.role(), resident.residentMode(), resident.assignmentState(), - resident.boundWorkAreaUuid(), + workBuildingUuid, serviceBuilding == null ? null : serviceBuilding.buildingTypeId() ); updatedResidents.add(new SettlementResidentRecord( @@ -244,7 +256,7 @@ static List applyResidentServiceContracts(List applyResidentJobDefinitions(List applyResidentJobTargetSelectionStates(List resident.residentMode(), resident.ownerUuid(), resident.teamId(), - resident.boundWorkAreaUuid(), + resident.effectiveWorkBuildingUuid(), resident.assignmentState(), resident.roleProfile(), resident.schedulePolicy() @@ -330,11 +342,10 @@ static List applyResidentJobTargetSelectionStates(List } static List collectBuildings(ServerLevel level, - RecruitsClaim claim) { + RecruitsClaim claim, + List workAreas, + List validatedBuildings) { List buildings = new ArrayList<>(); - List workAreas = collectWorkAreas(level, claim, AbstractWorkAreaEntity.class); - SettlementRecord settlementRecord = settlementRecordForClaim(level, claim); - List validatedBuildings = collectValidatedBuildings(level, settlementRecord); Map canonicalBindings = buildCanonicalWorkAreaBindings(validatedBuildings, workAreas); Set mergedLiveAreas = new LinkedHashSet<>(); @@ -373,16 +384,16 @@ static List collectBuildings(ServerLevel level, } static SettlementBuildingRecord mergeValidatedBuildingIntoLiveRecord(ValidatedBuildingRecord record, - SettlementBuildingRecord liveRecord) { + SettlementBuildingRecord liveRecord) { SettlementBuildingRecord validatedRecord = fromValidatedBuildingFields( - liveRecord.buildingUuid(), + record.buildingId(), record.type(), liveRecord.originPos(), record.capacity(), liveRecord.ownerUuid() ); return new SettlementBuildingRecord( - liveRecord.buildingUuid(), + record.buildingId(), liveRecord.buildingTypeId(), liveRecord.originPos(), liveRecord.ownerUuid(), @@ -520,7 +531,7 @@ private static SettlementBuildingRecord fromLiveWorkArea(AbstractWorkAreaEntity } public static Map buildCanonicalWorkAreaBindings(Collection validatedBuildings, - List workAreas) { + List workAreas) { Map canonicalBindings = new HashMap<>(); for (ValidatedBuildingRecord record : validatedBuildings) { List candidates = compatibleOverlappingWorkAreas(record, workAreas); @@ -535,6 +546,31 @@ public static Map buildCanonicalWorkAreaBindings(Collection buildAuthoritativeWorkBuildingBindings(Collection validatedBuildings, + List workAreas) { + Map canonicalBindings = buildCanonicalWorkAreaBindings(validatedBuildings, workAreas); + Map authoritativeBindings = new HashMap<>(canonicalBindings); + for (ValidatedBuildingRecord record : validatedBuildings) { + if (record == null || record.buildingId() == null) { + continue; + } + List candidates = compatibleOverlappingWorkAreas(record, workAreas); + AbstractWorkAreaEntity primary = primaryWorkAreaForValidatedBuilding(record, candidates); + if (primary == null) { + continue; + } + UUID authoritativeBuildingUuid = record.buildingId(); + authoritativeBindings.put(canonicalBindings.getOrDefault(primary.getUUID(), primary.getUUID()), authoritativeBuildingUuid); + for (AbstractWorkAreaEntity candidate : candidates) { + if (candidate != null) { + authoritativeBindings.put(candidate.getUUID(), authoritativeBuildingUuid); + } + } + } + return authoritativeBindings; + } + + private static List compatibleOverlappingWorkAreas(ValidatedBuildingRecord record, List workAreas) { if (record == null || workAreas.isEmpty()) { @@ -608,12 +644,13 @@ static List applyAssignedResidents(List> assignedResidentsByBuilding = new LinkedHashMap<>(); for (SettlementResidentRecord resident : residents) { + UUID workBuildingUuid = resident.effectiveWorkBuildingUuid(); if (resident.role() != SettlementResidentRole.CONTROLLED_WORKER || resident.assignmentState() != SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING - || resident.boundWorkAreaUuid() == null) { + || workBuildingUuid == null) { continue; } - assignedResidentsByBuilding.computeIfAbsent(resident.boundWorkAreaUuid(), ignored -> new ArrayList<>()) + assignedResidentsByBuilding.computeIfAbsent(workBuildingUuid, ignored -> new ArrayList<>()) .add(resident.residentUuid()); } diff --git a/src/main/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapService.java b/src/main/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapService.java index 1c8dfac0..9cefb4f9 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapService.java @@ -6,6 +6,11 @@ import com.talhanation.bannermod.persistence.military.RecruitsClaim; import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; import com.talhanation.bannermod.registry.citizen.ModCitizenEntityTypes; +import com.talhanation.bannermod.society.NpcFamilyAccess; +import com.talhanation.bannermod.society.NpcHouseholdAccess; +import com.talhanation.bannermod.society.NpcLifeStage; +import com.talhanation.bannermod.society.NpcSex; +import com.talhanation.bannermod.society.NpcSocietyAccess; import com.talhanation.bannermod.war.WarRuntimeContext; import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; import com.talhanation.bannermod.war.registry.PoliticalMembership; @@ -23,7 +28,9 @@ import net.minecraft.world.scores.PlayerTeam; import javax.annotation.Nullable; +import java.util.ArrayList; import java.util.List; +import java.util.Random; import java.util.UUID; public final class SettlementBootstrapService { @@ -81,8 +88,8 @@ static String starterWorkerReadinessMessage(int spawnedWorkers) { static String starterWorkerReadinessMessage(int spawnedWorkers, int spawnedFreeCitizens) { return "Settlement bootstrapped. Starter workers spawned: " + spawnedWorkers - + ". Free citizens available for vacancies: " + Math.max(0, spawnedFreeCitizens) - + ". Ready: farmer has a starter crop area. Waiting: miner needs a mine, lumberjack needs a lumber camp, builder needs an architect workshop/build area. If vacancies remain empty, no free citizen is close enough or available yet."; + + ". Starter households seeded: " + Math.max(0, spawnedFreeCitizens) + + " residents. Adult free citizens can fill vacancies; adolescents and children stay in their families. Starter workers wait for player-marked or validated work areas; fort founding no longer auto-ploughs a field on its own. Waiting: farmer needs a crop area, miner needs a mine, lumberjack needs a lumber camp, builder needs an architect workshop/build area. If vacancies remain empty, no free adult citizen is close enough or available yet."; } public static BootstrapResult bootstrapSettlement(ServerLevel level, @@ -162,7 +169,7 @@ private static BootstrapResult createSettlement(ServerLevel level, UUID ownerPla ); registry.put(settlement); int spawnedWorkers = spawnStarterCitizens(level, authorityPos, claim); - int spawnedFreeCitizens = spawnStarterFreeCitizens(level, authorityPos, claim); + int spawnedFreeCitizens = spawnStarterFamilies(level, authorityPos, claim); return BootstrapResult.success(starterWorkerReadinessMessage(spawnedWorkers, spawnedFreeCitizens), settlement); } @@ -240,32 +247,66 @@ private static int spawnStarterCitizens(ServerLevel level, BlockPos authorityPos for (WorkerSettlementSpawnRules.WorkerProfession profession : STARTER_PROFESSIONS) { WorkerSettlementSpawnRules.Decision decision = new WorkerSettlementSpawnRules.Decision(true, profession, null, 0L); - if (WorkerSettlementSpawner.spawnClaimWorker(level, authorityPos, decision, claim) != null) { + var worker = WorkerSettlementSpawner.spawnClaimWorker(level, authorityPos, decision, claim); + if (worker != null) { spawned++; } } return spawned; } - private static int spawnStarterFreeCitizens(ServerLevel level, BlockPos authorityPos, RecruitsClaim claim) { + private static int spawnStarterFamilies(ServerLevel level, BlockPos authorityPos, RecruitsClaim claim) { + Random random = new Random(authorityPos.asLong() ^ claim.getUUID().getMostSignificantBits() ^ claim.getUUID().getLeastSignificantBits()); + List households = planStarterHouseholds(random); int spawned = 0; - for (int i = 0; i < STARTER_FREE_CITIZEN_COUNT; i++) { - BlockPos spawnPos = authorityPos.offset((i % 2) + 1, 0, (i / 2) + 1); - if (spawnFreeCitizen(level, spawnPos, claim)) { + long gameTime = level.getGameTime(); + for (int householdIndex = 0; householdIndex < households.size(); householdIndex++) { + StarterHouseholdSeed household = households.get(householdIndex); + BlockPos householdOrigin = authorityPos.offset(2 + (householdIndex % 3) * 4, 0, 2 + (householdIndex / 3) * 4); + List residentIds = new ArrayList<>(); + UUID headResidentUuid = null; + for (int memberIndex = 0; memberIndex < household.members().size(); memberIndex++) { + StarterResidentSeed member = household.members().get(memberIndex); + BlockPos spawnPos = householdOrigin.offset(memberIndex % 2, 0, memberIndex / 2); + CitizenEntity citizen = spawnFreeCitizen(level, spawnPos, claim, member.lifeStage(), member.sex()); + if (citizen == null) { + continue; + } spawned++; + residentIds.add(citizen.getUUID()); + if (headResidentUuid == null && member.isHouseholdHeadCandidate()) { + headResidentUuid = citizen.getUUID(); + } } + if (residentIds.isEmpty()) { + continue; + } + UUID householdId = UUID.randomUUID(); + NpcHouseholdAccess.seedHousehold( + level, + householdId, + headResidentUuid == null ? residentIds.getFirst() : headResidentUuid, + residentIds, + gameTime + ); + NpcFamilyAccess.reconcileHousehold(level, householdId, gameTime); } return spawned; } - private static boolean spawnFreeCitizen(ServerLevel level, BlockPos spawnPos, RecruitsClaim claim) { + private static @Nullable CitizenEntity spawnFreeCitizen(ServerLevel level, + BlockPos spawnPos, + RecruitsClaim claim, + NpcLifeStage lifeStage, + NpcSex sex) { CitizenEntity citizen = ModCitizenEntityTypes.CITIZEN.get().create(level); if (citizen == null) { - return false; + return null; } BlockPos safeSpawnPos = resolveSafeSpawnPos(level, spawnPos); citizen.moveTo(safeSpawnPos.getX() + 0.5D, safeSpawnPos.getY(), safeSpawnPos.getZ() + 0.5D, 0.0F, 0.0F); citizen.setOwned(true); + citizen.setFemale(sex == NpcSex.FEMALE); PoliticalEntityRecord owner = claim.getOwnerPoliticalEntityId() == null ? null : WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null); @@ -276,13 +317,64 @@ private static boolean spawnFreeCitizen(ServerLevel level, BlockPos spawnPos, Re } BannerModNpcNamePool.ensureNamed(citizen); level.addFreshEntity(citizen); + NpcSocietyAccess.seedResident(level, citizen.getUUID(), lifeStage, sex, level.getGameTime()); if (owner != null) { PlayerTeam team = level.getScoreboard().getPlayerTeam(owner.name()); if (team != null) { level.getScoreboard().addPlayerToTeam(citizen.getScoreboardName(), team); } } - return true; + return citizen; + } + + private static List planStarterHouseholds(Random random) { + int householdCount = 2 + random.nextInt(3); + List households = new ArrayList<>(); + households.add(rootHousehold(random)); + while (households.size() < householdCount) { + int roll = random.nextInt(4); + if (roll == 0) { + households.add(newlyweds()); + } else if (roll == 1) { + households.add(youngFamily(random, true)); + } else { + households.add(youngFamily(random, false)); + } + } + return households; + } + + private static StarterHouseholdSeed rootHousehold(Random random) { + List members = new ArrayList<>(); + members.add(new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.MALE)); + members.add(new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.FEMALE)); + members.add(new StarterResidentSeed(randomMinorStage(random), random.nextBoolean() ? NpcSex.MALE : NpcSex.FEMALE)); + if (random.nextBoolean()) { + members.add(new StarterResidentSeed(randomMinorStage(random), random.nextBoolean() ? NpcSex.MALE : NpcSex.FEMALE)); + } + return new StarterHouseholdSeed(List.copyOf(members)); + } + + private static StarterHouseholdSeed youngFamily(Random random, boolean withTwoChildren) { + List members = new ArrayList<>(); + members.add(new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.MALE)); + members.add(new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.FEMALE)); + members.add(new StarterResidentSeed(randomMinorStage(random), random.nextBoolean() ? NpcSex.MALE : NpcSex.FEMALE)); + if (withTwoChildren || random.nextBoolean()) { + members.add(new StarterResidentSeed(randomMinorStage(random), random.nextBoolean() ? NpcSex.MALE : NpcSex.FEMALE)); + } + return new StarterHouseholdSeed(List.copyOf(members)); + } + + private static StarterHouseholdSeed newlyweds() { + return new StarterHouseholdSeed(List.of( + new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.MALE), + new StarterResidentSeed(NpcLifeStage.ADULT, NpcSex.FEMALE) + )); + } + + private static NpcLifeStage randomMinorStage(Random random) { + return random.nextBoolean() ? NpcLifeStage.ADOLESCENT : NpcLifeStage.CHILD; } private static BlockPos resolveSafeSpawnPos(ServerLevel level, BlockPos preferredPos) { @@ -305,4 +397,13 @@ private static boolean isSpawnSpaceClear(ServerLevel level, BlockPos pos) { && level.getBlockState(pos.above()).isAir() && !level.getBlockState(pos.below()).isAir(); } + + private record StarterHouseholdSeed(List members) { + } + + private record StarterResidentSeed(NpcLifeStage lifeStage, NpcSex sex) { + private boolean isHouseholdHeadCandidate() { + return this.lifeStage == NpcLifeStage.ADULT && this.sex == NpcSex.MALE; + } + } } diff --git a/src/main/java/com/talhanation/bannermod/settlement/civilian/WorkerSettlementSpawner.java b/src/main/java/com/talhanation/bannermod/settlement/civilian/WorkerSettlementSpawner.java index 97c8e01c..044aa225 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/civilian/WorkerSettlementSpawner.java +++ b/src/main/java/com/talhanation/bannermod/settlement/civilian/WorkerSettlementSpawner.java @@ -4,9 +4,17 @@ import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import com.talhanation.bannermod.persistence.military.RecruitsClaim; import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.entity.civilian.AnimalFarmerEntity; import com.talhanation.bannermod.entity.civilian.FarmerEntity; -import com.talhanation.bannermod.ai.civilian.FarmerPlantingPreparation; +import com.talhanation.bannermod.entity.civilian.FishermanEntity; +import com.talhanation.bannermod.entity.civilian.LumberjackEntity; +import com.talhanation.bannermod.entity.civilian.MinerEntity; +import com.talhanation.bannermod.entity.civilian.workarea.AbstractWorkAreaEntity; +import com.talhanation.bannermod.entity.civilian.workarea.AnimalPenArea; import com.talhanation.bannermod.entity.civilian.workarea.CropArea; +import com.talhanation.bannermod.entity.civilian.workarea.FishingArea; +import com.talhanation.bannermod.entity.civilian.workarea.LumberArea; +import com.talhanation.bannermod.entity.civilian.workarea.MiningArea; import com.talhanation.bannermod.entity.civilian.workarea.WorkAreaIndex; import com.talhanation.bannermod.registry.civilian.ModEntityTypes; import com.talhanation.bannermod.shared.settlement.BannerModSettlementRefreshSupport; @@ -14,19 +22,12 @@ import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; import net.minecraft.network.chat.Component; import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobSpawnType; import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.world.entity.npc.Villager; -import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.CropBlock; -import net.minecraft.world.level.block.StemBlock; -import net.minecraft.world.level.block.BushBlock; -import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.scores.PlayerTeam; @@ -132,15 +133,30 @@ private static AbstractWorkerEntity spawnWorker(ServerLevel level, if (team != null) { level.getScoreboard().addPlayerToTeam(worker.getScoreboardName(), team); } - seedClaimWorkAreaDefaults(level, worker, claim, owner, safeSpawnPos); + bindExistingClaimWorkArea(level, claim, worker); + reportInitialAssignmentStatus(worker, profession); BannerModSettlementRefreshSupport.refreshSnapshot(level, worker.blockPosition()); - if (worker instanceof FarmerEntity farmer && farmer.getCurrentCropArea() == null) { - seedClaimWorkAreaDefaults(level, farmer, claim, owner, safeSpawnPos); - } return worker; } + public static void reportInitialAssignmentStatus(@Nullable AbstractWorkerEntity worker, + @Nullable WorkerSettlementSpawnRules.WorkerProfession profession) { + if (worker == null || worker.getCurrentWorkArea() != null || profession == null) { + return; + } + String reasonToken = switch (profession) { + case FARMER -> "farmer_no_area"; + case LUMBERJACK -> "lumberjack_no_area"; + case MINER -> "miner_no_area"; + case BUILDER -> "builder_no_area"; + case MERCHANT -> "merchant_no_market"; + case FISHERMAN -> "fisherman_no_area"; + case ANIMAL_FARMER -> "animal_farmer_no_pen"; + }; + worker.reportIdleReason(reasonToken, null); + } + private static BlockPos resolveSafeSpawnPos(ServerLevel level, BlockPos preferredPos) { if (isSpawnSpaceClear(level, preferredPos)) { return preferredPos; @@ -162,48 +178,49 @@ private static boolean isSpawnSpaceClear(ServerLevel level, BlockPos pos) { && !level.getBlockState(pos.below()).isAir(); } - private static void seedClaimWorkAreaDefaults(ServerLevel level, - AbstractWorkerEntity worker, - RecruitsClaim claim, - PoliticalEntityRecord owner, - BlockPos spawnPos) { - if (!(worker instanceof FarmerEntity farmer) || claim == null || owner == null) { - return; + private static boolean bindExistingClaimWorkArea(ServerLevel level, + RecruitsClaim claim, + AbstractWorkerEntity worker) { + if (worker instanceof FarmerEntity farmer) { + CropArea area = findClaimCropArea(level, claim, farmer); + if (area != null) { + farmer.setCurrentWorkArea(area); + return true; + } + return false; } - - CropArea existingArea = findClaimCropArea(level, claim, farmer); - if (existingArea != null) { - farmer.setCurrentWorkArea(existingArea); - return; + if (worker instanceof LumberjackEntity lumberjack) { + LumberArea area = findClaimArea(level, claim, LumberArea.class, lumberjack); + if (area != null) { + lumberjack.setCurrentWorkArea(area); + return true; + } + return false; } - - BlockPos fieldCenter = findFieldCenter(level, claim, spawnPos); - if (fieldCenter == null) { - return; + if (worker instanceof MinerEntity miner) { + MiningArea area = findClaimArea(level, claim, MiningArea.class, miner); + if (area != null) { + miner.setCurrentWorkArea(area); + return true; + } + return false; } - - CropArea cropArea = new CropArea(ModEntityTypes.CROPAREA.get(), level); - cropArea.setWidthSize(9); - cropArea.setHeightSize(2); - cropArea.setDepthSize(9); - cropArea.setFacing(Direction.NORTH); - cropArea.moveTo(fieldCenter.getX() - 4, fieldCenter.getY(), fieldCenter.getZ() + 4, 0.0F, 0.0F); - cropArea.createArea(); - cropArea.setDone(false); - cropArea.setTeamStringID(owner.name()); - cropArea.setPlayerUUID(owner.leaderUuid()); - cropArea.setPlayerName(owner.name()); - cropArea.setCustomName(Component.literal("")); - - ItemStack seedStack = resolveFieldSeed(level, fieldCenter); - if (!seedStack.isEmpty()) { - cropArea.setSeedStack(seedStack); - cropArea.updateType(); + if (worker instanceof AnimalFarmerEntity animalFarmer) { + AnimalPenArea area = findClaimArea(level, claim, AnimalPenArea.class, animalFarmer); + if (area != null) { + animalFarmer.setCurrentWorkArea(area); + return true; + } + return false; } - - level.addFreshEntity(cropArea); - WorkAreaIndex.instance().onEntityJoin(cropArea); - farmer.setCurrentWorkArea(cropArea); + if (worker instanceof FishermanEntity fisherman) { + FishingArea area = findClaimArea(level, claim, FishingArea.class, fisherman); + if (area != null) { + fisherman.setCurrentWorkArea(area); + return true; + } + } + return false; } @Nullable @@ -221,148 +238,16 @@ private static List getClaimCropAreas(ServerLevel level, RecruitsClaim } @Nullable - private static BlockPos findFieldCenter(ServerLevel level, RecruitsClaim claim, BlockPos spawnPos) { - BlockPos waterCenteredField = findWaterCenteredField(level, claim, spawnPos); - if (waterCenteredField != null) { - return waterCenteredField; - } - - return findPreparedFieldFallbackCenter(level, claim); - } - - @Nullable - private static BlockPos findWaterCenteredField(ServerLevel level, RecruitsClaim claim, BlockPos spawnPos) { - BlockPos bestPos = null; - int bestScore = 0; - double bestDistance = Double.MAX_VALUE; - int minChunkX = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.x).min().orElse(claim.getCenter().x); - int maxChunkX = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.x).max().orElse(claim.getCenter().x); - int minChunkZ = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.z).min().orElse(claim.getCenter().z); - int maxChunkZ = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.z).max().orElse(claim.getCenter().z); - int minX = minChunkX << 4; - int maxX = (maxChunkX << 4) + 15; - int minZ = minChunkZ << 4; - int maxZ = (maxChunkZ << 4) + 15; - - for (int x = minX; x <= maxX; x++) { - for (int z = minZ; z <= maxZ; z++) { - for (int y = level.getMinBuildHeight(); y <= level.getMaxBuildHeight() - 1; y++) { - BlockPos candidate = new BlockPos(x, y, z); - if (!level.getBlockState(candidate).is(Blocks.WATER)) { - continue; - } - - int score = scoreFieldAround(level, candidate); - if (score < 12) { - continue; - } - - double distance = candidate.distSqr(spawnPos); - if (score > bestScore || (score == bestScore && distance < bestDistance)) { - bestScore = score; - bestDistance = distance; - bestPos = candidate; - } - } - } - } - - return bestPos; - } - - @Nullable - private static BlockPos findPreparedFieldFallbackCenter(ServerLevel level, RecruitsClaim claim) { - int minChunkX = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.x).min().orElse(claim.getCenter().x); - int maxChunkX = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.x).max().orElse(claim.getCenter().x); - int minChunkZ = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.z).min().orElse(claim.getCenter().z); - int maxChunkZ = claim.getClaimedChunks().stream().mapToInt(chunk -> chunk.z).max().orElse(claim.getCenter().z); - int minX = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int minZ = Integer.MAX_VALUE; - int maxZ = Integer.MIN_VALUE; - int fieldY = Integer.MIN_VALUE; - - for (int x = minChunkX << 4; x <= (maxChunkX << 4) + 15; x++) { - for (int z = minChunkZ << 4; z <= (maxChunkZ << 4) + 15; z++) { - for (int y = level.getMinBuildHeight(); y <= level.getMaxBuildHeight() - 1; y++) { - BlockPos candidate = new BlockPos(x, y, z); - if (!isPreparedFieldBlock(level, candidate)) { - continue; - } - if (fieldY == Integer.MIN_VALUE) { - fieldY = y; - } - if (y != fieldY) { - continue; - } - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minZ = Math.min(minZ, z); - maxZ = Math.max(maxZ, z); - } - } - } - - if (fieldY == Integer.MIN_VALUE) { - return null; - } - - return new BlockPos((minX + maxX) / 2, fieldY, (minZ + maxZ) / 2); - } - - private static int scoreFieldAround(ServerLevel level, BlockPos center) { - int score = 0; - for (int dx = -4; dx <= 4; dx++) { - for (int dz = -4; dz <= 4; dz++) { - if (dx == 0 && dz == 0) { - continue; - } - - BlockPos groundPos = center.offset(dx, 0, dz); - BlockState groundState = level.getBlockState(groundPos); - BlockState cropState = level.getBlockState(groundPos.above()); - if (groundState.is(Blocks.FARMLAND) - || cropState.getBlock() instanceof CropBlock - || cropState.getBlock() instanceof StemBlock - || cropState.getBlock() instanceof BushBlock) { - score++; - } - } - } - return score; - } - - private static boolean isPreparedFieldBlock(ServerLevel level, BlockPos pos) { - BlockState groundState = level.getBlockState(pos); - BlockState cropState = level.getBlockState(pos.above()); - return groundState.is(Blocks.FARMLAND) - || cropState.getBlock() instanceof CropBlock - || cropState.getBlock() instanceof StemBlock - || cropState.getBlock() instanceof BushBlock; - } - - private static ItemStack resolveFieldSeed(ServerLevel level, BlockPos center) { - for (int dx = -4; dx <= 4; dx++) { - for (int dz = -4; dz <= 4; dz++) { - ItemStack seed = resolveSeedFromCropState(level.getBlockState(center.offset(dx, 1, dz))); - if (!seed.isEmpty()) { - return seed; - } + private static T findClaimArea(ServerLevel level, + RecruitsClaim claim, + Class areaType, + AbstractWorkerEntity worker) { + for (T area : WorkAreaIndex.instance().queryInChunks(level, claim.getClaimedChunks(), areaType)) { + if (area != null && area.canWorkHere(worker)) { + return area; } } - return ItemStack.EMPTY; - } - - private static ItemStack resolveSeedFromCropState(BlockState cropState) { - if (cropState == null || cropState.isAir()) { - return ItemStack.EMPTY; - } - - Item item = cropState.getBlock().asItem(); - if (FarmerPlantingPreparation.isSupportedSeedItem(item)) { - return new ItemStack(item); - } - return ItemStack.EMPTY; + return null; } @Nullable diff --git a/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java index 8e72e0bc..935e64b6 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/dispatch/SellerResidentGoal.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.dispatch; import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.SettlementMarketState; import com.talhanation.bannermod.settlement.SettlementResidentServiceContract; import com.talhanation.bannermod.settlement.SettlementSellerDispatchRecord; @@ -26,17 +28,17 @@ *
    *
  • Resident must hold a market-profile service contract * (actor state == LOCAL_BUILDING_SERVICE).
  • - *
  • A {@link SettlementSellerDispatchRecord} with - * {@link SettlementSellerDispatchState#READY} and a matching + *
  • A {@link BannerModSettlementSellerDispatchRecord} with + * {@link BannerModSettlementSellerDispatchState#READY} and a matching * resident UUID must exist in the supplied market state.
  • *
  • The runtime must not already have the seller in a non-idle phase.
  • *
* *

FIXME(marketStateSupplier): the spec reads - * {@code Supplier} but the shipped + * {@code Supplier} but the shipped * name is an enum (READY / MARKET_CLOSED); the actual bag of seed records - * lives on {@link SettlementMarketState}, so we take a - * {@code Supplier} here. A later slice can + * lives on {@link BannerModSettlementMarketState}, so we take a + * {@code Supplier} here. A later slice can * replace this with a dedicated facade if naming ambiguity bites. */ public final class SellerResidentGoal implements ResidentGoal { @@ -93,7 +95,14 @@ public int computePriority(ResidentGoalContext ctx) { if (ctx == null || !ctx.isActivePhase()) { return 0; } - return this.findReadyMarketUuid(ctx) != null ? SELLER_PRIORITY : 0; + if (this.findReadyMarketUuid(ctx) == null) { + return 0; + } + int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) + 8; + if (score <= 8) { + return 0; + } + return Math.max(SELLER_PRIORITY, score); } @Override @@ -108,7 +117,8 @@ public boolean canStart(ResidentGoalContext ctx) { if (this.runtime.isActive(residentUuid)) { return false; } - return this.findReadyMarketUuid(ctx) != null; + SettlementResidentServiceContract contract = ctx.resident().serviceContract(); + return contract != null && contract.actorState() == SettlementServiceActorState.LOCAL_BUILDING_SERVICE; } @Override diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java b/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java index 92fb3e7f..53bb9b93 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalScheduler.java @@ -1,13 +1,19 @@ package com.talhanation.bannermod.settlement.goal; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseOneRuntime; +import com.talhanation.bannermod.society.NpcSocietyIntentRules; import com.talhanation.bannermod.settlement.SettlementMarketState; import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime; import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.DeliverResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.FetchResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; -import com.talhanation.bannermod.settlement.goal.impl.SocialiseResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; @@ -28,43 +34,55 @@ * persisted schedule policy + window seed, picks the highest-priority startable * {@link ResidentGoal}, and tracks its {@link ResidentTask} + cooldowns in * memory. Strictly additive: this class does not itself mutate any resident or - * worker entity — it only publishes "what this resident should do right now." - * Down-stream slices (D/E/F) translate published tasks into real NPC behavior. + * worker entity; it only publishes "what this resident should do right now." + * Downstream runtime code translates published tasks into real NPC behavior. * *

Dormant by default. Registration via * {@link #withDefaultGoals()} gives the stock six-goal set; tests supply their * own {@code List} for determinism. */ public final class BannerModResidentGoalScheduler { + private static final int FAILURE_BASE_COOLDOWN_TICKS = 80; + private static final int CONTEXT_INVALID_EXTRA_BACKOFF_TICKS = 40; + private static final int GOAL_REEVALUATION_HEARTBEAT_TICKS = 20; private final List goals; + private final Map goalsById; private final Map activeTasks = new HashMap<>(); + private final Map lastFinishedTasks = new HashMap<>(); private final Map> cooldownExpiries = new HashMap<>(); + private final Map recentOutcomes = new HashMap<>(); public BannerModResidentGoalScheduler(List goals) { if (goals == null) { throw new IllegalArgumentException("goals must not be null"); } this.goals = List.copyOf(goals); + Map goalsById = new HashMap<>(); + for (ResidentGoal goal : this.goals) { + if (goal != null && goal.id() != null) { + goalsById.put(goal.id(), goal); + } + } + this.goalsById = Map.copyOf(goalsById); } /** Default scheduler wired with the six stock stub goals. */ public static BannerModResidentGoalScheduler withDefaultGoals() { return new BannerModResidentGoalScheduler(List.of( + new DefendResidentGoal(), + new HideResidentGoal(), new IdleResidentGoal(), new RestResidentGoal(), + new EatResidentGoal(), new WorkResidentGoal(), - new SocialiseResidentGoal(), + new SeekSuppliesResidentGoal(), new DeliverResidentGoal(), new FetchResidentGoal() )); } - /** - * Default scheduler extended with the current Phase 25 household and seller - * runtime seams. This keeps those slices additive and composable without - * forcing live settlement orchestration to land in the same change. - */ + /** Default scheduler extended with household and seller runtime seams. */ public static BannerModResidentGoalScheduler withDefaultGoals( BannerModHomeAssignmentRuntime homeAssignmentRuntime, Supplier marketStateSupplier, @@ -80,12 +98,15 @@ public static BannerModResidentGoalScheduler withDefaultGoals( throw new IllegalArgumentException("sellerDispatchRuntime must not be null"); } return new BannerModResidentGoalScheduler(List.of( + new DefendResidentGoal(), + new HideResidentGoal(), new GoHomeResidentGoal(homeAssignmentRuntime), new RestResidentGoal(), + new EatResidentGoal(), new LeaveHomeResidentGoal(homeAssignmentRuntime), new SellerResidentGoal(marketStateSupplier, sellerDispatchRuntime), new WorkResidentGoal(), - new SocialiseResidentGoal(), + new SeekSuppliesResidentGoal(), new DeliverResidentGoal(), new FetchResidentGoal(), new IdleResidentGoal() @@ -104,7 +125,22 @@ public void tick(ResidentGoalContext ctx) { UUID residentId = ctx.residentId(); ResidentTask active = this.activeTasks.get(residentId); if (active != null && !active.isDone()) { - active.advance(); + GoalSelection alternative = this.shouldEvaluateAlternativeGoal(ctx, active) + ? this.selectBestGoal(ctx, active.goalId()) + : null; + if (this.shouldPreemptActiveTask(ctx, active, alternative)) { + active.syncElapsed(ctx.gameTime()); + if (active.finishTimedOutIfExpired()) { + this.onTaskFinished(residentId, active); + this.startSelection(ctx, alternative); + return; + } + active.finish(ResidentStopReason.PRE_EMPTED); + this.onTaskFinished(residentId, active); + this.startSelection(ctx, alternative); + return; + } + active.advance(ctx.gameTime()); if (active.isDone()) { this.onTaskFinished(residentId, active); } @@ -116,9 +152,14 @@ public void tick(ResidentGoalContext ctx) { this.startNextGoal(ctx); } - /** Current task for a resident, or empty if nothing scheduled. */ + /** Current active task, or the most recent finished task if nothing is active. */ public Optional currentTask(UUID residentId) { - return Optional.ofNullable(this.activeTasks.get(residentId)); + ResidentTask active = this.activeTasks.get(residentId); + return Optional.ofNullable(active != null ? active : this.lastFinishedTasks.get(residentId)); + } + + public Optional lastOutcome(UUID residentId) { + return Optional.ofNullable(this.recentOutcomes.get(residentId)); } /** @@ -140,7 +181,9 @@ public void forceStop(UUID residentId, ResidentStopReason reason) { /** Drop all in-memory state. Intended for test isolation and reloads. */ public void reset() { this.activeTasks.clear(); + this.lastFinishedTasks.clear(); this.cooldownExpiries.clear(); + this.recentOutcomes.clear(); } /** Read-only view of the registered goals, in registration order. */ @@ -153,9 +196,29 @@ public List goals() { // ------------------------------------------------------------------ private void startNextGoal(ResidentGoalContext ctx) { + this.startSelection(ctx, this.selectBestGoal(ctx, null)); + } + + private void startSelection(ResidentGoalContext ctx, @Nullable GoalSelection selection) { + if (selection == null) { + this.activeTasks.remove(ctx.residentId()); + return; + } + ResidentTask task = selection.goal.start(ctx); + if (task == null) { + this.activeTasks.remove(ctx.residentId()); + return; + } + this.activeTasks.put(ctx.residentId(), task); + } + + private @Nullable GoalSelection selectBestGoal(ResidentGoalContext ctx, @Nullable ResourceLocation excludedGoalId) { ResidentGoal best = null; int bestPriority = 0; for (ResidentGoal goal : this.goals) { + if (excludedGoalId != null && excludedGoalId.equals(goal.id())) { + continue; + } if (this.isOnCooldown(ctx.residentId(), goal.id(), ctx.gameTime())) { continue; } @@ -166,45 +229,103 @@ private void startNextGoal(ResidentGoalContext ctx) { if (priority <= 0) { continue; } - if (priority > bestPriority - || (priority == bestPriority && best != null && idOrderBefore(goal.id(), best.id()))) { + if (best == null + || priority > bestPriority + || (priority == bestPriority && idOrderBefore(goal.id(), best.id()))) { best = goal; bestPriority = priority; } } - if (best == null) { - this.activeTasks.remove(ctx.residentId()); - return; + return best == null ? null : new GoalSelection(best); + } + + private boolean shouldPreemptActiveTask(ResidentGoalContext ctx, + ResidentTask activeTask, + @Nullable GoalSelection alternative) { + if (ctx == null || activeTask == null || activeTask.goalId() == null || alternative == null) { + return false; } - ResidentTask task = best.start(ctx); - if (task == null) { - this.activeTasks.remove(ctx.residentId()); - return; + ResidentGoal activeGoal = this.findGoal(activeTask.goalId()); + if (activeGoal == null) { + return true; } - this.activeTasks.put(ctx.residentId(), task); + int currentPriority = activeGoal.computePriority(ctx); + if (currentPriority <= 0 || !activeGoal.canStart(ctx)) { + return true; + } + ResourceLocation currentGoalId = activeTask.goalId(); + ResourceLocation nextGoalId = alternative.goal.id(); + if (currentGoalId.equals(nextGoalId)) { + return false; + } + if (IdleResidentGoal.ID.equals(currentGoalId)) { + return true; + } + if (GoHomeResidentGoal.ID.equals(currentGoalId) + && RestResidentGoal.ID.equals(nextGoalId) + && ctx.isReadyToSettleAtHome()) { + return true; + } + if (isDangerOverride(nextGoalId)) { + return !isDangerOverride(currentGoalId); + } + return isNightHomeOverride(nextGoalId) + && !isNightHomeOverride(currentGoalId) + && (ctx.isRestPhase() || ctx.fatigueNeed() >= 85 || ctx.safetyNeed() >= 70); + } + + private boolean shouldEvaluateAlternativeGoal(ResidentGoalContext ctx, ResidentTask activeTask) { + if (ctx == null || activeTask == null || activeTask.goalId() == null) { + return false; + } + if (IdleResidentGoal.ID.equals(activeTask.goalId())) { + return true; + } + if (ctx.safetyNeed() >= 35) { + return true; + } + if (ctx.isRestPhase() && !isNightHomeOverride(activeTask.goalId())) { + return true; + } + long activeAge = Math.max(0L, ctx.gameTime() - activeTask.startGameTime()); + return activeAge <= 0L || activeAge % GOAL_REEVALUATION_HEARTBEAT_TICKS == 0L; } private void onTaskFinished(UUID residentId, ResidentTask task) { + if (residentId == null || task == null || task.stopReason() == null) { + return; + } ResidentGoal goal = this.findGoal(task.goalId()); + long finishedAt = task.startGameTime() + Math.max(0, task.elapsedTicks()); long expiresAt = 0L; if (goal != null && goal.cooldownTicks() > 0 && task.stopReason() == ResidentStopReason.COMPLETED) { - expiresAt = task.startGameTime() + task.elapsedTicks() + goal.cooldownTicks(); + expiresAt = finishedAt + goal.cooldownTicks(); + } + if (isFailure(task.stopReason())) { + expiresAt = Math.max(expiresAt, finishedAt + failureBackoffTicks(task.stopReason())); } if (expiresAt > 0L) { this.cooldownExpiries .computeIfAbsent(residentId, k -> new HashMap<>()) .put(task.goalId(), expiresAt); } + this.activeTasks.remove(residentId); + this.lastFinishedTasks.put(residentId, task); + this.recentOutcomes.put(residentId, new ResidentTaskOutcome(task.goalId(), task.stopReason(), finishedAt)); + } + + private static boolean isFailure(@Nullable ResidentStopReason reason) { + return reason == ResidentStopReason.TIMED_OUT || reason == ResidentStopReason.CONTEXT_INVALID; + } + + private static int failureBackoffTicks(@Nullable ResidentStopReason reason) { + return FAILURE_BASE_COOLDOWN_TICKS + + (reason == ResidentStopReason.CONTEXT_INVALID ? CONTEXT_INVALID_EXTRA_BACKOFF_TICKS : 0); } @Nullable private ResidentGoal findGoal(ResourceLocation id) { - for (ResidentGoal goal : this.goals) { - if (goal.id().equals(id)) { - return goal; - } - } - return null; + return id == null ? null : this.goalsById.get(id); } private boolean isOnCooldown(UUID residentId, ResourceLocation goalId, long gameTime) { @@ -227,6 +348,17 @@ private static boolean idOrderBefore(ResourceLocation a, ResourceLocation b) { return a.toString().compareTo(b.toString()) < 0; } + private static boolean isDangerOverride(ResourceLocation goalId) { + return HideResidentGoal.ID.equals(goalId) || DefendResidentGoal.ID.equals(goalId); + } + + private static boolean isNightHomeOverride(ResourceLocation goalId) { + return GoHomeResidentGoal.ID.equals(goalId) || RestResidentGoal.ID.equals(goalId); + } + + private record GoalSelection(ResidentGoal goal) { + } + // ------------------------------------------------------------------ // Test hooks (package-private; production code must not rely on these) // ------------------------------------------------------------------ @@ -238,4 +370,8 @@ Map activeTasksForTests() { Map> cooldownsForTests() { return Collections.unmodifiableMap(this.cooldownExpiries); } + + Map finishedTasksForTests() { + return Collections.unmodifiableMap(this.lastFinishedTasks); + } } diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java index 9c48e78b..f00fb0a2 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentGoalContext.java @@ -1,9 +1,20 @@ package com.talhanation.bannermod.settlement.goal; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.society.NpcHouseholdHousingState; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyDecisionSnapshot; +import com.talhanation.bannermod.society.NpcSocietyPhaseOneRuntime; +import com.talhanation.bannermod.society.NpcSocietyProfile; +import com.talhanation.bannermod.society.NpcLifeStage; import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; import com.talhanation.bannermod.settlement.SettlementResidentSchedulePolicy; import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; +import com.talhanation.bannermod.settlement.SettlementResidentRole; import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; import javax.annotation.Nullable; import java.util.UUID; @@ -11,9 +22,61 @@ public record ResidentGoalContext( SettlementResidentRecord resident, @Nullable SettlementSnapshot settlement, - long gameTime + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile societyProfile, + @Nullable Vec3 currentPosition ) { + private static final double HOME_SETTLE_DISTANCE_SQR = 36.0D; + private static final double LEAVE_HOME_FAN_OUT_DISTANCE_SQR = 16.0D; + + public ResidentGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot settlement, + long gameTime) { + this(resident, settlement, gameTime, gameTime, null, null); + } + + public ResidentGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot settlement, + long gameTime, + @Nullable NpcSocietyProfile societyProfile) { + this(resident, settlement, gameTime, gameTime, societyProfile, null); + } + + public ResidentGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot settlement, + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile societyProfile) { + this(resident, settlement, gameTime, worldDayTime, societyProfile, null); + } + + public ResidentGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot settlement, + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile societyProfile, + int householdSize, + NpcHouseholdHousingState householdHousingState, + boolean hasSpouse, + int childCount) { + this(resident, settlement, gameTime, worldDayTime, societyProfile, null); + } + + public ResidentGoalContext(SettlementResidentRecord resident, + @Nullable SettlementSnapshot settlement, + long gameTime, + long worldDayTime, + @Nullable NpcSocietyProfile societyProfile, + int householdSize, + NpcHouseholdHousingState householdHousingState, + boolean hasSpouse, + int childCount, + @Nullable Vec3 currentPosition) { + this(resident, settlement, gameTime, worldDayTime, societyProfile, currentPosition); + } + public UUID residentId() { return this.resident.residentUuid(); } @@ -28,7 +91,7 @@ public SettlementResidentScheduleWindowSeed window() { /** Minecraft day-of-time (0..23999) derived from absolute game time. */ public int dayTime() { - long time = this.gameTime % 24000L; + long time = this.worldDayTime % 24000L; if (time < 0L) { time += 24000L; } @@ -48,4 +111,196 @@ public boolean isRestPhase() { SettlementResidentScheduleWindowSeed w = this.window(); return t >= w.restStartTick() || t < w.activeStartTick(); } + + /** True during the gap between labor/civic work and the rest window. */ + public boolean isLeisurePhase() { + int t = this.dayTime(); + SettlementResidentScheduleWindowSeed w = this.window(); + return t >= w.activeEndTick() && t < w.restStartTick(); + } + + public boolean isDayRoutinePhase() { + return this.isActivePhase() || this.isLeisurePhase(); + } + + public int ticksSinceActiveStart() { + int t = this.dayTime(); + int activeStart = this.window().activeStartTick(); + return t < activeStart ? -1 : t - activeStart; + } + + public int ticksUntilRestStart() { + if (this.isRestPhase()) { + return 0; + } + int t = this.dayTime(); + int restStart = this.window().restStartTick(); + return t >= restStart ? -1 : restStart - t; + } + + public boolean isEarlyActiveWindow(int windowTicks) { + int sinceStart = this.ticksSinceActiveStart(); + return this.isActivePhase() && windowTicks > 0 && sinceStart >= 0 && sinceStart < windowTicks; + } + + public boolean isLateDayWindow(int windowTicks) { + int untilRest = this.ticksUntilRestStart(); + return windowTicks > 0 && untilRest > 0 && untilRest <= windowTicks; + } + + public NpcIntent currentPublishedIntent() { + return this.societyProfile == null || this.societyProfile.currentIntent() == null + ? NpcIntent.UNSPECIFIED + : this.societyProfile.currentIntent(); + } + + public NpcIntent lastPublishedIntent() { + if (this.societyProfile == null || this.societyProfile.decisionSnapshot() == null) { + return NpcIntent.UNSPECIFIED; + } + return NpcIntent.fromName(this.societyProfile.decisionSnapshot().lastIntentTag()); + } + + public long currentIntentAgeTicks() { + if (this.societyProfile == null || this.societyProfile.decisionSnapshot() == null) { + return 0L; + } + long started = this.societyProfile.decisionSnapshot().currentIntentStartedGameTime(); + if (started <= 0L) { + return 0L; + } + return Math.max(0L, this.gameTime - started); + } + + public @Nullable String previousBlockedGoalId() { + NpcSocietyDecisionSnapshot snapshot = this.societyProfile == null ? null : this.societyProfile.decisionSnapshot(); + if (snapshot == null || snapshot.blockedGoalId() == null || snapshot.blockedGoalId().isBlank()) { + return null; + } + return snapshot.blockedGoalId(); + } + + public String previousBlockedReasonTag() { + NpcSocietyDecisionSnapshot snapshot = this.societyProfile == null ? null : this.societyProfile.decisionSnapshot(); + return snapshot == null ? "NONE" : snapshot.blockedReasonTag(); + } + + public NpcIntent previousBlockedIntent() { + String goalId = this.previousBlockedGoalId(); + if (goalId == null) { + return NpcIntent.UNSPECIFIED; + } + return NpcSocietyPhaseOneRuntime.intentForGoal(ResourceLocation.tryParse(goalId)); + } + + public boolean isReadyToSettleAtHome() { + if (!this.isRestPhase() + || this.currentPublishedIntent() != NpcIntent.GO_HOME + || this.currentIntentAgeTicks() < 80L) { + return false; + } + Vec3 homeCenter = this.homeBuildingCenter(); + if (this.currentPosition == null || homeCenter == null) { + return true; + } + return this.currentPosition.distanceToSqr(homeCenter) <= HOME_SETTLE_DISTANCE_SQR; + } + + public boolean isReadyToFanOutFromLeaveHome() { + if (!this.isActivePhase() + || this.currentPublishedIntent() != NpcIntent.LEAVE_HOME + || this.currentIntentAgeTicks() < 50L) { + return false; + } + Vec3 homeCenter = this.homeBuildingCenter(); + if (this.currentPosition == null || homeCenter == null) { + return true; + } + return this.currentPosition.distanceToSqr(homeCenter) >= LEAVE_HOME_FAN_OUT_DISTANCE_SQR; + } + + public boolean recentlyCameFromHome() { + return this.currentPublishedIntent() == NpcIntent.LEAVE_HOME + || this.lastPublishedIntent() == NpcIntent.LEAVE_HOME; + } + + public boolean hasHome() { + return this.societyProfile != null && this.societyProfile.homeBuildingUuid() != null; + } + + private @Nullable Vec3 homeBuildingCenter() { + if (this.settlement == null || this.societyProfile == null || this.societyProfile.homeBuildingUuid() == null) { + return null; + } + for (SettlementBuildingRecord building : this.settlement.buildings()) { + if (building != null && this.societyProfile.homeBuildingUuid().equals(building.buildingUuid()) && building.originPos() != null) { + return Vec3.atCenterOf(building.originPos()); + } + } + return null; + } + + public boolean hasWorkAssignment() { + SettlementResidentAssignmentState state = this.resident.assignmentState(); + return this.resident.effectiveWorkBuildingUuid() != null + && (state == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + || state == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING); + } + + public int hungerNeed() { + return this.societyProfile == null ? 0 : this.societyProfile.hungerNeed(); + } + + public int fatigueNeed() { + return this.societyProfile == null ? 0 : this.societyProfile.fatigueNeed(); + } + + public int safetyNeed() { + return this.societyProfile == null ? 0 : this.societyProfile.safetyNeed(); + } + + public boolean canDefend() { + return this.resident.role() == SettlementResidentRole.GOVERNOR_RECRUIT; + } + + public boolean isAdolescent() { + return this.societyProfile != null && this.societyProfile.lifeStage() == NpcLifeStage.ADOLESCENT; + } + + public boolean hasMarketFoodAccess() { + return this.settlement != null && this.settlement.marketState().openMarketCount() > 0; + } + + public boolean hasSupplyAccess() { + if (this.hasMarketFoodAccess()) { + return true; + } + if (this.settlement == null) { + return false; + } + if (this.settlement.stockpileSummary().storageBuildingCount() > 0) { + return true; + } + for (SettlementBuildingRecord building : this.settlement.buildings()) { + if (building != null && building.stockpileBuilding()) { + return true; + } + } + return false; + } + + public boolean hasOnlyStockpileFoodAccess() { + return this.hasSupplyAccess() && !this.hasMarketFoodAccess(); + } + + public boolean shouldEscalateMealRecoveryToSupplies() { + if (!this.hasSupplyAccess()) { + return false; + } + if (this.hungerNeed() < 70) { + return false; + } + return !this.hasHome() && !this.hasMarketFoodAccess(); + } + } diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java index ba4e82c1..7714955e 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTask.java @@ -13,6 +13,7 @@ public final class ResidentTask { private final ResourceLocation goalId; private final long startGameTime; private final int maxTicks; + private long lastAdvancedGameTime; private int elapsedTicks; private boolean done; @Nullable @@ -28,6 +29,7 @@ public ResidentTask(ResourceLocation goalId, long startGameTime, int maxTicks) { this.goalId = goalId; this.startGameTime = startGameTime; this.maxTicks = maxTicks; + this.lastAdvancedGameTime = startGameTime; this.elapsedTicks = 0; this.done = false; this.stopReason = null; @@ -58,16 +60,32 @@ public ResidentStopReason stopReason() { return this.stopReason; } - /** Advance one tick. If max reached, finish with TIMED_OUT. */ - void advance() { + void syncElapsed(long gameTime) { if (this.done) { return; } - this.elapsedTicks++; - if (this.maxTicks > 0 && this.elapsedTicks >= this.maxTicks) { - this.done = true; - this.stopReason = ResidentStopReason.TIMED_OUT; + long normalizedGameTime = Math.max(this.lastAdvancedGameTime, gameTime); + long delta = Math.max(0L, normalizedGameTime - this.lastAdvancedGameTime); + this.lastAdvancedGameTime = normalizedGameTime; + this.elapsedTicks = (int) Math.min(Integer.MAX_VALUE, (long) this.elapsedTicks + delta); + } + + boolean finishTimedOutIfExpired() { + if (this.done || this.maxTicks <= 0 || this.elapsedTicks < this.maxTicks) { + return false; + } + this.done = true; + this.stopReason = ResidentStopReason.TIMED_OUT; + return true; + } + + /** Advance by elapsed game time since the last scheduler heartbeat. */ + void advance(long gameTime) { + this.syncElapsed(gameTime); + if (this.done) { + return; } + this.finishTimedOutIfExpired(); } /** Mark finished with the given reason. Idempotent: first call wins. */ diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java new file mode 100644 index 00000000..693b4d00 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/ResidentTaskOutcome.java @@ -0,0 +1,23 @@ +package com.talhanation.bannermod.settlement.goal; + +import net.minecraft.resources.ResourceLocation; + +public record ResidentTaskOutcome( + ResourceLocation goalId, + ResidentStopReason stopReason, + long finishedGameTime +) { + public ResidentTaskOutcome { + if (goalId == null) { + throw new IllegalArgumentException("goalId must not be null"); + } + if (stopReason == null) { + throw new IllegalArgumentException("stopReason must not be null"); + } + } + + public boolean isFailure() { + return this.stopReason == ResidentStopReason.TIMED_OUT + || this.stopReason == ResidentStopReason.CONTEXT_INVALID; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java new file mode 100644 index 00000000..800b4c84 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DefendResidentGoal.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.settlement.goal.impl; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; +import com.talhanation.bannermod.settlement.goal.ResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import net.minecraft.resources.ResourceLocation; + +public final class DefendResidentGoal implements ResidentGoal { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/defend"); + + private static final int DEFEND_DURATION_TICKS = 100; + private static final int DEFEND_COOLDOWN_TICKS = 80; + + @Override + public ResourceLocation id() { + return ID; + } + + @Override + public int computePriority(ResidentGoalContext ctx) { + return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.DEFEND); + } + + @Override + public boolean canStart(ResidentGoalContext ctx) { + return ctx.canDefend() && this.computePriority(ctx) > 0; + } + + @Override + public ResidentTask start(ResidentGoalContext ctx) { + return new ResidentTask(ID, ctx.gameTime(), DEFEND_DURATION_TICKS); + } + + @Override + public int cooldownTicks() { + return DEFEND_COOLDOWN_TICKS; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java index d06de770..b750131a 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/DeliverResidentGoal.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.goal.impl; import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; import com.talhanation.bannermod.settlement.goal.ResidentGoal; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; @@ -9,8 +11,7 @@ /** * Placeholder for the "deliver goods from workplace to stockpile/market" goal. - * Wired only for residents with a bound workplace; concrete target resolution - * lands in slice 25-next-C (project bridge) + 25-next-D (seller dispatch). + * Wired only for residents with a bound workplace. */ public final class DeliverResidentGoal implements ResidentGoal { @@ -26,7 +27,14 @@ public ResourceLocation id() { @Override public int computePriority(ResidentGoalContext ctx) { - return ctx.isActivePhase() ? DELIVER_PRIORITY : 0; + if (!ctx.isActivePhase()) { + return 0; + } + int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) - 2; + if (score <= 0) { + return 0; + } + return Math.max(DELIVER_PRIORITY, score); } @Override @@ -34,7 +42,7 @@ public boolean canStart(ResidentGoalContext ctx) { if (!ctx.isActivePhase()) { return false; } - if (ctx.resident().boundWorkAreaUuid() == null) { + if (ctx.resident().effectiveWorkBuildingUuid() == null) { return false; } return ctx.resident().assignmentState() == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING; diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java new file mode 100644 index 00000000..bfe7b8be --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/EatResidentGoal.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.settlement.goal.impl; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; +import com.talhanation.bannermod.settlement.goal.ResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import net.minecraft.resources.ResourceLocation; + +public final class EatResidentGoal implements ResidentGoal { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/eat"); + + private static final int EAT_DURATION_TICKS = 200; + private static final int EAT_COOLDOWN_TICKS = 200; + + @Override + public ResourceLocation id() { + return ID; + } + + @Override + public int computePriority(ResidentGoalContext ctx) { + return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.EAT); + } + + @Override + public boolean canStart(ResidentGoalContext ctx) { + return ctx.hasHome() || ctx.hasMarketFoodAccess(); + } + + @Override + public ResidentTask start(ResidentGoalContext ctx) { + return new ResidentTask(ID, ctx.gameTime(), EAT_DURATION_TICKS); + } + + @Override + public int cooldownTicks() { + return EAT_COOLDOWN_TICKS; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java index 2e442b22..598ab6b7 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/FetchResidentGoal.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.goal.impl; import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; import com.talhanation.bannermod.settlement.goal.ResidentGoal; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; @@ -27,7 +29,14 @@ public ResourceLocation id() { @Override public int computePriority(ResidentGoalContext ctx) { - return ctx.isActivePhase() ? FETCH_PRIORITY : 0; + if (!ctx.isActivePhase()) { + return 0; + } + int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK) - 3; + if (score <= 0) { + return 0; + } + return Math.max(FETCH_PRIORITY, score); } @Override @@ -35,7 +44,7 @@ public boolean canStart(ResidentGoalContext ctx) { if (!ctx.isActivePhase()) { return false; } - if (ctx.resident().boundWorkAreaUuid() == null) { + if (ctx.resident().effectiveWorkBuildingUuid() == null) { return false; } return ctx.resident().assignmentState() == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING; diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java new file mode 100644 index 00000000..1279dac5 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/HideResidentGoal.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.settlement.goal.impl; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; +import com.talhanation.bannermod.settlement.goal.ResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import net.minecraft.resources.ResourceLocation; + +public final class HideResidentGoal implements ResidentGoal { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/hide"); + + private static final int HIDE_DURATION_TICKS = 200; + private static final int HIDE_COOLDOWN_TICKS = 120; + + @Override + public ResourceLocation id() { + return ID; + } + + @Override + public int computePriority(ResidentGoalContext ctx) { + return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.HIDE); + } + + @Override + public boolean canStart(ResidentGoalContext ctx) { + return true; + } + + @Override + public ResidentTask start(ResidentGoalContext ctx) { + return new ResidentTask(ID, ctx.gameTime(), HIDE_DURATION_TICKS); + } + + @Override + public int cooldownTicks() { + return HIDE_COOLDOWN_TICKS; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java index ef7b5785..b0b18631 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/IdleResidentGoal.java @@ -6,13 +6,13 @@ import com.talhanation.bannermod.bootstrap.BannerModMain; import net.minecraft.resources.ResourceLocation; -/** Lowest-priority fallback. Always startable; runs for 40 ticks. */ +/** Lowest-priority fallback. Always startable; runs on a coarse heartbeat. */ public final class IdleResidentGoal implements ResidentGoal { public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/idle"); private static final int IDLE_PRIORITY = 1; - private static final int IDLE_DURATION_TICKS = 40; + private static final int IDLE_DURATION_TICKS = 160; @Override public ResourceLocation id() { diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java index 6af08554..e3367bf6 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/RestResidentGoal.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.goal.impl; import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.goal.ResidentGoal; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; import com.talhanation.bannermod.settlement.goal.ResidentTask; @@ -12,7 +14,7 @@ public final class RestResidentGoal implements ResidentGoal { public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/rest"); private static final int REST_PRIORITY = 90; - private static final int REST_DURATION_TICKS = 200; + private static final int REST_DURATION_TICKS = 400; private static final int REST_COOLDOWN_TICKS = 600; @Override @@ -22,12 +24,16 @@ public ResourceLocation id() { @Override public int computePriority(ResidentGoalContext ctx) { - return ctx.isRestPhase() ? REST_PRIORITY : 0; + int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.REST); + if (ctx.isReadyToSettleAtHome()) { + score = Math.max(score, REST_PRIORITY + 18); + } + return ctx.isRestPhase() ? Math.max(REST_PRIORITY - 4, score) : score; } @Override public boolean canStart(ResidentGoalContext ctx) { - return ctx.isRestPhase(); + return ctx.isRestPhase() || (ctx.hasHome() && ctx.fatigueNeed() >= 85); } @Override diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java new file mode 100644 index 00000000..308eb071 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SeekSuppliesResidentGoal.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.settlement.goal.impl; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; +import com.talhanation.bannermod.settlement.goal.ResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import net.minecraft.resources.ResourceLocation; + +public final class SeekSuppliesResidentGoal implements ResidentGoal { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/seek_supplies"); + + private static final int SEEK_SUPPLIES_DURATION_TICKS = 240; + private static final int SEEK_SUPPLIES_COOLDOWN_TICKS = 200; + + @Override + public ResourceLocation id() { + return ID; + } + + @Override + public int computePriority(ResidentGoalContext ctx) { + return NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.SEEK_SUPPLIES); + } + + @Override + public boolean canStart(ResidentGoalContext ctx) { + return ctx.hasSupplyAccess(); + } + + @Override + public ResidentTask start(ResidentGoalContext ctx) { + return new ResidentTask(ID, ctx.gameTime(), SEEK_SUPPLIES_DURATION_TICKS); + } + + @Override + public int cooldownTicks() { + return SEEK_SUPPLIES_COOLDOWN_TICKS; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java deleted file mode 100644 index a29a7c3c..00000000 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/SocialiseResidentGoal.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.talhanation.bannermod.settlement.goal.impl; - -import com.talhanation.bannermod.bootstrap.BannerModMain; -import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; -import com.talhanation.bannermod.settlement.goal.ResidentGoal; -import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; -import com.talhanation.bannermod.settlement.goal.ResidentTask; -import net.minecraft.resources.ResourceLocation; - -/** Mid-priority leisure goal active during civic-day windows. */ -public final class SocialiseResidentGoal implements ResidentGoal { - - public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/socialise"); - - private static final int SOCIALISE_PRIORITY = 30; - private static final int SOCIALISE_DURATION_TICKS = 120; - private static final int SOCIALISE_COOLDOWN_TICKS = 400; - - @Override - public ResourceLocation id() { - return ID; - } - - @Override - public int computePriority(ResidentGoalContext ctx) { - if (!ctx.isActivePhase()) { - return 0; - } - return ctx.window() == SettlementResidentScheduleWindowSeed.CIVIC_DAY - || ctx.window() == SettlementResidentScheduleWindowSeed.DAYLIGHT_FLEX - ? SOCIALISE_PRIORITY - : 0; - } - - @Override - public boolean canStart(ResidentGoalContext ctx) { - return this.computePriority(ctx) > 0; - } - - @Override - public ResidentTask start(ResidentGoalContext ctx) { - return new ResidentTask(ID, ctx.gameTime(), SOCIALISE_DURATION_TICKS); - } - - @Override - public int cooldownTicks() { - return SOCIALISE_COOLDOWN_TICKS; - } -} diff --git a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java index 0b59cf02..9e4e9f68 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/goal/impl/WorkResidentGoal.java @@ -1,7 +1,8 @@ package com.talhanation.bannermod.settlement.goal.impl; import com.talhanation.bannermod.bootstrap.BannerModMain; -import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.SettlementResidentRole; import com.talhanation.bannermod.settlement.goal.ResidentGoal; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; @@ -9,16 +10,15 @@ import net.minecraft.resources.ResourceLocation; /** - * Core day-labor goal for residents that have a workplace assignment. Sits - * above {@link SocialiseResidentGoal} in priority during active hours so - * bound workers prefer their job over chatter. + * Core day-labor goal for residents that have a workplace assignment. + * Bound workers should prefer useful labor during active hours. */ public final class WorkResidentGoal implements ResidentGoal { public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/work"); private static final int WORK_PRIORITY = 60; - private static final int WORK_DURATION_TICKS = 400; + private static final int WORK_DURATION_TICKS = 600; @Override public ResourceLocation id() { @@ -27,7 +27,14 @@ public ResourceLocation id() { @Override public int computePriority(ResidentGoalContext ctx) { - return ctx.isActivePhase() ? WORK_PRIORITY : 0; + if (!ctx.isActivePhase()) { + return 0; + } + int score = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK); + if (score <= 0) { + return 0; + } + return Math.max(WORK_PRIORITY - 10, score); } @Override @@ -35,12 +42,13 @@ public boolean canStart(ResidentGoalContext ctx) { if (!ctx.isActivePhase()) { return false; } + if (ctx.fatigueNeed() >= 90) { + return false; + } if (ctx.resident().role() == SettlementResidentRole.GOVERNOR_RECRUIT) { return false; } - SettlementResidentAssignmentState state = ctx.resident().assignmentState(); - return state == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING - || state == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING; + return ctx.hasWorkAssignment(); } @Override diff --git a/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java b/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java index 1d1582a5..050da893 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java +++ b/src/main/java/com/talhanation/bannermod/settlement/growth/PendingProject.java @@ -3,6 +3,7 @@ import com.talhanation.bannermod.settlement.SettlementBuildingCategory; import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed; import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; import javax.annotation.Nullable; import java.util.UUID; @@ -16,6 +17,7 @@ public record PendingProject( UUID projectId, ProjectKind kind, @Nullable UUID targetBuildingUuid, + @Nullable ResourceLocation prefabId, SettlementBuildingCategory buildingCategory, SettlementBuildingProfileSeed profileSeed, int priorityScore, @@ -46,6 +48,18 @@ public record PendingProject( } } + public PendingProject(UUID projectId, + ProjectKind kind, + @Nullable UUID targetBuildingUuid, + SettlementBuildingCategory buildingCategory, + SettlementBuildingProfileSeed profileSeed, + int priorityScore, + long proposedAtGameTime, + int estimatedTickCost, + ProjectBlocker blockerReason) { + this(projectId, kind, targetBuildingUuid, null, buildingCategory, profileSeed, priorityScore, proposedAtGameTime, estimatedTickCost, blockerReason); + } + public CompoundTag toTag() { CompoundTag tag = new CompoundTag(); tag.putUUID("Id", projectId); @@ -53,6 +67,9 @@ public CompoundTag toTag() { if (targetBuildingUuid != null) { tag.putUUID("Target", targetBuildingUuid); } + if (prefabId != null) { + tag.putString("PrefabId", prefabId.toString()); + } tag.putString("Category", buildingCategory.name()); tag.putString("Profile", profileSeed.name()); tag.putInt("Priority", priorityScore); @@ -68,6 +85,7 @@ public static PendingProject fromTag(CompoundTag tag) { tag.getUUID("Id"), kindFromTagName(tag.getString("Kind")), target, + tag.contains("PrefabId") ? ResourceLocation.tryParse(tag.getString("PrefabId")) : null, SettlementBuildingCategory.fromTagName(tag.getString("Category")), SettlementBuildingProfileSeed.fromTagName(tag.getString("Profile")), tag.getInt("Priority"), diff --git a/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java b/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java index 9fa076de..09b54911 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java +++ b/src/main/java/com/talhanation/bannermod/settlement/growth/SettlementGrowthManager.java @@ -321,6 +321,7 @@ PendingProject toPendingProject(SettlementGrowthContext ctx) { projectId, ProjectKind.NEW_BUILDING, null, + null, category, this.profile, this.clampedScore(), diff --git a/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java index 64d2a16f..f9d0d83d 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/household/GoHomeResidentGoal.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.household; import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyPhaseTwoIntentScorer; import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; import com.talhanation.bannermod.settlement.goal.ResidentGoal; import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; @@ -20,7 +22,7 @@ public final class GoHomeResidentGoal implements ResidentGoal { public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/go_home"); private static final int GO_HOME_PRIORITY = 95; - private static final int GO_HOME_DURATION_TICKS = 120; + private static final int GO_HOME_DURATION_TICKS = 200; private static final int GO_HOME_COOLDOWN_TICKS = 400; /** Tick-window before the rest phase begins where the goal also arms. */ @@ -48,7 +50,16 @@ public int computePriority(ResidentGoalContext ctx) { if (this.runtime.homeFor(ctx.residentId()).isEmpty()) { return 0; } - return GO_HOME_PRIORITY; + int goHomeBias = GO_HOME_PRIORITY; + if (ctx.isRestPhase()) { + goHomeBias += 35; + } else if (ctx.fatigueNeed() >= 80) { + goHomeBias += 20; + } + if (ctx.isReadyToSettleAtHome()) { + goHomeBias -= 42; + } + return Math.max(goHomeBias, NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.GO_HOME)); } @Override @@ -70,6 +81,9 @@ public int cooldownTicks() { } private static boolean isRestOrApproachingRest(ResidentGoalContext ctx) { + if (ctx.hasHome() && ctx.fatigueNeed() >= 80) { + return true; + } if (ctx.isRestPhase()) { return true; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java b/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java index 3f8e3f84..b9d8a97c 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java +++ b/src/main/java/com/talhanation/bannermod/settlement/household/LeaveHomeResidentGoal.java @@ -21,7 +21,7 @@ public final class LeaveHomeResidentGoal implements ResidentGoal { public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, "resident/goal/leave_home"); private static final int LEAVE_HOME_PRIORITY = 80; - private static final int LEAVE_HOME_DURATION_TICKS = 60; + private static final int LEAVE_HOME_DURATION_TICKS = 120; private static final int LEAVE_HOME_COOLDOWN_TICKS = 400; /** Window after active-phase start where this goal may still fire. */ @@ -49,7 +49,13 @@ public int computePriority(ResidentGoalContext ctx) { if (this.runtime.homeFor(ctx.residentId()).isEmpty()) { return 0; } - return LEAVE_HOME_PRIORITY; + int priority = LEAVE_HOME_PRIORITY; + if (ctx.isReadyToFanOutFromLeaveHome()) { + priority -= 52; + } else if (ctx.currentPublishedIntent() == com.talhanation.bannermod.society.NpcIntent.LEAVE_HOME) { + priority += 8; + } + return priority; } @Override @@ -57,6 +63,9 @@ public boolean canStart(ResidentGoalContext ctx) { if (this.runtime.homeFor(ctx.residentId()).isEmpty()) { return false; } + if (ctx.fatigueNeed() >= 85) { + return false; + } return isEarlyActive(ctx); } diff --git a/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java b/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java index 4159e112..25e69379 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java +++ b/src/main/java/com/talhanation/bannermod/settlement/prefab/staffing/PrefabAutoStaffingRuntime.java @@ -23,6 +23,8 @@ import com.talhanation.bannermod.settlement.prefab.impl.MinePrefab; import com.talhanation.bannermod.settlement.building.BuildingType; import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import com.talhanation.bannermod.society.NpcLifeStage; +import com.talhanation.bannermod.society.NpcSocietyAccess; import net.minecraft.core.BlockPos; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; @@ -286,6 +288,10 @@ public static void assignCitizenToNearestVacancy(ServerLevel level, CitizenEntit if (level == null || citizen == null || !citizen.isAlive() || citizen.isRemoved()) { return; } + NpcLifeStage lifeStage = NpcSocietyAccess.ensureResident(level, citizen.getUUID(), level.getGameTime()).lifeStage(); + if (lifeStage != NpcLifeStage.ADULT && lifeStage != NpcLifeStage.ELDER) { + return; + } long pausedUntil = citizen.getPersistentData().getLong(TAG_ASSIGNMENT_PAUSE_UNTIL); if (pausedUntil > level.getGameTime()) { return; diff --git a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java index 1cc40eac..24fcff45 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java +++ b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectRuntime.java @@ -101,7 +101,7 @@ public Optional tickClaim( && scheduler.peek(claimUuid) .filter(project -> project.kind() == ProjectKind.NEW_BUILDING) .isPresent() - && SettlementProjectWorldExecution.ensureExecutableTarget( + && BannerModSettlementProjectWorldExecution.ensureExecutableTarget( ignoredLevel, claimUuid, scheduler.peek(claimUuid).orElse(null))) { diff --git a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java index 56498607..e6f3e642 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java +++ b/src/main/java/com/talhanation/bannermod/settlement/project/SettlementProjectWorldExecution.java @@ -13,6 +13,8 @@ import com.talhanation.bannermod.settlement.prefab.impl.LumberCampPrefab; import com.talhanation.bannermod.settlement.prefab.impl.MarketStallPrefab; import com.talhanation.bannermod.settlement.prefab.impl.StoragePrefab; +import com.talhanation.bannermod.society.NpcHousingRequestAccess; +import com.talhanation.bannermod.society.NpcHousingRequestRecord; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.resources.ResourceLocation; @@ -23,9 +25,9 @@ import java.util.List; import java.util.UUID; -final class SettlementProjectWorldExecution { +final class BannerModSettlementProjectWorldExecution { - private SettlementProjectWorldExecution() { + private BannerModSettlementProjectWorldExecution() { } static boolean ensureExecutableTarget(ServerLevel level, UUID claimUuid, PendingProject project) { @@ -44,13 +46,35 @@ static boolean ensureExecutableTarget(ServerLevel level, UUID claimUuid, Pending if (buildAreas.stream().anyMatch(buildArea -> buildArea != null && buildArea.isAlive() && !buildArea.isDone())) { return false; } - return BuildingPlacementService.placeForClaim( + NpcHousingRequestRecord housingRequest = NpcHousingRequestAccess.requestForProject(level, project.projectId()); + BuildingPlacementService.Result result = BuildingPlacementService.placeForClaim( level, claim, - prefabIdFor(project.profileSeed()), - choosePlacementPos(level, claim, buildAreas.size()), + prefabIdFor(project), + housingRequest != null && housingRequest.reservedPlotPos() != null + ? housingRequest.reservedPlotPos() + : choosePlacementPos(level, claim, buildAreas.size()), Direction.SOUTH - ) == BuildingPlacementService.Result.PLACED; + ); + if (result != BuildingPlacementService.Result.PLACED) { + return false; + } + if (project != null && project.prefabId() != null) { + BuildArea placedArea = BannerModBuildAreaProjectBridge.collectBuildAreas(level, claim).stream() + .filter(buildArea -> buildArea != null && buildArea.isAlive() && !buildArea.isDone()) + .max(java.util.Comparator.comparingInt(net.minecraft.world.entity.Entity::getId)) + .orElse(null); + if (placedArea != null) { + if (housingRequest != null) { + NpcHousingRequestAccess.bindBuildArea(level, housingRequest.householdId(), placedArea.getUUID(), level.getGameTime()); + } + // First autonomous livelihood slice: once the ruler approves the request, + // material bootstrap is granted immediately so the new workplace can start + // supporting the settlement instead of deadlocking on missing resources. + placedArea.setStartBuild(true); + } + } + return true; } private static RecruitsClaim resolveClaim(UUID claimUuid) { @@ -62,7 +86,11 @@ private static RecruitsClaim resolveClaim(UUID claimUuid) { return null; } - private static ResourceLocation prefabIdFor(SettlementBuildingProfileSeed profileSeed) { + private static ResourceLocation prefabIdFor(PendingProject project) { + if (project != null && project.prefabId() != null) { + return project.prefabId(); + } + SettlementBuildingProfileSeed profileSeed = project == null ? null : project.profileSeed(); return switch (profileSeed == null ? SettlementBuildingProfileSeed.GENERAL : profileSeed) { case FOOD_PRODUCTION -> FarmPrefab.ID; case MATERIAL_PRODUCTION -> LumberCampPrefab.ID; diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java index d9e157cb..1ca2e51c 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/SettlementWorkOrderPublishContext.java @@ -3,6 +3,8 @@ import com.talhanation.bannermod.settlement.SettlementBuildingRecord; import com.talhanation.bannermod.settlement.SettlementSnapshot; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; import javax.annotation.Nullable; import java.util.Objects; @@ -28,4 +30,23 @@ public record SettlementWorkOrderPublishContext( Objects.requireNonNull(building, "building"); Objects.requireNonNull(snapshot, "snapshot"); } + + public @Nullable T resolveBuildingEntity(Class entityClass) { + if (entityClass == null || this.level == null) { + return null; + } + Entity direct = this.level.getEntity(this.building.buildingUuid()); + if (entityClass.isInstance(direct) && direct != null && direct.isAlive()) { + return entityClass.cast(direct); + } + if (this.building.originPos() == null) { + return null; + } + AABB searchBox = new AABB(this.building.originPos()).inflate(1.5D); + return this.level.getEntitiesOfClass(entityClass, searchBox, + entity -> entity != null && entity.isAlive() && this.building.originPos().equals(entity.blockPosition())) + .stream() + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java index 294530d8..b1f6b60b 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/AnimalPenWorkOrderPublisher.java @@ -34,8 +34,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof AnimalPenArea pen) || !pen.isAlive()) { + AnimalPenArea pen = ctx.resolveBuildingEntity(AnimalPenArea.class); + if (pen == null) { return; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java index 495f6074..a8d4d505 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/BuildAreaWorkOrderPublisher.java @@ -38,8 +38,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof BuildArea buildArea) || !buildArea.isAlive()) { + BuildArea buildArea = ctx.resolveBuildingEntity(BuildArea.class); + if (buildArea == null) { return; } if (!buildArea.hasPendingBuildWork()) { diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java index 1d44318c..7f52bcee 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/CropAreaWorkOrderPublisher.java @@ -36,8 +36,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof CropArea cropArea) || !cropArea.isAlive()) { + CropArea cropArea = ctx.resolveBuildingEntity(CropArea.class); + if (cropArea == null) { return; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java index 55593703..e5c5605c 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/FishingAreaWorkOrderPublisher.java @@ -25,8 +25,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof FishingArea fishingArea) || !fishingArea.isAlive()) { + FishingArea fishingArea = ctx.resolveBuildingEntity(FishingArea.class); + if (fishingArea == null) { return; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java index ba73e759..10b3c852 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/LumberAreaWorkOrderPublisher.java @@ -37,8 +37,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof LumberArea lumberArea) || !lumberArea.isAlive()) { + LumberArea lumberArea = ctx.resolveBuildingEntity(LumberArea.class); + if (lumberArea == null) { return; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java index 387c9825..562e48d7 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/MiningAreaWorkOrderPublisher.java @@ -32,8 +32,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity entity = level.getEntity(ctx.building().buildingUuid()); - if (!(entity instanceof MiningArea miningArea) || !miningArea.isAlive()) { + MiningArea miningArea = ctx.resolveBuildingEntity(MiningArea.class); + if (miningArea == null) { return; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java index d1f24f6b..b8d36c52 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java +++ b/src/main/java/com/talhanation/bannermod/settlement/workorder/publisher/StockpileTransportWorkOrderPublisher.java @@ -44,8 +44,8 @@ public void publish(SettlementWorkOrderPublishContext ctx) { if (level == null) { return; } - Entity sourceEntity = level.getEntity(ctx.building().buildingUuid()); - if (!(sourceEntity instanceof StorageArea source) || !source.isAlive()) { + StorageArea source = ctx.resolveBuildingEntity(StorageArea.class); + if (source == null) { return; } BannerModLogisticsAuthoringState authoring = source.getLogisticsRouteAuthoringState(); diff --git a/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java b/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java new file mode 100644 index 00000000..e10136d9 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcAnchorType.java @@ -0,0 +1,21 @@ +package com.talhanation.bannermod.society; + +public enum NpcAnchorType { + NONE, + HOME, + WORKPLACE, + MARKET, + BARRACKS, + STREET; + + public static NpcAnchorType fromName(String name) { + if (name == null || name.isBlank()) { + return NONE; + } + try { + return NpcAnchorType.valueOf(name); + } catch (IllegalArgumentException ignored) { + return NONE; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java b/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java new file mode 100644 index 00000000..368b8f72 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcDailyPhase.java @@ -0,0 +1,20 @@ +package com.talhanation.bannermod.society; + +public enum NpcDailyPhase { + UNSPECIFIED, + ACTIVE, + DEPARTING_HOME, + RETURNING_HOME, + REST; + + public static NpcDailyPhase fromName(String name) { + if (name == null || name.isBlank()) { + return UNSPECIFIED; + } + try { + return NpcDailyPhase.valueOf(name); + } catch (IllegalArgumentException ignored) { + return UNSPECIFIED; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java new file mode 100644 index 00000000..6d386b22 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyAccess.java @@ -0,0 +1,279 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class NpcFamilyAccess { + private NpcFamilyAccess() { + } + + public static void reconcileFamilyForResident(ServerLevel level, UUID residentUuid, long gameTime) { + if (level == null || residentUuid == null) { + return; + } + NpcHouseholdRecord household = NpcHouseholdAccess.householdForResident(level, residentUuid).orElse(null); + if (household == null) { + return; + } + reconcileHousehold(level, household, gameTime); + } + + public static void reconcileHousehold(ServerLevel level, UUID householdId, long gameTime) { + if (level == null || householdId == null) { + return; + } + NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, householdId).orElse(null); + if (household == null) { + return; + } + reconcileHousehold(level, household, gameTime); + } + + public static void moveResident(ServerLevel level, UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (level == null) { + return; + } + NpcFamilySavedData.get(level).runtime().moveResident(fromResidentUuid, toResidentUuid, gameTime); + } + + public static NpcFamilyTreeSnapshot familyTreeSnapshot(ServerLevel level, UUID residentUuid, long gameTime) { + if (level == null || residentUuid == null) { + return NpcFamilyTreeSnapshot.empty(); + } + NpcSocietyProfile selfProfile = NpcSocietyAccess.ensureResident(level, residentUuid, gameTime); + NpcFamilyRecord family = NpcFamilySavedData.get(level).runtime().familyFor(residentUuid).orElse(null); + NpcFamilyMemberSnapshot self = memberSnapshot(level, residentUuid, selfProfile, "self", gameTime); + if (family == null) { + return new NpcFamilyTreeSnapshot(self, null, null, null, List.of()); + } + NpcFamilyMemberSnapshot spouse = family.spouseUuid() == null ? null : memberSnapshot(level, family.spouseUuid(), null, "spouse", gameTime); + NpcFamilyMemberSnapshot mother = family.motherUuid() == null ? null : memberSnapshot(level, family.motherUuid(), null, "mother", gameTime); + NpcFamilyMemberSnapshot father = family.fatherUuid() == null ? null : memberSnapshot(level, family.fatherUuid(), null, "father", gameTime); + List children = new ArrayList<>(); + for (UUID childUuid : family.childUuids()) { + if (childUuid != null) { + children.add(memberSnapshot(level, childUuid, null, "child", gameTime)); + } + } + return new NpcFamilyTreeSnapshot(self, spouse, mother, father, List.copyOf(children)); + } + + private static void reconcileHousehold(ServerLevel level, NpcHouseholdRecord household, long gameTime) { + NpcFamilyRuntime runtime = NpcFamilySavedData.get(level).runtime(); + List members = new ArrayList<>(); + Map existingByResident = new LinkedHashMap<>(); + for (UUID memberResidentUuid : household.memberResidentUuids()) { + if (memberResidentUuid == null) { + continue; + } + members.add(NpcSocietyAccess.ensureResident(level, memberResidentUuid, gameTime)); + runtime.familyFor(memberResidentUuid).ifPresent(record -> existingByResident.put(memberResidentUuid, record)); + } + members.sort(memberOrder()); + Set validResidentIds = new LinkedHashSet<>(); + for (NpcSocietyProfile member : members) { + if (member.residentUuid() != null) { + validResidentIds.add(member.residentUuid()); + } + } + + UUID headResidentUuid = chooseHead(household, members, validResidentIds); + SpousePair spousePair = chooseSpousePair(members, existingByResident, validResidentIds); + UUID defaultMotherUuid = spousePair == null ? null : spousePair.femaleAdult(); + UUID defaultFatherUuid = spousePair == null ? null : spousePair.maleAdult(); + + Map motherByChild = new LinkedHashMap<>(); + Map fatherByChild = new LinkedHashMap<>(); + for (NpcSocietyProfile member : members) { + UUID residentUuid = member.residentUuid(); + if (residentUuid == null || !isMinor(member)) { + continue; + } + NpcFamilyRecord existing = existingByResident.get(residentUuid); + UUID motherUuid = existing != null && validResidentIds.contains(existing.motherUuid()) ? existing.motherUuid() : defaultMotherUuid; + UUID fatherUuid = existing != null && validResidentIds.contains(existing.fatherUuid()) ? existing.fatherUuid() : defaultFatherUuid; + motherByChild.put(residentUuid, motherUuid); + fatherByChild.put(residentUuid, fatherUuid); + } + + Map> childrenByParent = new LinkedHashMap<>(); + for (NpcSocietyProfile member : members) { + if (member.residentUuid() != null) { + childrenByParent.put(member.residentUuid(), new ArrayList<>()); + } + } + for (Map.Entry entry : motherByChild.entrySet()) { + if (entry.getValue() != null && childrenByParent.containsKey(entry.getValue())) { + childrenByParent.get(entry.getValue()).add(entry.getKey()); + } + } + for (Map.Entry entry : fatherByChild.entrySet()) { + if (entry.getValue() != null && childrenByParent.containsKey(entry.getValue()) + && !childrenByParent.get(entry.getValue()).contains(entry.getKey())) { + childrenByParent.get(entry.getValue()).add(entry.getKey()); + } + } + + List records = new ArrayList<>(); + for (NpcSocietyProfile member : members) { + UUID residentUuid = member.residentUuid(); + if (residentUuid == null) { + continue; + } + NpcFamilyRecord existing = existingByResident.get(residentUuid); + UUID spouseUuid = resolveSpouse(residentUuid, spousePair, validResidentIds, existing); + UUID motherUuid = isMinor(member) ? motherByChild.get(residentUuid) : null; + UUID fatherUuid = isMinor(member) ? fatherByChild.get(residentUuid) : null; + List ownChildren = childrenByParent.getOrDefault(residentUuid, List.of()); + records.add(NpcFamilyRecord.create( + residentUuid, + household.householdId(), + spouseUuid, + motherUuid, + fatherUuid, + ownChildren, + gameTime + )); + } + + NpcHouseholdAccess.updateHead(level, household.householdId(), headResidentUuid, gameTime); + runtime.replaceHousehold(household.householdId(), records); + } + + private static @Nullable UUID chooseHead(NpcHouseholdRecord household, + List members, + Set validResidentIds) { + if (household.headResidentUuid() != null && validResidentIds.contains(household.headResidentUuid())) { + return household.headResidentUuid(); + } + for (NpcSocietyProfile member : members) { + if (isAdult(member) && member.residentUuid() != null) { + return member.residentUuid(); + } + } + return members.isEmpty() ? null : members.getFirst().residentUuid(); + } + + private static @Nullable SpousePair chooseSpousePair(List members, + Map existingByResident, + Set validResidentIds) { + for (NpcSocietyProfile member : members) { + UUID residentUuid = member.residentUuid(); + if (residentUuid == null || !isAdult(member)) { + continue; + } + NpcFamilyRecord existing = existingByResident.get(residentUuid); + if (existing == null || existing.spouseUuid() == null || !validResidentIds.contains(existing.spouseUuid())) { + continue; + } + NpcFamilyRecord spouseRecord = existingByResident.get(existing.spouseUuid()); + if (spouseRecord == null || !residentUuid.equals(spouseRecord.spouseUuid())) { + continue; + } + NpcSocietyProfile spouseProfile = profileById(members, existing.spouseUuid()); + if (spouseProfile != null && isAdult(spouseProfile)) { + return SpousePair.of(member, spouseProfile); + } + } + + NpcSocietyProfile female = null; + NpcSocietyProfile male = null; + for (NpcSocietyProfile member : members) { + if (!isAdult(member) || member.residentUuid() == null) { + continue; + } + if (female == null && member.sex() == NpcSex.FEMALE) { + female = member; + } else if (male == null && member.sex() == NpcSex.MALE) { + male = member; + } + } + if (female != null && male != null && !female.residentUuid().equals(male.residentUuid())) { + return SpousePair.of(female, male); + } + return null; + } + + private static @Nullable UUID resolveSpouse(UUID residentUuid, + @Nullable SpousePair spousePair, + Set validResidentIds, + @Nullable NpcFamilyRecord existing) { + if (spousePair != null) { + if (residentUuid.equals(spousePair.first())) { + return spousePair.second(); + } + if (residentUuid.equals(spousePair.second())) { + return spousePair.first(); + } + } + if (existing != null && validResidentIds.contains(existing.spouseUuid())) { + return existing.spouseUuid(); + } + return null; + } + + private static boolean isAdult(NpcSocietyProfile profile) { + return profile.lifeStage() == NpcLifeStage.ADULT || profile.lifeStage() == NpcLifeStage.ELDER; + } + + private static boolean isMinor(NpcSocietyProfile profile) { + return profile.lifeStage() == NpcLifeStage.CHILD || profile.lifeStage() == NpcLifeStage.ADOLESCENT; + } + + private static Comparator memberOrder() { + return Comparator + .comparingInt((NpcSocietyProfile profile) -> isAdult(profile) ? 0 : 1) + .thenComparing(profile -> profile.residentUuid(), Comparator.nullsLast(Comparator.naturalOrder())); + } + + private static @Nullable NpcSocietyProfile profileById(List members, UUID residentUuid) { + for (NpcSocietyProfile member : members) { + if (residentUuid != null && residentUuid.equals(member.residentUuid())) { + return member; + } + } + return null; + } + + private static NpcFamilyMemberSnapshot memberSnapshot(ServerLevel level, + UUID residentUuid, + @Nullable NpcSocietyProfile profile, + String relationTag, + long gameTime) { + NpcSocietyProfile resolvedProfile = profile == null + ? NpcSocietyAccess.ensureResident(level, residentUuid, gameTime) + : profile; + Entity entity = level.getEntity(residentUuid); + String displayName = entity == null ? shortId(residentUuid) : entity.getName().getString(); + return new NpcFamilyMemberSnapshot( + residentUuid, + entity == null ? -1 : entity.getId(), + displayName, + resolvedProfile.lifeStage().name(), + resolvedProfile.sex().name(), + relationTag + ); + } + + private static String shortId(UUID residentUuid) { + return residentUuid == null ? "-" : residentUuid.toString().substring(0, 8); + } + + private record SpousePair(UUID first, UUID second, UUID femaleAdult, UUID maleAdult) { + private static SpousePair of(NpcSocietyProfile left, NpcSocietyProfile right) { + UUID femaleAdult = left.sex() == NpcSex.FEMALE ? left.residentUuid() : right.sex() == NpcSex.FEMALE ? right.residentUuid() : null; + UUID maleAdult = left.sex() == NpcSex.MALE ? left.residentUuid() : right.sex() == NpcSex.MALE ? right.residentUuid() : null; + return new SpousePair(left.residentUuid(), right.residentUuid(), femaleAdult, maleAdult); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java new file mode 100644 index 00000000..cf4df2cc --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyMemberSnapshot.java @@ -0,0 +1,46 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.network.FriendlyByteBuf; + +import java.util.UUID; + +public record NpcFamilyMemberSnapshot( + UUID residentUuid, + int entityId, + String displayName, + String lifeStageTag, + String sexTag, + String relationTag +) { + public void toBytes(FriendlyByteBuf buf) { + buf.writeUUID(this.residentUuid); + buf.writeVarInt(this.entityId); + buf.writeUtf(this.displayName == null ? "" : this.displayName); + buf.writeUtf(this.lifeStageTag == null ? NpcLifeStage.UNSPECIFIED.name() : this.lifeStageTag); + buf.writeUtf(this.sexTag == null ? NpcSex.UNSPECIFIED.name() : this.sexTag); + buf.writeUtf(this.relationTag == null ? "self" : this.relationTag); + } + + public static NpcFamilyMemberSnapshot fromBytes(FriendlyByteBuf buf) { + return new NpcFamilyMemberSnapshot( + buf.readUUID(), + buf.readVarInt(), + buf.readUtf(), + buf.readUtf(), + buf.readUtf(), + buf.readUtf() + ); + } + + public String lifeStageTranslationKey() { + return "gui.bannermod.society.life_stage." + this.lifeStageTag.toLowerCase(java.util.Locale.ROOT); + } + + public String sexTranslationKey() { + return "gui.bannermod.society.sex." + this.sexTag.toLowerCase(java.util.Locale.ROOT); + } + + public String relationTranslationKey() { + return "gui.bannermod.society.family_relation." + this.relationTag.toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java new file mode 100644 index 00000000..302b9ec8 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRecord.java @@ -0,0 +1,168 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public record NpcFamilyRecord( + UUID residentUuid, + UUID householdId, + @Nullable UUID spouseUuid, + @Nullable UUID motherUuid, + @Nullable UUID fatherUuid, + List childUuids, + long version, + long lastUpdatedGameTime +) { + public NpcFamilyRecord { + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + childUuids = sanitizeChildren(childUuids); + version = Math.max(1L, version); + } + + public static NpcFamilyRecord create(UUID residentUuid, + UUID householdId, + @Nullable UUID spouseUuid, + @Nullable UUID motherUuid, + @Nullable UUID fatherUuid, + @Nullable Collection childUuids, + long gameTime) { + return new NpcFamilyRecord(residentUuid, householdId, spouseUuid, motherUuid, fatherUuid, copyChildren(childUuids), 1L, gameTime); + } + + public NpcFamilyRecord moveResident(UUID toResidentUuid, long gameTime) { + if (toResidentUuid == null || toResidentUuid.equals(this.residentUuid)) { + return this; + } + return new NpcFamilyRecord( + toResidentUuid, + this.householdId, + this.spouseUuid, + this.motherUuid, + this.fatherUuid, + this.childUuids, + this.version + 1L, + gameTime + ); + } + + public NpcFamilyRecord replaceReference(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) { + return this; + } + boolean changed = false; + UUID spouse = this.spouseUuid; + UUID mother = this.motherUuid; + UUID father = this.fatherUuid; + if (fromResidentUuid.equals(spouse)) { + spouse = toResidentUuid; + changed = true; + } + if (fromResidentUuid.equals(mother)) { + mother = toResidentUuid; + changed = true; + } + if (fromResidentUuid.equals(father)) { + father = toResidentUuid; + changed = true; + } + List children = new ArrayList<>(this.childUuids); + for (int i = 0; i < children.size(); i++) { + if (fromResidentUuid.equals(children.get(i))) { + children.set(i, toResidentUuid); + changed = true; + } + } + if (!changed) { + return this; + } + return new NpcFamilyRecord( + this.residentUuid, + this.householdId, + spouse, + mother, + father, + children, + this.version + 1L, + gameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ResidentUuid", this.residentUuid); + tag.putUUID("HouseholdId", this.householdId); + if (this.spouseUuid != null) { + tag.putUUID("SpouseUuid", this.spouseUuid); + } + if (this.motherUuid != null) { + tag.putUUID("MotherUuid", this.motherUuid); + } + if (this.fatherUuid != null) { + tag.putUUID("FatherUuid", this.fatherUuid); + } + ListTag children = new ListTag(); + for (UUID childUuid : this.childUuids) { + if (childUuid == null) { + continue; + } + CompoundTag childTag = new CompoundTag(); + childTag.putUUID("ChildUuid", childUuid); + children.add(childTag); + } + tag.put("ChildUuids", children); + tag.putLong("Version", this.version); + tag.putLong("LastUpdatedGameTime", this.lastUpdatedGameTime); + return tag; + } + + public static NpcFamilyRecord fromTag(CompoundTag tag) { + List children = new ArrayList<>(); + for (Tag entry : tag.getList("ChildUuids", Tag.TAG_COMPOUND)) { + CompoundTag childTag = (CompoundTag) entry; + if (childTag.contains("ChildUuid")) { + children.add(childTag.getUUID("ChildUuid")); + } + } + return new NpcFamilyRecord( + tag.getUUID("ResidentUuid"), + tag.contains("HouseholdId") ? tag.getUUID("HouseholdId") : tag.getUUID("ResidentUuid"), + tag.contains("SpouseUuid") ? tag.getUUID("SpouseUuid") : null, + tag.contains("MotherUuid") ? tag.getUUID("MotherUuid") : null, + tag.contains("FatherUuid") ? tag.getUUID("FatherUuid") : null, + children, + Math.max(1L, tag.getLong("Version")), + tag.getLong("LastUpdatedGameTime") + ); + } + + private static List copyChildren(@Nullable Collection children) { + return children == null ? List.of() : new ArrayList<>(children); + } + + private static List sanitizeChildren(@Nullable Collection children) { + if (children == null || children.isEmpty()) { + return List.of(); + } + Set ordered = new LinkedHashSet<>(); + for (UUID child : children) { + if (child != null) { + ordered.add(child); + } + } + return List.copyOf(ordered); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java new file mode 100644 index 00000000..2b3a868a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyRuntime.java @@ -0,0 +1,126 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcFamilyRuntime { + private final Map familyByResident = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { + }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { + } : dirtyListener; + } + + public Optional familyFor(UUID residentUuid) { + if (residentUuid == null) { + return Optional.empty(); + } + return Optional.ofNullable(this.familyByResident.get(residentUuid)); + } + + public void replaceHousehold(UUID householdId, Collection records) { + boolean changed = false; + if (householdId == null) { + return; + } + List toRemove = new ArrayList<>(); + for (NpcFamilyRecord record : this.familyByResident.values()) { + if (record != null && householdId.equals(record.householdId())) { + toRemove.add(record.residentUuid()); + } + } + for (UUID residentUuid : toRemove) { + if (this.familyByResident.remove(residentUuid) != null) { + changed = true; + } + } + if (records != null) { + for (NpcFamilyRecord record : records) { + if (record != null) { + NpcFamilyRecord previous = this.familyByResident.put(record.residentUuid(), record); + if (!record.equals(previous)) { + changed = true; + } + } + } + } + if (changed) { + markDirty(); + } + } + + public void moveResident(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) { + return; + } + boolean changed = false; + NpcFamilyRecord ownRecord = this.familyByResident.remove(fromResidentUuid); + if (ownRecord != null) { + this.familyByResident.put(toResidentUuid, ownRecord.moveResident(toResidentUuid, gameTime)); + changed = true; + } + List updated = new ArrayList<>(); + for (NpcFamilyRecord record : this.familyByResident.values()) { + updated.add(record == null ? null : record.replaceReference(fromResidentUuid, toResidentUuid, gameTime)); + } + if (!updated.isEmpty()) { + this.familyByResident.clear(); + for (NpcFamilyRecord record : updated) { + if (record != null) { + this.familyByResident.put(record.residentUuid(), record); + } + } + changed = true; + } + if (changed) { + markDirty(); + } + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag families = new ListTag(); + for (NpcFamilyRecord record : this.familyByResident.values()) { + families.add(record.toTag()); + } + tag.put("Families", families); + return tag; + } + + public static NpcFamilyRuntime fromTag(CompoundTag tag) { + NpcFamilyRuntime runtime = new NpcFamilyRuntime(); + List records = new ArrayList<>(); + for (Tag entry : tag.getList("Families", Tag.TAG_COMPOUND)) { + records.add(NpcFamilyRecord.fromTag((CompoundTag) entry)); + } + runtime.restoreSnapshot(records); + return runtime; + } + + public void restoreSnapshot(@Nullable Collection records) { + this.familyByResident.clear(); + if (records != null) { + for (NpcFamilyRecord record : records) { + if (record != null) { + this.familyByResident.put(record.residentUuid(), record); + } + } + } + } + + private void markDirty() { + this.dirtyListener.run(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java new file mode 100644 index 00000000..743715d9 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilySavedData.java @@ -0,0 +1,42 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class NpcFamilySavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcFamily"; + private static final SavedData.Factory FACTORY = + new SavedData.Factory<>(NpcFamilySavedData::new, NpcFamilySavedData::load); + + private final NpcFamilyRuntime runtime; + + public NpcFamilySavedData() { + this(new NpcFamilyRuntime()); + } + + private NpcFamilySavedData(NpcFamilyRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static NpcFamilySavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcFamilySavedData load(CompoundTag tag, HolderLookup.Provider registries) { + return new NpcFamilySavedData(NpcFamilyRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag runtimeTag = this.runtime.toTag(); + tag.put("Families", runtimeTag.getList("Families", 10)); + return tag; + } + + public NpcFamilyRuntime runtime() { + return this.runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java new file mode 100644 index 00000000..5702f714 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcFamilyTreeSnapshot.java @@ -0,0 +1,60 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.network.FriendlyByteBuf; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; + +public record NpcFamilyTreeSnapshot( + NpcFamilyMemberSnapshot self, + @Nullable NpcFamilyMemberSnapshot spouse, + @Nullable NpcFamilyMemberSnapshot mother, + @Nullable NpcFamilyMemberSnapshot father, + List children +) { + public static NpcFamilyTreeSnapshot empty() { + return new NpcFamilyTreeSnapshot( + new NpcFamilyMemberSnapshot(new java.util.UUID(0L, 0L), -1, "-", NpcLifeStage.UNSPECIFIED.name(), NpcSex.UNSPECIFIED.name(), "self"), + null, + null, + null, + List.of() + ); + } + + public void toBytes(FriendlyByteBuf buf) { + (this.self == null ? empty().self() : this.self).toBytes(buf); + buf.writeBoolean(this.spouse != null); + if (this.spouse != null) { + this.spouse.toBytes(buf); + } + buf.writeBoolean(this.mother != null); + if (this.mother != null) { + this.mother.toBytes(buf); + } + buf.writeBoolean(this.father != null); + if (this.father != null) { + this.father.toBytes(buf); + } + buf.writeVarInt(this.children == null ? 0 : this.children.size()); + if (this.children != null) { + for (NpcFamilyMemberSnapshot child : this.children) { + child.toBytes(buf); + } + } + } + + public static NpcFamilyTreeSnapshot fromBytes(FriendlyByteBuf buf) { + NpcFamilyMemberSnapshot self = NpcFamilyMemberSnapshot.fromBytes(buf); + NpcFamilyMemberSnapshot spouse = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null; + NpcFamilyMemberSnapshot mother = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null; + NpcFamilyMemberSnapshot father = buf.readBoolean() ? NpcFamilyMemberSnapshot.fromBytes(buf) : null; + int childCount = buf.readVarInt(); + List children = new ArrayList<>(); + for (int i = 0; i < childCount; i++) { + children.add(NpcFamilyMemberSnapshot.fromBytes(buf)); + } + return new NpcFamilyTreeSnapshot(self, spouse, mother, father, List.copyOf(children)); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java new file mode 100644 index 00000000..be1bcdd1 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdAccess.java @@ -0,0 +1,59 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +public final class NpcHouseholdAccess { + private NpcHouseholdAccess() { + } + + public static @Nullable UUID reconcileResidentHome(ServerLevel level, + UUID residentUuid, + @Nullable UUID homeBuildingUuid, + int residentCapacity, + long gameTime) { + return NpcHouseholdSavedData.get(level).runtime().reconcileResidentHome(residentUuid, homeBuildingUuid, residentCapacity, gameTime); + } + + public static void clearResident(ServerLevel level, UUID residentUuid, long gameTime) { + NpcHouseholdSavedData.get(level).runtime().clearResident(residentUuid, gameTime); + } + + public static void moveResident(ServerLevel level, + UUID fromResidentUuid, + UUID toResidentUuid, + long gameTime) { + NpcHouseholdSavedData.get(level).runtime().moveResident(fromResidentUuid, toResidentUuid, gameTime); + } + + public static void updateHead(ServerLevel level, + UUID householdId, + @Nullable UUID headResidentUuid, + long gameTime) { + NpcHouseholdSavedData.get(level).runtime().updateHead(householdId, headResidentUuid, gameTime); + } + + public static void seedHousehold(ServerLevel level, + UUID householdId, + @Nullable UUID headResidentUuid, + Collection members, + long gameTime) { + NpcHouseholdSavedData.get(level).runtime().seedHousehold(householdId, headResidentUuid, members, gameTime); + } + + public static Optional householdForResident(ServerLevel level, UUID residentUuid) { + return NpcHouseholdSavedData.get(level).runtime().householdForResident(residentUuid); + } + + public static Optional householdFor(ServerLevel level, UUID householdId) { + return NpcHouseholdSavedData.get(level).runtime().householdFor(householdId); + } + + public static Optional householdForHome(ServerLevel level, UUID homeBuildingUuid) { + return NpcHouseholdSavedData.get(level).runtime().householdForHome(homeBuildingUuid); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java new file mode 100644 index 00000000..9d5c1f13 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdHousingState.java @@ -0,0 +1,18 @@ +package com.talhanation.bannermod.society; + +public enum NpcHouseholdHousingState { + NORMAL, + HOMELESS, + OVERCROWDED; + + public static NpcHouseholdHousingState fromName(String name) { + if (name == null || name.isBlank()) { + return HOMELESS; + } + try { + return NpcHouseholdHousingState.valueOf(name); + } catch (IllegalArgumentException ignored) { + return HOMELESS; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java new file mode 100644 index 00000000..3869b1b2 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRecord.java @@ -0,0 +1,248 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public record NpcHouseholdRecord( + UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID headResidentUuid, + List memberResidentUuids, + int residentCapacity, + NpcHouseholdHousingState housingState, + long version, + long lastUpdatedGameTime +) { + public NpcHouseholdRecord { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + memberResidentUuids = sanitizeMembers(memberResidentUuids); + residentCapacity = Math.max(0, residentCapacity); + housingState = housingState == null ? NpcHouseholdHousingState.HOMELESS : housingState; + version = Math.max(1L, version); + } + + public static NpcHouseholdRecord create(UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID headResidentUuid, + @Nullable Collection memberResidentUuids, + int residentCapacity, + NpcHouseholdHousingState housingState, + long gameTime) { + return new NpcHouseholdRecord( + householdId, + homeBuildingUuid, + headResidentUuid, + copyMembers(memberResidentUuids), + residentCapacity, + housingState, + 1L, + gameTime + ); + } + + public boolean hasMember(UUID residentUuid) { + return residentUuid != null && this.memberResidentUuids.contains(residentUuid); + } + + public boolean isEmpty() { + return this.memberResidentUuids.isEmpty(); + } + + public NpcHouseholdRecord withHome(@Nullable UUID homeBuildingUuid, long gameTime) { + if (sameNullableUuid(this.homeBuildingUuid, homeBuildingUuid)) { + return this; + } + return new NpcHouseholdRecord( + this.householdId, + homeBuildingUuid, + this.headResidentUuid, + this.memberResidentUuids, + this.residentCapacity, + this.housingState, + this.version + 1L, + gameTime + ); + } + + public NpcHouseholdRecord withHousing(@Nullable UUID homeBuildingUuid, + int residentCapacity, + NpcHouseholdHousingState housingState, + long gameTime) { + int normalizedCapacity = Math.max(0, residentCapacity); + NpcHouseholdHousingState normalizedState = housingState == null ? NpcHouseholdHousingState.HOMELESS : housingState; + if (sameNullableUuid(this.homeBuildingUuid, homeBuildingUuid) + && this.residentCapacity == normalizedCapacity + && this.housingState == normalizedState) { + return this; + } + return new NpcHouseholdRecord( + this.householdId, + homeBuildingUuid, + this.headResidentUuid, + this.memberResidentUuids, + normalizedCapacity, + normalizedState, + this.version + 1L, + gameTime + ); + } + + public NpcHouseholdRecord withHead(@Nullable UUID headResidentUuid, long gameTime) { + if (sameNullableUuid(this.headResidentUuid, headResidentUuid)) { + return this; + } + return new NpcHouseholdRecord( + this.householdId, + this.homeBuildingUuid, + headResidentUuid, + this.memberResidentUuids, + this.residentCapacity, + this.housingState, + this.version + 1L, + gameTime + ); + } + + public NpcHouseholdRecord addMember(UUID residentUuid, long gameTime) { + if (residentUuid == null || this.memberResidentUuids.contains(residentUuid)) { + return this; + } + List updatedMembers = new ArrayList<>(this.memberResidentUuids); + updatedMembers.add(residentUuid); + return new NpcHouseholdRecord( + this.householdId, + this.homeBuildingUuid, + this.headResidentUuid, + updatedMembers, + this.residentCapacity, + this.housingState, + this.version + 1L, + gameTime + ); + } + + public NpcHouseholdRecord removeMember(UUID residentUuid, long gameTime) { + if (residentUuid == null || !this.memberResidentUuids.contains(residentUuid)) { + return this; + } + List updatedMembers = new ArrayList<>(this.memberResidentUuids); + updatedMembers.remove(residentUuid); + return new NpcHouseholdRecord( + this.householdId, + this.homeBuildingUuid, + residentUuid.equals(this.headResidentUuid) ? null : this.headResidentUuid, + updatedMembers, + this.residentCapacity, + this.housingState, + this.version + 1L, + gameTime + ); + } + + public NpcHouseholdRecord moveMember(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (fromResidentUuid == null || toResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) { + return this; + } + if (!this.memberResidentUuids.contains(fromResidentUuid)) { + return this; + } + List updatedMembers = new ArrayList<>(this.memberResidentUuids); + updatedMembers.remove(fromResidentUuid); + if (!updatedMembers.contains(toResidentUuid)) { + updatedMembers.add(toResidentUuid); + } + return new NpcHouseholdRecord( + this.householdId, + this.homeBuildingUuid, + fromResidentUuid.equals(this.headResidentUuid) ? toResidentUuid : this.headResidentUuid, + updatedMembers, + this.residentCapacity, + this.housingState, + this.version + 1L, + gameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("HouseholdId", this.householdId); + if (this.homeBuildingUuid != null) { + tag.putUUID("HomeBuildingUuid", this.homeBuildingUuid); + } + if (this.headResidentUuid != null) { + tag.putUUID("HeadResidentUuid", this.headResidentUuid); + } + ListTag members = new ListTag(); + for (UUID member : this.memberResidentUuids) { + if (member == null) { + continue; + } + CompoundTag memberTag = new CompoundTag(); + memberTag.putUUID("ResidentUuid", member); + members.add(memberTag); + } + tag.put("Members", members); + tag.putInt("ResidentCapacity", this.residentCapacity); + tag.putString("HousingState", this.housingState.name()); + tag.putLong("Version", this.version); + tag.putLong("LastUpdatedGameTime", this.lastUpdatedGameTime); + return tag; + } + + public static NpcHouseholdRecord fromTag(CompoundTag tag) { + List members = new ArrayList<>(); + for (Tag entry : tag.getList("Members", Tag.TAG_COMPOUND)) { + CompoundTag memberTag = (CompoundTag) entry; + if (memberTag.contains("ResidentUuid")) { + members.add(memberTag.getUUID("ResidentUuid")); + } + } + return new NpcHouseholdRecord( + tag.getUUID("HouseholdId"), + tag.contains("HomeBuildingUuid") ? tag.getUUID("HomeBuildingUuid") : null, + tag.contains("HeadResidentUuid") ? tag.getUUID("HeadResidentUuid") : null, + members, + tag.contains("ResidentCapacity") ? tag.getInt("ResidentCapacity") : 0, + tag.contains("HousingState") + ? NpcHouseholdHousingState.fromName(tag.getString("HousingState")) + : (tag.contains("HomeBuildingUuid") ? NpcHouseholdHousingState.NORMAL : NpcHouseholdHousingState.HOMELESS), + Math.max(1L, tag.getLong("Version")), + tag.getLong("LastUpdatedGameTime") + ); + } + + private static boolean sameNullableUuid(@Nullable UUID left, @Nullable UUID right) { + if (left == null || right == null) { + return left == null && right == null; + } + return left.equals(right); + } + + private static List copyMembers(@Nullable Collection members) { + return members == null ? List.of() : new ArrayList<>(members); + } + + private static List sanitizeMembers(@Nullable Collection members) { + if (members == null || members.isEmpty()) { + return List.of(); + } + Set ordered = new LinkedHashSet<>(); + for (UUID member : members) { + if (member != null) { + ordered.add(member); + } + } + return List.copyOf(ordered); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java new file mode 100644 index 00000000..00839b1f --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdRuntime.java @@ -0,0 +1,381 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcHouseholdRuntime { + private final Map householdsById = new LinkedHashMap<>(); + private final Map householdByResident = new LinkedHashMap<>(); + private final Map householdByHomeBuilding = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { + }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { + } : dirtyListener; + } + + public Optional householdFor(UUID householdId) { + if (householdId == null) { + return Optional.empty(); + } + return Optional.ofNullable(this.householdsById.get(householdId)); + } + + public Optional householdForResident(UUID residentUuid) { + if (residentUuid == null) { + return Optional.empty(); + } + return householdFor(this.householdByResident.get(residentUuid)); + } + + public Optional householdForHome(UUID homeBuildingUuid) { + if (homeBuildingUuid == null) { + return Optional.empty(); + } + return householdFor(this.householdByHomeBuilding.get(homeBuildingUuid)); + } + + public void updateHead(UUID householdId, @Nullable UUID headResidentUuid, long gameTime) { + if (householdId == null) { + return; + } + NpcHouseholdRecord household = this.householdsById.get(householdId); + if (household == null) { + return; + } + NpcHouseholdRecord updated = household.withHead(headResidentUuid, gameTime); + if (!updated.equals(household)) { + this.householdsById.put(householdId, updated); + markDirty(); + } + } + + public void seedHousehold(UUID householdId, + @Nullable UUID headResidentUuid, + Collection members, + long gameTime) { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + List orderedMembers = new ArrayList<>(); + if (members != null) { + for (UUID member : members) { + if (member == null) { + continue; + } + clearResidentInternal(member, gameTime); + if (!orderedMembers.contains(member)) { + orderedMembers.add(member); + } + } + } + NpcHouseholdRecord record = NpcHouseholdRecord.create( + householdId, + null, + headResidentUuid, + orderedMembers, + 0, + orderedMembers.isEmpty() ? NpcHouseholdHousingState.HOMELESS : NpcHouseholdHousingState.HOMELESS, + gameTime + ); + this.householdsById.put(householdId, record); + for (UUID member : orderedMembers) { + this.householdByResident.put(member, householdId); + } + markDirty(); + } + + public @Nullable UUID reconcileResidentHome(UUID residentUuid, + @Nullable UUID homeBuildingUuid, + int residentCapacity, + long gameTime) { + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + UUID currentHouseholdId = this.householdByResident.get(residentUuid); + NpcHouseholdRecord currentHousehold = currentHouseholdId == null ? null : this.householdsById.get(currentHouseholdId); + if (homeBuildingUuid == null) { + return reconcileHomelessResident(residentUuid, currentHousehold, gameTime); + } + + int normalizedCapacity = Math.max(1, residentCapacity); + boolean changed = false; + NpcHouseholdRecord targetHousehold = householdForHome(homeBuildingUuid).orElse(null); + if (targetHousehold == null) { + if (currentHousehold != null + && currentHousehold.homeBuildingUuid() == null + && currentHousehold.memberResidentUuids().size() == 1 + && currentHousehold.hasMember(residentUuid)) { + targetHousehold = currentHousehold; + } else { + targetHousehold = NpcHouseholdRecord.create( + UUID.randomUUID(), + homeBuildingUuid, + null, + List.of(), + normalizedCapacity, + NpcHouseholdHousingState.NORMAL, + gameTime + ); + } + this.householdsById.put(targetHousehold.householdId(), targetHousehold); + changed = true; + } + + if (currentHouseholdId != null && !currentHouseholdId.equals(targetHousehold.householdId())) { + changed |= clearResidentInternal(residentUuid, gameTime); + } + + NpcHouseholdRecord stored = this.householdsById.get(targetHousehold.householdId()); + if (stored == null) { + stored = targetHousehold; + this.householdsById.put(stored.householdId(), stored); + changed = true; + } + + NpcHouseholdRecord updated = stored.addMember(residentUuid, gameTime); + updated = applyHousing(updated, homeBuildingUuid, normalizedCapacity, gameTime); + if (!updated.equals(stored)) { + this.householdsById.put(updated.householdId(), updated); + changed = true; + } + + UUID previousResidentHousehold = this.householdByResident.put(residentUuid, updated.householdId()); + if (!updated.householdId().equals(previousResidentHousehold)) { + changed = true; + } + UUID previousHomeHousehold = this.householdByHomeBuilding.put(homeBuildingUuid, updated.householdId()); + if (!updated.householdId().equals(previousHomeHousehold)) { + changed = true; + } + + if (changed) { + markDirty(); + } + return updated.householdId(); + } + + public void clearResident(UUID residentUuid, long gameTime) { + if (residentUuid == null) { + return; + } + if (clearResidentInternal(residentUuid, gameTime)) { + markDirty(); + } + } + + public void moveResident(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (toResidentUuid == null) { + throw new IllegalArgumentException("toResidentUuid must not be null"); + } + if (fromResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) { + return; + } + UUID fromHouseholdId = this.householdByResident.get(fromResidentUuid); + if (fromHouseholdId == null) { + clearResident(toResidentUuid, gameTime); + return; + } + + boolean changed = false; + UUID toHouseholdId = this.householdByResident.get(toResidentUuid); + if (toHouseholdId != null && !toHouseholdId.equals(fromHouseholdId)) { + changed |= clearResidentInternal(toResidentUuid, gameTime); + } + + NpcHouseholdRecord household = this.householdsById.get(fromHouseholdId); + if (household != null) { + NpcHouseholdRecord updated = household.moveMember(fromResidentUuid, toResidentUuid, gameTime); + updated = applyHousing(updated, updated.homeBuildingUuid(), updated.residentCapacity(), gameTime); + if (!updated.equals(household)) { + this.householdsById.put(updated.householdId(), updated); + changed = true; + } + } + + if (this.householdByResident.remove(fromResidentUuid) != null) { + changed = true; + } + UUID previous = this.householdByResident.put(toResidentUuid, fromHouseholdId); + if (!fromHouseholdId.equals(previous)) { + changed = true; + } + + if (changed) { + markDirty(); + } + } + + public List snapshot() { + return Collections.unmodifiableList(new ArrayList<>(this.householdsById.values())); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag households = new ListTag(); + for (NpcHouseholdRecord household : snapshot()) { + households.add(household.toTag()); + } + tag.put("Households", households); + return tag; + } + + public static NpcHouseholdRuntime fromTag(CompoundTag tag) { + NpcHouseholdRuntime runtime = new NpcHouseholdRuntime(); + List households = new ArrayList<>(); + for (Tag entry : tag.getList("Households", Tag.TAG_COMPOUND)) { + households.add(NpcHouseholdRecord.fromTag((CompoundTag) entry)); + } + runtime.restoreSnapshot(households); + return runtime; + } + + public void restoreSnapshot(@Nullable Collection households) { + List before = snapshot(); + this.householdsById.clear(); + this.householdByResident.clear(); + this.householdByHomeBuilding.clear(); + if (households != null) { + for (NpcHouseholdRecord household : households) { + if (household == null || household.householdId() == null) { + continue; + } + this.householdsById.put(household.householdId(), household); + if (household.homeBuildingUuid() != null) { + this.householdByHomeBuilding.put(household.homeBuildingUuid(), household.householdId()); + } + for (UUID member : household.memberResidentUuids()) { + if (member != null) { + this.householdByResident.put(member, household.householdId()); + } + } + } + } + if (!before.equals(snapshot())) { + markDirty(); + } + } + + public void reset() { + if (this.householdsById.isEmpty() && this.householdByResident.isEmpty() && this.householdByHomeBuilding.isEmpty()) { + return; + } + this.householdsById.clear(); + this.householdByResident.clear(); + this.householdByHomeBuilding.clear(); + markDirty(); + } + + private UUID reconcileHomelessResident(UUID residentUuid, + @Nullable NpcHouseholdRecord currentHousehold, + long gameTime) { + boolean changed = false; + NpcHouseholdRecord household = currentHousehold; + if (household == null) { + household = NpcHouseholdRecord.create( + UUID.randomUUID(), + null, + residentUuid, + List.of(residentUuid), + 0, + NpcHouseholdHousingState.HOMELESS, + gameTime + ); + this.householdsById.put(household.householdId(), household); + this.householdByResident.put(residentUuid, household.householdId()); + markDirty(); + return household.householdId(); + } + + if (household.homeBuildingUuid() != null) { + changed |= clearResidentInternal(residentUuid, gameTime); + household = NpcHouseholdRecord.create( + UUID.randomUUID(), + null, + residentUuid, + List.of(residentUuid), + 0, + NpcHouseholdHousingState.HOMELESS, + gameTime + ); + this.householdsById.put(household.householdId(), household); + changed = true; + } else { + NpcHouseholdRecord updated = applyHousing(household.addMember(residentUuid, gameTime), null, 0, gameTime); + if (!updated.equals(household)) { + this.householdsById.put(updated.householdId(), updated); + household = updated; + changed = true; + } + } + + UUID previous = this.householdByResident.put(residentUuid, household.householdId()); + if (!household.householdId().equals(previous)) { + changed = true; + } + if (changed) { + markDirty(); + } + return household.householdId(); + } + + private boolean clearResidentInternal(UUID residentUuid, long gameTime) { + UUID householdId = this.householdByResident.remove(residentUuid); + if (householdId == null) { + return false; + } + NpcHouseholdRecord household = this.householdsById.get(householdId); + if (household == null) { + return true; + } + NpcHouseholdRecord updated = household.removeMember(residentUuid, gameTime); + if (updated.isEmpty()) { + this.householdsById.remove(householdId); + if (household.homeBuildingUuid() != null && householdId.equals(this.householdByHomeBuilding.get(household.homeBuildingUuid()))) { + this.householdByHomeBuilding.remove(household.homeBuildingUuid()); + } + } else { + updated = applyHousing(updated, updated.homeBuildingUuid(), updated.residentCapacity(), gameTime); + if (!updated.equals(household)) { + this.householdsById.put(householdId, updated); + } + } + return true; + } + + private NpcHouseholdRecord applyHousing(NpcHouseholdRecord household, + @Nullable UUID homeBuildingUuid, + int residentCapacity, + long gameTime) { + int normalizedCapacity = homeBuildingUuid == null ? 0 : Math.max(1, residentCapacity); + NpcHouseholdHousingState housingState = resolveHousingState(homeBuildingUuid, normalizedCapacity, household.memberResidentUuids().size()); + return household.withHousing(homeBuildingUuid, normalizedCapacity, housingState, gameTime); + } + + private static NpcHouseholdHousingState resolveHousingState(@Nullable UUID homeBuildingUuid, + int residentCapacity, + int memberCount) { + if (homeBuildingUuid == null) { + return NpcHouseholdHousingState.HOMELESS; + } + return memberCount > Math.max(1, residentCapacity) + ? NpcHouseholdHousingState.OVERCROWDED + : NpcHouseholdHousingState.NORMAL; + } + + private void markDirty() { + this.dirtyListener.run(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java new file mode 100644 index 00000000..2e811fa7 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHouseholdSavedData.java @@ -0,0 +1,42 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class NpcHouseholdSavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcHouseholds"; + private static final SavedData.Factory FACTORY = + new SavedData.Factory<>(NpcHouseholdSavedData::new, NpcHouseholdSavedData::load); + + private final NpcHouseholdRuntime runtime; + + public NpcHouseholdSavedData() { + this(new NpcHouseholdRuntime()); + } + + private NpcHouseholdSavedData(NpcHouseholdRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static NpcHouseholdSavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcHouseholdSavedData load(CompoundTag tag, HolderLookup.Provider registries) { + return new NpcHouseholdSavedData(NpcHouseholdRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag runtimeTag = this.runtime.toTag(); + tag.put("Households", runtimeTag.getList("Households", 10)); + return tag; + } + + public NpcHouseholdRuntime runtime() { + return this.runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java new file mode 100644 index 00000000..c1db4080 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingLedgerEntry.java @@ -0,0 +1,142 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.Locale; +import java.util.UUID; + +public record NpcHousingLedgerEntry( + UUID householdId, + UUID residentUuid, + UUID claimUuid, + @Nullable UUID headResidentUuid, + @Nullable UUID homeBuildingUuid, + @Nullable UUID buildAreaUuid, + @Nullable BlockPos reservedPlotPos, + String statusTag, + String housingStateTag, + String urgencyTag, + String reasonTag, + int householdSize, + int waitingDays, + int priorityScore, + int queueRank, + long requestedAtGameTime, + long updatedAtGameTime +) { + public NpcHousingLedgerEntry { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + if (claimUuid == null) { + throw new IllegalArgumentException("claimUuid must not be null"); + } + statusTag = safeTag(statusTag); + housingStateTag = safeTag(housingStateTag); + urgencyTag = safeTag(urgencyTag); + reasonTag = safeTag(reasonTag); + householdSize = Math.max(0, householdSize); + waitingDays = Math.max(0, waitingDays); + priorityScore = Math.max(0, priorityScore); + queueRank = Math.max(0, queueRank); + } + + public NpcHousingLedgerEntry withQueueRank(int queueRank) { + return new NpcHousingLedgerEntry( + this.householdId, + this.residentUuid, + this.claimUuid, + this.headResidentUuid, + this.homeBuildingUuid, + this.buildAreaUuid, + this.reservedPlotPos, + this.statusTag, + this.housingStateTag, + this.urgencyTag, + this.reasonTag, + this.householdSize, + this.waitingDays, + this.priorityScore, + queueRank, + this.requestedAtGameTime, + this.updatedAtGameTime + ); + } + + public String statusTranslationKey() { + return "gui.bannermod.society.housing_request." + safeTag(this.statusTag).toLowerCase(Locale.ROOT); + } + + public String housingStateTranslationKey() { + return "gui.bannermod.society.household_housing." + safeTag(this.housingStateTag).toLowerCase(Locale.ROOT); + } + + public String urgencyTranslationKey() { + return "gui.bannermod.housing_ledger.urgency." + safeTag(this.urgencyTag).toLowerCase(Locale.ROOT); + } + + public String reasonTranslationKey() { + return "gui.bannermod.housing_ledger.reason." + safeTag(this.reasonTag).toLowerCase(Locale.ROOT); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("HouseholdId", this.householdId); + tag.putUUID("ResidentUuid", this.residentUuid); + tag.putUUID("ClaimUuid", this.claimUuid); + if (this.headResidentUuid != null) { + tag.putUUID("HeadResidentUuid", this.headResidentUuid); + } + if (this.homeBuildingUuid != null) { + tag.putUUID("HomeBuildingUuid", this.homeBuildingUuid); + } + if (this.buildAreaUuid != null) { + tag.putUUID("BuildAreaUuid", this.buildAreaUuid); + } + if (this.reservedPlotPos != null) { + tag.putLong("ReservedPlotPos", this.reservedPlotPos.asLong()); + } + tag.putString("Status", this.statusTag); + tag.putString("HousingState", this.housingStateTag); + tag.putString("Urgency", this.urgencyTag); + tag.putString("Reason", this.reasonTag); + tag.putInt("HouseholdSize", this.householdSize); + tag.putInt("WaitingDays", this.waitingDays); + tag.putInt("PriorityScore", this.priorityScore); + tag.putInt("QueueRank", this.queueRank); + tag.putLong("RequestedAt", this.requestedAtGameTime); + tag.putLong("UpdatedAt", this.updatedAtGameTime); + return tag; + } + + public static NpcHousingLedgerEntry fromTag(CompoundTag tag) { + return new NpcHousingLedgerEntry( + tag.getUUID("HouseholdId"), + tag.getUUID("ResidentUuid"), + tag.getUUID("ClaimUuid"), + tag.contains("HeadResidentUuid") ? tag.getUUID("HeadResidentUuid") : null, + tag.contains("HomeBuildingUuid") ? tag.getUUID("HomeBuildingUuid") : null, + tag.contains("BuildAreaUuid") ? tag.getUUID("BuildAreaUuid") : null, + tag.contains("ReservedPlotPos") ? BlockPos.of(tag.getLong("ReservedPlotPos")) : null, + tag.getString("Status"), + tag.getString("HousingState"), + tag.getString("Urgency"), + tag.getString("Reason"), + tag.getInt("HouseholdSize"), + tag.getInt("WaitingDays"), + tag.getInt("PriorityScore"), + tag.getInt("QueueRank"), + tag.getLong("RequestedAt"), + tag.getLong("UpdatedAt") + ); + } + + private static String safeTag(@Nullable String tag) { + return tag == null || tag.isBlank() ? "UNSPECIFIED" : tag; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java new file mode 100644 index 00000000..1bdcef92 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingPlotPlanner.java @@ -0,0 +1,254 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.Heightmap; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +public final class NpcHousingPlotPlanner { + private static final int RESERVED_PLOT_SPACING = 7; + private static final int RESERVED_HOME_MATCH_RADIUS = 12; + private static final int RESERVED_PLOT_CHUNK_RADIUS = 3; + + private NpcHousingPlotPlanner() { + } + + public static NpcHousingRequestRecord ensureReservedPlot(ServerLevel level, + SettlementSnapshot snapshot, + NpcHousingRequestRecord request, + long gameTime) { + if (level == null || snapshot == null || request == null || request.reservedPlotPos() != null) { + return request; + } + NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, request.householdId()).orElse(null); + BlockPos reservedPlot = chooseReservedPlot(level, snapshot, request, household); + return reservedPlot == null ? request : NpcHousingRequestAccess.reservePlot(level, request.householdId(), reservedPlot, gameTime); + } + + public static @Nullable UUID findReservedHomeBuilding(SettlementSnapshot snapshot, + BannerModHomeAssignmentRuntime homeRuntime, + NpcHousingRequestRecord request) { + if (snapshot == null || homeRuntime == null || request == null || request.reservedPlotPos() == null) { + return null; + } + UUID best = null; + double bestDistSqr = Double.POSITIVE_INFINITY; + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (!isHousingCandidate(building)) { + continue; + } + if (homeRuntime.assignmentsForBuilding(building.buildingUuid()).size() >= building.residentCapacity()) { + continue; + } + double distSqr = building.originPos().distSqr(request.reservedPlotPos()); + if (distSqr > RESERVED_HOME_MATCH_RADIUS * RESERVED_HOME_MATCH_RADIUS) { + continue; + } + if (distSqr < bestDistSqr) { + bestDistSqr = distSqr; + best = building.buildingUuid(); + } + } + return best; + } + + public static @Nullable HousingPlotInfo nearestPlotInfo(ServerLevel level, + UUID claimUuid, + BlockPos pos, + double maxDistance) { + if (level == null || claimUuid == null || pos == null) { + return null; + } + NpcHousingRequestRecord bestRequest = null; + double bestDistSqr = Math.max(1.0D, maxDistance * maxDistance); + for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) { + if (request == null || request.reservedPlotPos() == null) { + continue; + } + double distSqr = request.reservedPlotPos().distSqr(pos); + if (distSqr <= bestDistSqr) { + bestDistSqr = distSqr; + bestRequest = request; + } + } + if (bestRequest == null) { + return null; + } + NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, bestRequest.householdId()).orElse(null); + return new HousingPlotInfo(bestRequest, household, bestRequest.reservedPlotPos(), bestDistSqr); + } + + private static @Nullable BlockPos chooseReservedPlot(ServerLevel level, + SettlementSnapshot snapshot, + NpcHousingRequestRecord request, + @Nullable NpcHouseholdRecord household) { + RecruitsClaim claim = resolveClaim(snapshot.claimUuid()); + if (claim == null) { + return null; + } + List candidates = candidateFortPlots(level, claim, snapshot); + if (candidates.isEmpty()) { + return null; + } + List occupied = occupiedOrigins(snapshot); + List reserved = reservedPlots(level, request.claimUuid(), request.householdId()); + BlockPos reference = referencePos(level, snapshot, request, household); + return candidates.stream() + .filter(candidate -> farEnough(candidate, occupied)) + .filter(candidate -> farEnough(candidate, reserved)) + .min(Comparator.comparingDouble(candidate -> score(candidate, reference, occupied, reserved))) + .orElse(null); + } + + private static double score(BlockPos candidate, + BlockPos reference, + List occupied, + List reserved) { + double distanceToReference = candidate.distSqr(reference); + double distanceToNeighborhood = nearestDistance(candidate, occupied, reserved); + return distanceToReference - Math.min(distanceToNeighborhood, 64.0D); + } + + private static double nearestDistance(BlockPos candidate, List occupied, List reserved) { + double best = Double.POSITIVE_INFINITY; + for (BlockPos pos : occupied) { + best = Math.min(best, candidate.distSqr(pos)); + } + for (BlockPos pos : reserved) { + best = Math.min(best, candidate.distSqr(pos)); + } + return Double.isInfinite(best) ? 64.0D : best; + } + + private static boolean farEnough(BlockPos candidate, List others) { + for (BlockPos other : others) { + if (other != null && candidate.closerThan(other, RESERVED_PLOT_SPACING)) { + return false; + } + } + return true; + } + + private static List occupiedOrigins(SettlementSnapshot snapshot) { + List occupied = new ArrayList<>(); + if (snapshot == null) { + return occupied; + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (isHousingCandidate(building)) { + occupied.add(building.originPos()); + } + } + return occupied; + } + + private static List reservedPlots(ServerLevel level, UUID claimUuid, UUID requestingHouseholdId) { + List reserved = new ArrayList<>(); + for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) { + if (request == null || request.reservedPlotPos() == null || request.householdId().equals(requestingHouseholdId)) { + continue; + } + reserved.add(request.reservedPlotPos()); + } + return reserved; + } + + private static BlockPos referencePos(ServerLevel level, + SettlementSnapshot snapshot, + NpcHousingRequestRecord request, + @Nullable NpcHouseholdRecord household) { + if (household != null && household.homeBuildingUuid() != null) { + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && household.homeBuildingUuid().equals(building.buildingUuid())) { + return building.originPos(); + } + } + } + ChunkPos anchorChunk = snapshot.anchorChunk(); + return level.getHeightmapPos( + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + new BlockPos(anchorChunk.getMiddleBlockX(), level.getSeaLevel(), anchorChunk.getMiddleBlockZ()) + ); + } + + private static BlockPos surfacePos(ServerLevel level, ChunkPos chunk, int localX, int localZ) { + return level.getHeightmapPos( + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + new BlockPos(chunk.getMinBlockX() + localX, level.getSeaLevel(), chunk.getMinBlockZ() + localZ) + ); + } + + private static boolean isHousingCandidate(@Nullable SettlementBuildingRecord building) { + if (building == null || building.buildingUuid() == null || building.residentCapacity() <= 0) { + return false; + } + String buildingTypeId = building.buildingTypeId(); + if (buildingTypeId == null || buildingTypeId.isBlank()) { + return false; + } + net.minecraft.resources.ResourceLocation typeId = net.minecraft.resources.ResourceLocation.tryParse(buildingTypeId); + String path = typeId == null ? buildingTypeId.toLowerCase(java.util.Locale.ROOT) : typeId.getPath().toLowerCase(java.util.Locale.ROOT); + return building != null + && (path.contains("house") || path.contains("zemlyanka") || path.contains("hut")); + } + + private static @Nullable RecruitsClaim resolveClaim(UUID claimUuid) { + if (claimUuid == null || ClaimEvents.claimManager() == null) { + return null; + } + for (RecruitsClaim claim : ClaimEvents.claimManager().getAllClaims()) { + if (claim != null && claimUuid.equals(claim.getUUID())) { + return claim; + } + } + return null; + } + + public record HousingPlotInfo(NpcHousingRequestRecord request, + @Nullable NpcHouseholdRecord household, + BlockPos plotPos, + double distanceSqr) { + } + + private static List candidateFortPlots(ServerLevel level, + RecruitsClaim claim, + SettlementSnapshot snapshot) { + return candidatePlots(level, claim, snapshot == null ? claim.getCenter() : snapshot.anchorChunk()); + } + + private static List candidatePlots(ServerLevel level, + RecruitsClaim claim, + @Nullable ChunkPos anchorChunk) { + List candidates = new ArrayList<>(); + List claimedChunks = claim.getClaimedChunks(); + if (claimedChunks.isEmpty() && claim.getCenter() != null) { + claimedChunks = List.of(claim.getCenter()); + } + for (ChunkPos chunk : claimedChunks) { + int distance = anchorChunk == null ? 0 : chunkDistance(anchorChunk, chunk); + if (anchorChunk != null && distance >= RESERVED_PLOT_CHUNK_RADIUS) { + continue; + } + candidates.add(surfacePos(level, chunk, 4, 4)); + candidates.add(surfacePos(level, chunk, 11, 4)); + candidates.add(surfacePos(level, chunk, 4, 11)); + candidates.add(surfacePos(level, chunk, 11, 11)); + } + return candidates; + } + + private static int chunkDistance(ChunkPos anchor, ChunkPos other) { + return Math.max(Math.abs(anchor.x - other.x), Math.abs(anchor.z - other.z)); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingPriorityService.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingPriorityService.java new file mode 100644 index 00000000..8ab8a820 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingPriorityService.java @@ -0,0 +1,171 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public final class NpcHousingPriorityService { + private static final long TICKS_PER_DAY = 24000L; + private static final Comparator ENTRY_ORDER = + Comparator.comparingInt((NpcHousingLedgerEntry entry) -> statusOrder(entry.statusTag())) + .thenComparing(Comparator.comparingInt(NpcHousingLedgerEntry::priorityScore).reversed()) + .thenComparingLong(NpcHousingLedgerEntry::requestedAtGameTime) + .thenComparing(entry -> entry.householdId().toString()); + + private NpcHousingPriorityService() { + } + + public static List activeEntriesForClaim(ServerLevel level, @Nullable UUID claimUuid, long currentGameTime) { + if (level == null || claimUuid == null) { + return List.of(); + } + List entries = new ArrayList<>(); + for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) { + if (!isVisibleStatus(request.status())) { + continue; + } + NpcHouseholdRecord household = NpcHouseholdAccess.householdFor(level, request.householdId()).orElse(null); + entries.add(describe(request, household, currentGameTime)); + } + return rankEntries(entries); + } + + static List rankEntries(Iterable entries) { + List rankedEntries = new ArrayList<>(); + if (entries != null) { + for (NpcHousingLedgerEntry entry : entries) { + if (entry != null) { + rankedEntries.add(entry); + } + } + } + rankedEntries.sort(ENTRY_ORDER); + List ranked = new ArrayList<>(rankedEntries.size()); + for (int i = 0; i < rankedEntries.size(); i++) { + ranked.add(rankedEntries.get(i).withQueueRank(i + 1)); + } + return List.copyOf(ranked); + } + + public static NpcHousingLedgerEntry describe(NpcHousingRequestRecord request, + @Nullable NpcHouseholdRecord household, + long currentGameTime) { + NpcHousingRequestStatus status = request == null ? NpcHousingRequestStatus.NONE : request.status(); + NpcHouseholdHousingState housingState = household == null ? NpcHouseholdHousingState.NORMAL : household.housingState(); + int householdSize = household == null ? 0 : household.memberResidentUuids().size(); + int waitingDays = waitingDays(request == null ? 0L : request.requestedAtGameTime(), currentGameTime); + int priorityScore = score(status, housingState, householdSize, waitingDays); + return new NpcHousingLedgerEntry( + request.householdId(), + request.residentUuid(), + request.claimUuid(), + household == null ? null : household.headResidentUuid(), + household == null ? null : household.homeBuildingUuid(), + request.buildAreaUuid(), + request.reservedPlotPos(), + status.name(), + housingState.name(), + urgencyTag(priorityScore, housingState), + reasonTag(status, housingState, householdSize, waitingDays), + householdSize, + waitingDays, + priorityScore, + 0, + request.requestedAtGameTime(), + request.updatedAtGameTime() + ); + } + + public static boolean canApprove(@Nullable NpcHousingLedgerEntry entry) { + NpcHousingRequestStatus status = entry == null ? NpcHousingRequestStatus.NONE : NpcHousingRequestStatus.fromName(entry.statusTag()); + return status == NpcHousingRequestStatus.REQUESTED || status == NpcHousingRequestStatus.DENIED; + } + + public static boolean canDeny(@Nullable NpcHousingLedgerEntry entry) { + NpcHousingRequestStatus status = entry == null ? NpcHousingRequestStatus.NONE : NpcHousingRequestStatus.fromName(entry.statusTag()); + return status == NpcHousingRequestStatus.REQUESTED; + } + + private static boolean isVisibleStatus(NpcHousingRequestStatus status) { + return status != null && status != NpcHousingRequestStatus.NONE && status != NpcHousingRequestStatus.FULFILLED; + } + + private static int waitingDays(long requestedAtGameTime, long currentGameTime) { + if (requestedAtGameTime <= 0L || currentGameTime <= requestedAtGameTime) { + return 0; + } + return (int) Math.max(0L, (currentGameTime - requestedAtGameTime) / TICKS_PER_DAY); + } + + private static int score(NpcHousingRequestStatus status, + NpcHouseholdHousingState housingState, + int householdSize, + int waitingDays) { + int score = switch (status == null ? NpcHousingRequestStatus.NONE : status) { + case REQUESTED -> 40; + case DENIED -> 24; + case APPROVED -> 8; + case NONE, FULFILLED -> 0; + }; + score += switch (housingState == null ? NpcHouseholdHousingState.NORMAL : housingState) { + case HOMELESS -> 300; + case OVERCROWDED -> 180; + case NORMAL -> 60; + }; + score += Math.min(6, Math.max(0, householdSize)) * 12; + score += Math.min(30, Math.max(0, waitingDays)) * 4; + return score; + } + + private static String urgencyTag(int priorityScore, NpcHouseholdHousingState housingState) { + if (housingState == NpcHouseholdHousingState.HOMELESS || priorityScore >= 300) { + return "CRITICAL"; + } + if (housingState == NpcHouseholdHousingState.OVERCROWDED || priorityScore >= 180) { + return "HIGH"; + } + if (priorityScore >= 110) { + return "MEDIUM"; + } + return "LOW"; + } + + private static String reasonTag(NpcHousingRequestStatus status, + NpcHouseholdHousingState housingState, + int householdSize, + int waitingDays) { + if (housingState == NpcHouseholdHousingState.HOMELESS) { + return "HOMELESS"; + } + if (housingState == NpcHouseholdHousingState.OVERCROWDED) { + return "OVERCROWDED"; + } + if (waitingDays >= 7) { + return "LONG_WAIT"; + } + if (householdSize >= 4) { + return "LARGE_HOUSEHOLD"; + } + if (status == NpcHousingRequestStatus.DENIED) { + return "DENIED_REVIEW"; + } + if (status == NpcHousingRequestStatus.APPROVED) { + return "APPROVED_PIPELINE"; + } + return "STANDARD"; + } + + private static int statusOrder(@Nullable String statusTag) { + return switch (NpcHousingRequestStatus.fromName(statusTag == null ? "" : statusTag.toUpperCase(Locale.ROOT))) { + case REQUESTED -> 0; + case DENIED -> 1; + case APPROVED -> 2; + case NONE, FULFILLED -> 99; + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingProjectPlanner.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingProjectPlanner.java new file mode 100644 index 00000000..c1fc6543 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingProjectPlanner.java @@ -0,0 +1,214 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.growth.PendingProject; +import com.talhanation.bannermod.settlement.growth.ProjectBlocker; +import com.talhanation.bannermod.settlement.growth.ProjectKind; +import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; +import com.talhanation.bannermod.war.WarRuntimeContext; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public final class NpcHousingProjectPlanner { + private static final int HOUSE_REQUEST_PRIORITY = 880; + private static final int HOUSE_REQUEST_TICK_COST = 500; + + private NpcHousingProjectPlanner() { + } + + public static List collectApprovedHouseProjects(ServerLevel level, + SettlementSnapshot snapshot, + BannerModHomeAssignmentRuntime homeRuntime, + long gameTime) { + if (level == null || snapshot == null || homeRuntime == null) { + return List.of(); + } + UUID lordUuid = resolveLordUuid(level, snapshot.claimUuid()); + List projects = new ArrayList<>(); + Set visitedHouseholds = new LinkedHashSet<>(); + for (SettlementResidentRecord resident : snapshot.residents()) { + if (resident == null || resident.residentUuid() == null) { + continue; + } + UUID residentUuid = resident.residentUuid(); + NpcHouseholdRecord household = NpcHouseholdAccess.householdForResident(level, residentUuid).orElse(null); + if (household == null || !visitedHouseholds.add(household.householdId())) { + continue; + } + if (household.housingState() == NpcHouseholdHousingState.NORMAL) { + NpcHousingRequestAccess.markFulfilled(level, residentUuid, gameTime); + continue; + } + UUID requesterResidentUuid = pickRequesterResident(level, household, gameTime); + if (requesterResidentUuid == null) { + continue; + } + NpcHousingRequestRecord request = NpcHousingRequestAccess.requestHouse( + level, + household.householdId(), + requesterResidentUuid, + snapshot.claimUuid(), + lordUuid, + gameTime + ); + request = NpcHousingPlotPlanner.ensureReservedPlot(level, snapshot, request, gameTime); + if (request.status() == NpcHousingRequestStatus.REQUESTED) { + if (request.requestedAtGameTime() == gameTime) { + notifyLord(level, request, household); + } + } + if (request.status() == NpcHousingRequestStatus.APPROVED) { + projects.add(new PendingProject( + request.projectId(), + ProjectKind.NEW_BUILDING, + null, + prefabIdFor(snapshot, request), + SettlementBuildingProfileSeed.GENERAL.category(), + SettlementBuildingProfileSeed.GENERAL, + HOUSE_REQUEST_PRIORITY, + gameTime, + HOUSE_REQUEST_TICK_COST, + ProjectBlocker.NONE + )); + } + } + return projects; + } + + public static Set approvedRequesterIdsForClaim(ServerLevel level, UUID claimUuid) { + if (level == null || claimUuid == null) { + return Set.of(); + } + Set ordered = new LinkedHashSet<>(); + for (NpcHousingRequestRecord request : NpcHousingRequestSavedData.get(level).runtime().requestsForClaim(claimUuid)) { + if (request != null && request.status() == NpcHousingRequestStatus.APPROVED) { + ordered.add(request.residentUuid()); + } + } + return ordered; + } + + @Nullable + private static UUID pickRequesterResident(ServerLevel level, + NpcHouseholdRecord household, + long gameTime) { + UUID fallback = null; + for (UUID memberResidentUuid : household.memberResidentUuids()) { + if (memberResidentUuid == null) { + continue; + } + if (fallback == null) { + fallback = memberResidentUuid; + } + NpcSocietyProfile profile = NpcSocietyAccess.ensureResident(level, memberResidentUuid, gameTime); + if (profile.lifeStage() == NpcLifeStage.ADULT || profile.lifeStage() == NpcLifeStage.ELDER) { + return memberResidentUuid; + } + } + return fallback; + } + + @Nullable + private static UUID resolveLordUuid(ServerLevel level, @Nullable UUID claimUuid) { + if (level == null || claimUuid == null || ClaimEvents.claimManager() == null) { + return null; + } + RecruitsClaim claim = null; + for (RecruitsClaim candidate : ClaimEvents.claimManager().getAllClaims()) { + if (candidate != null && claimUuid.equals(candidate.getUUID())) { + claim = candidate; + break; + } + } + if (claim == null || claim.getOwnerPoliticalEntityId() == null) { + return null; + } + PoliticalEntityRecord owner = WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null); + return owner == null ? null : owner.leaderUuid(); + } + + private static net.minecraft.resources.ResourceLocation prefabIdFor(SettlementSnapshot snapshot, + NpcHousingRequestRecord request) { + return null; + } + + private static void notifyLord(ServerLevel level, + NpcHousingRequestRecord request, + NpcHouseholdRecord household) { + if (level == null || request == null || request.lordPlayerUuid() == null || household == null) { + return; + } + ServerPlayer lord = level.getServer().getPlayerList().getPlayer(request.lordPlayerUuid()); + if (lord == null) { + return; + } + String householdId = household.householdId().toString(); + MutableComponent approve = actionButton( + "gui.bannermod.society.housing_request.action.approve", + "/bannermod society housing approve " + householdId, + ChatFormatting.GREEN, + "gui.bannermod.society.housing_request.action.approve.tooltip" + ); + MutableComponent deny = actionButton( + "gui.bannermod.society.housing_request.action.deny", + "/bannermod society housing deny " + householdId, + ChatFormatting.RED, + "gui.bannermod.society.housing_request.action.deny.tooltip" + ); + MutableComponent list = actionButton( + "gui.bannermod.society.housing_request.action.list", + "/bannermod society housing list", + ChatFormatting.GOLD, + "gui.bannermod.society.housing_request.action.list.tooltip" + ); + Component housingState = Component.translatable( + "gui.bannermod.society.household_housing." + + household.housingState().name().toLowerCase(java.util.Locale.ROOT) + ); + Component plotPos = request.reservedPlotPos() == null + ? Component.literal("-") + : Component.literal(request.reservedPlotPos().getX() + " " + + request.reservedPlotPos().getY() + " " + + request.reservedPlotPos().getZ()); + lord.sendSystemMessage(Component.translatable( + "gui.bannermod.society.housing_request.notice", + request.residentUuid().toString().substring(0, 8), + housingState, + household.memberResidentUuids().size(), + plotPos + ).append(Component.literal(" ")) + .append(approve) + .append(Component.literal(" ")) + .append(deny) + .append(Component.literal(" ")) + .append(list)); + } + + private static MutableComponent actionButton(String labelKey, + String command, + ChatFormatting color, + String tooltipKey) { + return Component.translatable(labelKey) + .withStyle(style -> style.withColor(color) + .withUnderlined(true) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, command)) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(tooltipKey)))); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestAccess.java new file mode 100644 index 00000000..06711522 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestAccess.java @@ -0,0 +1,115 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.UUID; + +public final class NpcHousingRequestAccess { + private NpcHousingRequestAccess() { + } + + public static NpcHousingRequestRecord requestHouse(ServerLevel level, + UUID householdId, + UUID residentUuid, + UUID claimUuid, + @Nullable UUID lordPlayerUuid, + long gameTime) { + return NpcHousingRequestSavedData.get(level).runtime().ensureRequest( + householdId, + residentUuid, + claimUuid, + deterministicProjectId(householdId, claimUuid), + lordPlayerUuid, + gameTime + ); + } + + public static NpcHousingRequestRecord approve(ServerLevel level, UUID residentUuid, long gameTime) { + UUID householdId = householdIdFor(level, residentUuid); + if (householdId == null) { + throw new IllegalArgumentException("No household exists for resident " + residentUuid); + } + return NpcHousingRequestSavedData.get(level).runtime().approve(householdId, gameTime); + } + + public static NpcHousingRequestRecord approveHousehold(ServerLevel level, UUID householdId, long gameTime) { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + return NpcHousingRequestSavedData.get(level).runtime().approve(householdId, gameTime); + } + + public static NpcHousingRequestRecord denyHousehold(ServerLevel level, UUID householdId, long gameTime) { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + return NpcHousingRequestSavedData.get(level).runtime().deny(householdId, gameTime); + } + + public static NpcHousingRequestRecord reservePlot(ServerLevel level, + UUID householdId, + @Nullable BlockPos reservedPlotPos, + long gameTime) { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + return NpcHousingRequestSavedData.get(level).runtime().reservePlot(householdId, reservedPlotPos, gameTime); + } + + public static NpcHousingRequestRecord bindBuildArea(ServerLevel level, + UUID householdId, + @Nullable UUID buildAreaUuid, + long gameTime) { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + return NpcHousingRequestSavedData.get(level).runtime().bindBuildArea(householdId, buildAreaUuid, gameTime); + } + + public static @Nullable NpcHousingRequestRecord requestForHousehold(ServerLevel level, UUID householdId) { + if (householdId == null) { + return null; + } + return NpcHousingRequestSavedData.get(level).runtime().requestForHousehold(householdId).orElse(null); + } + + public static @Nullable NpcHousingRequestRecord requestForProject(ServerLevel level, UUID projectId) { + if (projectId == null) { + return null; + } + return NpcHousingRequestSavedData.get(level).runtime().requestForProject(projectId).orElse(null); + } + + public static void markFulfilled(ServerLevel level, UUID residentUuid, long gameTime) { + NpcHouseholdRecord household = NpcHouseholdAccess.householdForResident(level, residentUuid).orElse(null); + if (household == null || household.housingState() != NpcHouseholdHousingState.NORMAL) { + return; + } + NpcHousingRequestSavedData.get(level).runtime().fulfill(household.householdId(), gameTime); + } + + public static NpcHousingRequestStatus statusFor(ServerLevel level, UUID residentUuid) { + UUID householdId = householdIdFor(level, residentUuid); + if (householdId == null) { + return NpcHousingRequestStatus.NONE; + } + return NpcHousingRequestSavedData.get(level).runtime() + .requestForHousehold(householdId) + .map(NpcHousingRequestRecord::status) + .orElse(NpcHousingRequestStatus.NONE); + } + + private static @Nullable UUID householdIdFor(ServerLevel level, UUID residentUuid) { + return NpcHouseholdAccess.householdForResident(level, residentUuid) + .map(NpcHouseholdRecord::householdId) + .orElse(null); + } + + private static UUID deterministicProjectId(UUID householdId, UUID claimUuid) { + long hi = householdId.getMostSignificantBits() ^ claimUuid.getMostSignificantBits() ^ 0x484F5553454C4FL; + long lo = householdId.getLeastSignificantBits() ^ claimUuid.getLeastSignificantBits() ^ 0x52455155455354L; + return new UUID(hi, lo); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRecord.java new file mode 100644 index 00000000..1277c181 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRecord.java @@ -0,0 +1,194 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.UUID; + +public record NpcHousingRequestRecord( + UUID householdId, + UUID residentUuid, + UUID claimUuid, + UUID projectId, + @Nullable UUID lordPlayerUuid, + @Nullable BlockPos reservedPlotPos, + @Nullable UUID buildAreaUuid, + NpcHousingRequestStatus status, + long requestedAtGameTime, + long updatedAtGameTime +) { + public NpcHousingRequestRecord { + if (householdId == null) { + throw new IllegalArgumentException("householdId must not be null"); + } + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + if (claimUuid == null) { + throw new IllegalArgumentException("claimUuid must not be null"); + } + if (projectId == null) { + throw new IllegalArgumentException("projectId must not be null"); + } + if (status == null) { + status = NpcHousingRequestStatus.NONE; + } + } + + public static NpcHousingRequestRecord create(UUID householdId, + UUID residentUuid, + UUID claimUuid, + UUID projectId, + @Nullable UUID lordPlayerUuid, + long gameTime) { + return new NpcHousingRequestRecord( + householdId, + residentUuid, + claimUuid, + projectId, + lordPlayerUuid, + null, + null, + NpcHousingRequestStatus.REQUESTED, + gameTime, + gameTime + ); + } + + public NpcHousingRequestRecord approve(long gameTime) { + if (this.status == NpcHousingRequestStatus.APPROVED) { + return this; + } + return new NpcHousingRequestRecord( + this.householdId, + this.residentUuid, + this.claimUuid, + this.projectId, + this.lordPlayerUuid, + this.reservedPlotPos, + this.buildAreaUuid, + NpcHousingRequestStatus.APPROVED, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcHousingRequestRecord deny(long gameTime) { + if (this.status == NpcHousingRequestStatus.DENIED) { + return this; + } + return new NpcHousingRequestRecord( + this.householdId, + this.residentUuid, + this.claimUuid, + this.projectId, + this.lordPlayerUuid, + this.reservedPlotPos, + this.buildAreaUuid, + NpcHousingRequestStatus.DENIED, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcHousingRequestRecord fulfill(long gameTime) { + if (this.status == NpcHousingRequestStatus.FULFILLED) { + return this; + } + return new NpcHousingRequestRecord( + this.householdId, + this.residentUuid, + this.claimUuid, + this.projectId, + this.lordPlayerUuid, + this.reservedPlotPos, + this.buildAreaUuid, + NpcHousingRequestStatus.FULFILLED, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcHousingRequestRecord reservePlot(@Nullable BlockPos reservedPlotPos, long gameTime) { + if (sameBlockPos(this.reservedPlotPos, reservedPlotPos)) { + return this; + } + return new NpcHousingRequestRecord( + this.householdId, + this.residentUuid, + this.claimUuid, + this.projectId, + this.lordPlayerUuid, + reservedPlotPos, + this.buildAreaUuid, + this.status, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcHousingRequestRecord bindBuildArea(@Nullable UUID buildAreaUuid, long gameTime) { + if (sameNullableUuid(this.buildAreaUuid, buildAreaUuid)) { + return this; + } + return new NpcHousingRequestRecord( + this.householdId, + this.residentUuid, + this.claimUuid, + this.projectId, + this.lordPlayerUuid, + this.reservedPlotPos, + buildAreaUuid, + this.status, + this.requestedAtGameTime, + gameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("HouseholdId", this.householdId); + tag.putUUID("ResidentUuid", this.residentUuid); + tag.putUUID("ClaimUuid", this.claimUuid); + tag.putUUID("ProjectId", this.projectId); + if (this.lordPlayerUuid != null) { + tag.putUUID("LordPlayerUuid", this.lordPlayerUuid); + } + if (this.reservedPlotPos != null) { + tag.putLong("ReservedPlotPos", this.reservedPlotPos.asLong()); + } + if (this.buildAreaUuid != null) { + tag.putUUID("BuildAreaUuid", this.buildAreaUuid); + } + tag.putString("Status", this.status.name()); + tag.putLong("RequestedAt", this.requestedAtGameTime); + tag.putLong("UpdatedAt", this.updatedAtGameTime); + return tag; + } + + public static NpcHousingRequestRecord fromTag(CompoundTag tag) { + UUID residentUuid = tag.getUUID("ResidentUuid"); + UUID householdId = tag.contains("HouseholdId") ? tag.getUUID("HouseholdId") : residentUuid; + return new NpcHousingRequestRecord( + householdId, + residentUuid, + tag.getUUID("ClaimUuid"), + tag.getUUID("ProjectId"), + tag.contains("LordPlayerUuid") ? tag.getUUID("LordPlayerUuid") : null, + tag.contains("ReservedPlotPos") ? BlockPos.of(tag.getLong("ReservedPlotPos")) : null, + tag.contains("BuildAreaUuid") ? tag.getUUID("BuildAreaUuid") : null, + NpcHousingRequestStatus.fromName(tag.getString("Status")), + tag.getLong("RequestedAt"), + tag.getLong("UpdatedAt") + ); + } + + private static boolean sameNullableUuid(@Nullable UUID left, @Nullable UUID right) { + return left == null ? right == null : left.equals(right); + } + + private static boolean sameBlockPos(@Nullable BlockPos left, @Nullable BlockPos right) { + return left == null ? right == null : left.equals(right); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRuntime.java new file mode 100644 index 00000000..7b4d7086 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestRuntime.java @@ -0,0 +1,174 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcHousingRequestRuntime { + private final Map requestsByHousehold = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { + }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { + } : dirtyListener; + } + + public Optional requestForHousehold(UUID householdId) { + if (householdId == null) { + return Optional.empty(); + } + return Optional.ofNullable(this.requestsByHousehold.get(householdId)); + } + + public NpcHousingRequestRecord ensureRequest(UUID householdId, + UUID residentUuid, + UUID claimUuid, + UUID projectId, + @Nullable UUID lordPlayerUuid, + long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing != null && existing.status() != NpcHousingRequestStatus.FULFILLED) { + return existing; + } + NpcHousingRequestRecord created = NpcHousingRequestRecord.create(householdId, residentUuid, claimUuid, projectId, lordPlayerUuid, gameTime); + this.requestsByHousehold.put(householdId, created); + markDirty(); + return created; + } + + public NpcHousingRequestRecord approve(UUID householdId, long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing == null) { + throw new IllegalArgumentException("No housing request exists for household " + householdId); + } + NpcHousingRequestRecord updated = existing.approve(gameTime); + if (!updated.equals(existing)) { + this.requestsByHousehold.put(householdId, updated); + markDirty(); + } + return updated; + } + + public NpcHousingRequestRecord deny(UUID householdId, long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing == null) { + throw new IllegalArgumentException("No housing request exists for household " + householdId); + } + NpcHousingRequestRecord updated = existing.deny(gameTime); + if (!updated.equals(existing)) { + this.requestsByHousehold.put(householdId, updated); + markDirty(); + } + return updated; + } + + public NpcHousingRequestRecord reservePlot(UUID householdId, @Nullable BlockPos reservedPlotPos, long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing == null) { + throw new IllegalArgumentException("No housing request exists for household " + householdId); + } + NpcHousingRequestRecord updated = existing.reservePlot(reservedPlotPos, gameTime); + if (!updated.equals(existing)) { + this.requestsByHousehold.put(householdId, updated); + markDirty(); + } + return updated; + } + + public NpcHousingRequestRecord bindBuildArea(UUID householdId, @Nullable UUID buildAreaUuid, long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing == null) { + throw new IllegalArgumentException("No housing request exists for household " + householdId); + } + NpcHousingRequestRecord updated = existing.bindBuildArea(buildAreaUuid, gameTime); + if (!updated.equals(existing)) { + this.requestsByHousehold.put(householdId, updated); + markDirty(); + } + return updated; + } + + public void fulfill(UUID householdId, long gameTime) { + NpcHousingRequestRecord existing = this.requestsByHousehold.get(householdId); + if (existing == null) { + return; + } + NpcHousingRequestRecord updated = existing.fulfill(gameTime); + if (!updated.equals(existing)) { + this.requestsByHousehold.put(householdId, updated); + markDirty(); + } + } + + public List requestsForClaim(UUID claimUuid) { + if (claimUuid == null) { + return Collections.emptyList(); + } + List matches = new ArrayList<>(); + for (NpcHousingRequestRecord request : this.requestsByHousehold.values()) { + if (request != null && claimUuid.equals(request.claimUuid())) { + matches.add(request); + } + } + return matches; + } + + public Optional requestForProject(UUID projectId) { + if (projectId == null) { + return Optional.empty(); + } + for (NpcHousingRequestRecord request : this.requestsByHousehold.values()) { + if (request != null && projectId.equals(request.projectId())) { + return Optional.of(request); + } + } + return Optional.empty(); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag requests = new ListTag(); + for (NpcHousingRequestRecord request : this.requestsByHousehold.values()) { + requests.add(request.toTag()); + } + tag.put("Requests", requests); + return tag; + } + + public static NpcHousingRequestRuntime fromTag(CompoundTag tag) { + NpcHousingRequestRuntime runtime = new NpcHousingRequestRuntime(); + List requests = new ArrayList<>(); + for (Tag entry : tag.getList("Requests", Tag.TAG_COMPOUND)) { + requests.add(NpcHousingRequestRecord.fromTag((CompoundTag) entry)); + } + runtime.restoreSnapshot(requests); + return runtime; + } + + public void restoreSnapshot(@Nullable Collection requests) { + this.requestsByHousehold.clear(); + if (requests != null) { + for (NpcHousingRequestRecord request : requests) { + if (request != null) { + this.requestsByHousehold.put(request.householdId(), request); + } + } + } + } + + private void markDirty() { + this.dirtyListener.run(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestSavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestSavedData.java new file mode 100644 index 00000000..8658a426 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestSavedData.java @@ -0,0 +1,42 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class NpcHousingRequestSavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcHousingRequests"; + private static final SavedData.Factory FACTORY = + new SavedData.Factory<>(NpcHousingRequestSavedData::new, NpcHousingRequestSavedData::load); + + private final NpcHousingRequestRuntime runtime; + + public NpcHousingRequestSavedData() { + this(new NpcHousingRequestRuntime()); + } + + private NpcHousingRequestSavedData(NpcHousingRequestRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static NpcHousingRequestSavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcHousingRequestSavedData load(CompoundTag tag, HolderLookup.Provider registries) { + return new NpcHousingRequestSavedData(NpcHousingRequestRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag runtimeTag = this.runtime.toTag(); + tag.put("Requests", runtimeTag.getList("Requests", 10)); + return tag; + } + + public NpcHousingRequestRuntime runtime() { + return this.runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestStatus.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestStatus.java new file mode 100644 index 00000000..2f300ed3 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingRequestStatus.java @@ -0,0 +1,20 @@ +package com.talhanation.bannermod.society; + +public enum NpcHousingRequestStatus { + NONE, + REQUESTED, + DENIED, + APPROVED, + FULFILLED; + + public static NpcHousingRequestStatus fromName(String name) { + if (name == null || name.isBlank()) { + return NONE; + } + try { + return NpcHousingRequestStatus.valueOf(name); + } catch (IllegalArgumentException ignored) { + return NONE; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcHousingSnapshotContract.java b/src/main/java/com/talhanation/bannermod/society/NpcHousingSnapshotContract.java new file mode 100644 index 00000000..b2807ce5 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcHousingSnapshotContract.java @@ -0,0 +1,43 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; + +import javax.annotation.Nullable; +import java.util.UUID; + +public final class NpcHousingSnapshotContract { + public static final String NBT_HAS_CLAIM = "HasClaim"; + public static final String NBT_CLAIM_UUID = "ClaimUuid"; + public static final String NBT_CAN_MANAGE = "CanManage"; + public static final String NBT_DENIAL_KEY = "DenialKey"; + public static final String NBT_REQUESTS = "Requests"; + + private NpcHousingSnapshotContract() { + } + + public static CompoundTag encode(@Nullable UUID claimUuid, + boolean canManage, + @Nullable String denialKey, + Iterable requests) { + CompoundTag tag = new CompoundTag(); + tag.putBoolean(NBT_HAS_CLAIM, claimUuid != null); + if (claimUuid != null) { + tag.putUUID(NBT_CLAIM_UUID, claimUuid); + } + tag.putBoolean(NBT_CAN_MANAGE, canManage); + if (denialKey != null && !denialKey.isBlank()) { + tag.putString(NBT_DENIAL_KEY, denialKey); + } + ListTag requestTags = new ListTag(); + if (requests != null) { + for (NpcHousingLedgerEntry entry : requests) { + if (entry != null) { + requestTags.add(entry.toTag()); + } + } + } + tag.put(NBT_REQUESTS, requestTags); + return tag; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcIntent.java b/src/main/java/com/talhanation/bannermod/society/NpcIntent.java new file mode 100644 index 00000000..af0c3a33 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcIntent.java @@ -0,0 +1,28 @@ +package com.talhanation.bannermod.society; + +public enum NpcIntent { + UNSPECIFIED, + IDLE, + GO_HOME, + LEAVE_HOME, + REST, + EAT, + WORK, + SEEK_SUPPLIES, + HIDE, + DEFEND, + SELL, + FETCH, + DELIVER; + + public static NpcIntent fromName(String name) { + if (name == null || name.isBlank()) { + return UNSPECIFIED; + } + try { + return NpcIntent.valueOf(name); + } catch (IllegalArgumentException ignored) { + return UNSPECIFIED; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLifeStage.java b/src/main/java/com/talhanation/bannermod/society/NpcLifeStage.java new file mode 100644 index 00000000..2a38e137 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLifeStage.java @@ -0,0 +1,20 @@ +package com.talhanation.bannermod.society; + +public enum NpcLifeStage { + UNSPECIFIED, + CHILD, + ADOLESCENT, + ADULT, + ELDER; + + public static NpcLifeStage fromName(String name) { + if (name == null || name.isBlank()) { + return UNSPECIFIED; + } + try { + return NpcLifeStage.valueOf(name); + } catch (IllegalArgumentException ignored) { + return UNSPECIFIED; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodProjectPlanner.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodProjectPlanner.java new file mode 100644 index 00000000..fc71d0bd --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodProjectPlanner.java @@ -0,0 +1,234 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.growth.PendingProject; +import com.talhanation.bannermod.settlement.growth.ProjectBlocker; +import com.talhanation.bannermod.settlement.growth.ProjectKind; +import com.talhanation.bannermod.war.WarRuntimeContext; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; + +import javax.annotation.Nullable; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public final class NpcLivelihoodProjectPlanner { + private static final int LUMBER_PRIORITY = 860; + private static final int MINE_PRIORITY = 840; + private static final int ANIMAL_PEN_PRIORITY = 820; + private static final int PROJECT_TICK_COST = 20 * 60; + + private NpcLivelihoodProjectPlanner() { + } + + public static List collectApprovedProjects(ServerLevel level, + SettlementSnapshot snapshot, + long gameTime) { + if (level == null || snapshot == null || snapshot.claimUuid() == null) { + return List.of(); + } + for (NpcLivelihoodRequestType type : NpcLivelihoodRequestType.values()) { + if (hasLivelihoodBuilding(snapshot, type)) { + NpcLivelihoodRequestRecord existing = NpcLivelihoodRequestAccess.requestFor(level, snapshot.claimUuid(), type); + if (existing != null && existing.status() == NpcLivelihoodRequestStatus.APPROVED) { + NpcLivelihoodRequestAccess.fulfill(level, snapshot.claimUuid(), type, gameTime); + } + continue; + } + if (NpcLivelihoodRequestAccess.requestFor(level, snapshot.claimUuid(), type) != null || !shouldRequest(snapshot, type)) { + continue; + } + UUID representative = pickRepresentative(snapshot); + if (representative == null) { + continue; + } + NpcLivelihoodRequestRecord created = NpcLivelihoodRequestAccess.request( + level, + snapshot.claimUuid(), + representative, + type, + resolveLordUuid(level, snapshot.claimUuid()), + gameTime + ); + if (created.status() == NpcLivelihoodRequestStatus.REQUESTED && created.requestedAtGameTime() == gameTime) { + notifyLord(level, created); + } + } + List projects = new java.util.ArrayList<>(); + for (NpcLivelihoodRequestRecord request : NpcLivelihoodRequestSavedData.get(level).runtime().requestsForClaim(snapshot.claimUuid())) { + if (request == null || request.status() == NpcLivelihoodRequestStatus.DENIED) { + continue; + } + if (hasLivelihoodBuilding(snapshot, request.type())) { + if (request.status() == NpcLivelihoodRequestStatus.APPROVED) { + NpcLivelihoodRequestAccess.fulfill(level, snapshot.claimUuid(), request.type(), gameTime); + } + continue; + } + if (request.status() != NpcLivelihoodRequestStatus.APPROVED) { + continue; + } + projects.add(new PendingProject( + request.projectId(), + ProjectKind.NEW_BUILDING, + null, + request.type().prefabId(), + request.type().profileSeed().category(), + request.type().profileSeed(), + priorityFor(request.type()), + gameTime, + PROJECT_TICK_COST, + ProjectBlocker.NONE + )); + } + return projects; + } + + private static boolean shouldRequest(SettlementSnapshot snapshot, + NpcLivelihoodRequestType type) { + if (snapshot == null) { + return false; + } + boolean laborPressure = snapshot.unassignedWorkerCount() > 0 || snapshot.missingWorkAreaAssignmentCount() > 0; + if (!laborPressure) { + return false; + } + return switch (type) { + case LUMBER_CAMP, MINE -> true; + case ANIMAL_PEN -> snapshot.residents().size() >= 4; + }; + } + + private static boolean hasLivelihoodBuilding(SettlementSnapshot snapshot, NpcLivelihoodRequestType type) { + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (matchesType(building, type)) { + return true; + } + } + return false; + } + + private static boolean matchesType(@Nullable SettlementBuildingRecord building, + NpcLivelihoodRequestType type) { + if (building == null || building.buildingTypeId() == null) { + return false; + } + ResourceLocation id = ResourceLocation.tryParse(building.buildingTypeId()); + String path = id == null ? building.buildingTypeId().toLowerCase(Locale.ROOT) : id.getPath().toLowerCase(Locale.ROOT); + return switch (type) { + case LUMBER_CAMP -> path.contains("lumber_camp") || path.contains("lumber_area"); + case MINE -> path.equals("mine") || path.contains("validated_mine") || path.contains("mining_area"); + case ANIMAL_PEN -> path.contains("animal_pen"); + }; + } + + @Nullable + private static UUID pickRepresentative(SettlementSnapshot snapshot) { + UUID fallback = null; + for (SettlementResidentRecord resident : snapshot.residents()) { + if (resident == null || resident.residentUuid() == null) { + continue; + } + if (fallback == null) { + fallback = resident.residentUuid(); + } + if (resident.role() != SettlementResidentRole.CONTROLLED_WORKER) { + continue; + } + if (resident.assignmentState() != SettlementResidentAssignmentState.UNASSIGNED + && resident.assignmentState() != SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING) { + continue; + } + return resident.residentUuid(); + } + return fallback; + } + + @Nullable + private static UUID resolveLordUuid(ServerLevel level, @Nullable UUID claimUuid) { + if (level == null || claimUuid == null || ClaimEvents.claimManager() == null) { + return null; + } + for (RecruitsClaim claim : ClaimEvents.claimManager().getAllClaims()) { + if (claim != null && claimUuid.equals(claim.getUUID())) { + if (claim.getOwnerPoliticalEntityId() == null) { + return null; + } + PoliticalEntityRecord owner = WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null); + return owner == null ? null : owner.leaderUuid(); + } + } + return null; + } + + private static void notifyLord(ServerLevel level, NpcLivelihoodRequestRecord request) { + if (level == null || request == null || request.lordPlayerUuid() == null) { + return; + } + ServerPlayer lord = level.getServer().getPlayerList().getPlayer(request.lordPlayerUuid()); + if (lord == null) { + return; + } + String claimId = request.claimUuid().toString(); + String type = request.type().name(); + lord.sendSystemMessage(Component.translatable( + "gui.bannermod.society.livelihood_request.notice", + Component.translatable("gui.bannermod.society.livelihood_request.type." + request.type().translationSuffix()), + request.representativeResidentUuid().toString().substring(0, 8) + ).append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.livelihood_request.action.approve", + "/bannermod society livelihood approve " + claimId + " " + type, + ChatFormatting.GREEN, + "gui.bannermod.society.livelihood_request.action.approve.tooltip" + )) + .append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.livelihood_request.action.deny", + "/bannermod society livelihood deny " + claimId + " " + type, + ChatFormatting.RED, + "gui.bannermod.society.livelihood_request.action.deny.tooltip" + )) + .append(Component.literal(" ")) + .append(actionButton( + "gui.bannermod.society.livelihood_request.action.list", + "/bannermod society livelihood list", + ChatFormatting.GOLD, + "gui.bannermod.society.livelihood_request.action.list.tooltip" + ))); + } + + private static int priorityFor(NpcLivelihoodRequestType type) { + return switch (type) { + case LUMBER_CAMP -> LUMBER_PRIORITY; + case MINE -> MINE_PRIORITY; + case ANIMAL_PEN -> ANIMAL_PEN_PRIORITY; + }; + } + + private static MutableComponent actionButton(String labelKey, + String command, + ChatFormatting color, + String tooltipKey) { + return Component.translatable(labelKey) + .withStyle(style -> style.withColor(color) + .withUnderlined(true) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, command)) + .withHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, Component.translatable(tooltipKey)))); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestAccess.java new file mode 100644 index 00000000..bb1ddf63 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestAccess.java @@ -0,0 +1,63 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.UUID; + +public final class NpcLivelihoodRequestAccess { + private NpcLivelihoodRequestAccess() { + } + + public static NpcLivelihoodRequestRecord request(ServerLevel level, + UUID claimUuid, + UUID representativeResidentUuid, + NpcLivelihoodRequestType type, + @Nullable UUID lordPlayerUuid, + long gameTime) { + return NpcLivelihoodRequestSavedData.get(level).runtime().ensureRequest( + claimUuid, + representativeResidentUuid, + type, + deterministicProjectId(claimUuid, type), + lordPlayerUuid, + gameTime + ); + } + + public static @Nullable NpcLivelihoodRequestRecord requestFor(ServerLevel level, + UUID claimUuid, + NpcLivelihoodRequestType type) { + if (claimUuid == null || type == null) { + return null; + } + return NpcLivelihoodRequestSavedData.get(level).runtime().requestFor(claimUuid, type).orElse(null); + } + + public static NpcLivelihoodRequestRecord approve(ServerLevel level, + UUID claimUuid, + NpcLivelihoodRequestType type, + long gameTime) { + return NpcLivelihoodRequestSavedData.get(level).runtime().approve(claimUuid, type, gameTime); + } + + public static NpcLivelihoodRequestRecord deny(ServerLevel level, + UUID claimUuid, + NpcLivelihoodRequestType type, + long gameTime) { + return NpcLivelihoodRequestSavedData.get(level).runtime().deny(claimUuid, type, gameTime); + } + + public static void fulfill(ServerLevel level, + UUID claimUuid, + NpcLivelihoodRequestType type, + long gameTime) { + NpcLivelihoodRequestSavedData.get(level).runtime().fulfill(claimUuid, type, gameTime); + } + + private static UUID deterministicProjectId(UUID claimUuid, NpcLivelihoodRequestType type) { + long hi = claimUuid.getMostSignificantBits() ^ (0x4C4956454C49484FL + type.ordinal()); + long lo = claimUuid.getLeastSignificantBits() ^ (0x4F4F4450524A545L + (type.ordinal() * 31L)); + return new UUID(hi, lo); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRecord.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRecord.java new file mode 100644 index 00000000..86edf3c3 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRecord.java @@ -0,0 +1,133 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.UUID; + +public record NpcLivelihoodRequestRecord( + UUID claimUuid, + UUID representativeResidentUuid, + NpcLivelihoodRequestType type, + UUID projectId, + @Nullable UUID lordPlayerUuid, + NpcLivelihoodRequestStatus status, + long requestedAtGameTime, + long updatedAtGameTime +) { + public NpcLivelihoodRequestRecord { + if (claimUuid == null) { + throw new IllegalArgumentException("claimUuid must not be null"); + } + if (representativeResidentUuid == null) { + throw new IllegalArgumentException("representativeResidentUuid must not be null"); + } + if (type == null) { + throw new IllegalArgumentException("type must not be null"); + } + if (projectId == null) { + throw new IllegalArgumentException("projectId must not be null"); + } + if (status == null) { + status = NpcLivelihoodRequestStatus.NONE; + } + } + + public static NpcLivelihoodRequestRecord create(UUID claimUuid, + UUID representativeResidentUuid, + NpcLivelihoodRequestType type, + UUID projectId, + @Nullable UUID lordPlayerUuid, + long gameTime) { + return new NpcLivelihoodRequestRecord( + claimUuid, + representativeResidentUuid, + type, + projectId, + lordPlayerUuid, + NpcLivelihoodRequestStatus.REQUESTED, + gameTime, + gameTime + ); + } + + public NpcLivelihoodRequestRecord approve(long gameTime) { + if (this.status == NpcLivelihoodRequestStatus.APPROVED) { + return this; + } + return new NpcLivelihoodRequestRecord( + this.claimUuid, + this.representativeResidentUuid, + this.type, + this.projectId, + this.lordPlayerUuid, + NpcLivelihoodRequestStatus.APPROVED, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcLivelihoodRequestRecord deny(long gameTime) { + if (this.status == NpcLivelihoodRequestStatus.DENIED) { + return this; + } + return new NpcLivelihoodRequestRecord( + this.claimUuid, + this.representativeResidentUuid, + this.type, + this.projectId, + this.lordPlayerUuid, + NpcLivelihoodRequestStatus.DENIED, + this.requestedAtGameTime, + gameTime + ); + } + + public NpcLivelihoodRequestRecord fulfill(long gameTime) { + if (this.status == NpcLivelihoodRequestStatus.FULFILLED) { + return this; + } + return new NpcLivelihoodRequestRecord( + this.claimUuid, + this.representativeResidentUuid, + this.type, + this.projectId, + this.lordPlayerUuid, + NpcLivelihoodRequestStatus.FULFILLED, + this.requestedAtGameTime, + gameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ClaimUuid", this.claimUuid); + tag.putUUID("RepresentativeResidentUuid", this.representativeResidentUuid); + tag.putString("Type", this.type.name()); + tag.putUUID("ProjectId", this.projectId); + if (this.lordPlayerUuid != null) { + tag.putUUID("LordPlayerUuid", this.lordPlayerUuid); + } + tag.putString("Status", this.status.name()); + tag.putLong("RequestedAt", this.requestedAtGameTime); + tag.putLong("UpdatedAt", this.updatedAtGameTime); + return tag; + } + + public static NpcLivelihoodRequestRecord fromTag(CompoundTag tag) { + NpcLivelihoodRequestType type = NpcLivelihoodRequestType.fromName(tag.getString("Type")); + if (type == null) { + type = NpcLivelihoodRequestType.LUMBER_CAMP; + } + return new NpcLivelihoodRequestRecord( + tag.getUUID("ClaimUuid"), + tag.getUUID("RepresentativeResidentUuid"), + type, + tag.getUUID("ProjectId"), + tag.contains("LordPlayerUuid") ? tag.getUUID("LordPlayerUuid") : null, + NpcLivelihoodRequestStatus.fromName(tag.getString("Status")), + tag.getLong("RequestedAt"), + tag.getLong("UpdatedAt") + ); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRuntime.java new file mode 100644 index 00000000..08a56681 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestRuntime.java @@ -0,0 +1,147 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcLivelihoodRequestRuntime { + private final Map> requestsByClaim = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { + }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { + } : dirtyListener; + } + + public Optional requestFor(UUID claimUuid, NpcLivelihoodRequestType type) { + if (claimUuid == null || type == null) { + return Optional.empty(); + } + Map byType = this.requestsByClaim.get(claimUuid); + return byType == null ? Optional.empty() : Optional.ofNullable(byType.get(type)); + } + + public NpcLivelihoodRequestRecord ensureRequest(UUID claimUuid, + UUID representativeResidentUuid, + NpcLivelihoodRequestType type, + UUID projectId, + @Nullable UUID lordPlayerUuid, + long gameTime) { + NpcLivelihoodRequestRecord existing = requestFor(claimUuid, type).orElse(null); + if (existing != null && existing.status() != NpcLivelihoodRequestStatus.FULFILLED) { + return existing; + } + NpcLivelihoodRequestRecord created = NpcLivelihoodRequestRecord.create( + claimUuid, + representativeResidentUuid, + type, + projectId, + lordPlayerUuid, + gameTime + ); + this.requestsByClaim.computeIfAbsent(claimUuid, ignored -> new EnumMap<>(NpcLivelihoodRequestType.class)) + .put(type, created); + markDirty(); + return created; + } + + public NpcLivelihoodRequestRecord approve(UUID claimUuid, NpcLivelihoodRequestType type, long gameTime) { + NpcLivelihoodRequestRecord existing = require(claimUuid, type); + NpcLivelihoodRequestRecord updated = existing.approve(gameTime); + storeIfChanged(claimUuid, type, existing, updated); + return updated; + } + + public NpcLivelihoodRequestRecord deny(UUID claimUuid, NpcLivelihoodRequestType type, long gameTime) { + NpcLivelihoodRequestRecord existing = require(claimUuid, type); + NpcLivelihoodRequestRecord updated = existing.deny(gameTime); + storeIfChanged(claimUuid, type, existing, updated); + return updated; + } + + public void fulfill(UUID claimUuid, NpcLivelihoodRequestType type, long gameTime) { + NpcLivelihoodRequestRecord existing = requestFor(claimUuid, type).orElse(null); + if (existing == null) { + return; + } + NpcLivelihoodRequestRecord updated = existing.fulfill(gameTime); + storeIfChanged(claimUuid, type, existing, updated); + } + + public List requestsForClaim(UUID claimUuid) { + if (claimUuid == null) { + return Collections.emptyList(); + } + Map byType = this.requestsByClaim.get(claimUuid); + if (byType == null || byType.isEmpty()) { + return Collections.emptyList(); + } + return new ArrayList<>(byType.values()); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag requests = new ListTag(); + for (Map byType : this.requestsByClaim.values()) { + for (NpcLivelihoodRequestRecord request : byType.values()) { + requests.add(request.toTag()); + } + } + tag.put("Requests", requests); + return tag; + } + + public static NpcLivelihoodRequestRuntime fromTag(CompoundTag tag) { + NpcLivelihoodRequestRuntime runtime = new NpcLivelihoodRequestRuntime(); + List requests = new ArrayList<>(); + for (Tag entry : tag.getList("Requests", Tag.TAG_COMPOUND)) { + requests.add(NpcLivelihoodRequestRecord.fromTag((CompoundTag) entry)); + } + runtime.restoreSnapshot(requests); + return runtime; + } + + public void restoreSnapshot(@Nullable Collection requests) { + this.requestsByClaim.clear(); + if (requests != null) { + for (NpcLivelihoodRequestRecord request : requests) { + if (request != null) { + this.requestsByClaim.computeIfAbsent(request.claimUuid(), ignored -> new EnumMap<>(NpcLivelihoodRequestType.class)) + .put(request.type(), request); + } + } + } + } + + private NpcLivelihoodRequestRecord require(UUID claimUuid, NpcLivelihoodRequestType type) { + return requestFor(claimUuid, type) + .orElseThrow(() -> new IllegalArgumentException("No livelihood request exists for claim " + claimUuid + " and type " + type)); + } + + private void storeIfChanged(UUID claimUuid, + NpcLivelihoodRequestType type, + NpcLivelihoodRequestRecord existing, + NpcLivelihoodRequestRecord updated) { + if (!updated.equals(existing)) { + this.requestsByClaim.computeIfAbsent(claimUuid, ignored -> new EnumMap<>(NpcLivelihoodRequestType.class)) + .put(type, updated); + markDirty(); + } + } + + private void markDirty() { + this.dirtyListener.run(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestSavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestSavedData.java new file mode 100644 index 00000000..420e3a3c --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestSavedData.java @@ -0,0 +1,42 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class NpcLivelihoodRequestSavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcLivelihoodRequests"; + private static final SavedData.Factory FACTORY = + new SavedData.Factory<>(NpcLivelihoodRequestSavedData::new, NpcLivelihoodRequestSavedData::load); + + private final NpcLivelihoodRequestRuntime runtime; + + public NpcLivelihoodRequestSavedData() { + this(new NpcLivelihoodRequestRuntime()); + } + + private NpcLivelihoodRequestSavedData(NpcLivelihoodRequestRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static NpcLivelihoodRequestSavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcLivelihoodRequestSavedData load(CompoundTag tag, HolderLookup.Provider registries) { + return new NpcLivelihoodRequestSavedData(NpcLivelihoodRequestRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag serialized = this.runtime.toTag(); + tag.merge(serialized); + return tag; + } + + public NpcLivelihoodRequestRuntime runtime() { + return this.runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestStatus.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestStatus.java new file mode 100644 index 00000000..c9663da8 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestStatus.java @@ -0,0 +1,20 @@ +package com.talhanation.bannermod.society; + +public enum NpcLivelihoodRequestStatus { + NONE, + REQUESTED, + DENIED, + APPROVED, + FULFILLED; + + public static NpcLivelihoodRequestStatus fromName(String name) { + if (name == null || name.isBlank()) { + return NONE; + } + try { + return NpcLivelihoodRequestStatus.valueOf(name); + } catch (IllegalArgumentException ignored) { + return NONE; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestType.java b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestType.java new file mode 100644 index 00000000..fdb187a7 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcLivelihoodRequestType.java @@ -0,0 +1,47 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementBuildingProfileSeed; +import com.talhanation.bannermod.settlement.prefab.impl.AnimalPenPrefab; +import com.talhanation.bannermod.settlement.prefab.impl.LumberCampPrefab; +import com.talhanation.bannermod.settlement.prefab.impl.MinePrefab; +import net.minecraft.resources.ResourceLocation; + +import javax.annotation.Nullable; + +public enum NpcLivelihoodRequestType { + LUMBER_CAMP(LumberCampPrefab.ID, SettlementBuildingProfileSeed.MATERIAL_PRODUCTION), + MINE(MinePrefab.ID, SettlementBuildingProfileSeed.MATERIAL_PRODUCTION), + ANIMAL_PEN(AnimalPenPrefab.ID, SettlementBuildingProfileSeed.FOOD_PRODUCTION); + + private final ResourceLocation prefabId; + private final SettlementBuildingProfileSeed profileSeed; + + NpcLivelihoodRequestType(ResourceLocation prefabId, + SettlementBuildingProfileSeed profileSeed) { + this.prefabId = prefabId; + this.profileSeed = profileSeed; + } + + public ResourceLocation prefabId() { + return this.prefabId; + } + + public SettlementBuildingProfileSeed profileSeed() { + return this.profileSeed; + } + + public String translationSuffix() { + return this.name().toLowerCase(java.util.Locale.ROOT); + } + + public static @Nullable NpcLivelihoodRequestType fromName(String name) { + if (name == null || name.isBlank()) { + return null; + } + try { + return NpcLivelihoodRequestType.valueOf(name.toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshot.java new file mode 100644 index 00000000..3772da01 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshot.java @@ -0,0 +1,332 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; + +import javax.annotation.Nullable; +import java.util.Locale; +import java.util.UUID; + +public record NpcPhaseOneSnapshot( + String lifeStageTag, + String sexTag, + @Nullable UUID householdId, + @Nullable UUID householdHeadResidentUuid, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + @Nullable String cultureId, + @Nullable String faithId, + String dailyPhaseTag, + String currentIntentTag, + String currentAnchorTag, + String aiStateTag, + @Nullable String aiCurrentGoalId, + String aiChoiceReasonTag, + String aiRouteReasonTag, + @Nullable String aiBlockedGoalId, + String aiBlockedReasonTag, + int householdSize, + String householdHousingStateTag, + int hungerNeed, + int fatigueNeed, + int safetyNeed, + String housingRequestStatusTag, + String housingUrgencyTag, + String housingReasonTag, + int housingWaitingDays +) { + public static NpcPhaseOneSnapshot empty() { + return new NpcPhaseOneSnapshot( + NpcLifeStage.UNSPECIFIED.name(), + NpcSex.UNSPECIFIED.name(), + null, + null, + null, + null, + null, + null, + NpcDailyPhase.UNSPECIFIED.name(), + NpcIntent.UNSPECIFIED.name(), + NpcAnchorType.NONE.name(), + "IDLE", + null, + "NO_STARTABLE_GOAL", + "NO_CLEAR_ROUTE", + null, + "NONE", + 0, + NpcHouseholdHousingState.HOMELESS.name(), + 0, + 0, + 0, + NpcHousingRequestStatus.NONE.name(), + "LOW", + "STABLE", + 0 + ); + } + + public void toBytes(FriendlyByteBuf buf) { + buf.writeUtf(safeTag(this.lifeStageTag)); + buf.writeUtf(safeTag(this.sexTag)); + writeNullableUuid(buf, this.householdId); + writeNullableUuid(buf, this.householdHeadResidentUuid); + writeNullableUuid(buf, this.homeBuildingUuid); + writeNullableUuid(buf, this.workBuildingUuid); + writeNullableString(buf, this.cultureId); + writeNullableString(buf, this.faithId); + buf.writeUtf(safeTag(this.dailyPhaseTag)); + buf.writeUtf(safeTag(this.currentIntentTag)); + buf.writeUtf(safeTag(this.currentAnchorTag)); + buf.writeUtf(safeTag(this.aiStateTag)); + writeNullableString(buf, this.aiCurrentGoalId); + buf.writeUtf(safeTag(this.aiChoiceReasonTag)); + buf.writeUtf(safeTag(this.aiRouteReasonTag)); + writeNullableString(buf, this.aiBlockedGoalId); + buf.writeUtf(safeTag(this.aiBlockedReasonTag)); + buf.writeVarInt(Math.max(0, this.householdSize)); + buf.writeUtf(safeTag(this.householdHousingStateTag)); + buf.writeVarInt(Math.max(0, this.hungerNeed)); + buf.writeVarInt(Math.max(0, this.fatigueNeed)); + buf.writeVarInt(Math.max(0, this.safetyNeed)); + buf.writeUtf(safeTag(this.housingRequestStatusTag)); + buf.writeUtf(safeTag(this.housingUrgencyTag)); + buf.writeUtf(safeTag(this.housingReasonTag)); + buf.writeVarInt(Math.max(0, this.housingWaitingDays)); + } + + public static NpcPhaseOneSnapshot fromBytes(FriendlyByteBuf buf) { + String lifeStageTag = buf.readUtf(); + String sexTag = buf.readUtf(); + UUID householdId = readNullableUuid(buf); + UUID householdHeadResidentUuid = readNullableUuid(buf); + UUID homeBuildingUuid = readNullableUuid(buf); + UUID workBuildingUuid = readNullableUuid(buf); + String cultureId = readNullableString(buf); + String faithId = readNullableString(buf); + String dailyPhaseTag = buf.readUtf(); + String currentIntentTag = buf.readUtf(); + String currentAnchorTag = buf.readUtf(); + String aiStateTag = buf.readUtf(); + String aiCurrentGoalId = readNullableString(buf); + String aiChoiceReasonTag = buf.readUtf(); + String aiRouteReasonTag = buf.readUtf(); + String aiBlockedGoalId = readNullableString(buf); + String aiBlockedReasonTag = buf.readUtf(); + int householdSize = buf.readVarInt(); + String householdHousingStateTag = buf.readUtf(); + int hungerNeed = buf.readVarInt(); + int fatigueNeed = buf.readVarInt(); + int safetyNeed = buf.readVarInt(); + String housingRequestStatusTag = buf.readUtf(); + String housingUrgencyTag = buf.readUtf(); + String housingReasonTag = buf.readUtf(); + int housingWaitingDays = buf.readVarInt(); + return new NpcPhaseOneSnapshot( + lifeStageTag, + sexTag, + householdId, + householdHeadResidentUuid, + homeBuildingUuid, + workBuildingUuid, + cultureId, + faithId, + dailyPhaseTag, + currentIntentTag, + currentAnchorTag, + aiStateTag, + aiCurrentGoalId, + aiChoiceReasonTag, + aiRouteReasonTag, + aiBlockedGoalId, + aiBlockedReasonTag, + householdSize, + householdHousingStateTag, + hungerNeed, + fatigueNeed, + safetyNeed, + housingRequestStatusTag, + housingUrgencyTag, + housingReasonTag, + housingWaitingDays + ); + } + + public String lifeStageTranslationKey() { + return "gui.bannermod.society.life_stage." + safeTag(this.lifeStageTag).toLowerCase(Locale.ROOT); + } + + public String sexTranslationKey() { + return "gui.bannermod.society.sex." + safeTag(this.sexTag).toLowerCase(Locale.ROOT); + } + + public String dailyPhaseTranslationKey() { + return "gui.bannermod.society.daily_phase." + safeTag(this.dailyPhaseTag).toLowerCase(Locale.ROOT); + } + + public String currentIntentTranslationKey() { + return "gui.bannermod.society.intent." + safeTag(this.currentIntentTag).toLowerCase(Locale.ROOT); + } + + public String currentAnchorTranslationKey() { + return "gui.bannermod.society.anchor." + safeTag(this.currentAnchorTag).toLowerCase(Locale.ROOT); + } + + public String aiStateTranslationKey() { + return "gui.bannermod.society.ai.state." + safeTag(this.aiStateTag).toLowerCase(Locale.ROOT); + } + + public String aiChoiceReasonTranslationKey() { + return "gui.bannermod.society.ai.reason." + safeTag(this.aiChoiceReasonTag).toLowerCase(Locale.ROOT); + } + + public String aiBlockedReasonTranslationKey() { + return "gui.bannermod.society.ai.reason." + safeTag(this.aiBlockedReasonTag).toLowerCase(Locale.ROOT); + } + + public String aiRouteReasonTranslationKey() { + return "gui.bannermod.society.ai.route." + safeTag(this.aiRouteReasonTag).toLowerCase(Locale.ROOT); + } + + public boolean isRecoveringState() { + return "RECOVERING".equalsIgnoreCase(this.aiStateTag); + } + + public boolean isBlockedState() { + return "BLOCKED".equalsIgnoreCase(this.aiStateTag) || this.isRecoveringState(); + } + + public boolean hasAiCurrentGoal() { + return this.aiCurrentGoalId != null && !this.aiCurrentGoalId.isBlank(); + } + + public boolean hasAiBlockedGoal() { + return this.aiBlockedGoalId != null && !this.aiBlockedGoalId.isBlank(); + } + + public String householdHousingStateTranslationKey() { + return "gui.bannermod.society.household_housing." + safeTag(this.householdHousingStateTag).toLowerCase(Locale.ROOT); + } + + public String housingRequestTranslationKey() { + return "gui.bannermod.society.housing_request." + safeTag(this.housingRequestStatusTag).toLowerCase(Locale.ROOT); + } + + public String housingUrgencyTranslationKey() { + return "gui.bannermod.housing_ledger.urgency." + safeTag(this.housingUrgencyTag).toLowerCase(Locale.ROOT); + } + + public String housingReasonTranslationKey() { + return "gui.bannermod.housing_ledger.reason." + safeTag(this.housingReasonTag).toLowerCase(Locale.ROOT); + } + + public String householdRoleTranslationKey(@Nullable UUID residentUuid) { + if (residentUuid == null || this.householdId == null) { + return "gui.bannermod.society.household_role.unknown"; + } + if (this.householdHeadResidentUuid != null && this.householdHeadResidentUuid.equals(residentUuid)) { + return "gui.bannermod.society.household_role.head"; + } + return "gui.bannermod.society.household_role.member"; + } + + public String cultureLabel() { + return this.cultureId == null || this.cultureId.isBlank() ? "-" : this.cultureId; + } + + public String faithLabel() { + return this.faithId == null || this.faithId.isBlank() ? "-" : this.faithId; + } + + public static String shortId(@Nullable UUID uuid) { + return uuid == null ? "-" : uuid.toString().substring(0, 8); + } + + public String aiCurrentGoalLabel() { + return NpcSocietyDecisionSnapshot.goalLabelOrDash(this.aiCurrentGoalId); + } + + public String aiBlockedGoalLabel() { + return NpcSocietyDecisionSnapshot.goalLabelOrDash(this.aiBlockedGoalId); + } + + public Component aiCurrentGoalComponent() { + return goalComponent(this.aiCurrentGoalId); + } + + public Component aiBlockedGoalComponent() { + return goalComponent(this.aiBlockedGoalId); + } + + public Component aiRouteSecondaryComponent() { + if ((this.isBlockedState() || !this.hasAiCurrentGoal()) && this.hasAiBlockedGoal()) { + return Component.translatable( + "gui.bannermod.society.ai.route.blocked_detail", + this.aiBlockedGoalComponent(), + Component.translatable(this.aiBlockedReasonTranslationKey()) + ); + } + return Component.translatable( + "gui.bannermod.society.ai.route.target", + Component.translatable(this.currentAnchorTranslationKey()) + ); + } + + public Component aiReadableRoutineReasonComponent() { + Component route = Component.translatable(this.aiRouteReasonTranslationKey()); + if ((this.isBlockedState() || !this.hasAiCurrentGoal()) && this.hasAiBlockedGoal()) { + return Component.translatable( + "gui.bannermod.society.ai.route.blocked_summary", + this.aiBlockedGoalComponent(), + Component.translatable(this.aiBlockedReasonTranslationKey()) + ); + } + return route; + } + + private static void writeNullableUuid(FriendlyByteBuf buf, @Nullable UUID value) { + buf.writeBoolean(value != null); + if (value != null) { + buf.writeUUID(value); + } + } + + private static @Nullable UUID readNullableUuid(FriendlyByteBuf buf) { + return buf.readBoolean() ? buf.readUUID() : null; + } + + private static void writeNullableString(FriendlyByteBuf buf, @Nullable String value) { + buf.writeBoolean(value != null); + if (value != null) { + buf.writeUtf(value); + } + } + + private static @Nullable String readNullableString(FriendlyByteBuf buf) { + return buf.readBoolean() ? buf.readUtf() : null; + } + + private static String safeTag(@Nullable String value) { + return value == null || value.isBlank() ? "UNSPECIFIED" : value; + } + + private static Component goalComponent(@Nullable String goalId) { + String label = NpcSocietyDecisionSnapshot.goalLabelOrDash(goalId); + return switch (label) { + case "-" -> Component.translatable("gui.bannermod.common.none"); + case "go_home" -> Component.translatable("gui.bannermod.society.ai.goal.go_home"); + case "leave_home" -> Component.translatable("gui.bannermod.society.ai.goal.leave_home"); + case "rest" -> Component.translatable("gui.bannermod.society.ai.goal.rest"); + case "eat" -> Component.translatable("gui.bannermod.society.ai.goal.eat"); + case "seek_supplies" -> Component.translatable("gui.bannermod.society.ai.goal.seek_supplies"); + case "hide" -> Component.translatable("gui.bannermod.society.ai.goal.hide"); + case "defend" -> Component.translatable("gui.bannermod.society.ai.goal.defend"); + case "work" -> Component.translatable("gui.bannermod.society.ai.goal.work"); + case "sell", "seller" -> Component.translatable("gui.bannermod.society.ai.goal.sell"); + case "fetch" -> Component.translatable("gui.bannermod.society.ai.goal.fetch"); + case "deliver" -> Component.translatable("gui.bannermod.society.ai.goal.deliver"); + case "idle" -> Component.translatable("gui.bannermod.society.ai.goal.idle"); + default -> Component.literal(label.replace('_', ' ')); + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSex.java b/src/main/java/com/talhanation/bannermod/society/NpcSex.java new file mode 100644 index 00000000..c15a734c --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSex.java @@ -0,0 +1,18 @@ +package com.talhanation.bannermod.society; + +public enum NpcSex { + UNSPECIFIED, + MALE, + FEMALE; + + public static NpcSex fromName(String name) { + if (name == null || name.isBlank()) { + return UNSPECIFIED; + } + try { + return NpcSex.valueOf(name); + } catch (IllegalArgumentException ignored) { + return UNSPECIFIED; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyAccess.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyAccess.java new file mode 100644 index 00000000..ab3b6216 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyAccess.java @@ -0,0 +1,228 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.entity.citizen.CitizenEntity; +import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.entity.civilian.workarea.AbstractWorkAreaEntity; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementService; +import com.talhanation.bannermod.settlement.bootstrap.SettlementRecord; +import com.talhanation.bannermod.settlement.bootstrap.SettlementRegistryData; +import com.talhanation.bannermod.settlement.building.BuildingValidationState; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRegistryData; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.entity.Entity; +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcSocietyAccess { + private NpcSocietyAccess() { + } + + public static NpcSocietyProfile ensureResident(ServerLevel level, UUID residentUuid, long gameTime) { + return NpcSocietySavedData.get(level).runtime().ensureResident(residentUuid, gameTime); + } + + public static NpcSocietyProfile seedResident(ServerLevel level, + UUID residentUuid, + NpcLifeStage lifeStage, + NpcSex sex, + long gameTime) { + return NpcSocietySavedData.get(level).runtime().seedResident( + NpcSocietyProfile.createSeeded(residentUuid, lifeStage, sex, gameTime) + ); + } + + public static NpcSocietyProfile ensureResidentForEntity(ServerLevel level, Entity entity) { + if (level == null || entity == null) { + throw new IllegalArgumentException("level and entity must not be null"); + } + NpcSocietyRuntime runtime = NpcSocietySavedData.get(level).runtime(); + return runtime.profileFor(entity.getUUID()).orElseGet(() -> runtime.seedResident(seedProfileFor(entity, level.getGameTime()))); + } + + public static NpcSocietyProfile reconcilePhaseOneState(ServerLevel level, + UUID residentUuid, + @Nullable UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + NpcDailyPhase dailyPhase, + NpcIntent currentIntent, + NpcAnchorType currentAnchor, + @Nullable NpcSocietyDecisionSnapshot decisionSnapshot, + long gameTime) { + return NpcSocietySavedData.get(level).runtime().reconcilePhaseOneState( + residentUuid, + householdId, + homeBuildingUuid, + workBuildingUuid, + dailyPhase, + currentIntent, + currentAnchor, + decisionSnapshot, + gameTime + ); + } + + public static Optional profileFor(ServerLevel level, UUID residentUuid) { + return NpcSocietySavedData.get(level).runtime().profileFor(residentUuid); + } + + public static NpcSocietyProfile reconcileNeedState(ServerLevel level, + UUID residentUuid, + int hungerNeed, + int fatigueNeed, + int safetyNeed, + long gameTime) { + return NpcSocietySavedData.get(level).runtime().reconcileNeedState( + residentUuid, + hungerNeed, + fatigueNeed, + safetyNeed, + gameTime + ); + } + + public static NpcSocietyProfile moveResidentProfile(ServerLevel level, + UUID fromResidentUuid, + UUID toResidentUuid, + long gameTime) { + NpcHouseholdAccess.moveResident(level, fromResidentUuid, toResidentUuid, gameTime); + NpcFamilyAccess.moveResident(level, fromResidentUuid, toResidentUuid, gameTime); + return NpcSocietySavedData.get(level).runtime().moveResident(fromResidentUuid, toResidentUuid, gameTime); + } + + public static NpcPhaseOneSnapshot phaseOneSnapshot(ServerLevel level, + UUID residentUuid, + @Nullable UUID fallbackWorkBuildingUuid) { + NpcSocietyProfile profile = ensureResident(level, residentUuid, level.getGameTime()); + UUID workBuildingUuid = authoritativeFallbackWorkBuildingUuid( + level, + residentUuid, + profile.workBuildingUuid() != null ? profile.workBuildingUuid() : fallbackWorkBuildingUuid + ); + NpcHouseholdRecord household = NpcHouseholdAccess.householdForResident(level, residentUuid).orElse(null); + UUID householdId = household == null ? profile.householdId() : household.householdId(); + NpcHousingRequestRecord housingRequest = householdId == null ? null : NpcHousingRequestAccess.requestForHousehold(level, householdId); + String housingUrgencyTag = "LOW"; + String housingReasonTag = "STABLE"; + int housingWaitingDays = 0; + if (housingRequest != null + && housingRequest.status() != NpcHousingRequestStatus.NONE + && housingRequest.status() != NpcHousingRequestStatus.FULFILLED) { + NpcHousingLedgerEntry housingEntry = NpcHousingPriorityService.describe(housingRequest, household, level.getGameTime()); + housingUrgencyTag = housingEntry.urgencyTag(); + housingReasonTag = housingEntry.reasonTag(); + housingWaitingDays = housingEntry.waitingDays(); + } else if (household == null || household.housingState() == NpcHouseholdHousingState.HOMELESS) { + housingUrgencyTag = "CRITICAL"; + housingReasonTag = "HOMELESS"; + } else if (household.housingState() == NpcHouseholdHousingState.OVERCROWDED) { + housingUrgencyTag = "HIGH"; + housingReasonTag = "OVERCROWDED"; + } + NpcSocietyDecisionSnapshot decisionSnapshot = profile.decisionSnapshot() == null + ? NpcSocietyDecisionSnapshot.empty() + : profile.decisionSnapshot(); + return new NpcPhaseOneSnapshot( + profile.lifeStage().name(), + profile.sex().name(), + householdId, + household == null ? null : household.headResidentUuid(), + profile.homeBuildingUuid(), + workBuildingUuid, + profile.cultureId(), + profile.faithId(), + profile.dailyPhase().name(), + profile.currentIntent().name(), + profile.currentAnchor().name(), + decisionSnapshot.stateTag(), + decisionSnapshot.currentGoalId(), + decisionSnapshot.choiceReasonTag(), + decisionSnapshot.routeReasonTag(), + decisionSnapshot.blockedGoalId(), + decisionSnapshot.blockedReasonTag(), + household == null ? 0 : household.memberResidentUuids().size(), + household == null ? NpcHouseholdHousingState.HOMELESS.name() : household.housingState().name(), + profile.hungerNeed(), + profile.fatigueNeed(), + profile.safetyNeed(), + NpcHousingRequestAccess.statusFor(level, residentUuid).name(), + housingUrgencyTag, + housingReasonTag, + housingWaitingDays + ); + } + + private static @Nullable UUID authoritativeFallbackWorkBuildingUuid(ServerLevel level, + UUID residentUuid, + @Nullable UUID fallbackWorkBuildingUuid) { + if (level == null || residentUuid == null || fallbackWorkBuildingUuid == null || ClaimEvents.claimManager() == null) { + return fallbackWorkBuildingUuid; + } + Entity fallbackWorkBuilding = level.getEntity(fallbackWorkBuildingUuid); + RecruitsClaim claim = fallbackWorkBuilding == null + ? null + : ClaimEvents.claimManager().getClaim(new ChunkPos(fallbackWorkBuilding.blockPosition())); + if (claim == null) { + Entity residentEntity = level.getEntity(residentUuid); + if (residentEntity == null) { + return fallbackWorkBuildingUuid; + } + claim = ClaimEvents.claimManager().getClaim(new ChunkPos(residentEntity.blockPosition())); + } + if (claim == null) { + return fallbackWorkBuildingUuid; + } + RecruitsClaim authoritativeClaim = claim; + SettlementRecord settlementRecord = SettlementRegistryData.get(level).getSettlementByClaimId(claim.getUUID()); + if (settlementRecord == null) { + return fallbackWorkBuildingUuid; + } + List workAreas = level.getEntitiesOfClass( + AbstractWorkAreaEntity.class, + SettlementService.claimBounds(level, authoritativeClaim), + area -> area != null && area.isAlive() && authoritativeClaim.containsChunk(area.chunkPosition()) + ); + List validatedBuildings = new ArrayList<>(); + for (ValidatedBuildingRecord record : ValidatedBuildingRegistryData.get(level).allRecords()) { + if (record != null + && record.state() == BuildingValidationState.VALID + && level.dimension().equals(record.dimension()) + && settlementRecord.settlementId().equals(record.settlementId())) { + validatedBuildings.add(record); + } + } + Map authoritativeBindings = SettlementService.buildAuthoritativeWorkBuildingBindings(validatedBuildings, workAreas); + return authoritativeBindings.getOrDefault(fallbackWorkBuildingUuid, fallbackWorkBuildingUuid); + } + + public static NpcFamilyTreeSnapshot familyTreeSnapshot(ServerLevel level, UUID residentUuid) { + return NpcFamilyAccess.familyTreeSnapshot(level, residentUuid, level.getGameTime()); + } + + private static NpcSocietyProfile seedProfileFor(Entity entity, long gameTime) { + UUID residentUuid = entity.getUUID(); + NpcSex sex = ((residentUuid.getLeastSignificantBits() ^ residentUuid.getMostSignificantBits()) & 1L) == 0L + ? NpcSex.MALE + : NpcSex.FEMALE; + NpcLifeStage stage = NpcLifeStage.ADULT; + if (entity instanceof CitizenEntity && !(entity instanceof AbstractWorkerEntity) && !(entity instanceof AbstractRecruitEntity)) { + stage = seededCivilianStage(residentUuid); + } + return NpcSocietyProfile.createSeeded(residentUuid, stage, sex, gameTime); + } + + private static NpcLifeStage seededCivilianStage(UUID residentUuid) { + int roll = Math.floorMod(residentUuid.hashCode(), 10); + return roll <= 1 ? NpcLifeStage.ADOLESCENT : NpcLifeStage.ADULT; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoal.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoal.java new file mode 100644 index 00000000..5c10d086 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoal.java @@ -0,0 +1,542 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementManager; +import com.talhanation.bannermod.settlement.SettlementMarketRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.PathfinderMob; +import net.minecraft.world.entity.ai.goal.Goal; +import net.minecraft.world.phys.Vec3; + +import javax.annotation.Nullable; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class NpcSocietyAnchorGoal extends Goal { + private static final double ARRIVAL_DISTANCE_SQR = 5.0D; + private static final double HOME_INTENT_ARRIVAL_DISTANCE_SQR = 8.0D; + private static final double TARGET_SNAP_DISTANCE_SQR = 4.0D; + private static final double HOME_INTENT_TARGET_SNAP_DISTANCE_SQR = 20.25D; + private static final int REPATH_INTERVAL_TICKS = 30; + private static final int HOME_INTENT_REPATH_INTERVAL_TICKS = 60; + private static final int SNAPSHOT_LOOKUP_INTERVAL_TICKS = 40; + private static final int ROUTE_INVALID_STALL_LIMIT = 3; + private static final double ROUTE_PROGRESS_EPSILON_SQR = 1.0D; + private static final double ROUTE_TARGET_RESET_DISTANCE_SQR = 9.0D; + + private static final Map ROUTE_INVALIDATIONS = new ConcurrentHashMap<>(); + private static final Map SNAPSHOT_LOOKUPS = new ConcurrentHashMap<>(); + + private final PathfinderMob mob; + private Vec3 targetPos; + private int repathCooldown; + private long nextSnapshotLookupGameTime; + private @Nullable SettlementSnapshot cachedSnapshot; + private @Nullable SnapshotLookupCache cachedSnapshotLookup; + private @Nullable Vec3 lastMoveRequestTargetPos; + private double lastMoveRequestDistanceSqr = Double.MAX_VALUE; + private int stalledRouteCount; + + public NpcSocietyAnchorGoal(PathfinderMob mob) { + this.mob = mob; + this.setFlags(EnumSet.of(Flag.MOVE, Flag.LOOK)); + } + + @Override + public boolean canUse() { + if (this.mob instanceof AbstractWorkerEntity worker && worker.hasActiveCourierTask()) { + return false; + } + this.targetPos = resolveTarget(); + return this.targetPos != null; + } + + @Override + public boolean canContinueToUse() { + if (this.mob instanceof AbstractWorkerEntity worker && worker.hasActiveCourierTask()) { + return false; + } + Vec3 nextTarget = resolveTarget(); + if (nextTarget == null) { + return false; + } + if (this.targetPos == null || this.targetPos.distanceToSqr(nextTarget) > targetSnapDistanceSqr()) { + this.targetPos = nextTarget; + } + return true; + } + + @Override + public void start() { + this.repathCooldown = 0; + this.nextSnapshotLookupGameTime = 0L; + this.cachedSnapshot = null; + this.cachedSnapshotLookup = null; + resetRouteFailureTracking(); + clearRouteInvalidation(this.mob.getUUID()); + if (this.targetPos == null) { + return; + } + NpcSocietyProfile profile = profile(); + double distanceToTargetSqr = this.mob.position().distanceToSqr(this.targetPos); + if (distanceToTargetSqr > arrivalDistanceSqr(profile)) { + this.mob.getNavigation().moveTo(this.targetPos.x, this.targetPos.y, this.targetPos.z, speed()); + rememberMoveRequest(distanceToTargetSqr); + this.repathCooldown = repathIntervalTicks(profile); + } + } + + @Override + public void stop() { + this.targetPos = null; + this.repathCooldown = 0; + this.nextSnapshotLookupGameTime = 0L; + this.cachedSnapshot = null; + this.cachedSnapshotLookup = null; + resetRouteFailureTracking(); + clearRouteInvalidation(this.mob.getUUID()); + this.mob.getNavigation().stop(); + } + + @Override + public void tick() { + if (this.targetPos == null) { + return; + } + NpcSocietyProfile profile = profile(); + this.mob.getLookControl().setLookAt(this.targetPos.x, this.targetPos.y, this.targetPos.z); + double distanceToTargetSqr = this.mob.position().distanceToSqr(this.targetPos); + if (distanceToTargetSqr > arrivalDistanceSqr(profile)) { + boolean navigationDone = this.mob.getNavigation().isDone(); + if (targetChangedSinceLastMoveRequest()) { + resetRouteFailureTracking(); + } + if (this.repathCooldown <= 0 || navigationDone) { + if (navigationDone) { + if (madeMeaningfulRouteProgress(this.lastMoveRequestDistanceSqr, distanceToTargetSqr)) { + this.stalledRouteCount = 0; + } else { + this.stalledRouteCount++; + } + if (shouldInvalidateStalledRoute(this.stalledRouteCount, distanceToTargetSqr, arrivalDistanceSqr(profile))) { + signalRouteInvalidation(this.mob.getUUID(), profile == null ? NpcIntent.UNSPECIFIED : profile.currentIntent(), this.mob.level().getGameTime()); + this.mob.getNavigation().stop(); + this.repathCooldown = 0; + resetRouteFailureTracking(); + return; + } + } + this.mob.getNavigation().moveTo(this.targetPos.x, this.targetPos.y, this.targetPos.z, speed()); + rememberMoveRequest(distanceToTargetSqr); + this.repathCooldown = repathIntervalTicks(profile); + } else { + this.repathCooldown--; + } + return; + } + this.mob.getNavigation().stop(); + this.repathCooldown = 0; + resetRouteFailureTracking(); + } + + static boolean madeMeaningfulRouteProgress(double previousDistanceSqr, double currentDistanceSqr) { + return previousDistanceSqr - currentDistanceSqr >= ROUTE_PROGRESS_EPSILON_SQR; + } + + static boolean shouldInvalidateStalledRoute(int stalledRouteCount, double distanceToTargetSqr, double arrivalDistanceSqr) { + return stalledRouteCount >= ROUTE_INVALID_STALL_LIMIT && distanceToTargetSqr > arrivalDistanceSqr + 4.0D; + } + + public static void signalRouteInvalidation(UUID residentUuid, @Nullable NpcIntent intent, long gameTime) { + if (residentUuid == null || intent == null || intent == NpcIntent.UNSPECIFIED) { + return; + } + ROUTE_INVALIDATIONS.put(residentUuid, new RouteInvalidationSignal(intent, gameTime)); + } + + public static boolean consumeRouteInvalidation(UUID residentUuid, @Nullable NpcIntent expectedIntent, long gameTime) { + if (residentUuid == null) { + return false; + } + RouteInvalidationSignal signal = ROUTE_INVALIDATIONS.get(residentUuid); + if (signal == null) { + return false; + } + if (gameTime - signal.gameTime() > 1L) { + ROUTE_INVALIDATIONS.remove(residentUuid, signal); + return false; + } + if (expectedIntent == null || signal.intent() != expectedIntent) { + return false; + } + return ROUTE_INVALIDATIONS.remove(residentUuid, signal); + } + + private static void clearRouteInvalidation(UUID residentUuid) { + if (residentUuid != null) { + ROUTE_INVALIDATIONS.remove(residentUuid); + } + } + + private double speed() { + NpcSocietyProfile profile = profile(); + if (profile == null) { + return 0.8D; + } + return switch (profile.currentIntent()) { + case DEFEND -> 1.15D; + case HIDE, GO_HOME -> 1.0D; + case EAT, SEEK_SUPPLIES, LEAVE_HOME -> 0.9D; + default -> 0.75D; + }; + } + + private @Nullable Vec3 resolveTarget() { + if (!(this.mob.level() instanceof ServerLevel serverLevel)) { + return null; + } + NpcSocietyProfile profile = profile(); + if (profile == null || !NpcSocietyIntentRules.isAnchoredRoutineIntent(profile.currentIntent())) { + return null; + } + SettlementSnapshot snapshot = resolveSnapshot(serverLevel, profile); + Vec3 anchorBase = resolveAnchorBase(snapshot, profile); + if (anchorBase == null) { + anchorBase = resolveIntentBase(snapshot, profile); + } + return approachTarget(anchorBase, profile.currentIntent(), profile.currentAnchor()); + } + + private boolean isHouseholdHomeIntent(@Nullable NpcIntent intent) { + return intent == NpcIntent.GO_HOME + || intent == NpcIntent.REST + || intent == NpcIntent.EAT + || intent == NpcIntent.HIDE; + } + + private @Nullable Vec3 resolveAnchorBase(@Nullable SettlementSnapshot snapshot, NpcSocietyProfile profile) { + return switch (profile.currentAnchor()) { + case HOME -> buildingCenter(snapshot, profile.homeBuildingUuid()); + case WORKPLACE -> { + if (profile.currentIntent() == NpcIntent.SEEK_SUPPLIES) { + yield marketStockpileOrStreet(snapshot); + } + Vec3 workPos = buildingCenter(snapshot, profile.workBuildingUuid()); + yield workPos != null ? workPos : streetNear(settlementCenter(snapshot)); + } + case MARKET -> marketOrStreet(snapshot); + case BARRACKS -> barracksOrWork(snapshot, profile.workBuildingUuid()); + case STREET -> streetBase(snapshot, profile); + default -> null; + }; + } + + private double targetSnapDistanceSqr() { + NpcSocietyProfile profile = profile(); + if (profile == null) { + return TARGET_SNAP_DISTANCE_SQR; + } + if (profile.currentAnchor() == NpcAnchorType.HOME && isHouseholdHomeIntent(profile.currentIntent())) { + return HOME_INTENT_TARGET_SNAP_DISTANCE_SQR; + } + return TARGET_SNAP_DISTANCE_SQR; + } + + private double arrivalDistanceSqr(@Nullable NpcSocietyProfile profile) { + if (profile == null) { + return ARRIVAL_DISTANCE_SQR; + } + if (profile.currentAnchor() == NpcAnchorType.HOME && isHouseholdHomeIntent(profile.currentIntent())) { + return HOME_INTENT_ARRIVAL_DISTANCE_SQR; + } + return ARRIVAL_DISTANCE_SQR; + } + + private int repathIntervalTicks(@Nullable NpcSocietyProfile profile) { + if (profile == null) { + return REPATH_INTERVAL_TICKS; + } + if (profile.currentAnchor() == NpcAnchorType.HOME && isHouseholdHomeIntent(profile.currentIntent())) { + return HOME_INTENT_REPATH_INTERVAL_TICKS; + } + return REPATH_INTERVAL_TICKS; + } + + private @Nullable Vec3 resolveIntentBase(@Nullable SettlementSnapshot snapshot, NpcSocietyProfile profile) { + return switch (profile.currentIntent()) { + case GO_HOME -> buildingCenter(snapshot, profile.homeBuildingUuid()); + case REST -> profile.homeBuildingUuid() != null + ? buildingCenter(snapshot, profile.homeBuildingUuid()) + : streetNear(settlementCenter(snapshot)); + case LEAVE_HOME -> streetNear(buildingCenter(snapshot, profile.homeBuildingUuid())); + case EAT -> profile.homeBuildingUuid() != null + ? buildingCenter(snapshot, profile.homeBuildingUuid()) + : marketOrStreet(snapshot); + case SEEK_SUPPLIES -> marketStockpileOrStreet(snapshot); + case HIDE -> profile.homeBuildingUuid() != null + ? buildingCenter(snapshot, profile.homeBuildingUuid()) + : streetNear(settlementCenter(snapshot)); + case DEFEND -> barracksOrWork(snapshot, profile.workBuildingUuid()); + default -> null; + }; + } + + private @Nullable NpcSocietyProfile profile() { + if (!(this.mob.level() instanceof ServerLevel serverLevel)) { + return null; + } + return NpcSocietyAccess.profileFor(serverLevel, this.mob.getUUID()).orElse(null); + } + + private @Nullable SettlementSnapshot resolveSnapshot(ServerLevel level, NpcSocietyProfile profile) { + UUID residentUuid = this.mob.getUUID(); + long gameTime = level.getGameTime(); + if (this.cachedSnapshotLookup != null + && this.cachedSnapshotLookup.matches(profile) + && gameTime < this.nextSnapshotLookupGameTime) { + return this.cachedSnapshot; + } + SettlementManager manager = SettlementManager.get(level); + SnapshotLookupCache cached = SNAPSHOT_LOOKUPS.get(residentUuid); + if (cached != null && cached.matches(profile)) { + SettlementSnapshot snapshot = manager.getSnapshot(cached.claimUuid()); + if (snapshotMatchesProfile(snapshot, profile, residentUuid)) { + this.cachedSnapshot = snapshot; + this.cachedSnapshotLookup = cached; + this.nextSnapshotLookupGameTime = gameTime + SNAPSHOT_LOOKUP_INTERVAL_TICKS; + return snapshot; + } + } + for (SettlementSnapshot snapshot : manager.getAllSnapshots()) { + if (snapshot == null) { + continue; + } + if (snapshotMatchesProfile(snapshot, profile, residentUuid)) { + SnapshotLookupCache resolved = new SnapshotLookupCache(snapshot.claimUuid(), profile.homeBuildingUuid(), profile.workBuildingUuid()); + SNAPSHOT_LOOKUPS.put(residentUuid, resolved); + this.cachedSnapshot = snapshot; + this.cachedSnapshotLookup = resolved; + this.nextSnapshotLookupGameTime = gameTime + SNAPSHOT_LOOKUP_INTERVAL_TICKS; + return snapshot; + } + } + SNAPSHOT_LOOKUPS.remove(residentUuid); + this.cachedSnapshot = null; + this.cachedSnapshotLookup = new SnapshotLookupCache(null, profile.homeBuildingUuid(), profile.workBuildingUuid()); + this.nextSnapshotLookupGameTime = gameTime + SNAPSHOT_LOOKUP_INTERVAL_TICKS; + return null; + } + + private boolean snapshotMatchesProfile(@Nullable SettlementSnapshot snapshot, + NpcSocietyProfile profile, + UUID residentUuid) { + if (snapshot == null) { + return false; + } + if (containsBuilding(snapshot, profile.homeBuildingUuid()) || containsBuilding(snapshot, profile.workBuildingUuid())) { + return true; + } + for (var resident : snapshot.residents()) { + if (resident != null && residentUuid.equals(resident.residentUuid())) { + return true; + } + } + return false; + } + + private boolean containsBuilding(SettlementSnapshot snapshot, @Nullable UUID buildingUuid) { + if (snapshot == null || buildingUuid == null) { + return false; + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && buildingUuid.equals(building.buildingUuid())) { + return true; + } + } + return false; + } + + private @Nullable Vec3 buildingCenter(@Nullable SettlementSnapshot snapshot, @Nullable UUID buildingUuid) { + if (snapshot == null || buildingUuid == null) { + return null; + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && buildingUuid.equals(building.buildingUuid())) { + return Vec3.atCenterOf(building.originPos()); + } + } + return null; + } + + private @Nullable Vec3 marketOrStreet(@Nullable SettlementSnapshot snapshot) { + if (snapshot != null) { + for (SettlementMarketRecord market : snapshot.marketState().markets()) { + if (market != null && market.open()) { + Vec3 marketPos = buildingCenter(snapshot, market.buildingUuid()); + if (marketPos != null) { + return marketPos; + } + } + } + } + return streetNear(settlementCenter(snapshot)); + } + + private @Nullable Vec3 marketStockpileOrStreet(@Nullable SettlementSnapshot snapshot) { + if (snapshot != null) { + for (SettlementMarketRecord market : snapshot.marketState().markets()) { + if (market != null && market.open()) { + Vec3 marketPos = buildingCenter(snapshot, market.buildingUuid()); + if (marketPos != null) { + return marketPos; + } + } + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building != null && building.stockpileBuilding()) { + return Vec3.atCenterOf(building.originPos()); + } + } + } + return streetNear(settlementCenter(snapshot)); + } + + private @Nullable Vec3 barracksOrWork(@Nullable SettlementSnapshot snapshot, @Nullable UUID workBuildingUuid) { + if (snapshot != null) { + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building == null || building.buildingTypeId() == null) { + continue; + } + if (building.buildingTypeId().contains("barracks")) { + return Vec3.atCenterOf(building.originPos()); + } + } + } + Vec3 workPos = buildingCenter(snapshot, workBuildingUuid); + return workPos != null ? workPos : streetNear(firstBuildingCenter(snapshot)); + } + + private @Nullable Vec3 firstBuildingCenter(@Nullable SettlementSnapshot snapshot) { + if (snapshot == null || snapshot.buildings().isEmpty()) { + return this.mob.position(); + } + Vec3 nearest = this.mob.position(); + double nearestDistanceSqr = Double.MAX_VALUE; + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building == null || building.originPos() == null) { + continue; + } + Vec3 candidate = Vec3.atCenterOf(building.originPos()); + double candidateDistanceSqr = candidate.distanceToSqr(this.mob.position()); + if (candidateDistanceSqr < nearestDistanceSqr) { + nearest = candidate; + nearestDistanceSqr = candidateDistanceSqr; + } + } + return nearest; + } + + private Vec3 settlementCenter(@Nullable SettlementSnapshot snapshot) { + if (snapshot == null || snapshot.buildings().isEmpty()) { + return this.mob.position(); + } + double x = 0.0D; + double y = 0.0D; + double z = 0.0D; + int count = 0; + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (building == null || building.originPos() == null) { + continue; + } + Vec3 center = Vec3.atCenterOf(building.originPos()); + x += center.x; + y += center.y; + z += center.z; + count++; + } + if (count <= 0) { + return firstBuildingCenter(snapshot); + } + return new Vec3(x / count, y / count, z / count); + } + private Vec3 streetBase(@Nullable SettlementSnapshot snapshot, NpcSocietyProfile profile) { + if (profile.currentIntent() == NpcIntent.LEAVE_HOME) { + return streetNear(buildingCenter(snapshot, profile.homeBuildingUuid())); + } + if (profile.currentIntent() == NpcIntent.HIDE && profile.homeBuildingUuid() != null) { + return streetNear(buildingCenter(snapshot, profile.homeBuildingUuid())); + } + return streetNear(settlementCenter(snapshot)); + } + + private Vec3 streetNear(@Nullable Vec3 base) { + Vec3 center = base == null ? this.mob.position() : base; + double angle = (Math.floorMod(this.mob.getUUID().hashCode(), 360) / 180.0D) * Math.PI; + double radius = 4.0D + Math.floorMod(this.mob.getUUID().hashCode(), 3); + return new Vec3( + center.x + Math.cos(angle) * radius, + center.y, + center.z + Math.sin(angle) * radius + ); + } + + private @Nullable Vec3 approachTarget(@Nullable Vec3 base, @Nullable NpcIntent intent, @Nullable NpcAnchorType anchor) { + if (base == null) { + return null; + } + double radius = switch (intent == null ? NpcIntent.UNSPECIFIED : intent) { + case GO_HOME -> 0.9D; + case REST, HIDE, EAT -> 1.4D; + case SEEK_SUPPLIES, LEAVE_HOME, DEFEND -> 1.8D; + default -> 0.0D; + }; + if (radius <= 0.0D) { + return base; + } + int seed = this.mob.getUUID().hashCode() * 31 + (intent == null ? 0 : intent.ordinal() * 17); + double angle = (Math.floorMod(seed, 360) / 180.0D) * Math.PI; + return new Vec3( + base.x + Math.cos(angle) * radius, + base.y, + base.z + Math.sin(angle) * radius + ); + } + + private boolean targetChangedSinceLastMoveRequest() { + return this.lastMoveRequestTargetPos != null + && this.targetPos != null + && this.lastMoveRequestTargetPos.distanceToSqr(this.targetPos) > ROUTE_TARGET_RESET_DISTANCE_SQR; + } + + private void rememberMoveRequest(double distanceToTargetSqr) { + this.lastMoveRequestTargetPos = this.targetPos; + this.lastMoveRequestDistanceSqr = distanceToTargetSqr; + } + + private void resetRouteFailureTracking() { + this.lastMoveRequestTargetPos = null; + this.lastMoveRequestDistanceSqr = Double.MAX_VALUE; + this.stalledRouteCount = 0; + } + + private record RouteInvalidationSignal(NpcIntent intent, long gameTime) { + } + + private record SnapshotLookupCache(UUID claimUuid, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid) { + private boolean matches(NpcSocietyProfile profile) { + return sameUuid(this.homeBuildingUuid, profile.homeBuildingUuid()) + && sameUuid(this.workBuildingUuid, profile.workBuildingUuid()); + } + + private static boolean sameUuid(@Nullable UUID left, @Nullable UUID right) { + return left == null ? right == null : left.equals(right); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshot.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshot.java new file mode 100644 index 00000000..a3f91b8d --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshot.java @@ -0,0 +1,264 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import com.talhanation.bannermod.settlement.goal.ResidentTaskOutcome; +import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.DeliverResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.FetchResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; +import com.talhanation.bannermod.settlement.household.LeaveHomeResidentGoal; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.ResourceLocation; + +import javax.annotation.Nullable; +import java.util.Locale; + +public record NpcSocietyDecisionSnapshot( + String stateTag, + @Nullable String currentGoalId, + String choiceReasonTag, + String routeReasonTag, + @Nullable String blockedGoalId, + String blockedReasonTag, + String lastIntentTag, + long currentIntentStartedGameTime +) { + public static final String BLOCKED_REASON_NONE = "NONE"; + public static final String BLOCKED_REASON_TASK_TIMED_OUT = "TASK_TIMED_OUT"; + public static final String BLOCKED_REASON_CONTEXT_INVALIDATED = "CONTEXT_INVALIDATED"; + + public static NpcSocietyDecisionSnapshot empty() { + return new NpcSocietyDecisionSnapshot("IDLE", null, "NO_STARTABLE_GOAL", "NO_CLEAR_ROUTE", null, BLOCKED_REASON_NONE, NpcIntent.UNSPECIFIED.name(), 0L); + } + + public static NpcSocietyDecisionSnapshot capture(@Nullable ResidentGoalContext ctx, + @Nullable ResidentTask activeTask, + @Nullable String routeReasonTag, + @Nullable ResidentTaskOutcome lastOutcome) { + if (ctx == null) { + return empty(); + } + BlockedGoal blocked = describeBlockedGoal(ctx, activeTask, lastOutcome); + String stateTag = describeState(activeTask, blocked); + String currentGoalId = activeTask == null || activeTask.goalId() == null ? null : activeTask.goalId().toString(); + String choiceReasonTag = activeTask == null ? "NO_STARTABLE_GOAL" : describeChoiceReason(ctx, activeTask.goalId()); + NpcIntent currentIntent = activeTask == null || activeTask.goalId() == null + ? (ctx.isRestPhase() ? NpcIntent.REST : NpcIntent.IDLE) + : NpcSocietyPhaseOneRuntime.publishedIntentForGoal(activeTask.goalId()); + NpcIntent previousIntent = ctx.societyProfile() == null || ctx.societyProfile().currentIntent() == null + ? NpcIntent.UNSPECIFIED + : ctx.societyProfile().currentIntent(); + long currentIntentStartedGameTime = ctx.gameTime(); + if (ctx.societyProfile() != null + && ctx.societyProfile().decisionSnapshot() != null + && currentIntent == previousIntent + && currentIntent != NpcIntent.UNSPECIFIED) { + currentIntentStartedGameTime = Math.max(0L, ctx.societyProfile().decisionSnapshot().currentIntentStartedGameTime()); + if (currentIntentStartedGameTime <= 0L) { + currentIntentStartedGameTime = ctx.gameTime(); + } + } + return new NpcSocietyDecisionSnapshot( + stateTag, + currentGoalId, + choiceReasonTag, + safeTag(routeReasonTag), + blocked.goalId, + blocked.reasonTag, + previousIntent.name(), + currentIntentStartedGameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putString("StateTag", safeTag(this.stateTag)); + if (this.currentGoalId != null && !this.currentGoalId.isBlank()) { + tag.putString("CurrentGoalId", this.currentGoalId); + } + tag.putString("ChoiceReasonTag", safeTag(this.choiceReasonTag)); + tag.putString("RouteReasonTag", safeTag(this.routeReasonTag)); + if (this.blockedGoalId != null && !this.blockedGoalId.isBlank()) { + tag.putString("BlockedGoalId", this.blockedGoalId); + } + tag.putString("BlockedReasonTag", safeTag(this.blockedReasonTag)); + tag.putString("LastIntentTag", safeTag(this.lastIntentTag)); + tag.putLong("CurrentIntentStartedGameTime", Math.max(0L, this.currentIntentStartedGameTime)); + return tag; + } + + public static NpcSocietyDecisionSnapshot fromTag(@Nullable CompoundTag tag) { + if (tag == null || tag.isEmpty()) { + return empty(); + } + return new NpcSocietyDecisionSnapshot( + safeTag(tag.getString("StateTag")), + tag.contains("CurrentGoalId") ? tag.getString("CurrentGoalId") : null, + safeTag(tag.getString("ChoiceReasonTag")), + safeTag(tag.contains("RouteReasonTag") ? tag.getString("RouteReasonTag") : "NO_CLEAR_ROUTE"), + tag.contains("BlockedGoalId") ? tag.getString("BlockedGoalId") : null, + safeTag(tag.contains("BlockedReasonTag") ? tag.getString("BlockedReasonTag") : BLOCKED_REASON_NONE), + safeTag(tag.contains("LastIntentTag") ? tag.getString("LastIntentTag") : NpcIntent.UNSPECIFIED.name()), + Math.max(0L, tag.getLong("CurrentIntentStartedGameTime")) + ); + } + + public String stateTranslationKey() { + return "gui.bannermod.society.ai.state." + safeTag(this.stateTag).toLowerCase(Locale.ROOT); + } + + public String choiceReasonTranslationKey() { + return "gui.bannermod.society.ai.reason." + safeTag(this.choiceReasonTag).toLowerCase(Locale.ROOT); + } + + public String blockedReasonTranslationKey() { + return "gui.bannermod.society.ai.reason." + safeTag(this.blockedReasonTag).toLowerCase(Locale.ROOT); + } + + public static String goalLabelOrDash(@Nullable String goalId) { + if (goalId == null || goalId.isBlank()) { + return "-"; + } + int slash = goalId.lastIndexOf('/'); + return slash >= 0 && slash + 1 < goalId.length() ? goalId.substring(slash + 1) : goalId; + } + + private static String describeChoiceReason(ResidentGoalContext ctx, @Nullable ResourceLocation goalId) { + if (goalId == null) { + return "NO_STARTABLE_GOAL"; + } + String previousGoalId = ctx.societyProfile() == null || ctx.societyProfile().decisionSnapshot() == null + ? null + : ctx.societyProfile().decisionSnapshot().currentGoalId(); + if (goalId.toString().equals(previousGoalId) && shouldExplainCommitment(ctx, goalId)) { + return "COMMITTING_TO_CURRENT_GOAL"; + } + if (GoHomeResidentGoal.ID.equals(goalId)) { + if (ctx.isRestPhase()) { + return "REST_WINDOW"; + } + if (ctx.fatigueNeed() >= 80) { + return "FATIGUE_SPIKE"; + } + return "HOMEWARD_PULL"; + } + if (LeaveHomeResidentGoal.ID.equals(goalId)) { + return "EARLY_ACTIVE_WINDOW"; + } + if (RestResidentGoal.ID.equals(goalId)) { + return ctx.isRestPhase() ? "REST_WINDOW" : "FATIGUE_SPIKE"; + } + if (EatResidentGoal.ID.equals(goalId)) { + return ctx.hungerNeed() >= 80 ? "SEVERE_HUNGER" : "HUNGER_PRESSURE"; + } + if (SeekSuppliesResidentGoal.ID.equals(goalId)) { + if (!ctx.hasHome()) { + return "NO_HOME_FOOD_RUN"; + } + return "HOME_FOOD_SHORTAGE"; + } + if (HideResidentGoal.ID.equals(goalId)) { + return "THREAT_AVOIDANCE"; + } + if (DefendResidentGoal.ID.equals(goalId)) { + return "THREAT_RESPONSE"; + } + if (SellerResidentGoal.ID.equals(goalId)) { + return "READY_MARKET_DISPATCH"; + } + if (WorkResidentGoal.ID.equals(goalId)) { + return "ASSIGNED_SHIFT"; + } + if (FetchResidentGoal.ID.equals(goalId) || DeliverResidentGoal.ID.equals(goalId)) { + return "WORKFLOW_TRANSFER"; + } + if (IdleResidentGoal.ID.equals(goalId)) { + return "NO_HIGHER_PRIORITY_GOAL"; + } + return "UNKNOWN"; + } + + private static String describeState(@Nullable ResidentTask activeTask, BlockedGoal blocked) { + if (activeTask == null || activeTask.goalId() == null) { + return blocked.goalId != null ? "BLOCKED" : "IDLE"; + } + if (IdleResidentGoal.ID.equals(activeTask.goalId())) { + return blocked.goalId != null ? "BLOCKED" : "IDLE"; + } + return "EXECUTING"; + } + + private static BlockedGoal describeBlockedGoal(ResidentGoalContext ctx, + @Nullable ResidentTask activeTask, + @Nullable ResidentTaskOutcome lastOutcome) { + if (ctx.safetyNeed() >= 35 && !ctx.canDefend()) { + if (activeTask == null || HideResidentGoal.ID.equals(activeTask.goalId())) { + return new BlockedGoal(DefendResidentGoal.ID.toString(), "ROLE_CANNOT_DEFEND"); + } + } + if ((ctx.isRestPhase() || ctx.fatigueNeed() >= 70 || ctx.safetyNeed() >= 70) && !ctx.hasHome()) { + return new BlockedGoal(GoHomeResidentGoal.ID.toString(), "NO_HOME"); + } + if (ctx.hungerNeed() >= 35 && !ctx.hasHome() && !ctx.hasSupplyAccess()) { + return new BlockedGoal(EatResidentGoal.ID.toString(), "NO_FOOD_ACCESS"); + } + if (ctx.resident().role() == SettlementResidentRole.CONTROLLED_WORKER && ctx.isActivePhase()) { + if (ctx.fatigueNeed() >= 90) { + return new BlockedGoal(WorkResidentGoal.ID.toString(), "TOO_FATIGUED_FOR_WORK"); + } + if (!hasWorkAssignment(ctx)) { + return new BlockedGoal(WorkResidentGoal.ID.toString(), "NO_WORK_ASSIGNMENT"); + } + } + if (lastOutcome != null + && lastOutcome.isFailure() + && ctx.gameTime() - lastOutcome.finishedGameTime() <= 240L + && (activeTask == null || !lastOutcome.goalId().equals(activeTask.goalId()))) { + return new BlockedGoal(lastOutcome.goalId().toString(), outcomeReasonTag(lastOutcome)); + } + return new BlockedGoal(null, BLOCKED_REASON_NONE); + } + + private static String outcomeReasonTag(ResidentTaskOutcome lastOutcome) { + if (lastOutcome == null || lastOutcome.stopReason() == null) { + return BLOCKED_REASON_NONE; + } + return switch (lastOutcome.stopReason()) { + case TIMED_OUT -> BLOCKED_REASON_TASK_TIMED_OUT; + case CONTEXT_INVALID -> BLOCKED_REASON_CONTEXT_INVALIDATED; + default -> BLOCKED_REASON_NONE; + }; + } + + private static boolean hasWorkAssignment(ResidentGoalContext ctx) { + SettlementResidentAssignmentState assignmentState = ctx.resident().assignmentState(); + return assignmentState == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + || assignmentState == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING; + } + private static boolean shouldExplainCommitment(ResidentGoalContext ctx, ResourceLocation goalId) { + if (ctx == null || goalId == null) { + return false; + } + if (HideResidentGoal.ID.equals(goalId) || DefendResidentGoal.ID.equals(goalId)) { + return false; + } + return ctx.fatigueNeed() < 90 && ctx.hungerNeed() < 90 && ctx.safetyNeed() < 85; + } + + private static String safeTag(@Nullable String value) { + return value == null || value.isBlank() ? "UNSPECIFIED" : value; + } + + private record BlockedGoal(@Nullable String goalId, String reasonTag) { + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyEvents.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyEvents.java new file mode 100644 index 00000000..7cd62159 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyEvents.java @@ -0,0 +1,31 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.entity.citizen.AbstractCitizenEntity; +import com.talhanation.bannermod.entity.citizen.CitizenEntity; +import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import net.minecraft.server.level.ServerLevel; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; + +@EventBusSubscriber(modid = BannerModMain.MOD_ID, bus = EventBusSubscriber.Bus.GAME) +public final class NpcSocietyEvents { + private NpcSocietyEvents() { + } + + @SubscribeEvent + public static void onEntityJoin(EntityJoinLevelEvent event) { + if (!(event.getLevel() instanceof ServerLevel serverLevel)) { + return; + } + if (!(event.getEntity() instanceof CitizenEntity) + && !(event.getEntity() instanceof AbstractCitizenEntity) + && !(event.getEntity() instanceof AbstractWorkerEntity) + && !(event.getEntity() instanceof AbstractRecruitEntity)) { + return; + } + NpcSocietyAccess.ensureResidentForEntity(serverLevel, event.getEntity()); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyIntentRules.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyIntentRules.java new file mode 100644 index 00000000..d0c066d2 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyIntentRules.java @@ -0,0 +1,63 @@ +package com.talhanation.bannermod.society; + +import javax.annotation.Nullable; + +public final class NpcSocietyIntentRules { + private NpcSocietyIntentRules() { + } + + public static boolean isWorkerLaborIntent(@Nullable NpcIntent intent) { + return intent == null + || intent == NpcIntent.UNSPECIFIED + || intent == NpcIntent.WORK + || intent == NpcIntent.SELL + || intent == NpcIntent.FETCH + || intent == NpcIntent.DELIVER; + } + + public static boolean isWorkFamilyIntent(@Nullable NpcIntent intent) { + return intent == NpcIntent.WORK + || intent == NpcIntent.SELL + || intent == NpcIntent.FETCH + || intent == NpcIntent.DELIVER; + } + + public static boolean isRoutineDailyIntent(@Nullable NpcIntent intent) { + return isWorkFamilyIntent(intent) + || intent == NpcIntent.SEEK_SUPPLIES; + } + + public static boolean isSafeRecoveryIntent(@Nullable NpcIntent intent) { + return intent == NpcIntent.GO_HOME + || intent == NpcIntent.REST + || intent == NpcIntent.EAT + || intent == NpcIntent.SEEK_SUPPLIES + || intent == NpcIntent.HIDE; + } + + public static boolean isRestLikeIntent(@Nullable NpcIntent intent) { + return intent == NpcIntent.GO_HOME + || intent == NpcIntent.REST + || intent == NpcIntent.HIDE; + } + + public static boolean isAnchoredRoutineIntent(@Nullable NpcIntent intent) { + return intent == NpcIntent.GO_HOME + || intent == NpcIntent.LEAVE_HOME + || intent == NpcIntent.REST + || intent == NpcIntent.EAT + || intent == NpcIntent.SEEK_SUPPLIES + || intent == NpcIntent.HIDE + || intent == NpcIntent.DEFEND; + } + + public static boolean sharesFailureRetryFamily(@Nullable NpcIntent failedIntent, @Nullable NpcIntent nextIntent) { + if (failedIntent == null || nextIntent == null || failedIntent == NpcIntent.UNSPECIFIED || nextIntent == NpcIntent.UNSPECIFIED) { + return false; + } + if (failedIntent == nextIntent) { + return true; + } + return isWorkFamilyIntent(failedIntent) && isWorkFamilyIntent(nextIntent); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyNeedRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyNeedRuntime.java new file mode 100644 index 00000000..9432519e --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyNeedRuntime.java @@ -0,0 +1,85 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.goal.ResidentTask; + +import javax.annotation.Nullable; +import java.util.UUID; + +public final class NpcSocietyNeedRuntime { + private NpcSocietyNeedRuntime() { + } + + public static NpcSocietyProfile tickNeeds(NpcSocietyProfile profile, + @Nullable UUID homeBuildingUuid, + boolean activePhase, + boolean restPhase, + @Nullable ResidentTask activeTask, + boolean underThreat, + boolean canDefend, + long gameTime) { + if (profile == null) { + throw new IllegalArgumentException("profile must not be null"); + } + int hungerNeed = profile.hungerNeed(); + int fatigueNeed = profile.fatigueNeed(); + int safetyNeed = profile.safetyNeed(); + + if (restPhase) { + hungerNeed += 1; + fatigueNeed -= homeBuildingUuid == null ? 1 : 4; + safetyNeed -= homeBuildingUuid == null ? 0 : 3; + } else if (activePhase) { + hungerNeed += 2; + fatigueNeed += 2; + safetyNeed -= 1; + } else { + hungerNeed += 1; + fatigueNeed += 1; + safetyNeed -= 1; + } + + NpcIntent activeIntent = activeTask == null ? NpcIntent.UNSPECIFIED : NpcSocietyPhaseOneRuntime.intentForGoal(activeTask.goalId()); + if (NpcSocietyIntentRules.isRestLikeIntent(activeIntent)) { + fatigueNeed -= 3; + safetyNeed -= homeBuildingUuid == null ? 1 : 5; + } + if (NpcSocietyIntentRules.isWorkFamilyIntent(activeIntent)) { + fatigueNeed += 2; + hungerNeed += 1; + } + if (activeIntent == NpcIntent.EAT) { + hungerNeed -= 8; + safetyNeed -= 1; + } + if (activeIntent == NpcIntent.SEEK_SUPPLIES) { + hungerNeed -= 3; + } + if (activeIntent == NpcIntent.DEFEND) { + safetyNeed -= canDefend ? 6 : 1; + fatigueNeed += 1; + } + if (homeBuildingUuid == null) { + fatigueNeed += 1; + safetyNeed += 1; + } + + if (profile.lifeStage() == NpcLifeStage.ADOLESCENT) { + fatigueNeed += activePhase ? 1 : 0; + } + + if (underThreat) { + safetyNeed += canDefend ? 18 : 26; + } + + return profile.withNeedState( + clampNeed(hungerNeed), + clampNeed(fatigueNeed), + clampNeed(safetyNeed), + gameTime + ); + } + + private static int clampNeed(int value) { + return Math.max(0, Math.min(100, value)); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntime.java new file mode 100644 index 00000000..db3a6e27 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntime.java @@ -0,0 +1,280 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import com.talhanation.bannermod.settlement.goal.ResidentTaskOutcome; +import com.talhanation.bannermod.settlement.goal.impl.DeliverResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.FetchResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; +import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; +import com.talhanation.bannermod.settlement.household.LeaveHomeResidentGoal; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.UUID; + +public final class NpcSocietyPhaseOneRuntime { + private NpcSocietyPhaseOneRuntime() { + } + + public static void updateResidentProfile(ServerLevel level, + BannerModHomeAssignmentRuntime homeRuntime, + ResidentGoalContext ctx, + @Nullable ResidentTask activeTask, + Map buildingsByUuid) { + updateResidentProfile(level, homeRuntime, ctx, activeTask, null, buildingsByUuid); + } + + public static void updateResidentProfile(ServerLevel level, + BannerModHomeAssignmentRuntime homeRuntime, + ResidentGoalContext ctx, + @Nullable ResidentTask activeTask, + @Nullable ResidentTaskOutcome lastOutcome, + Map buildingsByUuid) { + if (level == null || homeRuntime == null || ctx == null) { + return; + } + UUID residentUuid = ctx.residentId(); + if (residentUuid == null) { + return; + } + UUID homeBuildingUuid = homeRuntime.homeFor(residentUuid) + .map(home -> home.homeBuildingUuid()) + .orElse(null); + UUID workBuildingUuid = resolveWorkBuildingUuid(ctx.resident()); + UUID householdId = ctx.societyProfile() == null ? null : ctx.societyProfile().householdId(); + NpcDailyPhase dailyPhase = resolveDailyPhase(ctx, activeTask); + NpcIntent goalIntent = resolveGoalIntent(ctx, activeTask); + NpcIntent currentIntent = publishedIntent(goalIntent); + NpcAnchorType currentAnchor = resolveAnchor(ctx, goalIntent, homeBuildingUuid, workBuildingUuid, buildingsByUuid); + String routeReasonTag = resolveRouteReason(ctx, homeBuildingUuid, workBuildingUuid, goalIntent, currentAnchor, buildingsByUuid); + NpcSocietyDecisionSnapshot decisionSnapshot = NpcSocietyDecisionSnapshot.capture(ctx, activeTask, routeReasonTag, lastOutcome); + NpcSocietyAccess.reconcilePhaseOneState( + level, + residentUuid, + householdId, + homeBuildingUuid, + workBuildingUuid, + dailyPhase, + currentIntent, + currentAnchor, + decisionSnapshot, + ctx.gameTime() + ); + } + + private static UUID resolveWorkBuildingUuid(SettlementResidentRecord resident) { + if (resident == null) { + return null; + } + return resident.effectiveWorkBuildingUuid(); + } + + private static NpcDailyPhase resolveDailyPhase(ResidentGoalContext ctx, @Nullable ResidentTask activeTask) { + if (activeTask != null && LeaveHomeResidentGoal.ID.equals(activeTask.goalId())) { + return NpcDailyPhase.DEPARTING_HOME; + } + if (activeTask != null && GoHomeResidentGoal.ID.equals(activeTask.goalId())) { + return NpcDailyPhase.RETURNING_HOME; + } + if (ctx.isRestPhase() || activeTask != null && RestResidentGoal.ID.equals(activeTask.goalId())) { + return NpcDailyPhase.REST; + } + if (ctx.isActivePhase() || ctx.isLeisurePhase()) { + return NpcDailyPhase.ACTIVE; + } + return NpcDailyPhase.UNSPECIFIED; + } + + private static NpcIntent resolveGoalIntent(ResidentGoalContext ctx, @Nullable ResidentTask activeTask) { + if (activeTask == null || activeTask.goalId() == null) { + return ctx.isRestPhase() ? NpcIntent.REST : NpcIntent.IDLE; + } + return intentForGoal(activeTask.goalId()); + } + + public static NpcIntent intentForGoal(@Nullable ResourceLocation goalId) { + if (goalId == null) { + return NpcIntent.UNSPECIFIED; + } + if (GoHomeResidentGoal.ID.equals(goalId)) { + return NpcIntent.GO_HOME; + } + if (LeaveHomeResidentGoal.ID.equals(goalId)) { + return NpcIntent.LEAVE_HOME; + } + if (RestResidentGoal.ID.equals(goalId)) { + return NpcIntent.REST; + } + if (EatResidentGoal.ID.equals(goalId)) { + return NpcIntent.EAT; + } + if (WorkResidentGoal.ID.equals(goalId)) { + return NpcIntent.WORK; + } + if (SeekSuppliesResidentGoal.ID.equals(goalId)) { + return NpcIntent.SEEK_SUPPLIES; + } + if (SellerResidentGoal.ID.equals(goalId)) { + return NpcIntent.SELL; + } + if (HideResidentGoal.ID.equals(goalId)) { + return NpcIntent.HIDE; + } + if (DefendResidentGoal.ID.equals(goalId)) { + return NpcIntent.DEFEND; + } + if (FetchResidentGoal.ID.equals(goalId)) { + return NpcIntent.FETCH; + } + if (DeliverResidentGoal.ID.equals(goalId)) { + return NpcIntent.DELIVER; + } + if (IdleResidentGoal.ID.equals(goalId)) { + return NpcIntent.IDLE; + } + return NpcIntent.UNSPECIFIED; + } + + public static NpcIntent publishedIntentForGoal(@Nullable ResourceLocation goalId) { + return publishedIntent(intentForGoal(goalId)); + } + + private static NpcIntent publishedIntent(@Nullable NpcIntent intent) { + if (intent == null) { + return NpcIntent.UNSPECIFIED; + } + if (intent == NpcIntent.SELL || intent == NpcIntent.FETCH || intent == NpcIntent.DELIVER) { + return NpcIntent.WORK; + } + return intent; + } + + private static NpcAnchorType resolveAnchor(ResidentGoalContext ctx, + NpcIntent intent, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + Map buildingsByUuid) { + boolean hasHome = homeBuildingUuid != null || ctx.hasHome(); + if (intent == NpcIntent.GO_HOME) { + return NpcAnchorType.HOME; + } + if (intent == NpcIntent.REST) { + return hasHome ? NpcAnchorType.HOME : NpcAnchorType.STREET; + } + if (intent == NpcIntent.EAT) { + return hasHome ? NpcAnchorType.HOME : NpcAnchorType.MARKET; + } + if (intent == NpcIntent.SELL) { + return NpcAnchorType.MARKET; + } + if (intent == NpcIntent.SEEK_SUPPLIES) { + return ctx.hasMarketFoodAccess() + ? NpcAnchorType.MARKET + : NpcAnchorType.WORKPLACE; + } + if (intent == NpcIntent.WORK || intent == NpcIntent.FETCH || intent == NpcIntent.DELIVER) { + return anchorForWorkBuilding(workBuildingUuid, buildingsByUuid); + } + if (intent == NpcIntent.LEAVE_HOME || intent == NpcIntent.IDLE) { + return NpcAnchorType.STREET; + } + if (intent == NpcIntent.HIDE) { + return hasHome ? NpcAnchorType.HOME : NpcAnchorType.STREET; + } + if (intent == NpcIntent.DEFEND) { + return NpcAnchorType.BARRACKS; + } + return NpcAnchorType.NONE; + } + + public static String resolveRouteReason(ResidentGoalContext ctx, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + NpcIntent intent, + NpcAnchorType anchor, + Map buildingsByUuid) { + if (intent == NpcIntent.GO_HOME) { + if (ctx.fatigueNeed() >= 75 && homeBuildingUuid != null && !ctx.isRestPhase()) { + return "TIRED_HOMEBOUND"; + } + if (ctx.isRestPhase() || ctx.isLateDayWindow(1000)) { + return "SOON_NIGHT_HOMEBOUND"; + } + return "RETURNING_HOME_ROUTE"; + } + if (intent == NpcIntent.REST) { + return homeBuildingUuid != null ? "RESTING_AT_HOME" : "RESTING_OFF_STREET"; + } + if (intent == NpcIntent.LEAVE_HOME) { + return hasWorkAssignment(ctx.resident()) ? "LEAVING_HOME_FOR_WORK" : "LEAVING_HOME_FOR_DAY"; + } + if (intent == NpcIntent.WORK) { + return ctx.recentlyCameFromHome() ? "STARTING_WORKDAY_AFTER_HOME" : "HEADING_TO_WORKPLACE"; + } + if (intent == NpcIntent.EAT) { + return homeBuildingUuid != null ? "MEAL_AT_HOME" : "MEAL_AT_MARKET"; + } + if (intent == NpcIntent.SEEK_SUPPLIES) { + return ctx.settlement() != null && ctx.settlement().marketState().openMarketCount() > 0 + ? "MARKET_SUPPLY_RUN" + : "STOCKPILE_SUPPLY_RUN"; + } + if (intent == NpcIntent.HIDE) { + return "SEEKING_SHELTER_ROUTE"; + } + if (intent == NpcIntent.DEFEND) { + return "MOVING_TO_DEFENSE_POST"; + } + if (intent == NpcIntent.SELL) { + return "MARKET_DUTY_ROUTE"; + } + if (intent == NpcIntent.FETCH || intent == NpcIntent.DELIVER) { + return workBuildingUuid != null && buildingsByUuid.containsKey(workBuildingUuid) + ? "WORKFLOW_TRANSFER_ROUTE" + : "HEADING_TO_WORKPLACE"; + } + return "NO_CLEAR_ROUTE"; + } + + private static boolean hasWorkAssignment(SettlementResidentRecord resident) { + if (resident == null) { + return false; + } + return resident.assignmentState() == SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + || resident.assignmentState() == SettlementResidentAssignmentState.ASSIGNED_MISSING_BUILDING; + } + + private static NpcAnchorType anchorForWorkBuilding(@Nullable UUID workBuildingUuid, + Map buildingsByUuid) { + if (workBuildingUuid == null) { + return NpcAnchorType.WORKPLACE; + } + SettlementBuildingRecord building = buildingsByUuid.get(workBuildingUuid); + if (building == null || building.buildingTypeId() == null || building.buildingTypeId().isBlank()) { + return NpcAnchorType.WORKPLACE; + } + ResourceLocation id = ResourceLocation.tryParse(building.buildingTypeId()); + String path = id == null ? building.buildingTypeId() : id.getPath(); + if (path.contains("market")) { + return NpcAnchorType.MARKET; + } + if (path.contains("barracks")) { + return NpcAnchorType.BARRACKS; + } + return NpcAnchorType.WORKPLACE; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorer.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorer.java new file mode 100644 index 00000000..a4fbacf2 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorer.java @@ -0,0 +1,148 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; + +public final class NpcSocietyPhaseTwoIntentScorer { + private NpcSocietyPhaseTwoIntentScorer() { + } + + public static int scoreIntent(ResidentGoalContext ctx, NpcIntent intent) { + if (ctx == null || intent == null) { + return 0; + } + return switch (intent) { + case GO_HOME -> scoreGoHome(ctx); + case REST -> scoreRest(ctx); + case EAT -> scoreEat(ctx); + case WORK -> scoreWork(ctx); + case SEEK_SUPPLIES -> scoreSeekSupplies(ctx); + case HIDE -> scoreHide(ctx); + case DEFEND -> scoreDefend(ctx); + case IDLE -> 1; + default -> 0; + }; + } + + private static int scoreGoHome(ResidentGoalContext ctx) { + if (!ctx.hasHome()) { + return 0; + } + int score = 0; + if (ctx.isRestPhase()) { + score = 84; + } + if (ctx.isLateDayWindow(1000)) { + score = Math.max(score, 68); + } + if (ctx.fatigueNeed() >= 80) { + score = Math.max(score, 76 + Math.max(0, ctx.fatigueNeed() - 80) / 2); + } + return clamp(applyIntentHistory(ctx, NpcIntent.GO_HOME, score, 4)); + } + + private static int scoreRest(ResidentGoalContext ctx) { + int score = ctx.isRestPhase() ? 86 + ctx.fatigueNeed() / 4 : 0; + if (ctx.isReadyToSettleAtHome()) { + score = Math.max(score, 112); + } + if (ctx.hasHome() && ctx.fatigueNeed() >= 85) { + score = Math.max(score, 72 + Math.max(0, ctx.fatigueNeed() - 85) / 2); + } + return clamp(applyIntentHistory(ctx, NpcIntent.REST, score, 4)); + } + + private static int scoreEat(ResidentGoalContext ctx) { + if (!ctx.hasHome() && !ctx.hasMarketFoodAccess()) { + return 0; + } + if (ctx.hungerNeed() < 70) { + return 0; + } + int score = 48 + ctx.hungerNeed() / 2; + if (ctx.hungerNeed() >= 85) { + score += 12; + } + score -= ctx.safetyNeed() / 4; + if (ctx.isRestPhase()) { + score += 4; + } + return clamp(applyIntentHistory(ctx, NpcIntent.EAT, score, 4)); + } + + private static int scoreWork(ResidentGoalContext ctx) { + if (!ctx.isActivePhase() || !ctx.hasWorkAssignment()) { + return 0; + } + if (ctx.isLeisurePhase() || ctx.isLateDayWindow(1200)) { + return 0; + } + if (ctx.fatigueNeed() >= 85 || ctx.hungerNeed() >= 80 || ctx.safetyNeed() >= 35) { + return 0; + } + int score = 72; + score -= ctx.fatigueNeed() / 3; + score -= ctx.hungerNeed() / 4; + score -= ctx.safetyNeed(); + if (ctx.isAdolescent()) { + score -= 10; + } + return clamp(applyIntentHistory(ctx, NpcIntent.WORK, score, 4)); + } + + private static int scoreSeekSupplies(ResidentGoalContext ctx) { + if (!ctx.hasSupplyAccess()) { + return 0; + } + if (ctx.hungerNeed() < 70) { + return 0; + } + if (!ctx.shouldEscalateMealRecoveryToSupplies()) { + return 0; + } + int score = 54 + ctx.hungerNeed() / 2 - ctx.safetyNeed() / 4; + if (!ctx.isActivePhase()) { + score -= 6; + } + return clamp(applyIntentHistory(ctx, NpcIntent.SEEK_SUPPLIES, score, 4)); + } + + private static int scoreHide(ResidentGoalContext ctx) { + int dangerPressure = ctx.safetyNeed(); + if (dangerPressure < 35) { + return 0; + } + int score = 100 + Math.max(0, dangerPressure - 35) / 2; + if (ctx.hasHome()) { + score += 4; + } + return clamp(applyIntentHistory(ctx, NpcIntent.HIDE, score, 4)); + } + + private static int scoreDefend(ResidentGoalContext ctx) { + int defendPressure = ctx.safetyNeed(); + if (!ctx.canDefend() || defendPressure < 35) { + return 0; + } + int score = 88 + defendPressure / 2; + if (ctx.resident().role() == SettlementResidentRole.GOVERNOR_RECRUIT) { + score += 4; + } + return clamp(applyIntentHistory(ctx, NpcIntent.DEFEND, score, 6)); + } + private static int clamp(int score) { + return Math.max(0, Math.min(120, score)); + } + + private static int applyIntentHistory(ResidentGoalContext ctx, NpcIntent intent, int score, int maxBonus) { + if (ctx == null || intent == null || score <= 0) { + return score; + } + if (ctx.currentPublishedIntent() != intent) { + return score; + } + long age = ctx.currentIntentAgeTicks(); + int bonus = (int) Math.max(0L, maxBonus - age / 80L); + return score + bonus; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyProfile.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyProfile.java new file mode 100644 index 00000000..deeca2c1 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyProfile.java @@ -0,0 +1,267 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.UUID; + +public record NpcSocietyProfile( + UUID residentUuid, + NpcLifeStage lifeStage, + NpcSex sex, + @Nullable UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + @Nullable String cultureId, + @Nullable String faithId, + NpcDailyPhase dailyPhase, + NpcIntent currentIntent, + NpcAnchorType currentAnchor, + NpcSocietyDecisionSnapshot decisionSnapshot, + int hungerNeed, + int fatigueNeed, + int socialNeed, + int safetyNeed, + long version, + long lastUpdatedGameTime +) { + public static NpcSocietyProfile createDefault(UUID residentUuid, long gameTime) { + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + return new NpcSocietyProfile( + residentUuid, + NpcLifeStage.ADULT, + defaultSexFor(residentUuid), + null, + null, + null, + null, + null, + NpcDailyPhase.UNSPECIFIED, + NpcIntent.UNSPECIFIED, + NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), + 10, + 10, + 0, + 10, + 1L, + gameTime + ); + } + + public static NpcSocietyProfile createSeeded(UUID residentUuid, + NpcLifeStage lifeStage, + NpcSex sex, + long gameTime) { + NpcSocietyProfile profile = createDefault(residentUuid, gameTime); + return new NpcSocietyProfile( + residentUuid, + lifeStage == null ? NpcLifeStage.ADULT : lifeStage, + sex == null ? defaultSexFor(residentUuid) : sex, + profile.householdId, + profile.homeBuildingUuid, + profile.workBuildingUuid, + profile.cultureId, + profile.faithId, + profile.dailyPhase, + profile.currentIntent, + profile.currentAnchor, + profile.decisionSnapshot, + profile.hungerNeed, + profile.fatigueNeed, + profile.socialNeed, + profile.safetyNeed, + profile.version, + gameTime + ); + } + + public NpcSocietyProfile withPhaseOneState(@Nullable UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + NpcDailyPhase dailyPhase, + NpcIntent currentIntent, + NpcAnchorType currentAnchor, + @Nullable NpcSocietyDecisionSnapshot decisionSnapshot, + long gameTime) { + NpcSocietyDecisionSnapshot nextDecisionSnapshot = decisionSnapshot == null ? NpcSocietyDecisionSnapshot.empty() : decisionSnapshot; + if (sameNullableUuid(this.householdId, householdId) + && sameNullableUuid(this.homeBuildingUuid, homeBuildingUuid) + && sameNullableUuid(this.workBuildingUuid, workBuildingUuid) + && sameEnum(this.dailyPhase, dailyPhase) + && sameEnum(this.currentIntent, currentIntent) + && sameEnum(this.currentAnchor, currentAnchor) + && this.decisionSnapshot.equals(nextDecisionSnapshot)) { + return this; + } + return new NpcSocietyProfile( + this.residentUuid, + this.lifeStage, + this.sex, + householdId, + homeBuildingUuid, + workBuildingUuid, + this.cultureId, + this.faithId, + dailyPhase == null ? NpcDailyPhase.UNSPECIFIED : dailyPhase, + currentIntent == null ? NpcIntent.UNSPECIFIED : currentIntent, + currentAnchor == null ? NpcAnchorType.NONE : currentAnchor, + nextDecisionSnapshot, + this.hungerNeed, + this.fatigueNeed, + this.socialNeed, + this.safetyNeed, + this.version + 1L, + gameTime + ); + } + + public NpcSocietyProfile withNeedState(int hungerNeed, + int fatigueNeed, + int safetyNeed, + long gameTime) { + return this.withNeedState(hungerNeed, fatigueNeed, 0, safetyNeed, gameTime); + } + + public NpcSocietyProfile withNeedState(int hungerNeed, + int fatigueNeed, + int socialNeed, + int safetyNeed, + long gameTime) { + int clampedHunger = clampNeed(hungerNeed); + int clampedFatigue = clampNeed(fatigueNeed); + int clampedSocial = 0; + int clampedSafety = clampNeed(safetyNeed); + if (this.hungerNeed == clampedHunger + && this.fatigueNeed == clampedFatigue + && this.socialNeed == clampedSocial + && this.safetyNeed == clampedSafety) { + return this; + } + return new NpcSocietyProfile( + this.residentUuid, + this.lifeStage, + this.sex, + this.householdId, + this.homeBuildingUuid, + this.workBuildingUuid, + this.cultureId, + this.faithId, + this.dailyPhase, + this.currentIntent, + this.currentAnchor, + this.decisionSnapshot, + clampedHunger, + clampedFatigue, + clampedSocial, + clampedSafety, + this.version + 1L, + gameTime + ); + } + + public NpcSocietyProfile moveToResident(UUID residentUuid, long gameTime) { + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + if (residentUuid.equals(this.residentUuid)) { + return this; + } + return new NpcSocietyProfile( + residentUuid, + this.lifeStage, + this.sex, + this.householdId, + this.homeBuildingUuid, + this.workBuildingUuid, + this.cultureId, + this.faithId, + this.dailyPhase, + this.currentIntent, + this.currentAnchor, + this.decisionSnapshot, + this.hungerNeed, + this.fatigueNeed, + this.socialNeed, + this.safetyNeed, + this.version + 1L, + gameTime + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ResidentUuid", this.residentUuid); + tag.putString("LifeStage", (this.lifeStage == null ? NpcLifeStage.UNSPECIFIED : this.lifeStage).name()); + tag.putString("Sex", (this.sex == null ? NpcSex.UNSPECIFIED : this.sex).name()); + if (this.householdId != null) { + tag.putUUID("HouseholdId", this.householdId); + } + if (this.homeBuildingUuid != null) { + tag.putUUID("HomeBuildingUuid", this.homeBuildingUuid); + } + if (this.workBuildingUuid != null) { + tag.putUUID("WorkBuildingUuid", this.workBuildingUuid); + } + if (this.cultureId != null && !this.cultureId.isBlank()) { + tag.putString("CultureId", this.cultureId); + } + if (this.faithId != null && !this.faithId.isBlank()) { + tag.putString("FaithId", this.faithId); + } + tag.putString("DailyPhase", (this.dailyPhase == null ? NpcDailyPhase.UNSPECIFIED : this.dailyPhase).name()); + tag.putString("CurrentIntent", (this.currentIntent == null ? NpcIntent.UNSPECIFIED : this.currentIntent).name()); + tag.putString("CurrentAnchor", (this.currentAnchor == null ? NpcAnchorType.NONE : this.currentAnchor).name()); + tag.put("DecisionSnapshot", (this.decisionSnapshot == null ? NpcSocietyDecisionSnapshot.empty() : this.decisionSnapshot).toTag()); + tag.putInt("HungerNeed", this.hungerNeed); + tag.putInt("FatigueNeed", this.fatigueNeed); + tag.putInt("SocialNeed", this.socialNeed); + tag.putInt("SafetyNeed", this.safetyNeed); + tag.putLong("Version", this.version); + tag.putLong("LastUpdatedGameTime", this.lastUpdatedGameTime); + return tag; + } + + public static NpcSocietyProfile fromTag(CompoundTag tag) { + UUID residentUuid = tag.getUUID("ResidentUuid"); + return new NpcSocietyProfile( + residentUuid, + NpcLifeStage.fromName(tag.getString("LifeStage")), + NpcSex.fromName(tag.getString("Sex")), + tag.contains("HouseholdId") ? tag.getUUID("HouseholdId") : null, + tag.contains("HomeBuildingUuid") ? tag.getUUID("HomeBuildingUuid") : null, + tag.contains("WorkBuildingUuid") ? tag.getUUID("WorkBuildingUuid") : null, + tag.contains("CultureId") ? tag.getString("CultureId") : null, + tag.contains("FaithId") ? tag.getString("FaithId") : null, + NpcDailyPhase.fromName(tag.getString("DailyPhase")), + NpcIntent.fromName(tag.getString("CurrentIntent")), + NpcAnchorType.fromName(tag.getString("CurrentAnchor")), + NpcSocietyDecisionSnapshot.fromTag(tag.contains("DecisionSnapshot") ? tag.getCompound("DecisionSnapshot") : null), + clampNeed(tag.getInt("HungerNeed")), + clampNeed(tag.getInt("FatigueNeed")), + 0, + clampNeed(tag.getInt("SafetyNeed")), + Math.max(1L, tag.getLong("Version")), + tag.getLong("LastUpdatedGameTime") + ); + } + + private static NpcSex defaultSexFor(UUID residentUuid) { + long bits = residentUuid.getLeastSignificantBits() ^ residentUuid.getMostSignificantBits(); + return (bits & 1L) == 0L ? NpcSex.MALE : NpcSex.FEMALE; + } + + private static boolean sameNullableUuid(@Nullable UUID left, @Nullable UUID right) { + return left == null ? right == null : left.equals(right); + } + + private static boolean sameEnum(@Nullable Enum left, @Nullable Enum right) { + return left == null ? right == null : left.equals(right); + } + + private static int clampNeed(int value) { + return Math.max(0, Math.min(100, value)); + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietyRuntime.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietyRuntime.java new file mode 100644 index 00000000..4fb8a2f3 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietyRuntime.java @@ -0,0 +1,264 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.goal.impl.DefendResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.EatResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; +import com.talhanation.bannermod.settlement.household.LeaveHomeResidentGoal; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class NpcSocietyRuntime { + private final Map profilesByResident = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { + }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { + } : dirtyListener; + } + + public Optional profileFor(UUID residentUuid) { + if (residentUuid == null) { + return Optional.empty(); + } + return Optional.ofNullable(this.profilesByResident.get(residentUuid)); + } + + public NpcSocietyProfile ensureResident(UUID residentUuid, long gameTime) { + if (residentUuid == null) { + throw new IllegalArgumentException("residentUuid must not be null"); + } + NpcSocietyProfile existing = this.profilesByResident.get(residentUuid); + if (existing != null) { + return existing; + } + NpcSocietyProfile created = NpcSocietyProfile.createDefault(residentUuid, gameTime); + this.profilesByResident.put(residentUuid, created); + markDirty(); + return created; + } + + public NpcSocietyProfile seedResident(NpcSocietyProfile initialProfile) { + if (initialProfile == null || initialProfile.residentUuid() == null) { + throw new IllegalArgumentException("initialProfile must not be null"); + } + NpcSocietyProfile existing = this.profilesByResident.get(initialProfile.residentUuid()); + if (existing != null) { + return existing; + } + this.profilesByResident.put(initialProfile.residentUuid(), initialProfile); + markDirty(); + return initialProfile; + } + + public NpcSocietyProfile reconcilePhaseOneState(UUID residentUuid, + @Nullable UUID householdId, + @Nullable UUID homeBuildingUuid, + @Nullable UUID workBuildingUuid, + NpcDailyPhase dailyPhase, + NpcIntent currentIntent, + NpcAnchorType currentAnchor, + @Nullable NpcSocietyDecisionSnapshot decisionSnapshot, + long gameTime) { + NpcSocietyProfile profile = ensureResident(residentUuid, gameTime); + NpcSocietyDecisionSnapshot normalizedDecisionSnapshot = normalizeDecisionSnapshot( + profile, + currentIntent, + decisionSnapshot, + gameTime + ); + NpcSocietyProfile updated = profile.withPhaseOneState( + householdId, + homeBuildingUuid, + workBuildingUuid, + dailyPhase, + currentIntent, + currentAnchor, + normalizedDecisionSnapshot, + gameTime + ); + if (updated == profile) { + return profile; + } + this.profilesByResident.put(residentUuid, updated); + markDirty(); + return updated; + } + + public NpcSocietyProfile moveResident(UUID fromResidentUuid, UUID toResidentUuid, long gameTime) { + if (toResidentUuid == null) { + throw new IllegalArgumentException("toResidentUuid must not be null"); + } + if (fromResidentUuid == null || fromResidentUuid.equals(toResidentUuid)) { + return ensureResident(toResidentUuid, gameTime); + } + NpcSocietyProfile source = this.profilesByResident.remove(fromResidentUuid); + NpcSocietyProfile target = source == null + ? NpcSocietyProfile.createDefault(toResidentUuid, gameTime) + : source.moveToResident(toResidentUuid, gameTime); + NpcSocietyProfile existing = this.profilesByResident.put(toResidentUuid, target); + if (!target.equals(existing)) { + markDirty(); + } + return target; + } + + public NpcSocietyProfile reconcileNeedState(UUID residentUuid, + int hungerNeed, + int fatigueNeed, + int safetyNeed, + long gameTime) { + NpcSocietyProfile profile = ensureResident(residentUuid, gameTime); + NpcSocietyProfile updated = profile.withNeedState(hungerNeed, fatigueNeed, safetyNeed, gameTime); + if (updated == profile) { + return profile; + } + this.profilesByResident.put(residentUuid, updated); + markDirty(); + return updated; + } + + public List snapshot() { + return Collections.unmodifiableList(new ArrayList<>(this.profilesByResident.values())); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag profiles = new ListTag(); + for (NpcSocietyProfile profile : snapshot()) { + profiles.add(profile.toTag()); + } + tag.put("Profiles", profiles); + return tag; + } + + public static NpcSocietyRuntime fromTag(CompoundTag tag) { + NpcSocietyRuntime runtime = new NpcSocietyRuntime(); + List profiles = new ArrayList<>(); + for (Tag entry : tag.getList("Profiles", Tag.TAG_COMPOUND)) { + profiles.add(NpcSocietyProfile.fromTag((CompoundTag) entry)); + } + runtime.restoreSnapshot(profiles); + return runtime; + } + + public void restoreSnapshot(@Nullable Collection profiles) { + List before = snapshot(); + this.profilesByResident.clear(); + if (profiles != null) { + for (NpcSocietyProfile profile : profiles) { + if (profile != null && profile.residentUuid() != null) { + this.profilesByResident.put(profile.residentUuid(), profile); + } + } + } + if (!before.equals(snapshot())) { + markDirty(); + } + } + + public void reset() { + if (!this.profilesByResident.isEmpty()) { + this.profilesByResident.clear(); + markDirty(); + } + } + + private void markDirty() { + this.dirtyListener.run(); + } + + private static NpcSocietyDecisionSnapshot normalizeDecisionSnapshot(NpcSocietyProfile profile, + NpcIntent currentIntent, + @Nullable NpcSocietyDecisionSnapshot decisionSnapshot, + long gameTime) { + if (currentIntent == null || currentIntent == NpcIntent.UNSPECIFIED) { + return decisionSnapshot == null ? NpcSocietyDecisionSnapshot.empty() : decisionSnapshot; + } + if (decisionSnapshot != null && decisionSnapshot.currentGoalId() != null && !decisionSnapshot.currentGoalId().isBlank()) { + return decisionSnapshot; + } + String goalId = goalIdForIntent(currentIntent); + if (goalId == null) { + return decisionSnapshot == null ? NpcSocietyDecisionSnapshot.empty() : decisionSnapshot; + } + String lastIntentTag = profile == null || profile.currentIntent() == null + ? NpcIntent.UNSPECIFIED.name() + : profile.currentIntent().name(); + return new NpcSocietyDecisionSnapshot( + "EXECUTING", + goalId, + defaultChoiceReasonTag(currentIntent), + defaultRouteReasonTag(currentIntent), + null, + NpcSocietyDecisionSnapshot.BLOCKED_REASON_NONE, + lastIntentTag, + gameTime + ); + } + + @Nullable + private static String goalIdForIntent(@Nullable NpcIntent intent) { + if (intent == null) { + return null; + } + return switch (intent) { + case GO_HOME -> GoHomeResidentGoal.ID.toString(); + case REST -> RestResidentGoal.ID.toString(); + case LEAVE_HOME -> LeaveHomeResidentGoal.ID.toString(); + case EAT -> EatResidentGoal.ID.toString(); + case SEEK_SUPPLIES -> SeekSuppliesResidentGoal.ID.toString(); + case IDLE -> IdleResidentGoal.ID.toString(); + case HIDE -> HideResidentGoal.ID.toString(); + case DEFEND -> DefendResidentGoal.ID.toString(); + case WORK -> WorkResidentGoal.ID.toString(); + default -> null; + }; + } + + private static String defaultChoiceReasonTag(NpcIntent intent) { + return switch (intent) { + case GO_HOME -> "HOMEWARD_PULL"; + case REST -> "REST_WINDOW"; + case LEAVE_HOME -> "EARLY_ACTIVE_WINDOW"; + case EAT -> "HUNGER_PRESSURE"; + case SEEK_SUPPLIES -> "HOME_FOOD_SHORTAGE"; + case IDLE -> "NO_HIGHER_PRIORITY_GOAL"; + case HIDE -> "THREAT_AVOIDANCE"; + case DEFEND -> "THREAT_RESPONSE"; + case WORK -> "ASSIGNED_SHIFT"; + default -> "UNKNOWN"; + }; + } + + private static String defaultRouteReasonTag(NpcIntent intent) { + return switch (intent) { + case GO_HOME -> "RETURNING_HOME_ROUTE"; + case REST -> "RESTING_AT_HOME"; + case LEAVE_HOME -> "LEAVING_HOME_FOR_DAY"; + case EAT -> "MEAL_AT_MARKET"; + case SEEK_SUPPLIES -> "MARKET_SUPPLY_RUN"; + case IDLE -> "NO_CLEAR_ROUTE"; + case HIDE -> "SEEKING_SHELTER_ROUTE"; + case DEFEND -> "MOVING_TO_DEFENSE_POST"; + case WORK -> "HEADING_TO_WORKPLACE"; + default -> "NO_CLEAR_ROUTE"; + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/NpcSocietySavedData.java b/src/main/java/com/talhanation/bannermod/society/NpcSocietySavedData.java new file mode 100644 index 00000000..dcedbf23 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/NpcSocietySavedData.java @@ -0,0 +1,42 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class NpcSocietySavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcSociety"; + private static final SavedData.Factory FACTORY = + new SavedData.Factory<>(NpcSocietySavedData::new, NpcSocietySavedData::load); + + private final NpcSocietyRuntime runtime; + + public NpcSocietySavedData() { + this(new NpcSocietyRuntime()); + } + + private NpcSocietySavedData(NpcSocietyRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static NpcSocietySavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcSocietySavedData load(CompoundTag tag, HolderLookup.Provider registries) { + return new NpcSocietySavedData(NpcSocietyRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag runtimeTag = this.runtime.toTag(); + tag.put("Profiles", runtimeTag.getList("Profiles", 10)); + return tag; + } + + public NpcSocietyRuntime runtime() { + return this.runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/society/client/NpcHousingClientState.java b/src/main/java/com/talhanation/bannermod/society/client/NpcHousingClientState.java new file mode 100644 index 00000000..110dada5 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/society/client/NpcHousingClientState.java @@ -0,0 +1,109 @@ +package com.talhanation.bannermod.society.client; + +import com.talhanation.bannermod.society.NpcHousingLedgerEntry; +import com.talhanation.bannermod.society.NpcHousingSnapshotContract; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public final class NpcHousingClientState { + private static List requests = List.of(); + private static Map requestsByHousehold = Map.of(); + private static boolean hasClaim; + private static boolean canManage; + private static boolean hasSnapshot; + private static boolean syncPending; + private static String denialKey = ""; + @Nullable + private static UUID claimUuid; + private static int version; + + private NpcHousingClientState() { + } + + public static List requests() { + return requests; + } + + public static @Nullable NpcHousingLedgerEntry requestByHousehold(@Nullable UUID householdId) { + return householdId == null ? null : requestsByHousehold.get(householdId); + } + + public static boolean hasClaim() { + return hasClaim; + } + + public static boolean canManage() { + return canManage; + } + + public static boolean hasSnapshot() { + return hasSnapshot; + } + + public static boolean syncPending() { + return syncPending; + } + + public static String denialKey() { + return denialKey; + } + + public static @Nullable UUID claimUuid() { + return claimUuid; + } + + public static int version() { + return version; + } + + public static void beginSync() { + syncPending = true; + } + + public static void clear() { + requests = List.of(); + requestsByHousehold = Map.of(); + hasClaim = false; + canManage = false; + hasSnapshot = false; + syncPending = false; + denialKey = ""; + claimUuid = null; + version++; + } + + public static void applyFromNbt(CompoundTag tag) { + if (tag == null) { + clear(); + return; + } + List decoded = new ArrayList<>(); + for (Tag entry : tag.getList(NpcHousingSnapshotContract.NBT_REQUESTS, Tag.TAG_COMPOUND)) { + decoded.add(NpcHousingLedgerEntry.fromTag((CompoundTag) entry)); + } + requests = List.copyOf(decoded); + Map byHousehold = new HashMap<>(); + for (NpcHousingLedgerEntry entry : decoded) { + byHousehold.put(entry.householdId(), entry); + } + requestsByHousehold = Map.copyOf(byHousehold); + hasClaim = tag.getBoolean(NpcHousingSnapshotContract.NBT_HAS_CLAIM); + claimUuid = hasClaim && tag.contains(NpcHousingSnapshotContract.NBT_CLAIM_UUID) + ? tag.getUUID(NpcHousingSnapshotContract.NBT_CLAIM_UUID) + : null; + canManage = tag.getBoolean(NpcHousingSnapshotContract.NBT_CAN_MANAGE); + denialKey = tag.contains(NpcHousingSnapshotContract.NBT_DENIAL_KEY) + ? tag.getString(NpcHousingSnapshotContract.NBT_DENIAL_KEY) + : ""; + hasSnapshot = true; + syncPending = false; + version++; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java b/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java index 93d9979a..97500af5 100644 --- a/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java +++ b/src/main/java/com/talhanation/bannermod/war/registry/PoliticalRegistryValidation.java @@ -24,6 +24,9 @@ public static Result validateCreate(String name, UUID leaderUuid, Collection %s. %s", + "gui.bannermod.worker_screen.needs": "Needs", + "gui.bannermod.worker_screen.needs.summary": "Hunger %s, fatigue %s, safety %s", "gui.bannermod.worker_screen.problem": "Problem", "gui.bannermod.worker_screen.transport": "Transport", "gui.bannermod.worker_screen.relation.friendly_claim": "Friendly claim", @@ -1926,6 +1932,7 @@ "gui.workers.command.text.up": "Up", "gui.workers.command.text.down": "Down", "item.bannermod.banner_almanac": "BannerMod Almanac", + "itemGroup.bannermod.main": "BannerMod", "item.bannermod.banner_almanac.tooltip.use": "Use: open the in-game handbook.", "item.bannermod.banner_almanac.tooltip.scope": "Covers settlement, workers, recruits, politics, war, and sieges.", "item.bannermod.banner_almanac.page_1": "BannerMod Almanac\n\nThree layers rule the mod:\n1. Claim = protected Overworld land.\n2. Settlement = your live base.\n3. State = your political side.\n\nHotkeys:\nM map and claims\nU War Room\nR recruits\nX workers\nV prefab preview.", @@ -1944,6 +1951,14 @@ "item.bannermod.banner_almanac.page_14": "War Room and politics\n\nPress U. Use it for:\nStates screen\nwar list\nstate details\ngovernment form\nally invites\nsiege placement\nbattle-window banner\n\nAllies are consent-based: invite, then accept or decline.\nRepublic lets co-leaders share more political authority.\nMonarchy keeps key authority on the leader.", "item.bannermod.banner_almanac.page_15": "Wars, sieges, revolts, and limits\n\nUse War Room or commands to declare war.\nPlace siege standards where economy matters: bridges, stockpiles, markets, ports, route junctions.\nRevolts exist but objective-based revolt flow is still incomplete.\nSmall Ships sea trade is visible through settlement logistics when a compatible carrier is bound.\nIf something is denied, usually check leader rights, battle window, side ownership, claim ownership, or missing infrastructure.", "item.bannermod.settlement_surveyor_tool": "Settlement Surveyor Tool", + "item.bannermod.kinlot_staff": "Kinlot Staff", + "item.bannermod.kinlot_staff.tooltip.1": "Hold it near a family plot to see who has claimed that lot.", + "item.bannermod.kinlot_staff.tooltip.2": "Right-click a claimed lot to read the household, request state, and active build marker.", + "item.bannermod.kinlot_staff.actionbar": "Kinlot %s | %s | %s | plot %s, %s", + "item.bannermod.kinlot_staff.no_claim": "This land is outside a settlement claim.", + "item.bannermod.kinlot_staff.no_plot": "No claimed family lot is marked here.", + "item.bannermod.kinlot_staff.detail.header": "Family lot %s at %s %s %s", + "item.bannermod.kinlot_staff.detail.line": "Representative %1$s | members %2$s | housing %3$s | request %4$s | build area %5$s", "bannermod.surveyor.tooltip.mode": "Survey mode: %s", "bannermod.surveyor.tooltip.role": "Marker role: %s", "bannermod.surveyor.tooltip.anchor": "Anchor: %s", @@ -2011,17 +2026,283 @@ "gui.bannermod.citizen_profile.assignment": "Assignment: %s", "gui.bannermod.citizen_profile.assignment.none": "Unassigned", "gui.bannermod.citizen_profile.assignment.area": "(area: %s)", + "gui.bannermod.citizen_profile.home": "Home: %s", + "gui.bannermod.citizen_profile.home.summary": "home %s, %s, %s", + "gui.bannermod.citizen_profile.family": "Family: %s", + "gui.bannermod.citizen_profile.family.summary": "head %s, role %s, kin %s", + "gui.bannermod.citizen_profile.household": "Household: %s", + "gui.bannermod.citizen_profile.household.summary": "%s residents, %s", + "gui.bannermod.citizen_profile.identity": "Identity: %s", + "gui.bannermod.citizen_profile.routine": "Routine: %s", + "gui.bannermod.citizen_profile.routine.summary": "%s, %s -> %s. %s", + "gui.bannermod.citizen_profile.housing": "Housing: %s", + "gui.bannermod.citizen_profile.housing.summary": "request %s, %s, %s, wait %sd", + "gui.bannermod.citizen_profile.needs": "Needs: %s", + "gui.bannermod.citizen_profile.needs.summary": "H %s, F %s, Safe %s", + "gui.bannermod.citizen_profile.life_stage": "Age: %s", + "gui.bannermod.citizen_profile.sex": "Sex: %s", + "gui.bannermod.citizen_profile.phase": "Day phase: %s", + "gui.bannermod.citizen_profile.intent": "Intent: %s", "gui.bannermod.citizen_profile.state": "State: %s", "gui.bannermod.citizen_profile.state.idle": "Idle citizen", "gui.bannermod.citizen_profile.state.working": "Serving a post", "gui.bannermod.citizen_profile.inventory": "Citizen satchel", "gui.bannermod.citizen_profile.player_inventory": "Your packs", + "gui.bannermod.society.ai.button": "AI", + "gui.bannermod.society.ai.tooltip": "Open the current AI state, chosen goal, and refusal reason for this resident.", + "gui.bannermod.society.ai.title": "AI Trace", + "gui.bannermod.society.ai.state": "State", + "gui.bannermod.society.ai.phase": "Phase", + "gui.bannermod.society.ai.intent": "Intent", + "gui.bannermod.society.ai.anchor": "Anchor", + "gui.bannermod.society.ai.route": "Current route", + "gui.bannermod.society.ai.route.target": "Heading toward: %s", + "gui.bannermod.society.ai.route.recovery_detail": "Last broken plan: %1$s. %2$s", + "gui.bannermod.society.ai.route.blocked_detail": "Blocked plan: %1$s. %2$s", + "gui.bannermod.society.ai.route.recovery_summary": "%1$s Last broken plan: %2$s. %3$s", + "gui.bannermod.society.ai.route.blocked_summary": "Cannot continue with %1$s. %2$s", + "gui.bannermod.society.ai.recovering_from": "Recovering after: %s", + "gui.bannermod.society.ai.goal": "Chosen goal", + "gui.bannermod.society.ai.blocked_goal": "Blocked goal", + "gui.bannermod.society.ai.last_broken_goal": "Last broken goal", + "gui.bannermod.society.ai.pressures": "Pressures", + "gui.bannermod.society.ai.pressures.line_one": "Hunger %s Fatigue %s", + "gui.bannermod.society.ai.pressures.line_two": "Safety %s Home %s Work %s", + "gui.bannermod.society.ai.state.unspecified": "Unspecified", + "gui.bannermod.society.ai.state.idle": "Idle", + "gui.bannermod.society.ai.state.executing": "Executing", + "gui.bannermod.society.ai.state.blocked": "Blocked", + "gui.bannermod.society.ai.state.recovering": "Recovering", + "gui.bannermod.society.ai.reason.unspecified": "No clear reason recorded.", + "gui.bannermod.society.ai.reason.none": "No major refusal recorded.", + "gui.bannermod.society.ai.reason.no_startable_goal": "No startable goal beat the fallback this tick.", + "gui.bannermod.society.ai.reason.rest_window": "Rest window is active.", + "gui.bannermod.society.ai.reason.fatigue_spike": "Fatigue pressure is too high.", + "gui.bannermod.society.ai.reason.seeking_shelter": "Safety pressure is pushing the resident toward shelter.", + "gui.bannermod.society.ai.reason.homeward_pull": "The resident has a valid home and wants to return there.", + "gui.bannermod.society.ai.reason.early_active_window": "The active day just started, so the resident is leaving home first.", + "gui.bannermod.society.ai.reason.severe_hunger": "Hunger is critical.", + "gui.bannermod.society.ai.reason.hunger_pressure": "Hunger pressure beat the other available goals.", + "gui.bannermod.society.ai.reason.no_home_food_run": "The resident has no home and is making a supply run for food.", + "gui.bannermod.society.ai.reason.social_pressure": "Social pressure beat the work and idle paths.", + "gui.bannermod.society.ai.reason.threat_avoidance": "Threat pressure made hiding safer than normal activity.", + "gui.bannermod.society.ai.reason.threat_response": "Threat pressure and role authority made defense the best response.", + "gui.bannermod.society.ai.reason.ready_market_dispatch": "A ready market dispatch outranked generic labor.", + "gui.bannermod.society.ai.reason.assigned_shift": "The resident is inside an active work shift.", + "gui.bannermod.society.ai.reason.workflow_transfer": "Workplace logistics outranked generic labor this tick.", + "gui.bannermod.society.ai.reason.no_higher_priority_goal": "No higher-priority goal beat the idle fallback.", + "gui.bannermod.society.ai.reason.unknown": "The current goal did not provide a more specific reason.", + "gui.bannermod.society.ai.reason.role_cannot_defend": "This resident role cannot take the defend path.", + "gui.bannermod.society.ai.reason.no_home": "The resident wants shelter but has no valid home.", + "gui.bannermod.society.ai.reason.no_food_access": "The resident is hungry but has no reachable food source.", + "gui.bannermod.society.ai.reason.too_fatigued_for_work": "The resident is too exhausted to work safely.", + "gui.bannermod.society.ai.reason.no_work_assignment": "The resident is a worker but has no usable assignment.", + "gui.bannermod.society.ai.reason.routine_window_mismatch": "The current schedule window does not allow that social routine.", + "gui.bannermod.society.ai.reason.committing_to_current_goal": "The resident is staying with an in-progress goal instead of bouncing between tasks.", + "gui.bannermod.society.ai.reason.returning_to_household": "The resident is being pulled back toward home and close kin.", + "gui.bannermod.society.ai.reason.household_recovery": "The resident is recovering near the household.", + "gui.bannermod.society.ai.reason.household_belonging": "The resident is drawn toward close kin and familiar company.", + "gui.bannermod.society.ai.reason.providing_for_household": "The resident is acting to support the household's stability and supplies.", + "gui.bannermod.society.ai.reason.protecting_household": "The resident is hiding with the household in mind.", + "gui.bannermod.society.ai.reason.defending_household": "The resident is defending home and kin.", + "gui.bannermod.society.ai.reason.food_recovery_run": "The last meal attempt failed, so the resident is looking for food or supplies instead of repeating the same mistake.", + "gui.bannermod.society.ai.reason.task_timed_out": "The last attempt took too long, so the resident is briefly backing off and reassessing.", + "gui.bannermod.society.ai.reason.context_invalidated": "The last attempt was invalidated because the situation around the goal changed.", + "gui.bannermod.society.ai.route.no_clear_route": "No stronger movement pull is active right now.", + "gui.bannermod.society.ai.route.regrouping_at_home": "Falling back home to regroup before trying something riskier again.", + "gui.bannermod.society.ai.route.soon_night_homebound": "Heading home because night is closing in.", + "gui.bannermod.society.ai.route.home_as_shelter": "Pulling back home because it feels safer there.", + "gui.bannermod.society.ai.route.returning_home_route": "Returning to the house before settling down.", + "gui.bannermod.society.ai.route.settling_at_home_for_rest": "Staying home now that it is time to rest.", + "gui.bannermod.society.ai.route.resting_after_regroup": "Staying home to rest and settle after a broken routine.", + "gui.bannermod.society.ai.route.resting_at_home": "Remaining at home to rest for the night.", + "gui.bannermod.society.ai.route.resting_off_street": "Keeping close to a safe corner to rest.", + "gui.bannermod.society.ai.route.leaving_home_for_work": "Stepping out of the house to begin the workday.", + "gui.bannermod.society.ai.route.leaving_home_for_day": "Stepping out of the house to join the day outside.", + "gui.bannermod.society.ai.route.starting_workday_after_home": "Heading toward work after leaving home.", + "gui.bannermod.society.ai.route.heading_to_workplace": "Moving toward the assigned workplace.", + "gui.bannermod.society.ai.route.meal_at_home": "Heading home for a meal.", + "gui.bannermod.society.ai.route.meal_at_market": "Heading toward the market to find food.", + "gui.bannermod.society.ai.route.market_supply_run": "Going to the market to look for supplies.", + "gui.bannermod.society.ai.route.stockpile_supply_run": "Going toward storage to look for supplies.", + "gui.bannermod.society.ai.route.food_recovery_run": "Making a food-and-supplies run after the last meal plan failed.", + "gui.bannermod.society.ai.route.evening_home_circle": "Staying near home to spend the evening with close kin.", + "gui.bannermod.society.ai.route.household_yard_gathering": "Staying near the household instead of wandering far for company.", + "gui.bannermod.society.ai.route.household_recovery_circle": "Keeping social recovery close to home and familiar faces.", + "gui.bannermod.society.ai.route.market_gathering": "Heading to the market where people naturally gather.", + "gui.bannermod.society.ai.route.tavern_gathering": "Heading to a tavern-like social spot.", + "gui.bannermod.society.ai.route.square_gathering": "Heading to the village square to mingle.", + "gui.bannermod.society.ai.route.hall_gathering": "Heading to a hall where villagers gather.", + "gui.bannermod.society.ai.route.hearth_gathering": "Heading to a hearth or fire where people cluster.", + "gui.bannermod.society.ai.route.well_gathering": "Heading to a well-side meeting point.", + "gui.bannermod.society.ai.route.street_side_chat": "Lingering near the settlement streets to find company.", + "gui.bannermod.society.ai.route.seeking_shelter_route": "Moving toward shelter because the area feels unsafe.", + "gui.bannermod.society.ai.route.hiding_close_to_household": "Keeping close to home and kin while taking cover.", + "gui.bannermod.society.ai.route.moving_to_defense_post": "Moving toward a point worth defending.", + "gui.bannermod.society.ai.route.market_duty_route": "Heading to the market because duty is pulling there.", + "gui.bannermod.society.ai.route.workflow_transfer_route": "Moving along a work transfer route.", + "gui.bannermod.society.ai.route.working_for_household": "Heading to work because the household needs support.", + "gui.bannermod.society.ai.goal.go_home": "Go home", + "gui.bannermod.society.ai.goal.leave_home": "Leave home", + "gui.bannermod.society.ai.goal.rest": "Rest", + "gui.bannermod.society.ai.goal.eat": "Eat", + "gui.bannermod.society.ai.goal.seek_supplies": "Seek supplies", + "gui.bannermod.society.ai.goal.hide": "Hide", + "gui.bannermod.society.ai.goal.defend": "Defend", + "gui.bannermod.society.ai.goal.work": "Work", + "gui.bannermod.society.ai.goal.sell": "Sell", + "gui.bannermod.society.ai.goal.fetch": "Fetch", + "gui.bannermod.society.ai.goal.deliver": "Deliver", + "gui.bannermod.society.ai.goal.idle": "Idle", "gui.bannermod.citizen_profile.profession.none": "Free citizen", "gui.bannermod.citizen_profile.profession.recruit_spear": "Recruit Spearman", "gui.bannermod.citizen_profile.profession.recruit_nomad": "Recruit Nomad", "gui.bannermod.citizen_profile.profession.recruit_scout": "Recruit Scout", "gui.bannermod.citizen_profile.profession.recruit_shieldman": "Recruit Shieldman", "gui.bannermod.citizen_profile.profession.noble": "Noble", + "gui.bannermod.society.life_stage.unspecified": "Unspecified", + "gui.bannermod.society.life_stage.child": "Child", + "gui.bannermod.society.life_stage.adolescent": "Adolescent", + "gui.bannermod.society.life_stage.adult": "Adult", + "gui.bannermod.society.life_stage.elder": "Elder", + "gui.bannermod.society.sex.unspecified": "Unspecified", + "gui.bannermod.society.sex.male": "Male", + "gui.bannermod.society.sex.female": "Female", + "gui.bannermod.society.daily_phase.unspecified": "Unspecified", + "gui.bannermod.society.daily_phase.active": "Active hours", + "gui.bannermod.society.daily_phase.departing_home": "Leaving home", + "gui.bannermod.society.daily_phase.returning_home": "Returning home", + "gui.bannermod.society.daily_phase.rest": "Rest phase", + "gui.bannermod.society.intent.unspecified": "Unspecified", + "gui.bannermod.society.intent.idle": "Idle", + "gui.bannermod.society.intent.go_home": "Go home", + "gui.bannermod.society.intent.leave_home": "Leave home", + "gui.bannermod.society.intent.rest": "Rest", + "gui.bannermod.society.intent.eat": "Eat", + "gui.bannermod.society.intent.work": "Work", + "gui.bannermod.society.intent.seek_supplies": "Seek supplies", + "gui.bannermod.society.intent.hide": "Hide", + "gui.bannermod.society.intent.defend": "Defend", + "gui.bannermod.society.intent.sell": "Sell", + "gui.bannermod.society.intent.fetch": "Fetch", + "gui.bannermod.society.intent.deliver": "Deliver", + "gui.bannermod.society.anchor.none": "No anchor", + "gui.bannermod.society.anchor.home": "Home", + "gui.bannermod.society.anchor.workplace": "Workplace", + "gui.bannermod.society.anchor.market": "Market", + "gui.bannermod.society.anchor.barracks": "Barracks", + "gui.bannermod.society.anchor.street": "Street", + "gui.bannermod.society.household_housing.normal": "settled", + "gui.bannermod.society.household_housing.homeless": "homeless", + "gui.bannermod.society.household_housing.overcrowded": "overcrowded", + "gui.bannermod.society.household_role.head": "head", + "gui.bannermod.society.household_role.member": "member", + "gui.bannermod.society.household_role.unknown": "unknown", + "gui.bannermod.society.family_relation.self": "Self", + "gui.bannermod.society.family_relation.spouse": "Spouse", + "gui.bannermod.society.family_relation.mother": "Mother", + "gui.bannermod.society.family_relation.father": "Father", + "gui.bannermod.society.family_relation.child": "Child", + "gui.bannermod.society.housing_request.none": "none", + "gui.bannermod.society.housing_request.requested": "requested", + "gui.bannermod.society.housing_request.denied": "denied", + "gui.bannermod.society.housing_request.approved": "approved", + "gui.bannermod.society.housing_request.fulfilled": "fulfilled", + "gui.bannermod.society.housing_request.notice": "Resident %s asks leave to raise a house. Household state: %s, residents: %s, reserved plot: %s.", + "gui.bannermod.society.housing_request.action.approve": "[Approve]", + "gui.bannermod.society.housing_request.action.approve.tooltip": "Approve this housing petition.", + "gui.bannermod.society.housing_request.action.deny": "[Deny]", + "gui.bannermod.society.housing_request.action.deny.tooltip": "Deny this housing petition.", + "gui.bannermod.society.housing_request.action.list": "[List]", + "gui.bannermod.society.housing_request.action.list.tooltip": "Show all open housing petitions in the current claim.", + "gui.bannermod.society.housing_request.command.no_claim": "You are not standing in a settlement claim.", + "gui.bannermod.society.housing_request.command.empty": "There are no open housing petitions in this claim.", + "gui.bannermod.society.housing_request.command.header": "Housing petitions: %s", + "gui.bannermod.society.housing_request.command.entry": "#%s resident %s, state %s, residents %s, status %s, urgency %s, reason %s, plot %s", + "gui.bannermod.society.housing_request.command.not_found": "Housing petition not found.", + "gui.bannermod.society.housing_request.command.invalid_id": "Invalid household id.", + "gui.bannermod.society.housing_request.command.approved_locked": "This petition is already approved and cannot be denied through the simple petition flow.", + "gui.bannermod.society.housing_request.command.fulfilled_locked": "This housing issue is already resolved.", + "gui.bannermod.society.housing_request.command.approved": "Approved resident %s's housing petition for plot %s.", + "gui.bannermod.society.housing_request.command.denied": "Denied resident %s's housing petition for plot %s.", + "gui.bannermod.society.livelihood_request.notice": "Settlement requests approval for %s. Representative resident: %s.", + "gui.bannermod.society.livelihood_request.action.approve": "[Approve]", + "gui.bannermod.society.livelihood_request.action.approve.tooltip": "Approve this livelihood building request.", + "gui.bannermod.society.livelihood_request.action.deny": "[Deny]", + "gui.bannermod.society.livelihood_request.action.deny.tooltip": "Deny this livelihood building request.", + "gui.bannermod.society.livelihood_request.action.list": "[List]", + "gui.bannermod.society.livelihood_request.action.list.tooltip": "Show livelihood building requests for the current claim.", + "gui.bannermod.society.livelihood_request.type.lumber_camp": "a lumber camp", + "gui.bannermod.society.livelihood_request.type.mine": "a mine", + "gui.bannermod.society.livelihood_request.type.animal_pen": "an animal pen", + "gui.bannermod.society.livelihood_request.status.none": "none", + "gui.bannermod.society.livelihood_request.status.requested": "requested", + "gui.bannermod.society.livelihood_request.status.denied": "denied", + "gui.bannermod.society.livelihood_request.status.approved": "approved", + "gui.bannermod.society.livelihood_request.status.fulfilled": "fulfilled", + "gui.bannermod.society.livelihood_request.command.no_claim": "You are not standing in a settlement claim.", + "gui.bannermod.society.livelihood_request.command.empty": "There are no open livelihood building requests in this claim.", + "gui.bannermod.society.livelihood_request.command.header": "Livelihood building requests: %s", + "gui.bannermod.society.livelihood_request.command.entry": "%s, representative %s, status %s", + "gui.bannermod.society.livelihood_request.command.not_found": "Livelihood building request not found.", + "gui.bannermod.society.livelihood_request.command.invalid_id": "Invalid claim id.", + "gui.bannermod.society.livelihood_request.command.invalid_type": "Invalid livelihood request type.", + "gui.bannermod.society.livelihood_request.command.approved_locked": "This livelihood request is already approved and cannot be denied through the simple request flow.", + "gui.bannermod.society.livelihood_request.command.fulfilled_locked": "This livelihood request is already fulfilled.", + "gui.bannermod.society.livelihood_request.command.approved": "Approved settlement request for %s.", + "gui.bannermod.society.livelihood_request.command.denied": "Denied settlement request for %s.", + "gui.bannermod.war_list.housing": "Housing", + "gui.bannermod.housing_ledger.title": "Housing", + "gui.bannermod.housing_ledger.heading": "Housing Ledger", + "gui.bannermod.housing_ledger.ledger_title": "Petitions And Orders", + "gui.bannermod.housing_ledger.list_title": "Housing Requests In Current Claim", + "gui.bannermod.housing_ledger.detail": "Request Details", + "gui.bannermod.housing_ledger.waiting_sync": "Waiting for housing data from the server...", + "gui.bannermod.housing_ledger.no_claim": "You are not standing in a settlement claim.", + "gui.bannermod.housing_ledger.empty": "There are no open housing requests in this claim.", + "gui.bannermod.housing_ledger.select_request": "Select a housing request from the list on the left.", + "gui.bannermod.housing_ledger.help": "This screen ranks current housing petitions with one shared fairness order so the ruler can review the worst shortages first.", + "gui.bannermod.housing_ledger.list_row": "Household %s | size %s", + "gui.bannermod.housing_ledger.action.approve": "Approve", + "gui.bannermod.housing_ledger.action.deny": "Deny", + "gui.bannermod.housing_ledger.action.authorized": "This petition is already settled for now; review its rank, reason, and reserved plot before reopening it by command or later pressure.", + "gui.bannermod.housing_ledger.action.read_only": "You do not have authority to change housing petitions in this claim.", + "gui.bannermod.housing_ledger.action.approve_ready": "This petition is ready for approval and is already ranked in the shared fairness queue.", + "gui.bannermod.housing_ledger.action.deny_ready": "This petition can still be denied in the current petition state.", + "gui.bannermod.housing_ledger.tooltip.select_request": "Select a housing request first.", + "gui.bannermod.housing_ledger.tooltip.approve_unavailable": "This housing request cannot be approved from its current status.", + "gui.bannermod.housing_ledger.tooltip.deny_unavailable": "This housing request cannot be denied from its current status.", + "gui.bannermod.housing_ledger.detail.rank": "Queue rank: %s | score %s", + "gui.bannermod.housing_ledger.detail.urgency": "Urgency: %s", + "gui.bannermod.housing_ledger.detail.reason": "Priority reason: %s", + "gui.bannermod.housing_ledger.detail.status": "Status: %s", + "gui.bannermod.housing_ledger.detail.household": "Household: %s | head %s", + "gui.bannermod.housing_ledger.detail.members": "Residents: %s | housing %s", + "gui.bannermod.housing_ledger.detail.wait": "Wait: %s days | requested at t%s", + "gui.bannermod.housing_ledger.detail.resident": "Representative resident: %s", + "gui.bannermod.housing_ledger.detail.claim": "Claim: %s", + "gui.bannermod.housing_ledger.detail.home": "Current home: %s", + "gui.bannermod.housing_ledger.detail.build_area": "Build area: %s", + "gui.bannermod.housing_ledger.detail.plot": "Reserved plot: %s", + "gui.bannermod.housing_ledger.urgency.critical": "critical", + "gui.bannermod.housing_ledger.urgency.high": "high", + "gui.bannermod.housing_ledger.urgency.medium": "medium", + "gui.bannermod.housing_ledger.urgency.low": "low", + "gui.bannermod.housing_ledger.reason.homeless": "household has no home", + "gui.bannermod.housing_ledger.reason.overcrowded": "household exceeds current home capacity", + "gui.bannermod.housing_ledger.reason.long_wait": "petition has been waiting for many days", + "gui.bannermod.housing_ledger.reason.large_household": "larger household would displace more residents", + "gui.bannermod.housing_ledger.reason.denied_review": "previously denied petition is still under pressure", + "gui.bannermod.housing_ledger.reason.approved_pipeline": "petition is already approved and moving through the build path", + "gui.bannermod.housing_ledger.reason.stable": "no urgent housing shortage is visible", + "gui.bannermod.housing_ledger.reason.standard": "baseline housing pressure", + "gui.bannermod.family_tree.open": "Family", + "gui.bannermod.family_tree.open.tooltip": "Open the household family tree.", + "gui.bannermod.family_tree.title": "Family Tree", + "gui.bannermod.family_tree.click_hint": "Click a relative to open their profile.", + "gui.bannermod.family_tree.children": "Children", + "gui.bannermod.family_tree.children.none": "No known children", + "gui.bannermod.family_tree.children.more": "+%s more", "bannermod.surveyor.mode_hint.house": "Build a small roofed home first, then mark the walkable room and the bed area.", "bannermod.surveyor.mode_hint.farm": "Build or plant the field first, then mark the full crop and farmland work area.", "bannermod.surveyor.mode_hint.mine": "Build the mine entrance or shed first, then mark the exposed mine face or tunnel work area.", @@ -2505,16 +2786,34 @@ "gui.bannermod.states.charter": "Charter", "gui.bannermod.states.dialog.charter.prompt": "Charter text (max %s chars; empty to clear):", "gui.bannermod.states.dialog.charter.title": "Realm charter", + "gui.bannermod.states.dialog.charter.subtitle": "Write a public charter or RP description for this realm. It is shown in realm details and the realm info page.", + "gui.bannermod.states.dialog.charter.clear": "Clear draft", + "gui.bannermod.states.dialog.charter.count": "%s / %s chars", + "gui.bannermod.states.dialog.charter.empty": "No charter yet. Leave the field blank and submit to clear it on the server.", + "gui.bannermod.states.dialog.charter.preview": "Preview: %s", "gui.bannermod.states.dialog.co_leader.prompt": "Player name or UUID:", + "gui.bannermod.states.dialog.co_leader.subtitle": "Pick an online player here, or use manual entry for an offline nickname or UUID.", + "gui.bannermod.states.dialog.co_leader.online": "Online player list:", + "gui.bannermod.states.dialog.co_leader.empty": "No online candidates yet. Use manual entry for an offline player.", + "gui.bannermod.states.dialog.co_leader.selected": "Selected: %s", + "gui.bannermod.states.dialog.co_leader.select": "Grant seat", + "gui.bannermod.states.dialog.co_leader.select.tooltip": "Grant this player a co-leader seat.", + "gui.bannermod.states.dialog.co_leader.manual": "Manual entry", "gui.bannermod.states.dialog.co_leader_add.title": "Add co-leader", "gui.bannermod.states.dialog.co_leader_remove.title": "Remove co-leader", "gui.bannermod.states.co_leader.state_not_found": "Cannot update co-leader: state not found.", - "gui.bannermod.states.co_leader.player_not_found": "Player not found. Use an online nickname or a known UUID.", + "gui.bannermod.states.co_leader.player_not_found": "Player not found. Use a known nickname or UUID.", "gui.bannermod.states.co_leader.no_change": "Co-leader update did not change the state.", "gui.bannermod.states.co_leader.added": "%s added to co-leaders for %s.", "gui.bannermod.states.co_leader.removed": "%s removed from co-leaders for %s.", "gui.bannermod.states.dialog.color.prompt": "Hex color (RRGGBB or AARRGGBB; empty to clear):", "gui.bannermod.states.dialog.color.title": "Realm color", + "gui.bannermod.states.palette.current": "Current: %s", + "gui.bannermod.states.palette.hint": "Pick a palette color, clear it, or enter a custom hex value.", + "gui.bannermod.states.palette.clear": "Clear", + "gui.bannermod.states.palette.clear.tooltip": "Remove the current realm color.", + "gui.bannermod.states.palette.custom": "HEX", + "gui.bannermod.states.palette.custom.tooltip": "Enter a custom hex color.", "gui.bannermod.states.dialog.create.prompt": "Pick a name (3-32 chars):", "gui.bannermod.states.dialog.create.title": "Create realm", "gui.bannermod.states.dialog.rename.prompt": "New name (3-32 chars):", @@ -2529,6 +2828,7 @@ "gui.bannermod.states.menu.manage": "Manage state", "gui.bannermod.states.detail": "Realm Detail", "gui.bannermod.states.detail.capital": "Capital: %s", + "gui.bannermod.states.detail.charter": "Charter: %s", "gui.bannermod.states.detail.co_leader_authority": "Co-leader authority: %s", "gui.bannermod.states.detail.co_leaders": "Co-leaders: %s", "gui.bannermod.states.detail.color": "Color: %s", diff --git a/src/main/resources/assets/bannermod/lang/ru_ru.json b/src/main/resources/assets/bannermod/lang/ru_ru.json index 4f94303c..880ce333 100644 --- a/src/main/resources/assets/bannermod/lang/ru_ru.json +++ b/src/main/resources/assets/bannermod/lang/ru_ru.json @@ -670,6 +670,12 @@ "gui.bannermod.worker_screen.owner": "Владелец", "gui.bannermod.worker_screen.political": "Власть", "gui.bannermod.worker_screen.assignment": "Назначение", + "gui.bannermod.worker_screen.identity": "Личность", + "gui.bannermod.worker_screen.identity.summary": "%s, %s, дом %s, жильё %s", + "gui.bannermod.worker_screen.routine": "Распорядок", + "gui.bannermod.worker_screen.routine.summary": "%s, %s -> %s. %s", + "gui.bannermod.worker_screen.needs": "Потребности", + "gui.bannermod.worker_screen.needs.summary": "Голод %s, усталость %s, опасность %s", "gui.bannermod.worker_screen.problem": "Проблема", "gui.bannermod.worker_screen.transport": "Транспорт", "gui.bannermod.worker_screen.relation.friendly_claim": "Дружественное владение", @@ -1838,6 +1844,7 @@ "bannermod.prefab.wand.screen.status.none": "На жезле пока нет сохраненного плана. Выберите один ниже.", "bannermod.prefab.wand.screen.status.selected": "Текущий план жезла: %s", "item.bannermod.banner_almanac": "Альманах BannerMod", + "itemGroup.bannermod.main": "BannerMod", "item.bannermod.banner_almanac.tooltip.use": "Использование: открыть внутриигровое руководство.", "item.bannermod.banner_almanac.tooltip.scope": "Покрывает поселение, рабочих, рекрутов, политику, войны и осады.", "item.bannermod.banner_almanac.page_1": "Альманах BannerMod\n\nВ моде есть три слоя:\n1. Клейм = защищенная земля обычного мира.\n2. Поселение = живая база.\n3. Государство = твоя политическая сторона.\n\nКлавиши:\nM карта и клеймы\nU War Room\nR рекруты\nX рабочие\nV превью построек.", @@ -1856,6 +1863,14 @@ "item.bannermod.banner_almanac.page_14": "War Room и политика\n\nЖми U. Там есть:\nэкран States\nсписок войн\nдетали государства\nформа правления\nприглашения союзников\nразмещение осадных штандартов\nбаннер battle window\n\nСоюзы работают через согласие: приглашение, затем принять или отклонить.\nРеспублика дает соруководителям больше полномочий.\nМонархия оставляет ключевую власть лидеру.", "item.bannermod.banner_almanac.page_15": "Войны, осады, восстания и пределы\n\nИспользуй War Room или команды, чтобы объявлять войну.\nСтавь осадные штандарты там, где важна экономика: мосты, склады, рынки, порты, узлы маршрутов.\nВосстания есть, но objective-based вариант еще не завершен.\nМорская торговля Small Ships видна в логистике поселения, когда привязан совместимый перевозчик.\nЕсли действие запрещено, обычно проверь права лидера, battle window, сторону, клейм или недостающую инфраструктуру.", "item.bannermod.settlement_surveyor_tool": "Инструмент землемера поселения", + "item.bannermod.kinlot_staff": "Родовая межа", + "item.bannermod.kinlot_staff.tooltip.1": "Держи рядом с семейным участком, чтобы увидеть, кто его занял.", + "item.bannermod.kinlot_staff.tooltip.2": "ПКМ по отмеченному участку покажет хозяйство, статус прошения и текущую стройку.", + "item.bannermod.kinlot_staff.actionbar": "Участок %s | %s | %s | место %s, %s", + "item.bannermod.kinlot_staff.no_claim": "Эта земля вне клейма поселения.", + "item.bannermod.kinlot_staff.no_plot": "Здесь нет отмеченного семейного участка.", + "item.bannermod.kinlot_staff.detail.header": "Семейный участок %s на %s %s %s", + "item.bannermod.kinlot_staff.detail.line": "Представитель %1$s | жителей %2$s | жильё %3$s | прошение %4$s | стройка %5$s", "bannermod.surveyor.tooltip.mode": "Режим замера: %s", "bannermod.surveyor.tooltip.role": "Роль маркера: %s", "bannermod.surveyor.tooltip.anchor": "Якорь: %s", @@ -1923,17 +1938,283 @@ "gui.bannermod.citizen_profile.assignment": "Назначение: %s", "gui.bannermod.citizen_profile.assignment.none": "Без назначения", "gui.bannermod.citizen_profile.assignment.area": "(зона: %s)", + "gui.bannermod.citizen_profile.home": "Дом: %s", + "gui.bannermod.citizen_profile.home.summary": "дом %s, %s, %s", + "gui.bannermod.citizen_profile.family": "Семья: %s", + "gui.bannermod.citizen_profile.family.summary": "глава %s, роль %s, родня %s", + "gui.bannermod.citizen_profile.household": "Хозяйство: %s", + "gui.bannermod.citizen_profile.household.summary": "%s жителей, %s", + "gui.bannermod.citizen_profile.identity": "Личность: %s", + "gui.bannermod.citizen_profile.routine": "Распорядок: %s", + "gui.bannermod.citizen_profile.routine.summary": "%s, %s -> %s. %s", + "gui.bannermod.citizen_profile.housing": "Жильё: %s", + "gui.bannermod.citizen_profile.housing.summary": "запрос %s, %s, %s, ждёт %sд", + "gui.bannermod.citizen_profile.needs": "Потребности: %s", + "gui.bannermod.citizen_profile.needs.summary": "Г %s, У %s, Б %s", + "gui.bannermod.citizen_profile.life_stage": "Возраст: %s", + "gui.bannermod.citizen_profile.sex": "Пол: %s", + "gui.bannermod.citizen_profile.phase": "Фаза дня: %s", + "gui.bannermod.citizen_profile.intent": "Намерение: %s", "gui.bannermod.citizen_profile.state": "Состояние: %s", "gui.bannermod.citizen_profile.state.idle": "Свободный житель", "gui.bannermod.citizen_profile.state.working": "Служит на посту", "gui.bannermod.citizen_profile.inventory": "Сумка жителя", "gui.bannermod.citizen_profile.player_inventory": "Твои вещи", + "gui.bannermod.society.ai.button": "ИИ", + "gui.bannermod.society.ai.tooltip": "Открыть текущее состояние ИИ, выбранную цель и причину отказа у этого жителя.", + "gui.bannermod.society.ai.title": "След ИИ", + "gui.bannermod.society.ai.state": "Состояние", + "gui.bannermod.society.ai.phase": "Фаза", + "gui.bannermod.society.ai.intent": "Намерение", + "gui.bannermod.society.ai.anchor": "Якорь", + "gui.bannermod.society.ai.route": "Текущий путь", + "gui.bannermod.society.ai.route.target": "Тянется к точке: %s", + "gui.bannermod.society.ai.route.recovery_detail": "Последний сорванный план: %1$s. %2$s", + "gui.bannermod.society.ai.route.blocked_detail": "Заблокированный план: %1$s. %2$s", + "gui.bannermod.society.ai.route.recovery_summary": "%1$s Последний сорванный план: %2$s. %3$s", + "gui.bannermod.society.ai.route.blocked_summary": "Не может продолжать %1$s. %2$s", + "gui.bannermod.society.ai.recovering_from": "Восстанавливается после: %s", + "gui.bannermod.society.ai.goal": "Выбранная цель", + "gui.bannermod.society.ai.blocked_goal": "Заблокированная цель", + "gui.bannermod.society.ai.last_broken_goal": "Последняя сорванная цель", + "gui.bannermod.society.ai.pressures": "Давления", + "gui.bannermod.society.ai.pressures.line_one": "Голод %s Усталость %s", + "gui.bannermod.society.ai.pressures.line_two": "Опасность %s Дом %s Работа %s", + "gui.bannermod.society.ai.state.unspecified": "Не указано", + "gui.bannermod.society.ai.state.idle": "Бездействует", + "gui.bannermod.society.ai.state.executing": "Исполняет", + "gui.bannermod.society.ai.state.blocked": "Заблокирован", + "gui.bannermod.society.ai.state.recovering": "Восстанавливается", + "gui.bannermod.society.ai.reason.unspecified": "Явная причина не записана.", + "gui.bannermod.society.ai.reason.none": "Крупного отказа не зафиксировано.", + "gui.bannermod.society.ai.reason.no_startable_goal": "В этот тик ни одна стартуемая цель не обошла запасной idle-путь.", + "gui.bannermod.society.ai.reason.rest_window": "Сейчас окно ночного отдыха.", + "gui.bannermod.society.ai.reason.fatigue_spike": "Давление усталости слишком велико.", + "gui.bannermod.society.ai.reason.seeking_shelter": "Давление опасности тянет жителя в укрытие.", + "gui.bannermod.society.ai.reason.homeward_pull": "У жителя есть дом, и он стремится вернуться туда.", + "gui.bannermod.society.ai.reason.early_active_window": "День только начался, поэтому житель сперва выходит из дома.", + "gui.bannermod.society.ai.reason.severe_hunger": "Голод достиг критической силы.", + "gui.bannermod.society.ai.reason.hunger_pressure": "Давление голода перевесило остальные доступные цели.", + "gui.bannermod.society.ai.reason.no_home_food_run": "У жителя нет дома, поэтому он ищет еду через снабжение.", + "gui.bannermod.society.ai.reason.social_pressure": "Потребность в общении перевесила труд и бездействие.", + "gui.bannermod.society.ai.reason.threat_avoidance": "Угроза сделала укрытие безопаснее обычной деятельности.", + "gui.bannermod.society.ai.reason.threat_response": "Угроза и должная роль сделали оборону лучшим ответом.", + "gui.bannermod.society.ai.reason.ready_market_dispatch": "Готовая рыночная отправка перевесила обычный труд.", + "gui.bannermod.society.ai.reason.assigned_shift": "Житель находится внутри активной рабочей смены.", + "gui.bannermod.society.ai.reason.workflow_transfer": "Логистика рабочего места в этот тик перевесила обычный труд.", + "gui.bannermod.society.ai.reason.no_higher_priority_goal": "Ни одна цель не превзошла запасной idle-путь.", + "gui.bannermod.society.ai.reason.unknown": "Для текущей цели не нашлось более точного объяснения.", + "gui.bannermod.society.ai.reason.role_cannot_defend": "Эта роль жителя не может пойти по пути обороны.", + "gui.bannermod.society.ai.reason.no_home": "Житель ищет укрытие, но у него нет действительного дома.", + "gui.bannermod.society.ai.reason.no_food_access": "Житель голоден, но не имеет доступного источника еды.", + "gui.bannermod.society.ai.reason.too_fatigued_for_work": "Житель слишком измождён, чтобы безопасно работать.", + "gui.bannermod.society.ai.reason.no_work_assignment": "Житель является работником, но не имеет пригодного назначения.", + "gui.bannermod.society.ai.reason.routine_window_mismatch": "Текущее окно распорядка не разрешает такой социальный выход.", + "gui.bannermod.society.ai.reason.committing_to_current_goal": "Житель продолжает уже начатое дело, чтобы не метаться между целями.", + "gui.bannermod.society.ai.reason.returning_to_household": "Жителя тянет обратно к своему дому и близким.", + "gui.bannermod.society.ai.reason.household_recovery": "Житель восстанавливается рядом со своим хозяйством.", + "gui.bannermod.society.ai.reason.household_belonging": "Житель тянется к своим близким и привычному кругу.", + "gui.bannermod.society.ai.reason.providing_for_household": "Житель действует ради снабжения и устойчивости своего хозяйства.", + "gui.bannermod.society.ai.reason.protecting_household": "Житель уходит в укрытие, стараясь сохранить своё хозяйство.", + "gui.bannermod.society.ai.reason.defending_household": "Житель встаёт на защиту своего дома и родни.", + "gui.bannermod.society.ai.reason.food_recovery_run": "Прошлая попытка поесть сорвалась, поэтому житель теперь ищет еду или припасы, а не повторяет ту же ошибку.", + "gui.bannermod.society.ai.reason.task_timed_out": "Прошлая попытка заняла слишком долго, поэтому житель ненадолго отступил и пересобирает решение.", + "gui.bannermod.society.ai.reason.context_invalidated": "Прошлая попытка сорвалась, потому что обстановка вокруг цели изменилась.", + "gui.bannermod.society.ai.route.no_clear_route": "Сейчас нет более сильной причины куда-то двигаться.", + "gui.bannermod.society.ai.route.regrouping_at_home": "Отступает домой, чтобы собраться и не ломиться снова в ту же неудачную цель.", + "gui.bannermod.society.ai.route.soon_night_homebound": "Идёт домой, потому что приближается ночь.", + "gui.bannermod.society.ai.route.home_as_shelter": "Тянется домой, потому что там безопаснее.", + "gui.bannermod.society.ai.route.returning_home_route": "Возвращается к дому, прежде чем окончательно осесть на месте.", + "gui.bannermod.society.ai.route.settling_at_home_for_rest": "Остаётся дома, потому что уже пора на ночной отдых.", + "gui.bannermod.society.ai.route.resting_after_regroup": "Остаётся дома отдохнуть и успокоиться после сорванного распорядка.", + "gui.bannermod.society.ai.route.resting_at_home": "Держится дома, чтобы спокойно отдыхать ночью.", + "gui.bannermod.society.ai.route.resting_off_street": "Ищет тихий и безопасный угол для отдыха.", + "gui.bannermod.society.ai.route.leaving_home_for_work": "Выходит из дома, чтобы начать рабочий день.", + "gui.bannermod.society.ai.route.leaving_home_for_day": "Выходит из дома, чтобы влиться в дневную жизнь.", + "gui.bannermod.society.ai.route.starting_workday_after_home": "Направляется к работе после выхода из дома.", + "gui.bannermod.society.ai.route.heading_to_workplace": "Идёт к назначенному месту труда.", + "gui.bannermod.society.ai.route.meal_at_home": "Идёт домой, чтобы поесть.", + "gui.bannermod.society.ai.route.meal_at_market": "Направляется к рынку в поисках еды.", + "gui.bannermod.society.ai.route.market_supply_run": "Идёт на рынок за припасами.", + "gui.bannermod.society.ai.route.stockpile_supply_run": "Идёт к складу в поисках припасов.", + "gui.bannermod.society.ai.route.food_recovery_run": "Идёт за едой и припасами после сорвавшегося плана с трапезой.", + "gui.bannermod.society.ai.route.evening_home_circle": "Остаётся возле дома, чтобы провести вечер с близкими.", + "gui.bannermod.society.ai.route.household_yard_gathering": "Держится рядом с хозяйством, а не уходит далеко ради компании.", + "gui.bannermod.society.ai.route.household_recovery_circle": "Держит социальное восстановление рядом с домом и знакомыми лицами.", + "gui.bannermod.society.ai.route.market_gathering": "Идёт к рынку, где люди обычно собираются.", + "gui.bannermod.society.ai.route.tavern_gathering": "Идёт к трактирной точке для общения.", + "gui.bannermod.society.ai.route.square_gathering": "Идёт на деревенскую площадь, чтобы быть среди людей.", + "gui.bannermod.society.ai.route.hall_gathering": "Идёт к залу, где обычно собираются жители.", + "gui.bannermod.society.ai.route.hearth_gathering": "Идёт к очагу или огню, где люди держатся вместе.", + "gui.bannermod.society.ai.route.well_gathering": "Идёт к колодцу, как к привычной точке встречи.", + "gui.bannermod.society.ai.route.street_side_chat": "Держится у улиц поселения в поисках компании.", + "gui.bannermod.society.ai.route.seeking_shelter_route": "Идёт в укрытие, потому что вокруг стало небезопасно.", + "gui.bannermod.society.ai.route.hiding_close_to_household": "Держится ближе к дому и близким, пока уходит в укрытие.", + "gui.bannermod.society.ai.route.moving_to_defense_post": "Идёт к точке, которую нужно оборонять.", + "gui.bannermod.society.ai.route.market_duty_route": "Идёт к рынку, потому что туда тянет служба.", + "gui.bannermod.society.ai.route.workflow_transfer_route": "Идёт по рабочему маршруту переноса.", + "gui.bannermod.society.ai.route.working_for_household": "Идёт работать, потому что хозяйству нужна поддержка.", + "gui.bannermod.society.ai.goal.go_home": "Идти домой", + "gui.bannermod.society.ai.goal.leave_home": "Выйти из дома", + "gui.bannermod.society.ai.goal.rest": "Отдыхать", + "gui.bannermod.society.ai.goal.eat": "Поесть", + "gui.bannermod.society.ai.goal.seek_supplies": "Искать припасы", + "gui.bannermod.society.ai.goal.hide": "Прятаться", + "gui.bannermod.society.ai.goal.defend": "Обороняться", + "gui.bannermod.society.ai.goal.work": "Работать", + "gui.bannermod.society.ai.goal.sell": "Торговать", + "gui.bannermod.society.ai.goal.fetch": "Забирать", + "gui.bannermod.society.ai.goal.deliver": "Доставлять", + "gui.bannermod.society.ai.goal.idle": "Бездействовать", "gui.bannermod.citizen_profile.profession.none": "Свободный житель", "gui.bannermod.citizen_profile.profession.recruit_spear": "Рекрут-копейщик", "gui.bannermod.citizen_profile.profession.recruit_nomad": "Рекрут-номад", "gui.bannermod.citizen_profile.profession.recruit_scout": "Рекрут-разведчик", "gui.bannermod.citizen_profile.profession.recruit_shieldman": "Рекрут-щитоносец", "gui.bannermod.citizen_profile.profession.noble": "Дворянин", + "gui.bannermod.society.life_stage.unspecified": "Не указано", + "gui.bannermod.society.life_stage.child": "Ребёнок", + "gui.bannermod.society.life_stage.adolescent": "Подросток", + "gui.bannermod.society.life_stage.adult": "Взрослый", + "gui.bannermod.society.life_stage.elder": "Старец", + "gui.bannermod.society.sex.unspecified": "Не указан", + "gui.bannermod.society.sex.male": "Мужской", + "gui.bannermod.society.sex.female": "Женский", + "gui.bannermod.society.daily_phase.unspecified": "Не указана", + "gui.bannermod.society.daily_phase.active": "Дневная работа", + "gui.bannermod.society.daily_phase.departing_home": "Выходит из дома", + "gui.bannermod.society.daily_phase.returning_home": "Возвращается домой", + "gui.bannermod.society.daily_phase.rest": "Ночной отдых", + "gui.bannermod.society.intent.unspecified": "Не указано", + "gui.bannermod.society.intent.idle": "Бездействует", + "gui.bannermod.society.intent.go_home": "Идёт домой", + "gui.bannermod.society.intent.leave_home": "Выходит из дома", + "gui.bannermod.society.intent.rest": "Отдыхает", + "gui.bannermod.society.intent.eat": "Ест", + "gui.bannermod.society.intent.work": "Работает", + "gui.bannermod.society.intent.seek_supplies": "Ищет припасы", + "gui.bannermod.society.intent.hide": "Прячется", + "gui.bannermod.society.intent.defend": "Обороняется", + "gui.bannermod.society.intent.sell": "Торгует", + "gui.bannermod.society.intent.fetch": "Забирает припасы", + "gui.bannermod.society.intent.deliver": "Доставляет", + "gui.bannermod.society.anchor.none": "Без якоря", + "gui.bannermod.society.anchor.home": "Дом", + "gui.bannermod.society.anchor.workplace": "Рабочее место", + "gui.bannermod.society.anchor.market": "Рынок", + "gui.bannermod.society.anchor.barracks": "Казарма", + "gui.bannermod.society.anchor.street": "Улица", + "gui.bannermod.society.household_housing.normal": "устроено", + "gui.bannermod.society.household_housing.homeless": "без дома", + "gui.bannermod.society.household_housing.overcrowded": "тесно", + "gui.bannermod.society.household_role.head": "глава", + "gui.bannermod.society.household_role.member": "член", + "gui.bannermod.society.household_role.unknown": "неясно", + "gui.bannermod.society.family_relation.self": "Сам", + "gui.bannermod.society.family_relation.spouse": "Супруг", + "gui.bannermod.society.family_relation.mother": "Мать", + "gui.bannermod.society.family_relation.father": "Отец", + "gui.bannermod.society.family_relation.child": "Ребёнок", + "gui.bannermod.society.housing_request.none": "нет", + "gui.bannermod.society.housing_request.requested": "запрошено", + "gui.bannermod.society.housing_request.denied": "отклонено", + "gui.bannermod.society.housing_request.approved": "разрешено", + "gui.bannermod.society.housing_request.fulfilled": "выдано", + "gui.bannermod.society.housing_request.notice": "Житель %s просит дозволения поставить дом. Состояние хозяйства: %s, жителей: %s, участок: %s.", + "gui.bannermod.society.housing_request.action.approve": "[Разрешить]", + "gui.bannermod.society.housing_request.action.approve.tooltip": "Одобрить это прошение о доме.", + "gui.bannermod.society.housing_request.action.deny": "[Отказать]", + "gui.bannermod.society.housing_request.action.deny.tooltip": "Отклонить это прошение о доме.", + "gui.bannermod.society.housing_request.action.list": "[Список]", + "gui.bannermod.society.housing_request.action.list.tooltip": "Показать все незакрытые прошения о домах в текущем клейме.", + "gui.bannermod.society.housing_request.command.no_claim": "Ты стоишь вне клейма поселения.", + "gui.bannermod.society.housing_request.command.empty": "В этом клейме нет незакрытых прошений о домах.", + "gui.bannermod.society.housing_request.command.header": "Прошения о домах: %s", + "gui.bannermod.society.housing_request.command.entry": "#%s житель %s, состояние %s, жителей %s, статус %s, срочность %s, причина %s, участок %s", + "gui.bannermod.society.housing_request.command.not_found": "Прошение о доме не найдено.", + "gui.bannermod.society.housing_request.command.invalid_id": "Неверный идентификатор хозяйства.", + "gui.bannermod.society.housing_request.command.approved_locked": "Это прошение уже одобрено и не может быть отклонено этим простым путём.", + "gui.bannermod.society.housing_request.command.fulfilled_locked": "Этот домовой вопрос уже закрыт.", + "gui.bannermod.society.housing_request.command.approved": "Прошение жителя %s на участок %s одобрено.", + "gui.bannermod.society.housing_request.command.denied": "Прошение жителя %s на участок %s отклонено.", + "gui.bannermod.society.livelihood_request.notice": "Поселение просит дозволения на %s. Представитель: %s.", + "gui.bannermod.society.livelihood_request.action.approve": "[Разрешить]", + "gui.bannermod.society.livelihood_request.action.approve.tooltip": "Одобрить эту просьбу на хозяйственную постройку.", + "gui.bannermod.society.livelihood_request.action.deny": "[Отказать]", + "gui.bannermod.society.livelihood_request.action.deny.tooltip": "Отклонить эту просьбу на хозяйственную постройку.", + "gui.bannermod.society.livelihood_request.action.list": "[Список]", + "gui.bannermod.society.livelihood_request.action.list.tooltip": "Показать просьбы на хозяйственные постройки в текущем клейме.", + "gui.bannermod.society.livelihood_request.type.lumber_camp": "лесной лагерь", + "gui.bannermod.society.livelihood_request.type.mine": "шахту", + "gui.bannermod.society.livelihood_request.type.animal_pen": "загон для скота", + "gui.bannermod.society.livelihood_request.status.none": "нет", + "gui.bannermod.society.livelihood_request.status.requested": "запрошено", + "gui.bannermod.society.livelihood_request.status.denied": "отклонено", + "gui.bannermod.society.livelihood_request.status.approved": "разрешено", + "gui.bannermod.society.livelihood_request.status.fulfilled": "выполнено", + "gui.bannermod.society.livelihood_request.command.no_claim": "Ты стоишь вне клейма поселения.", + "gui.bannermod.society.livelihood_request.command.empty": "В этом клейме нет открытых просьб на хозяйственные постройки.", + "gui.bannermod.society.livelihood_request.command.header": "Просьбы на хозяйственные постройки: %s", + "gui.bannermod.society.livelihood_request.command.entry": "%s, представитель %s, статус %s", + "gui.bannermod.society.livelihood_request.command.not_found": "Просьба на хозяйственную постройку не найдена.", + "gui.bannermod.society.livelihood_request.command.invalid_id": "Неверный идентификатор клейма.", + "gui.bannermod.society.livelihood_request.command.invalid_type": "Неверный тип хозяйственной просьбы.", + "gui.bannermod.society.livelihood_request.command.approved_locked": "Эта хозяйственная просьба уже одобрена и не может быть отклонена этим простым путём.", + "gui.bannermod.society.livelihood_request.command.fulfilled_locked": "Эта хозяйственная просьба уже выполнена.", + "gui.bannermod.society.livelihood_request.command.approved": "Просьба поселения на %s одобрена.", + "gui.bannermod.society.livelihood_request.command.denied": "Просьба поселения на %s отклонена.", + "gui.bannermod.war_list.housing": "Дома", + "gui.bannermod.housing_ledger.title": "Дома", + "gui.bannermod.housing_ledger.heading": "Книга домовых прошений", + "gui.bannermod.housing_ledger.ledger_title": "Прошения и приказы", + "gui.bannermod.housing_ledger.list_title": "Домовые прошения текущего клейма", + "gui.bannermod.housing_ledger.detail": "Сведения о прошении", + "gui.bannermod.housing_ledger.waiting_sync": "Ожидание ответа сервера по домовым прошениям...", + "gui.bannermod.housing_ledger.no_claim": "Ты стоишь вне клейма поселения.", + "gui.bannermod.housing_ledger.empty": "В этом клейме нет открытых домовых прошений.", + "gui.bannermod.housing_ledger.select_request": "Выбери домовое прошение из списка слева.", + "gui.bannermod.housing_ledger.help": "Экран ранжирует текущие домовые прошения по общей очереди справедливости, чтобы правитель сперва видел самые тяжёлые жилищные нужды.", + "gui.bannermod.housing_ledger.list_row": "Хоз. %s | людность %s", + "gui.bannermod.housing_ledger.action.approve": "Одобрить", + "gui.bannermod.housing_ledger.action.deny": "Отказать", + "gui.bannermod.housing_ledger.action.authorized": "Это прошение уже временно решено; можно сверить его ранг, причину и участок, прежде чем позже пересматривать его через команды или новое давление.", + "gui.bannermod.housing_ledger.action.read_only": "У тебя нет власти менять домовые прошения этого клейма.", + "gui.bannermod.housing_ledger.action.approve_ready": "Это прошение готово к одобрению и уже стоит в общей очереди справедливости.", + "gui.bannermod.housing_ledger.action.deny_ready": "Это прошение ещё можно отклонить в текущем состоянии.", + "gui.bannermod.housing_ledger.tooltip.select_request": "Сначала выбери домовое прошение.", + "gui.bannermod.housing_ledger.tooltip.approve_unavailable": "Это домовое прошение нельзя одобрить из его текущего состояния.", + "gui.bannermod.housing_ledger.tooltip.deny_unavailable": "Это домовое прошение нельзя отклонить из его текущего состояния.", + "gui.bannermod.housing_ledger.detail.rank": "Место в очереди: %s | счёт %s", + "gui.bannermod.housing_ledger.detail.urgency": "Срочность: %s", + "gui.bannermod.housing_ledger.detail.reason": "Причина приоритета: %s", + "gui.bannermod.housing_ledger.detail.status": "Статус: %s", + "gui.bannermod.housing_ledger.detail.household": "Хозяйство: %s | глава %s", + "gui.bannermod.housing_ledger.detail.members": "Жителей: %s | жильё %s", + "gui.bannermod.housing_ledger.detail.wait": "Ожидание: %s дней | подано в t%s", + "gui.bannermod.housing_ledger.detail.resident": "Представитель: %s", + "gui.bannermod.housing_ledger.detail.claim": "Клейм: %s", + "gui.bannermod.housing_ledger.detail.home": "Текущий дом: %s", + "gui.bannermod.housing_ledger.detail.build_area": "Зона стройки: %s", + "gui.bannermod.housing_ledger.detail.plot": "Отведённый участок: %s", + "gui.bannermod.housing_ledger.urgency.critical": "крайняя", + "gui.bannermod.housing_ledger.urgency.high": "высокая", + "gui.bannermod.housing_ledger.urgency.medium": "средняя", + "gui.bannermod.housing_ledger.urgency.low": "низкая", + "gui.bannermod.housing_ledger.reason.homeless": "у хозяйства нет дома", + "gui.bannermod.housing_ledger.reason.overcrowded": "хозяйство не помещается в текущем доме", + "gui.bannermod.housing_ledger.reason.long_wait": "прошение ждёт уже много дней", + "gui.bannermod.housing_ledger.reason.large_household": "крупное хозяйство вытеснит больше жителей", + "gui.bannermod.housing_ledger.reason.denied_review": "ранее отклонённое прошение всё ещё под давлением нужды", + "gui.bannermod.housing_ledger.reason.approved_pipeline": "прошение уже одобрено и идёт по пути стройки", + "gui.bannermod.housing_ledger.reason.stable": "срочной жилищной беды не видно", + "gui.bannermod.housing_ledger.reason.standard": "обычное жилищное давление", + "gui.bannermod.family_tree.open": "Семья", + "gui.bannermod.family_tree.open.tooltip": "Открыть древо семьи этого хозяйства.", + "gui.bannermod.family_tree.title": "Древо семьи", + "gui.bannermod.family_tree.click_hint": "Нажми на родственника, чтобы открыть его профиль.", + "gui.bannermod.family_tree.children": "Дети", + "gui.bannermod.family_tree.children.none": "Дети не известны", + "gui.bannermod.family_tree.children.more": "+ ещё %s", "bannermod.surveyor.mode_hint.house": "Сначала построй небольшой крытый дом, затем отметь проходимую комнату и зону кроватей.", "bannermod.surveyor.mode_hint.farm": "Сначала сделай поле или посадки, затем отметь всю рабочую зону грядок и посевов.", "bannermod.surveyor.mode_hint.mine": "Сначала построй вход или навес шахты, затем отметь рабочую зону открытого пласта или тоннеля.", @@ -2413,16 +2694,34 @@ "gui.bannermod.states.charter": "Хартия", "gui.bannermod.states.dialog.charter.prompt": "Текст хартии (макс. %s символов; пусто, чтобы очистить):", "gui.bannermod.states.dialog.charter.title": "Хартия владения", + "gui.bannermod.states.dialog.charter.subtitle": "Напиши публичную хартию или RP-описание владения. Оно показывается в деталях владения и на странице подробной информации.", + "gui.bannermod.states.dialog.charter.clear": "Очистить черновик", + "gui.bannermod.states.dialog.charter.count": "%s / %s символов", + "gui.bannermod.states.dialog.charter.empty": "Хартия пока не задана. Оставь поле пустым и нажми подтвердить, чтобы очистить ее на сервере.", + "gui.bannermod.states.dialog.charter.preview": "Предпросмотр: %s", "gui.bannermod.states.dialog.co_leader.prompt": "Ник или UUID игрока:", + "gui.bannermod.states.dialog.co_leader.subtitle": "Выбери здесь онлайн-игрока или используй ручной ввод для оффлайн-ника или UUID.", + "gui.bannermod.states.dialog.co_leader.online": "Список онлайн-игроков:", + "gui.bannermod.states.dialog.co_leader.empty": "Подходящих онлайн-игроков пока нет. Для оффлайн-игрока используй ручной ввод.", + "gui.bannermod.states.dialog.co_leader.selected": "Выбран: %s", + "gui.bannermod.states.dialog.co_leader.select": "Выдать место", + "gui.bannermod.states.dialog.co_leader.select.tooltip": "Выдать этому игроку место со-лидера.", + "gui.bannermod.states.dialog.co_leader.manual": "Ручной ввод", "gui.bannermod.states.dialog.co_leader_add.title": "Добавить со-лидера", "gui.bannermod.states.dialog.co_leader_remove.title": "Удалить со-лидера", "gui.bannermod.states.co_leader.state_not_found": "Не удалось обновить со-лидера: владение не найдено.", - "gui.bannermod.states.co_leader.player_not_found": "Игрок не найден. Используй ник онлайн-игрока или известный UUID.", + "gui.bannermod.states.co_leader.player_not_found": "Игрок не найден. Используй известный ник или UUID.", "gui.bannermod.states.co_leader.no_change": "Список со-лидеров не изменился.", "gui.bannermod.states.co_leader.added": "%s добавлен в со-лидеры владения %s.", "gui.bannermod.states.co_leader.removed": "%s удален из со-лидеров владения %s.", "gui.bannermod.states.dialog.color.prompt": "Hex-цвет (RRGGBB или AARRGGBB; пусто, чтобы очистить):", "gui.bannermod.states.dialog.color.title": "Цвет владения", + "gui.bannermod.states.palette.current": "Текущий: %s", + "gui.bannermod.states.palette.hint": "Выберите цвет из палитры, очистите его или введите свой hex.", + "gui.bannermod.states.palette.clear": "Очистить", + "gui.bannermod.states.palette.clear.tooltip": "Убрать текущий цвет владения.", + "gui.bannermod.states.palette.custom": "HEX", + "gui.bannermod.states.palette.custom.tooltip": "Ввести свой hex-цвет.", "gui.bannermod.states.dialog.create.prompt": "Выберите имя (3-32 символа):", "gui.bannermod.states.dialog.create.title": "Создать владение", "gui.bannermod.states.dialog.rename.prompt": "Новое имя (3-32 символа):", @@ -2437,6 +2736,7 @@ "gui.bannermod.states.menu.manage": "Управление государством", "gui.bannermod.states.detail": "Подробности владения", "gui.bannermod.states.detail.capital": "Столица: %s", + "gui.bannermod.states.detail.charter": "Хартия: %s", "gui.bannermod.states.detail.co_leader_authority": "Полномочия со-лидеров: %s", "gui.bannermod.states.detail.co_leaders": "Со-лидеры: %s", "gui.bannermod.states.detail.color": "Цвет: %s", diff --git a/src/main/resources/assets/bannermod/models/item/kinlot_staff.json b/src/main/resources/assets/bannermod/models/item/kinlot_staff.json new file mode 100644 index 00000000..7430d06a --- /dev/null +++ b/src/main/resources/assets/bannermod/models/item/kinlot_staff.json @@ -0,0 +1,3 @@ +{ + "parent": "minecraft:item/compass" +} diff --git a/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityCharterAndCoLeaderUiVerificationTest.java b/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityCharterAndCoLeaderUiVerificationTest.java new file mode 100644 index 00000000..6e7e7be0 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityCharterAndCoLeaderUiVerificationTest.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.client.military.gui; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PoliticalEntityCharterAndCoLeaderUiVerificationTest { + private static final Path ROOT = Path.of(""); + + @Test + void stateScreenUsesDedicatedCharterEditorAndPlayerPicker() throws IOException { + String listScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java"); + String charterScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCharterScreen.java"); + String coLeaderPickerScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityCoLeaderPickerScreen.java"); + String enLang = read("src/main/resources/assets/bannermod/lang/en_us.json"); + String ruLang = read("src/main/resources/assets/bannermod/lang/ru_ru.json"); + + assertTrue(listScreen.contains("new PoliticalEntityCharterScreen(")); + assertTrue(listScreen.contains("new PoliticalEntityCoLeaderPickerScreen(")); + assertTrue(listScreen.contains("gui.bannermod.states.detail.charter")); + assertTrue(charterScreen.contains("RecruitsMultiLineEditBox")); + assertTrue(charterScreen.contains("gui.bannermod.states.dialog.charter.subtitle")); + assertTrue(coLeaderPickerScreen.contains("MessageUpdateCoLeader")); + assertTrue(coLeaderPickerScreen.contains("ClientManager.onlinePlayersVersion")); + assertTrue(coLeaderPickerScreen.contains("this.init();")); + assertTrue(coLeaderPickerScreen.contains("drawWrapped(graphics, Component.translatable(\"gui.bannermod.states.dialog.co_leader.subtitle\")")); + assertTrue(coLeaderPickerScreen.contains("gui.bannermod.states.dialog.co_leader.manual")); + assertTrue(enLang.contains("gui.bannermod.states.dialog.co_leader.select.tooltip")); + assertTrue(enLang.contains("gui.bannermod.states.dialog.co_leader.manual")); + assertTrue(ruLang.contains("gui.bannermod.states.dialog.co_leader.select.tooltip")); + assertTrue(ruLang.contains("gui.bannermod.states.dialog.co_leader.manual")); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(ROOT.resolve(relativePath)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityColorPaletteVerificationTest.java b/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityColorPaletteVerificationTest.java new file mode 100644 index 00000000..ea180c2f --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/client/military/gui/PoliticalEntityColorPaletteVerificationTest.java @@ -0,0 +1,32 @@ +package com.talhanation.bannermod.client.military.gui; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PoliticalEntityColorPaletteVerificationTest { + private static final Path ROOT = Path.of(""); + + @Test + void politicalEntityColorUsesPaletteScreenAndLocalizedKeys() throws IOException { + String politicalEntityListScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityListScreen.java"); + String politicalEntityColorPaletteScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/war/PoliticalEntityColorPaletteScreen.java"); + String enLang = read("src/main/resources/assets/bannermod/lang/en_us.json"); + String ruLang = read("src/main/resources/assets/bannermod/lang/ru_ru.json"); + + assertTrue(politicalEntityListScreen.contains("new PoliticalEntityColorPaletteScreen(")); + assertTrue(politicalEntityColorPaletteScreen.contains("PoliticalColorParser.parseArgb")); + assertTrue(politicalEntityColorPaletteScreen.contains("gui.bannermod.states.palette.current")); + assertTrue(politicalEntityColorPaletteScreen.contains("this::applyCustomColor")); + assertTrue(enLang.contains("gui.bannermod.states.palette.current")); + assertTrue(ruLang.contains("gui.bannermod.states.palette.current")); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(ROOT.resolve(relativePath)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementClaimTickServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementClaimTickServiceTest.java new file mode 100644 index 00000000..e3a0f974 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementClaimTickServiceTest.java @@ -0,0 +1,86 @@ +package com.talhanation.bannermod.settlement; + +import com.talhanation.bannermod.society.NpcAnchorType; +import com.talhanation.bannermod.society.NpcDailyPhase; +import com.talhanation.bannermod.society.NpcHouseholdHousingState; +import com.talhanation.bannermod.society.NpcIntent; +import com.talhanation.bannermod.society.NpcSocietyAnchorGoal; +import com.talhanation.bannermod.society.NpcSocietyDecisionSnapshot; +import com.talhanation.bannermod.society.NpcSocietyProfile; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentStopReason; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.job.JobHandlerRegistry; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +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.assertTrue; + +class BannerModSettlementClaimTickServiceTest { + + @Test + void routeInvalidationSignalForceStopsActiveTaskAsContextInvalid() { + SettlementOrchestrator.LevelRuntimeState state = SettlementOrchestrator.detachedStateForTests(JobHandlerRegistry.defaults()); + SettlementResidentRecord resident = buildLocalWorker(); + long gameTime = 6000L; + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(resident.residentUuid(), gameTime) + .withPhaseOneState( + null, + UUID.fromString("00000000-0000-0000-0000-0000000000f1"), + resident.boundWorkAreaUuid(), + NpcDailyPhase.ACTIVE, + NpcIntent.WORK, + NpcAnchorType.WORKPLACE, + new NpcSocietyDecisionSnapshot("EXECUTING", WorkResidentGoal.ID.toString(), "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", null, "NONE", NpcIntent.LEAVE_HOME.name(), gameTime - 40L), + gameTime - 40L + ); + ResidentGoalContext ctx = new ResidentGoalContext( + resident, + null, + gameTime, + gameTime, + profile, + 0, + NpcHouseholdHousingState.NORMAL, + false, + 0 + ); + + state.goalScheduler.tick(ctx); + Optional started = state.goalScheduler.currentTask(resident.residentUuid()); + assertTrue(started.isPresent()); + assertEquals(WorkResidentGoal.ID, started.get().goalId()); + assertFalse(started.get().isDone()); + + NpcSocietyAnchorGoal.signalRouteInvalidation(resident.residentUuid(), NpcIntent.WORK, gameTime); + SettlementClaimTickService.applyRouteInvalidationIfNeeded(state, ctx); + + Optional stopped = state.goalScheduler.currentTask(resident.residentUuid()); + assertTrue(stopped.isPresent()); + assertTrue(stopped.get().isDone()); + assertEquals(ResidentStopReason.CONTEXT_INVALID, stopped.get().stopReason()); + assertEquals(ResidentStopReason.CONTEXT_INVALID, + state.goalScheduler.lastOutcome(resident.residentUuid()).orElseThrow().stopReason()); + } + + private static SettlementResidentRecord buildLocalWorker() { + return new SettlementResidentRecord( + UUID.fromString("00000000-0000-0000-0000-0000000000a2"), + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + UUID.fromString("00000000-0000-0000-0000-0000000000c2"), + "teamA", + UUID.fromString("00000000-0000-0000-0000-0000000000d2"), + SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + ); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentRecordTest.java b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentRecordTest.java index fa427c09..87cf21ad 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentRecordTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentRecordTest.java @@ -122,6 +122,36 @@ void residentRecordFallsBackForUnknownScheduleWindowSeed() { assertEquals(SettlementResidentSchedulePolicySeed.VILLAGE_LIFE_FLEX, resident.schedulePolicy().policySeed()); } + @Test + void effectiveWorkBuildingUuidPrefersDerivedServiceBindingOverLegacyTargets() { + UUID serviceBuildingUuid = UUID.randomUUID(); + UUID targetBuildingUuid = UUID.randomUUID(); + UUID legacyBoundUuid = UUID.randomUUID(); + SettlementResidentRecord resident = new SettlementResidentRecord( + UUID.randomUUID(), + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + new SettlementResidentServiceContract(SettlementServiceActorState.LOCAL_BUILDING_SERVICE, serviceBuildingUuid, "bannermod:crop_area"), + new SettlementResidentJobDefinition(SettlementJobHandlerSeed.LOCAL_BUILDING_LABOR, targetBuildingUuid, "bannermod:crop_area", SettlementBuildingCategory.FOOD, SettlementBuildingProfileSeed.FOOD_PRODUCTION), + new SettlementResidentJobTargetSelectionState(SettlementJobTargetSelectionMode.SERVICE_BUILDING, null, null), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + UUID.randomUUID(), + "blueguild", + legacyBoundUuid, + SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING, + SettlementResidentRoleProfile.defaultFor( + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + ) + ); + + assertEquals(serviceBuildingUuid, resident.effectiveWorkBuildingUuid()); + } + @Test void residentRecordFallsBackForUnknownScheduleSeed() { UUID workAreaUuid = UUID.randomUUID(); diff --git a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentStaffingServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentStaffingServiceTest.java index d4adf20f..5b98dd3c 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentStaffingServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementResidentStaffingServiceTest.java @@ -74,4 +74,84 @@ void appliesResidentAssignmentSemanticsAndRollsAssignedWorkersIntoBuildings() { assertEquals(SettlementBuildingCategory.FOOD, buildings.get(0).buildingCategory()); assertEquals(SettlementBuildingProfileSeed.FOOD_PRODUCTION, buildings.get(0).buildingProfileSeed()); } + + @Test + void derivedServiceBuildingBeatsLegacyBoundWorkAreaDuringStaffing() { + UUID localBuildingUuid = UUID.randomUUID(); + UUID staleBoundUuid = UUID.randomUUID(); + UUID residentUuid = UUID.randomUUID(); + + SettlementResidentRecord resident = new SettlementResidentRecord( + residentUuid, + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + new SettlementResidentServiceContract(SettlementServiceActorState.LOCAL_BUILDING_SERVICE, localBuildingUuid, "bannermod:crop_area"), + new SettlementResidentJobDefinition(SettlementJobHandlerSeed.LOCAL_BUILDING_LABOR, localBuildingUuid, "bannermod:crop_area", SettlementBuildingCategory.FOOD, SettlementBuildingProfileSeed.FOOD_PRODUCTION), + new SettlementResidentJobTargetSelectionState(SettlementJobTargetSelectionMode.SERVICE_BUILDING, null, null), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + UUID.randomUUID(), + "blueguild", + staleBoundUuid, + SettlementResidentAssignmentState.UNASSIGNED, + SettlementResidentRoleProfile.defaultFor( + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + SettlementResidentAssignmentState.UNASSIGNED + ) + ); + + SettlementResidentStaffingService.StaffingResult staffing = SettlementResidentStaffingService.apply( + List.of(resident), + List.of(new SettlementBuildingRecord(localBuildingUuid, "bannermod:crop_area", new BlockPos(16, 64, 16), UUID.randomUUID(), "blueguild", 0, 1, 0, List.of(), false, 0, 0, false, false, List.of())), + SettlementMarketState.empty(), + Set.of(localBuildingUuid) + ); + + assertEquals(SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING, staffing.residents().get(0).assignmentState()); + assertEquals(localBuildingUuid, staffing.residents().get(0).boundWorkAreaUuid()); + assertEquals(localBuildingUuid, staffing.residents().get(0).jobDefinition().targetBuildingUuid()); + assertEquals(1, staffing.buildings().get(0).assignedWorkerCount()); + assertEquals(List.of(residentUuid), staffing.buildings().get(0).assignedResidentUuids()); + } + + @Test + void derivedServiceBuildingAlsoBeatsStaleJobDefinitionTarget() { + UUID localBuildingUuid = UUID.randomUUID(); + UUID staleJobTargetUuid = UUID.randomUUID(); + SettlementResidentRecord resident = new SettlementResidentRecord( + UUID.randomUUID(), + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + new SettlementResidentServiceContract(SettlementServiceActorState.LOCAL_BUILDING_SERVICE, localBuildingUuid, "bannermod:crop_area"), + new SettlementResidentJobDefinition(SettlementJobHandlerSeed.LOCAL_BUILDING_LABOR, staleJobTargetUuid, "bannermod:mine_area", SettlementBuildingCategory.MATERIAL, SettlementBuildingProfileSeed.MATERIAL_PRODUCTION), + new SettlementResidentJobTargetSelectionState(SettlementJobTargetSelectionMode.SERVICE_BUILDING, null, null), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + UUID.randomUUID(), + "blueguild", + UUID.randomUUID(), + SettlementResidentAssignmentState.UNASSIGNED, + SettlementResidentRoleProfile.defaultFor( + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + SettlementResidentAssignmentState.UNASSIGNED + ) + ); + + SettlementResidentStaffingService.StaffingResult staffing = SettlementResidentStaffingService.apply( + List.of(resident), + List.of(new SettlementBuildingRecord(localBuildingUuid, "bannermod:crop_area", new BlockPos(20, 64, 20), UUID.randomUUID(), "blueguild", 0, 1, 0, List.of(), false, 0, 0, false, false, List.of())), + SettlementMarketState.empty(), + Set.of(localBuildingUuid) + ); + + assertEquals(localBuildingUuid, staffing.residents().get(0).boundWorkAreaUuid()); + assertEquals(localBuildingUuid, staffing.residents().get(0).effectiveWorkBuildingUuid()); + assertEquals(localBuildingUuid, staffing.residents().get(0).jobDefinition().targetBuildingUuid()); + } } diff --git a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementSnapshotRuntimeTest.java b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementSnapshotRuntimeTest.java index 2ad91699..b6c60851 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementSnapshotRuntimeTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/BannerModSettlementSnapshotRuntimeTest.java @@ -145,7 +145,7 @@ void validatedBuildingLookupUsesSettlementIdInsteadOfClaimId() { } @Test - void mergesValidatedCapacityIntoLiveWorkAreaRecordWithoutBreakingBindingUuid() { + void mergesValidatedCapacityIntoLiveWorkAreaRecordUsingValidatedBuildingAuthority() { UUID liveWorkAreaUuid = UUID.randomUUID(); UUID settlementId = UUID.randomUUID(); UUID ownerUuid = UUID.randomUUID(); @@ -186,7 +186,7 @@ void mergesValidatedCapacityIntoLiveWorkAreaRecordWithoutBreakingBindingUuid() { SettlementBuildingRecord merged = SettlementSnapshotRuntime.mergeValidatedBuildingIntoLiveRecord(record, liveRecord); - assertEquals(liveWorkAreaUuid, merged.buildingUuid()); + assertEquals(record.buildingId(), merged.buildingUuid()); assertEquals("bannermod:crop_area", merged.buildingTypeId()); assertEquals(expectedValidated.workplaceSlots(), merged.workplaceSlots()); assertEquals(expectedValidated.buildingCategory(), merged.buildingCategory()); @@ -195,6 +195,34 @@ void mergesValidatedCapacityIntoLiveWorkAreaRecordWithoutBreakingBindingUuid() { assertEquals(liveRecord.teamId(), merged.teamId()); } + @Test + void authoritativeWorkBindingUsesValidatedBuildingWhenPresent() { + UUID validatedBuildingUuid = UUID.randomUUID(); + UUID liveAreaUuid = UUID.randomUUID(); + + assertEquals( + validatedBuildingUuid, + SettlementSnapshotRuntime.authoritativeWorkBuildingBinding( + liveAreaUuid, + Map.of(liveAreaUuid, validatedBuildingUuid) + ) + ); + } + + @Test + void authoritativeWorkBindingFallsBackToCanonicalLiveAreaWithoutValidatedBuilding() { + UUID liveAreaUuid = UUID.randomUUID(); + UUID canonicalLiveAreaUuid = UUID.randomUUID(); + + assertEquals( + canonicalLiveAreaUuid, + SettlementSnapshotRuntime.authoritativeWorkBuildingBinding( + liveAreaUuid, + Map.of(liveAreaUuid, canonicalLiveAreaUuid) + ) + ); + } + @Test void ignoresLegacyValidatedBuildingAssignedCitizensOnReload() { UUID staleWorkerUuid = UUID.randomUUID(); diff --git a/src/test/java/com/talhanation/bannermod/settlement/SurveyorInteractionPacketVerificationTest.java b/src/test/java/com/talhanation/bannermod/settlement/SurveyorInteractionPacketVerificationTest.java new file mode 100644 index 00000000..0764a360 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/SurveyorInteractionPacketVerificationTest.java @@ -0,0 +1,30 @@ +package com.talhanation.bannermod.settlement; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SurveyorInteractionPacketVerificationTest { + private static final Path ROOT = Path.of(""); + + @Test + void surveyorClicksRouteThroughDedicatedPacket() throws IOException { + String surveyorTool = read("src/main/java/com/talhanation/bannermod/items/civilian/SettlementSurveyorToolItem.java"); + String useBlockMessage = read("src/main/java/com/talhanation/bannermod/network/messages/civilian/MessageUseSurveyorBlock.java"); + String packetCatalog = read("src/main/java/com/talhanation/bannermod/network/catalog/CivilianPacketCatalog.java"); + + assertTrue(surveyorTool.contains("new MessageUseSurveyorBlock(context.getHand(), clicked)")); + assertTrue(surveyorTool.contains("handleBlockClick(ServerPlayer player, ItemStack stack, BlockPos clicked)")); + assertTrue(useBlockMessage.contains("player.getEyePosition().distanceToSqr(Vec3.atCenterOf(this.clickedPos)) > MAX_CLICK_DISTANCE_SQR")); + assertTrue(useBlockMessage.contains("SettlementSurveyorToolItem.handleBlockClick(player, stack, this.clickedPos);")); + assertTrue(packetCatalog.contains("MessageUseSurveyorBlock.class")); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(ROOT.resolve(relativePath)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapServiceTest.java index 3482c707..23440047 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/bootstrap/SettlementBootstrapServiceTest.java @@ -9,8 +9,11 @@ class SettlementBootstrapServiceTest { void starterWorkerReadinessMessageNamesReadyAndWaitingJobs() { String message = SettlementBootstrapService.starterWorkerReadinessMessage(4, 4); - assertTrue(message.contains("Free citizens available for vacancies: 4")); - assertTrue(message.contains("farmer has a starter crop area")); + assertTrue(message.contains("Starter households seeded: 4 residents")); + assertTrue(message.contains("Adult free citizens can fill vacancies")); + assertTrue(message.contains("Starter workers wait for player-marked or validated work areas")); + assertTrue(message.contains("fort founding no longer auto-ploughs a field")); + assertTrue(message.contains("farmer needs a crop area")); assertTrue(message.contains("miner needs a mine")); assertTrue(message.contains("lumberjack needs a lumber camp")); assertTrue(message.contains("builder needs an architect workshop/build area")); diff --git a/src/test/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalSchedulerTest.java b/src/test/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalSchedulerTest.java index 3c86c44e..d284a73d 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalSchedulerTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/goal/BannerModResidentGoalSchedulerTest.java @@ -12,11 +12,12 @@ import com.talhanation.bannermod.settlement.SettlementSellerDispatchRecord; import com.talhanation.bannermod.settlement.SettlementSellerDispatchState; import com.talhanation.bannermod.settlement.SettlementServiceActorState; +import com.talhanation.bannermod.society.NpcSocietyProfile; import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime; +import com.talhanation.bannermod.settlement.goal.impl.HideResidentGoal; import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.IdleResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; -import com.talhanation.bannermod.settlement.goal.impl.SocialiseResidentGoal; import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; @@ -64,7 +65,7 @@ void nightTickSelectsRestOverIdle() { } @Test - void unassignedVillagerInDaylightFlexSocialisesRatherThanWorks() { + void unassignedVillagerInDaylightFlexFallsBackToIdle() { BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); SettlementResidentRecord resident = buildUnassignedVillager(); ResidentGoalContext ctx = new ResidentGoalContext(resident, null, DAY_TICK_ACTIVE); @@ -73,8 +74,28 @@ void unassignedVillagerInDaylightFlexSocialisesRatherThanWorks() { Optional task = scheduler.currentTask(resident.residentUuid()); assertTrue(task.isPresent()); - assertEquals(SocialiseResidentGoal.ID, task.get().goalId(), - "villager with no workplace falls through work/deliver/fetch to socialise in civic/flex windows"); + assertEquals(IdleResidentGoal.ID, task.get().goalId(), + "villager with no workplace should now fall through the cheap runtime to idle instead of a dedicated social goal"); + } + + @Test + void pressuredWorkerStopsSelectingWorkWhenScorerReturnsZero() { + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + SettlementResidentRecord worker = buildLocalWorker(); + ResidentGoalContext ctx = new ResidentGoalContext( + worker, + null, + DAY_TICK_ACTIVE, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_ACTIVE) + .withNeedState(10, 95, 10, 10, DAY_TICK_ACTIVE) + ); + + scheduler.tick(ctx); + + Optional task = scheduler.currentTask(worker.residentUuid()); + assertTrue(task.isPresent()); + assertFalse(WorkResidentGoal.ID.equals(task.get().goalId()), + "workers under severe fatigue should stop publishing WORK once the cheap scorer disables it"); } @Test @@ -109,6 +130,21 @@ void activeTaskAdvancesUntilMaxTicksThenTimesOut() { assertEquals(ResidentStopReason.TIMED_OUT, after.get().stopReason()); } + @Test + void activeTaskUsesElapsedGameTimeAcrossHeartbeatGaps() { + ResidentGoal fastGoal = new FixedDurationTestGoal("test/goal/heartbeat", 50, 3, false); + BannerModResidentGoalScheduler scheduler = new BannerModResidentGoalScheduler(List.of(fastGoal)); + SettlementResidentRecord resident = buildLocalWorker(); + UUID id = resident.residentUuid(); + + scheduler.tick(new ResidentGoalContext(resident, null, 100L)); + scheduler.tick(new ResidentGoalContext(resident, null, 104L)); + + ResidentTask task = scheduler.currentTask(id).orElseThrow(); + assertTrue(task.isDone(), "task should time out once the real game-time delta exceeds its budget"); + assertEquals(ResidentStopReason.TIMED_OUT, task.stopReason()); + } + @Test void cooldownSkipsSameGoalAfterCompletion() { ResidentGoal coolingGoal = new FixedDurationTestGoal("test/goal/cooling", 99, 2, true); @@ -230,6 +266,93 @@ void extendedDefaultGoalsPickSellerOverWorkWhenReadyDispatchExists() { assertTrue(sellerRuntime.phase(seller.residentUuid()).isPresent()); } + @Test + void dayShiftPreemptsOvernightRestIntoWork() { + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + SettlementResidentRecord worker = buildLocalWorker(UUID.fromString("00000000-0000-0000-0000-000000000031")); + + scheduler.tick(new ResidentGoalContext(worker, null, DAY_TICK_NIGHT)); + assertEquals(RestResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + + scheduler.tick(new ResidentGoalContext( + worker, + null, + DAY_TICK_ACTIVE, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_ACTIVE) + .withNeedState(10, 10, 10, 10, DAY_TICK_ACTIVE) + )); + + assertEquals(WorkResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + } + + @Test + void nightHomePreemptsActiveWorkWhenResidentHasHome() { + BannerModHomeAssignmentRuntime homeRuntime = new BannerModHomeAssignmentRuntime(); + BannerModSellerDispatchRuntime sellerRuntime = new BannerModSellerDispatchRuntime(); + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals( + homeRuntime, + SettlementMarketState::empty, + sellerRuntime + ); + SettlementResidentRecord worker = buildLocalWorker(UUID.fromString("00000000-0000-0000-0000-000000000032")); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-000000000132"); + homeRuntime.assign(worker.residentUuid(), homeId, com.talhanation.bannermod.settlement.household.HomePreference.ASSIGNED, 100L); + + scheduler.tick(new ResidentGoalContext( + worker, + null, + DAY_TICK_ACTIVE, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_ACTIVE) + .withPhaseOneState(null, homeId, null, com.talhanation.bannermod.society.NpcDailyPhase.ACTIVE, + com.talhanation.bannermod.society.NpcIntent.WORK, + com.talhanation.bannermod.society.NpcAnchorType.WORKPLACE, + com.talhanation.bannermod.society.NpcSocietyDecisionSnapshot.empty(), + DAY_TICK_ACTIVE) + .withNeedState(10, 10, 10, 10, DAY_TICK_ACTIVE) + )); + assertEquals(WorkResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + + scheduler.tick(new ResidentGoalContext( + worker, + null, + DAY_TICK_NIGHT, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_NIGHT) + .withPhaseOneState(null, homeId, null, com.talhanation.bannermod.society.NpcDailyPhase.REST, + com.talhanation.bannermod.society.NpcIntent.WORK, + com.talhanation.bannermod.society.NpcAnchorType.WORKPLACE, + com.talhanation.bannermod.society.NpcSocietyDecisionSnapshot.empty(), + DAY_TICK_NIGHT) + .withNeedState(10, 25, 10, 10, DAY_TICK_NIGHT) + )); + + assertEquals(GoHomeResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + } + + @Test + void dangerPreemptsActiveWorkIntoHide() { + BannerModResidentGoalScheduler scheduler = BannerModResidentGoalScheduler.withDefaultGoals(); + SettlementResidentRecord worker = buildLocalWorker(UUID.fromString("00000000-0000-0000-0000-000000000033")); + + scheduler.tick(new ResidentGoalContext( + worker, + null, + DAY_TICK_ACTIVE, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_ACTIVE) + .withNeedState(10, 10, 10, 10, DAY_TICK_ACTIVE) + )); + assertEquals(WorkResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + + scheduler.tick(new ResidentGoalContext( + worker, + null, + DAY_TICK_ACTIVE + 1, + NpcSocietyProfile.createDefault(worker.residentUuid(), DAY_TICK_ACTIVE + 1) + .withNeedState(10, 10, 10, 92, DAY_TICK_ACTIVE + 1) + )); + + assertEquals(HideResidentGoal.ID, scheduler.currentTask(worker.residentUuid()).orElseThrow().goalId()); + } + @Test void zeroPriorityGoalIsNotSelectedEvenIfCanStartReturnsTrue() { ResidentGoal zeroPriority = new FixedDurationTestGoal("test/goal/zero", 0, 5, false); @@ -246,7 +369,10 @@ void zeroPriorityGoalIsNotSelectedEvenIfCanStartReturnsTrue() { // ------------------------------------------------------------------ private static SettlementResidentRecord buildLocalWorker() { - UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); + return buildLocalWorker(UUID.fromString("00000000-0000-0000-0000-000000000001")); + } + + private static SettlementResidentRecord buildLocalWorker(UUID id) { UUID workArea = UUID.fromString("00000000-0000-0000-0000-000000000099"); return new SettlementResidentRecord( id, diff --git a/src/test/java/com/talhanation/bannermod/society/NpcHousingClientStateTest.java b/src/test/java/com/talhanation/bannermod/society/NpcHousingClientStateTest.java new file mode 100644 index 00000000..617850ce --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcHousingClientStateTest.java @@ -0,0 +1,66 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.society.client.NpcHousingClientState; +import net.minecraft.nbt.CompoundTag; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NpcHousingClientStateTest { + + @Test + void appliesSnapshotContractIntoClientMirror() { + UUID claimId = UUID.fromString("00000000-0000-0000-0000-000000000801"); + UUID householdId = UUID.fromString("00000000-0000-0000-0000-000000000802"); + UUID residentId = UUID.fromString("00000000-0000-0000-0000-000000000803"); + + NpcHousingLedgerEntry entry = new NpcHousingLedgerEntry( + householdId, + residentId, + claimId, + UUID.fromString("00000000-0000-0000-0000-000000000804"), + UUID.fromString("00000000-0000-0000-0000-000000000805"), + null, + null, + NpcHousingRequestStatus.REQUESTED.name(), + NpcHouseholdHousingState.OVERCROWDED.name(), + "HIGH", + "OVERCROWDED", + 4, + 9, + 268, + 1, + 100L, + 120L + ); + + CompoundTag snapshot = NpcHousingSnapshotContract.encode( + claimId, + true, + "", + List.of(entry) + ); + + NpcHousingClientState.clear(); + NpcHousingClientState.beginSync(); + NpcHousingClientState.applyFromNbt(snapshot); + + assertTrue(NpcHousingClientState.hasSnapshot()); + assertTrue(NpcHousingClientState.hasClaim()); + assertTrue(NpcHousingClientState.canManage()); + assertFalse(NpcHousingClientState.syncPending()); + assertEquals(claimId, NpcHousingClientState.claimUuid()); + assertEquals(1, NpcHousingClientState.requests().size()); + NpcHousingLedgerEntry mirrored = NpcHousingClientState.requestByHousehold(householdId); + assertNotNull(mirrored); + assertEquals(1, mirrored.queueRank()); + assertEquals("OVERCROWDED", mirrored.reasonTag()); + assertEquals(4, mirrored.householdSize()); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcHousingPriorityServiceTest.java b/src/test/java/com/talhanation/bannermod/society/NpcHousingPriorityServiceTest.java new file mode 100644 index 00000000..76acaa14 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcHousingPriorityServiceTest.java @@ -0,0 +1,101 @@ +package com.talhanation.bannermod.society; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NpcHousingPriorityServiceTest { + + @Test + void ranksHomelessAndOlderRequestsAheadOfWeakerEntries() { + UUID claimId = UUID.fromString("00000000-0000-0000-0000-000000000701"); + + NpcHousingLedgerEntry homeless = NpcHousingPriorityService.describe( + request("00000000-0000-0000-0000-000000000711", "00000000-0000-0000-0000-000000000721", claimId, NpcHousingRequestStatus.REQUESTED, 0L), + household("00000000-0000-0000-0000-000000000711", NpcHouseholdHousingState.HOMELESS, 2), + 20L * 24000L + ); + NpcHousingLedgerEntry oldDenied = NpcHousingPriorityService.describe( + request("00000000-0000-0000-0000-000000000712", "00000000-0000-0000-0000-000000000722", claimId, NpcHousingRequestStatus.DENIED, 2L * 24000L), + household("00000000-0000-0000-0000-000000000712", NpcHouseholdHousingState.OVERCROWDED, 5), + 20L * 24000L + ); + NpcHousingLedgerEntry approved = NpcHousingPriorityService.describe( + request("00000000-0000-0000-0000-000000000713", "00000000-0000-0000-0000-000000000723", claimId, NpcHousingRequestStatus.APPROVED, 19L * 24000L), + household("00000000-0000-0000-0000-000000000713", NpcHouseholdHousingState.NORMAL, 3), + 20L * 24000L + ); + + List ranked = NpcHousingPriorityService.rankEntries(List.of(approved, oldDenied, homeless)); + + assertEquals(homeless.householdId(), ranked.get(0).householdId()); + assertEquals(1, ranked.get(0).queueRank()); + assertEquals("CRITICAL", ranked.get(0).urgencyTag()); + assertEquals(oldDenied.householdId(), ranked.get(1).householdId()); + assertEquals("OVERCROWDED", ranked.get(1).reasonTag()); + assertEquals(approved.householdId(), ranked.get(2).householdId()); + assertEquals("APPROVED_PIPELINE", ranked.get(2).reasonTag()); + } + + @Test + void olderRequestWinsWhenSeverityMatches() { + UUID claimId = UUID.fromString("00000000-0000-0000-0000-000000000702"); + NpcHousingLedgerEntry older = NpcHousingPriorityService.describe( + request("00000000-0000-0000-0000-000000000731", "00000000-0000-0000-0000-000000000741", claimId, NpcHousingRequestStatus.REQUESTED, 1L * 24000L), + household("00000000-0000-0000-0000-000000000731", NpcHouseholdHousingState.OVERCROWDED, 3), + 12L * 24000L + ); + NpcHousingLedgerEntry newer = NpcHousingPriorityService.describe( + request("00000000-0000-0000-0000-000000000732", "00000000-0000-0000-0000-000000000742", claimId, NpcHousingRequestStatus.REQUESTED, 10L * 24000L), + household("00000000-0000-0000-0000-000000000732", NpcHouseholdHousingState.OVERCROWDED, 3), + 12L * 24000L + ); + + List ranked = NpcHousingPriorityService.rankEntries(List.of(newer, older)); + + assertEquals(older.householdId(), ranked.get(0).householdId()); + assertEquals(newer.householdId(), ranked.get(1).householdId()); + assertEquals(11, ranked.get(0).waitingDays()); + assertEquals(2, ranked.get(1).waitingDays()); + } + + private static NpcHousingRequestRecord request(String householdId, + String residentId, + UUID claimId, + NpcHousingRequestStatus status, + long requestedAtGameTime) { + return new NpcHousingRequestRecord( + UUID.fromString(householdId), + UUID.fromString(residentId), + claimId, + UUID.randomUUID(), + null, + null, + null, + status, + requestedAtGameTime, + requestedAtGameTime + ); + } + + private static NpcHouseholdRecord household(String householdId, + NpcHouseholdHousingState state, + int members) { + java.util.List residentIds = new java.util.ArrayList<>(); + for (int i = 0; i < members; i++) { + residentIds.add(new UUID(0L, 800L + i)); + } + return NpcHouseholdRecord.create( + UUID.fromString(householdId), + null, + residentIds.isEmpty() ? null : residentIds.getFirst(), + residentIds, + 2, + state, + 0L + ); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotRoundTripTest.java b/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotRoundTripTest.java new file mode 100644 index 00000000..091203c5 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotRoundTripTest.java @@ -0,0 +1,59 @@ +package com.talhanation.bannermod.society; + +import io.netty.buffer.Unpooled; +import net.minecraft.network.FriendlyByteBuf; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NpcPhaseOneSnapshotRoundTripTest { + + @Test + void roundTripsHouseholdHeadAndHousingContext() { + NpcPhaseOneSnapshot snapshot = new NpcPhaseOneSnapshot( + NpcLifeStage.ADULT.name(), + NpcSex.FEMALE.name(), + UUID.fromString("00000000-0000-0000-0000-000000000901"), + UUID.fromString("00000000-0000-0000-0000-000000000902"), + UUID.fromString("00000000-0000-0000-0000-000000000903"), + UUID.fromString("00000000-0000-0000-0000-000000000904"), + "culture.test", + "faith.test", + NpcDailyPhase.ACTIVE.name(), + NpcIntent.GO_HOME.name(), + NpcAnchorType.HOME.name(), + "EXECUTING", + "bannermod:resident/goal/go_home", + "REST_WINDOW", + "SOON_NIGHT_HOMEBOUND", + "bannermod:resident/goal/eat", + "NO_FOOD_ACCESS", + 5, + NpcHouseholdHousingState.OVERCROWDED.name(), + 12, + 22, + 42, + NpcHousingRequestStatus.REQUESTED.name(), + "HIGH", + "OVERCROWDED", + 9 + ); + + FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer()); + snapshot.toBytes(buf); + NpcPhaseOneSnapshot decoded = NpcPhaseOneSnapshot.fromBytes(buf); + + assertEquals(snapshot.householdHeadResidentUuid(), decoded.householdHeadResidentUuid()); + assertEquals(snapshot.housingUrgencyTag(), decoded.housingUrgencyTag()); + assertEquals(snapshot.housingReasonTag(), decoded.housingReasonTag()); + assertEquals(snapshot.housingWaitingDays(), decoded.housingWaitingDays()); + assertEquals(snapshot.aiStateTag(), decoded.aiStateTag()); + assertEquals(snapshot.aiCurrentGoalId(), decoded.aiCurrentGoalId()); + assertEquals(snapshot.aiChoiceReasonTag(), decoded.aiChoiceReasonTag()); + assertEquals(snapshot.aiRouteReasonTag(), decoded.aiRouteReasonTag()); + assertEquals(snapshot.aiBlockedGoalId(), decoded.aiBlockedGoalId()); + assertEquals(snapshot.aiBlockedReasonTag(), decoded.aiBlockedReasonTag()); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotTest.java b/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotTest.java new file mode 100644 index 00000000..6639dff8 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcPhaseOneSnapshotTest.java @@ -0,0 +1,103 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.TranslatableContents; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +class NpcPhaseOneSnapshotTest { + @Test + void knownGoalIdsExposeTranslatedGoalLabels() { + NpcPhaseOneSnapshot snapshot = snapshot( + "EXECUTING", + "bannermod:resident/goal/go_home", + "bannermod:resident/goal/seek_supplies", + "NONE" + ); + + assertEquals("gui.bannermod.society.ai.goal.go_home", translationKey(snapshot.aiCurrentGoalComponent())); + assertEquals("gui.bannermod.society.ai.goal.seek_supplies", translationKey(snapshot.aiBlockedGoalComponent())); + } + + @Test + void recoveringSnapshotsExposeRecoveryRouteHelpers() { + NpcPhaseOneSnapshot snapshot = snapshot( + "RECOVERING", + "bannermod:resident/goal/go_home", + "bannermod:resident/goal/work", + NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED + ); + + assertEquals("gui.bannermod.society.ai.route.recovery_detail", translationKey(snapshot.aiRouteSecondaryComponent())); + assertEquals("gui.bannermod.society.ai.route.recovery_summary", translationKey(snapshot.aiReadableRoutineReasonComponent())); + } + + @Test + void blockedSnapshotsExposeBlockedRouteHelpers() { + NpcPhaseOneSnapshot snapshot = snapshot( + "BLOCKED", + null, + "bannermod:resident/goal/work", + "NO_WORK_ASSIGNMENT" + ); + + assertEquals("gui.bannermod.society.ai.route.blocked_detail", translationKey(snapshot.aiRouteSecondaryComponent())); + assertEquals("gui.bannermod.society.ai.route.blocked_summary", translationKey(snapshot.aiReadableRoutineReasonComponent())); + } + + @Test + void blockedSnapshotsKeepBlockedHelpersEvenWithFallbackGoal() { + NpcPhaseOneSnapshot snapshot = snapshot( + "BLOCKED", + "bannermod:resident/goal/idle", + "bannermod:resident/goal/work", + "NO_WORK_ASSIGNMENT" + ); + + assertEquals("gui.bannermod.society.ai.route.blocked_detail", translationKey(snapshot.aiRouteSecondaryComponent())); + assertEquals("gui.bannermod.society.ai.route.blocked_summary", translationKey(snapshot.aiReadableRoutineReasonComponent())); + } + + private static NpcPhaseOneSnapshot snapshot(String aiStateTag, + String currentGoalId, + String blockedGoalId, + String blockedReasonTag) { + return new NpcPhaseOneSnapshot( + NpcLifeStage.ADULT.name(), + NpcSex.FEMALE.name(), + UUID.fromString("00000000-0000-0000-0000-00000000f001"), + UUID.fromString("00000000-0000-0000-0000-00000000f002"), + UUID.fromString("00000000-0000-0000-0000-00000000f003"), + UUID.fromString("00000000-0000-0000-0000-00000000f004"), + null, + null, + NpcDailyPhase.RETURNING_HOME.name(), + NpcIntent.GO_HOME.name(), + NpcAnchorType.HOME.name(), + aiStateTag, + currentGoalId, + "RETURNING_TO_HOUSEHOLD", + "REGROUPING_AT_HOME", + blockedGoalId, + blockedReasonTag, + 4, + NpcHouseholdHousingState.NORMAL.name(), + 10, + 55, + 18, + NpcHousingRequestStatus.NONE.name(), + "LOW", + "STABLE", + 0 + ); + } + + private static String translationKey(Component component) { + TranslatableContents contents = assertInstanceOf(TranslatableContents.class, component.getContents()); + return contents.getKey(); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoalTest.java b/src/test/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoalTest.java new file mode 100644 index 00000000..c2de6f9c --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcSocietyAnchorGoalTest.java @@ -0,0 +1,35 @@ +package com.talhanation.bannermod.society; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NpcSocietyAnchorGoalTest { + + @Test + void stalledRouteRequiresRepeatedFailuresBeforeInvalidation() { + assertFalse(NpcSocietyAnchorGoal.shouldInvalidateStalledRoute(2, 49.0D, 8.0D)); + assertTrue(NpcSocietyAnchorGoal.shouldInvalidateStalledRoute(3, 49.0D, 8.0D)); + } + + @Test + void meaningfulRouteProgressNeedsRealDistanceGain() { + assertFalse(NpcSocietyAnchorGoal.madeMeaningfulRouteProgress(36.0D, 35.5D)); + assertTrue(NpcSocietyAnchorGoal.madeMeaningfulRouteProgress(36.0D, 34.5D)); + } + + @Test + void routeInvalidationSignalMatchesIntentAndExpiresQuickly() { + UUID residentId = UUID.fromString("00000000-0000-0000-0000-0000000000d1"); + NpcSocietyAnchorGoal.signalRouteInvalidation(residentId, NpcIntent.WORK, 200L); + + assertFalse(NpcSocietyAnchorGoal.consumeRouteInvalidation(residentId, NpcIntent.GO_HOME, 200L)); + assertTrue(NpcSocietyAnchorGoal.consumeRouteInvalidation(residentId, NpcIntent.WORK, 200L)); + + NpcSocietyAnchorGoal.signalRouteInvalidation(residentId, NpcIntent.WORK, 200L); + assertFalse(NpcSocietyAnchorGoal.consumeRouteInvalidation(residentId, NpcIntent.WORK, 202L)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshotTest.java b/src/test/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshotTest.java new file mode 100644 index 00000000..89555b55 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcSocietyDecisionSnapshotTest.java @@ -0,0 +1,314 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentMode; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.SettlementResidentRuntimeRoleState; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleSeed; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; +import com.talhanation.bannermod.settlement.SettlementResidentServiceContract; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.ResidentStopReason; +import com.talhanation.bannermod.settlement.goal.ResidentTask; +import com.talhanation.bannermod.settlement.goal.ResidentTaskOutcome; +import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.SeekSuppliesResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import com.talhanation.bannermod.settlement.household.GoHomeResidentGoal; +import net.minecraft.core.BlockPos; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NpcSocietyDecisionSnapshotTest { + @Test + void recentTimedOutOutcomeAppearsAsBlockedRecoveryReason() { + long gameTime = 6000L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d101"); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + gameTime, + NpcSocietyProfile.createDefault(residentId, gameTime) + .withNeedState(10, 10, 10, 10, gameTime) + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + null, + "NO_CLEAR_ROUTE", + new ResidentTaskOutcome(WorkResidentGoal.ID, ResidentStopReason.TIMED_OUT, gameTime - 20L) + ); + + assertEquals("BLOCKED", snapshot.stateTag()); + assertEquals(WorkResidentGoal.ID.toString(), snapshot.blockedGoalId()); + assertEquals(NpcSocietyDecisionSnapshot.BLOCKED_REASON_TASK_TIMED_OUT, snapshot.blockedReasonTag()); + } + + @Test + void invalidatedOutcomeKeepsBlockedReasonWhileFallbackExecutes() { + long gameTime = 9100L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d104"); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-00000000d124"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + UUID.fromString("00000000-0000-0000-0000-00000000d134"), + NpcDailyPhase.ACTIVE, + NpcIntent.WORK, + NpcAnchorType.WORKPLACE, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", + WorkResidentGoal.ID.toString(), NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED, + NpcIntent.WORK.name(), gameTime - 30L), + gameTime + ) + .withNeedState(10, 54, 16, 14, gameTime); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + gameTime, + gameTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + new ResidentTask(GoHomeResidentGoal.ID, gameTime, 40), + "RETURNING_HOME_ROUTE", + new ResidentTaskOutcome(WorkResidentGoal.ID, ResidentStopReason.CONTEXT_INVALID, gameTime - 10L) + ); + + assertEquals("EXECUTING", snapshot.stateTag()); + assertEquals(NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED, snapshot.blockedReasonTag()); + } + + @Test + void restChoiceNowExplainsRestWindowDirectly() { + long gameTime = 15000L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d102"); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-00000000d122"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + UUID.fromString("00000000-0000-0000-0000-00000000d132"), + NpcDailyPhase.REST, + NpcIntent.REST, + NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", + WorkResidentGoal.ID.toString(), "TASK_TIMED_OUT", NpcIntent.WORK.name(), gameTime - 40L), + gameTime + ) + .withNeedState(10, 82, 12, 10, gameTime); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + gameTime, + gameTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + new ResidentTask(RestResidentGoal.ID, gameTime, 40), + "RESTING_AT_HOME", + null + ); + + assertEquals("REST_WINDOW", snapshot.choiceReasonTag()); + } + + @Test + void fallbackTaskAfterFailureStillPublishesExecutingState() { + long gameTime = 9200L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d103"); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-00000000d123"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + UUID.fromString("00000000-0000-0000-0000-00000000d133"), + NpcDailyPhase.ACTIVE, + NpcIntent.WORK, + NpcAnchorType.WORKPLACE, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", + WorkResidentGoal.ID.toString(), "TASK_TIMED_OUT", NpcIntent.WORK.name(), gameTime - 40L), + gameTime + ) + .withNeedState(12, 58, 18, 16, gameTime); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + gameTime, + gameTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + new ResidentTask(GoHomeResidentGoal.ID, gameTime, 40), + "RETURNING_HOME_ROUTE", + new ResidentTaskOutcome(WorkResidentGoal.ID, ResidentStopReason.TIMED_OUT, gameTime - 20L) + ); + + assertEquals("EXECUTING", snapshot.stateTag()); + assertEquals("HOMEWARD_PULL", snapshot.choiceReasonTag()); + } + + @Test + void nightGoHomeChoiceExplainsRestWindowBeforeGenericFallback() { + long gameTime = 15000L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d105"); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-00000000d125"); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + gameTime, + gameTime, + NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + null, + NpcDailyPhase.ACTIVE, + NpcIntent.UNSPECIFIED, + NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), + gameTime + ) + .withNeedState(10, 95, 10, 10, gameTime), + 1, + NpcHouseholdHousingState.NORMAL, + false, + 0 + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + new ResidentTask(GoHomeResidentGoal.ID, gameTime, 40), + "SOON_NIGHT_HOMEBOUND", + null + ); + + assertEquals("REST_WINDOW", snapshot.choiceReasonTag()); + } + + @Test + void supplyRunExplainsHomeFoodShortageWhenHouseStillExists() { + long gameTime = 9400L; + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000d104"); + UUID homeId = UUID.fromString("00000000-0000-0000-0000-00000000d124"); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + settlementWithStockpile(workerResident(residentId)), + gameTime, + gameTime, + NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + UUID.fromString("00000000-0000-0000-0000-00000000d134"), + NpcDailyPhase.ACTIVE, + NpcIntent.SEEK_SUPPLIES, + NpcAnchorType.WORKPLACE, + NpcSocietyDecisionSnapshot.empty(), + gameTime + ) + .withNeedState(74, 14, 10, 8, gameTime), + 2, + NpcHouseholdHousingState.NORMAL, + false, + 0 + ); + + NpcSocietyDecisionSnapshot snapshot = NpcSocietyDecisionSnapshot.capture( + ctx, + new ResidentTask(SeekSuppliesResidentGoal.ID, gameTime, 40), + "STOCKPILE_SUPPLY_RUN", + null + ); + + assertEquals("HOME_FOOD_SHORTAGE", snapshot.choiceReasonTag()); + } + + private static SettlementResidentRecord workerResident(UUID residentId) { + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + UUID.fromString("00000000-0000-0000-0000-00000000d111"), + "team-test", + UUID.fromString("00000000-0000-0000-0000-00000000d121"), + SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + ); + } + + private static SettlementSnapshot settlementWithStockpile(SettlementResidentRecord resident) { + return new SettlementSnapshot( + UUID.fromString("00000000-0000-0000-0000-00000000d201"), + 0, + 0, + null, + 9400L, + 4, + 4, + 1, + 1, + 0, + 0, + SettlementStockpileSummary.empty(), + new SettlementMarketState(1, 0, 0, 0, 0, 0, List.of(), List.of()), + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + List.of(resident), + List.of(new SettlementBuildingRecord( + UUID.fromString("00000000-0000-0000-0000-00000000d202"), + "bannermod:stockpile", + new BlockPos(4, 64, 4), + null, + null, + 0, + 0, + 0, + List.of(), + true, + 1, + 27, + false, + false, + List.of("food") + )) + ); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntimeTest.java b/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntimeTest.java new file mode 100644 index 00000000..78e68b6a --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseOneRuntimeTest.java @@ -0,0 +1,17 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.dispatch.SellerResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.DeliverResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.FetchResidentGoal; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NpcSocietyPhaseOneRuntimeTest { + @Test + void taskSpecificWorkGoalsKeepDistinctPublishedIntents() { + assertEquals(NpcIntent.SELL, NpcSocietyPhaseOneRuntime.intentForGoal(SellerResidentGoal.ID)); + assertEquals(NpcIntent.FETCH, NpcSocietyPhaseOneRuntime.intentForGoal(FetchResidentGoal.ID)); + assertEquals(NpcIntent.DELIVER, NpcSocietyPhaseOneRuntime.intentForGoal(DeliverResidentGoal.ID)); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorerTest.java b/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorerTest.java new file mode 100644 index 00000000..138930cd --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcSocietyPhaseTwoIntentScorerTest.java @@ -0,0 +1,521 @@ +package com.talhanation.bannermod.society; + +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementResidentAssignmentState; +import com.talhanation.bannermod.settlement.SettlementResidentMode; +import com.talhanation.bannermod.settlement.SettlementResidentRecord; +import com.talhanation.bannermod.settlement.SettlementResidentRole; +import com.talhanation.bannermod.settlement.SettlementResidentRuntimeRoleState; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleSeed; +import com.talhanation.bannermod.settlement.SettlementResidentScheduleWindowSeed; +import com.talhanation.bannermod.settlement.SettlementResidentServiceContract; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import com.talhanation.bannermod.settlement.goal.ResidentGoalContext; +import com.talhanation.bannermod.settlement.goal.impl.RestResidentGoal; +import com.talhanation.bannermod.settlement.goal.impl.WorkResidentGoal; +import net.minecraft.core.BlockPos; +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.assertTrue; + +class NpcSocietyPhaseTwoIntentScorerTest { + private static final long ACTIVE_TIME = 6000L; + private static final long REST_TIME = 15000L; + + @Test + void fearAxisNoLongerTriggersHideDuringActivePhaseByItself() { + ResidentGoalContext ctx = context( + villagerResident(), + ACTIVE_TIME, + null, + NpcSocietyProfile.createDefault(uuid("00000000-0000-0000-0000-00000000a001"), ACTIVE_TIME) + .withNeedState(5, 5, 5, 12, ACTIVE_TIME) + ); + + int hide = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.HIDE); + int rest = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.REST); + + assertEquals(0, hide, "hide should now key off coarse safety pressure instead of fear-memory weighting"); + assertEquals(0, rest, "rest should stay inactive during the day without actual fatigue or rest-window pressure"); + } + + @Test + void restGoalBasePriorityIsOnlyAppliedInRestPhase() { + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(uuid("00000000-0000-0000-0000-00000000a002"), ACTIVE_TIME) + .withNeedState(5, 5, 5, 12, ACTIVE_TIME); + RestResidentGoal goal = new RestResidentGoal(); + + int activePriority = goal.computePriority(context(villagerResident(), ACTIVE_TIME, null, profile)); + int restPriority = goal.computePriority(context(villagerResident(), REST_TIME, null, profile)); + + assertEquals(0, activePriority, "rest goal should now stay fully inactive during active time without real rest pressure"); + assertEquals(87, restPriority, "rest phase should still receive a strong overnight priority in the cheaper scorer"); + assertTrue(restPriority > activePriority, "rest priority must jump sharply once the resident is in rest phase"); + } + + @Test + void foodAccessEnablesEatAndSevereHungerBeatsWork() { + SettlementResidentRecord worker = workerResident(); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(uuid("00000000-0000-0000-0000-00000000a005"), ACTIVE_TIME) + .withNeedState(92, 10, 10, 10, ACTIVE_TIME); + + ResidentGoalContext noMarket = context(worker, ACTIVE_TIME, settlementWithOpenMarkets(worker, 0), profile); + ResidentGoalContext openMarket = context(worker, ACTIVE_TIME, settlementWithOpenMarkets(worker, 1), profile); + + int eatWithoutMarket = NpcSocietyPhaseTwoIntentScorer.scoreIntent(noMarket, NpcIntent.EAT); + int eatWithMarket = NpcSocietyPhaseTwoIntentScorer.scoreIntent(openMarket, NpcIntent.EAT); + int workWithMarket = NpcSocietyPhaseTwoIntentScorer.scoreIntent(openMarket, NpcIntent.WORK); + + assertEquals(0, eatWithoutMarket, "eat should stay unavailable when the resident has no home and no market access"); + assertTrue(eatWithMarket > 0, "severe hunger with food access should still produce a strong eat score"); + assertTrue(eatWithMarket > workWithMarket, "severe hunger should out-rank work once food is reachable"); + } + + @Test + void moderateNeedsKeepAssignedWorkersOnShift() { + UUID residentId = uuid("00000000-0000-0000-0000-00000000a005"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d005"); + SettlementResidentRecord worker = workerResident(residentId); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withPhaseOneState(null, homeId, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), ACTIVE_TIME) + .withNeedState(60, 72, 40, 10, ACTIVE_TIME); + ResidentGoalContext ctx = context(worker, ACTIVE_TIME, settlementWithOpenMarkets(worker, 1), profile); + + int work = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK); + int eat = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.EAT); + int goHome = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.GO_HOME); + int rest = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.REST); + + assertTrue(work > 0, "assigned workers should keep a live work score under moderate daytime pressure"); + assertEquals(0, eat, "moderate hunger should no longer interrupt work before hunger becomes strong"); + assertEquals(0, goHome, "moderate fatigue should no longer pull workers home during the day"); + assertEquals(0, rest, "daytime rest should stay off until fatigue becomes strong"); + } + + @Test + void governorDangerStillPrefersRestrictedDefendRole() { + ResidentGoalContext ctx = context( + governorResident(), + ACTIVE_TIME, + null, + NpcSocietyProfile.createDefault(uuid("00000000-0000-0000-0000-00000000a006"), ACTIVE_TIME) + .withNeedState(10, 10, 10, 40, ACTIVE_TIME) + ); + + int hide = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.HIDE); + int defend = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.DEFEND); + + assertTrue(hide > 0, "danger should still activate hide pressure even for defenders"); + assertTrue(defend > hide, "restricted defender roles should still keep their coarse defend fallback under danger"); + } + + @Test + void familyContextNoLongerChangesGoHomeScore() { + UUID residentId = uuid("00000000-0000-0000-0000-00000000a007"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d007"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withPhaseOneState(null, homeId, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), ACTIVE_TIME) + .withNeedState(10, 76, 15, 28, ACTIVE_TIME); + + ResidentGoalContext alone = context(villagerResident(residentId), ACTIVE_TIME, null, profile); + ResidentGoalContext family = new ResidentGoalContext( + villagerResident(residentId), + null, + ACTIVE_TIME, + ACTIVE_TIME, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + int aloneScore = NpcSocietyPhaseTwoIntentScorer.scoreIntent(alone, NpcIntent.GO_HOME); + int familyScore = NpcSocietyPhaseTwoIntentScorer.scoreIntent(family, NpcIntent.GO_HOME); + + assertEquals(aloneScore, familyScore, + "family metadata should no longer act as a broad go-home runtime multiplier"); + } + + @Test + void fearfulMemoryNoLongerChangesWorkOrHideScoring() { + UUID residentId = uuid("00000000-0000-0000-0000-00000000a008"); + NpcSocietyProfile calmProfile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(18, 12, 18, 18, ACTIVE_TIME); + NpcSocietyProfile fearfulProfile = calmProfile; + + int calmWork = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(workerResident(), ACTIVE_TIME, null, calmProfile), NpcIntent.WORK); + int fearfulWork = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(workerResident(), ACTIVE_TIME, null, fearfulProfile), NpcIntent.WORK); + int fearfulHide = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(workerResident(), ACTIVE_TIME, null, fearfulProfile), NpcIntent.HIDE); + + assertEquals(calmWork, fearfulWork, + "memory axes should no longer suppress ordinary work scoring"); + assertEquals(0, fearfulHide, + "fear-memory alone should not create a hide score without live safety pressure"); + } + + @Test + void eveningWindowStrengthensGoHomePressureBeforeRest() { + UUID residentId = uuid("00000000-0000-0000-0000-00000000a010"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d010"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withPhaseOneState(null, homeId, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), ACTIVE_TIME) + .withNeedState(10, 20, 20, 10, ACTIVE_TIME); + + int middayScore = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(villagerResident(residentId), ACTIVE_TIME, null, profile), NpcIntent.GO_HOME); + int eveningScore = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(villagerResident(residentId), 11550L, null, profile), NpcIntent.GO_HOME); + + assertTrue(eveningScore > middayScore, + "go-home pressure should rise as the rest window approaches so NPCs start pulling back toward home"); + } + + @Test + void recentGoHomeHistoryKeepsHomewardIntentMoreStable() { + long time = 11550L; + UUID residentId = uuid("00000000-0000-0000-0000-00000000a012"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d012"); + NpcSocietyProfile neutralProfile = NpcSocietyProfile.createDefault(residentId, time) + .withPhaseOneState(null, homeId, null, NpcDailyPhase.ACTIVE, NpcIntent.UNSPECIFIED, NpcAnchorType.NONE, + NpcSocietyDecisionSnapshot.empty(), time) + .withNeedState(10, 28, 12, 8, time); + NpcSocietyProfile stickyProfile = neutralProfile.withPhaseOneState( + null, + homeId, + null, + NpcDailyPhase.RETURNING_HOME, + NpcIntent.GO_HOME, + NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("EXECUTING", "bannermod:resident/goal/go_home", "REST_WINDOW", "SOON_NIGHT_HOMEBOUND", null, "NONE", NpcIntent.WORK.name(), time - 40L), + time + ); + + int neutral = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(villagerResident(residentId), time, null, neutralProfile), NpcIntent.GO_HOME); + int sticky = NpcSocietyPhaseTwoIntentScorer.scoreIntent(context(villagerResident(residentId), time, null, stickyProfile), NpcIntent.GO_HOME); + + assertTrue(sticky > neutral, + "a resident already heading home should keep a small stability edge instead of immediately reconsidering on every near-tie"); + } + + @Test + void dependentMetadataNoLongerChangesHideScore() { + UUID residentId = uuid("00000000-0000-0000-0000-00000000a013"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, ACTIVE_TIME) + .withNeedState(8, 8, 8, 55, ACTIVE_TIME); + ResidentGoalContext noDependents = context(governorResident(), ACTIVE_TIME, null, profile); + ResidentGoalContext withDependents = new ResidentGoalContext( + governorResident(), + null, + ACTIVE_TIME, + ACTIVE_TIME, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + int hideWithoutDependents = NpcSocietyPhaseTwoIntentScorer.scoreIntent(noDependents, NpcIntent.HIDE); + int hideWithDependents = NpcSocietyPhaseTwoIntentScorer.scoreIntent(withDependents, NpcIntent.HIDE); + + assertTrue(hideWithoutDependents > 0); + assertEquals(hideWithoutDependents, hideWithDependents, + "dependent metadata should no longer alter hide scoring under the cheap safety-first model"); + } + + @Test + void recentWorkFailureCanTemporarilyPullFamilyResidentHome() { + long gameTime = 9200L; + UUID residentId = uuid("00000000-0000-0000-0000-00000000a014"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d014"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, gameTime) + .withPhaseOneState( + null, + homeId, + uuid("00000000-0000-0000-0000-00000000e014"), + NpcDailyPhase.ACTIVE, + NpcIntent.WORK, + NpcAnchorType.WORKPLACE, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "ASSIGNED_SHIFT", "HEADING_TO_WORKPLACE", + WorkResidentGoal.ID.toString(), "TASK_TIMED_OUT", NpcIntent.WORK.name(), gameTime - 60L), + gameTime + ) + .withNeedState(16, 84, 24, 18, gameTime); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(), + null, + gameTime, + gameTime, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + int goHome = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.GO_HOME); + int work = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK); + + assertTrue(goHome > work, + "after a timed-out work attempt, a tired family-linked resident should be allowed to regroup at home before work reasserts itself"); + } + + @Test + void failedMealCanEscalateToSupplyRunEvenWhenResidentHasHome() { + long time = 9400L; + UUID residentId = uuid("00000000-0000-0000-0000-00000000a016"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d016"); + SettlementResidentRecord worker = workerResident(residentId); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, time) + .withPhaseOneState( + null, + homeId, + uuid("00000000-0000-0000-0000-00000000e016"), + NpcDailyPhase.ACTIVE, + NpcIntent.EAT, + NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "HUNGER_PRESSURE", "MEAL_AT_HOME", + "bannermod:resident/goal/eat", "TASK_TIMED_OUT", NpcIntent.WORK.name(), time - 60L), + time + ) + .withNeedState(84, 16, 12, 8, time); + ResidentGoalContext ctx = new ResidentGoalContext( + worker, + settlementWithStockpile(worker), + time, + time, + profile, + 3, + NpcHouseholdHousingState.NORMAL, + true, + 1 + ); + + int eat = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.EAT); + int supplies = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.SEEK_SUPPLIES); + + assertTrue(supplies > eat, + "after a failed meal attempt, a hungry resident with stockpile access should switch to a supply run instead of hammering the same eat path again"); + } + + @Test + void freshGoHomeRecoverySuppressesImmediateWorkRetry() { + long time = 9300L; + UUID residentId = uuid("00000000-0000-0000-0000-00000000a017"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d017"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, time) + .withPhaseOneState( + null, + homeId, + uuid("00000000-0000-0000-0000-00000000e017"), + NpcDailyPhase.RETURNING_HOME, + NpcIntent.GO_HOME, + NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("RECOVERING", "bannermod:resident/goal/go_home", + "RETURNING_TO_HOUSEHOLD", "REGROUPING_AT_HOME", + WorkResidentGoal.ID.toString(), NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED, + NpcIntent.WORK.name(), time - 40L), + time + ) + .withNeedState(18, 54, 20, 18, time); + ResidentGoalContext ctx = new ResidentGoalContext( + workerResident(residentId), + null, + time, + time, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + int goHome = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.GO_HOME); + int work = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.WORK); + + assertTrue(goHome > work, + "once a resident is already regrouping at home after a broken work route, fresh work pressure should stay suppressed until that recovery move settles"); + } + + @Test + void invalidatedMealRecoveryPrefersSupplyRunOverAnotherMealRetry() { + long time = 9400L; + UUID residentId = uuid("00000000-0000-0000-0000-00000000a018"); + UUID homeId = uuid("00000000-0000-0000-0000-00000000d018"); + SettlementResidentRecord worker = workerResident(residentId); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, time) + .withPhaseOneState( + null, + homeId, + uuid("00000000-0000-0000-0000-00000000e018"), + NpcDailyPhase.ACTIVE, + NpcIntent.EAT, + NpcAnchorType.HOME, + new NpcSocietyDecisionSnapshot("BLOCKED", null, "HUNGER_PRESSURE", "MEAL_AT_HOME", + "bannermod:resident/goal/eat", NpcSocietyDecisionSnapshot.BLOCKED_REASON_CONTEXT_INVALIDATED, + NpcIntent.WORK.name(), time - 60L), + time + ) + .withNeedState(82, 18, 10, 12, time); + ResidentGoalContext ctx = new ResidentGoalContext( + worker, + settlementWithStockpile(worker), + time, + time, + profile, + 4, + NpcHouseholdHousingState.NORMAL, + true, + 2 + ); + + int eat = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.EAT); + int supplies = NpcSocietyPhaseTwoIntentScorer.scoreIntent(ctx, NpcIntent.SEEK_SUPPLIES); + + assertTrue(supplies > eat, + "when a meal path breaks because the context changed, the resident should switch harder into supply recovery instead of repeating the same home-meal plan"); + } + + private static ResidentGoalContext context(SettlementResidentRecord resident, + long gameTime, + SettlementSnapshot settlement, + NpcSocietyProfile profile) { + return new ResidentGoalContext(resident, settlement, gameTime, profile); + } + + private static SettlementResidentRecord villagerResident() { + return villagerResident(uuid("00000000-0000-0000-0000-00000000b001")); + } + + private static SettlementResidentRecord villagerResident(UUID residentId) { + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.VILLAGER, + SettlementResidentScheduleSeed.SETTLEMENT_IDLE, + SettlementResidentScheduleWindowSeed.DAYLIGHT_FLEX, + SettlementResidentRuntimeRoleState.VILLAGE_LIFE, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.SETTLEMENT_RESIDENT, + null, + null, + null, + SettlementResidentAssignmentState.NOT_APPLICABLE + ); + } + + private static SettlementResidentRecord workerResident() { + return workerResident(uuid("00000000-0000-0000-0000-00000000b002")); + } + + private static SettlementResidentRecord workerResident(UUID residentId) { + return new SettlementResidentRecord( + residentId, + SettlementResidentRole.CONTROLLED_WORKER, + SettlementResidentScheduleSeed.ASSIGNED_WORK, + SettlementResidentScheduleWindowSeed.LABOR_DAY, + SettlementResidentRuntimeRoleState.LOCAL_LABOR, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.PROJECTED_CONTROLLED_WORKER, + uuid("00000000-0000-0000-0000-00000000b012"), + "team-test", + uuid("00000000-0000-0000-0000-00000000b022"), + SettlementResidentAssignmentState.ASSIGNED_LOCAL_BUILDING + ); + } + + private static SettlementResidentRecord governorResident() { + return new SettlementResidentRecord( + uuid("00000000-0000-0000-0000-00000000b003"), + SettlementResidentRole.GOVERNOR_RECRUIT, + SettlementResidentScheduleSeed.SETTLEMENT_IDLE, + SettlementResidentScheduleWindowSeed.DAYLIGHT_FLEX, + SettlementResidentRuntimeRoleState.VILLAGE_LIFE, + SettlementResidentServiceContract.notServiceActor(), + SettlementResidentMode.SETTLEMENT_RESIDENT, + null, + null, + null, + SettlementResidentAssignmentState.NOT_APPLICABLE + ); + } + + private static SettlementSnapshot settlementWithOpenMarkets(SettlementResidentRecord resident, int openMarketCount) { + return new SettlementSnapshot( + uuid("00000000-0000-0000-0000-00000000c001"), + 0, + 0, + null, + ACTIVE_TIME, + 4, + 4, + 1, + 1, + 0, + 0, + SettlementStockpileSummary.empty(), + new SettlementMarketState(Math.max(1, openMarketCount), openMarketCount, 0, 0, 0, 0, List.of(), List.of()), + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + List.of(resident), + List.of() + ); + } + + private static SettlementSnapshot settlementWithStockpile(SettlementResidentRecord resident) { + return new SettlementSnapshot( + uuid("00000000-0000-0000-0000-00000000c016"), + 0, + 0, + null, + ACTIVE_TIME, + 4, + 4, + 1, + 1, + 0, + 0, + SettlementStockpileSummary.empty(), + new SettlementMarketState(1, 0, 0, 0, 0, 0, List.of(), List.of()), + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + List.of(resident), + List.of(new SettlementBuildingRecord( + uuid("00000000-0000-0000-0000-00000000f016"), + "bannermod:stockpile", + new BlockPos(4, 64, 4), + null, + null, + 0, + 0, + 0, + List.of(), + true, + 1, + 27, + false, + false, + List.of("food") + )) + ); + } + + private static UUID uuid(String value) { + return UUID.fromString(value); + } +} diff --git a/src/test/java/com/talhanation/bannermod/society/NpcSocietyProfileTest.java b/src/test/java/com/talhanation/bannermod/society/NpcSocietyProfileTest.java new file mode 100644 index 00000000..5e26707f --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/society/NpcSocietyProfileTest.java @@ -0,0 +1,44 @@ +package com.talhanation.bannermod.society; + +import net.minecraft.nbt.CompoundTag; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class NpcSocietyProfileTest { + @Test + void socialNeedDefaultsToZeroAndStaysDemoted() { + UUID residentId = UUID.fromString("00000000-0000-0000-0000-00000000aa01"); + NpcSocietyProfile profile = NpcSocietyProfile.createDefault(residentId, 6000L) + .withNeedState(10, 20, 95, 30, 6001L); + + assertEquals(0, profile.socialNeed(), "social pressure should stay demoted out of the cheap runtime profile"); + } + + @Test + void legacyTagsDropStoredSocialNeedOnLoad() { + CompoundTag tag = NpcSocietyProfile.createDefault( + UUID.fromString("00000000-0000-0000-0000-00000000aa02"), + 6000L + ).toTag(); + tag.putInt("SocialNeed", 88); + tag.putInt("TrustScore", 77); + tag.putInt("FearScore", 66); + tag.putInt("AngerScore", 55); + tag.putInt("GratitudeScore", 44); + tag.putInt("LoyaltyScore", 33); + + NpcSocietyProfile loaded = NpcSocietyProfile.fromTag(tag); + CompoundTag normalized = loaded.toTag(); + + assertEquals(0, loaded.socialNeed(), "legacy social-need values should normalize to zero in the cheap runtime model"); + assertFalse(normalized.contains("TrustScore"), "legacy trust values should not survive the cheap runtime rewrite"); + assertFalse(normalized.contains("FearScore"), "legacy fear values should not survive the cheap runtime rewrite"); + assertFalse(normalized.contains("AngerScore"), "legacy anger values should not survive the cheap runtime rewrite"); + assertFalse(normalized.contains("GratitudeScore"), "legacy gratitude values should not survive the cheap runtime rewrite"); + assertFalse(normalized.contains("LoyaltyScore"), "legacy loyalty values should not survive the cheap runtime rewrite"); + } +} diff --git a/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java b/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java new file mode 100644 index 00000000..e25b82df --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/war/registry/PoliticalRegistryCreateValidationTest.java @@ -0,0 +1,34 @@ +package com.talhanation.bannermod.war.registry; + +import net.minecraft.core.BlockPos; +import org.junit.jupiter.api.Test; + +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.assertTrue; + +class PoliticalRegistryCreateValidationTest { + private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + + @Test + void validateCreateRejectsSecondEntityForSameLeader() { + PoliticalRegistryRuntime runtime = new PoliticalRegistryRuntime(); + runtime.create("Acadia", LEADER, BlockPos.ZERO, "", "", "", "", 0L).orElseThrow(); + + PoliticalRegistryValidation.Result validation = runtime.canCreate("Brittany", LEADER); + + assertFalse(validation.valid()); + assertEquals("leader_already_has_entity", validation.reason()); + } + + @Test + void createAllowsDifferentLeaders() { + PoliticalRegistryRuntime runtime = new PoliticalRegistryRuntime(); + runtime.create("Acadia", LEADER, BlockPos.ZERO, "", "", "", "", 0L).orElseThrow(); + + assertTrue(runtime.create("Brittany", UUID.fromString("00000000-0000-0000-0000-0000000000bb"), BlockPos.ZERO, + "", "", "", "", 0L).isPresent()); + } +}