feat: add physical animal care delivery

This commit is contained in:
Rijad Zuzo
2026-07-31 00:19:41 +02:00
parent 7d410d8193
commit ead75fca68
25 changed files with 1469 additions and 191 deletions
+39 -6
View File
@@ -53,6 +53,8 @@ SimulationManager tick
compatibility path for isolated adapters;
- checks simulation-owned ResourceStateRecord availability;
- reserves and returns stable resource target IDs;
- reserves the exact loaded animal before returning the pantry pickup waypoint,
then resolves only that same claim for the delivery leg;
- skips full-capacity activity sites based on authoritative NPC target claims;
- returns typed activity-site, storage, or wander positions without owning
presentation.
@@ -88,13 +90,16 @@ The manager publishes the latest `ActionSelectionResult` for presentation; the
UI does not recompute decisions. Each idle selection re-derives the capable
helper instead of consulting persisted assignment state. At completion it
atomically pays any definition-backed stored-resource cost before applying the
action effect. A late shortfall suppresses the effect and records a
action effect. `feed_animal` is the one bounded compound exception: pantry
arrival withdraws the exact cost into inventory and requests the already
claimed animal as the second destination; goat arrival then enters normal
working completion. A late shortfall suppresses the effect and records a
`task_blocked` fact. The manager also captures actor/nearby knowledge before
forwarding newly recorded events into `RelationshipSystem`. When an NPC arrives
beside a worker at the same non-storage activity site, the manager may transfer
one direct known fact and applies its consequence only to that newly informed
listener. An open opportunity's exact trigger receives bounded priority in
that conversation before normal lasting/recent ranking. It exposes
forwarding newly recorded events into `RelationshipSystem`. When an NPC
arrives beside a worker at the same non-storage activity site, the manager may
transfer one direct known fact and applies its consequence only to that newly
informed listener. An open opportunity's exact trigger receives bounded
priority in that conversation before normal lasting/recent ranking. It exposes
knowledge, provenance, relationship, and cause queries without moving social
authority into UI. After consequences are known, it protects current causal
facts, enforces the recent-memory cap, and runs age review from authoritative
@@ -103,6 +108,8 @@ simulation ticks.
### VillageEconomy
- owns storage and NPC-inventory transfer operations;
- supports exact all-or-nothing pickup and carried-inventory removal for the
animal-care cost;
- keeps `village.food` and `village.wood` synchronized as aggregate views;
- validates and pays definition-backed completion costs;
- emits completed transaction facts without owning their history.
@@ -164,6 +171,32 @@ State restore clears active feedback and re-derives the prompt from current
simulation and loaded-world facts. The HUD is separate from development
overlays so the embodied action remains legible in cinematic mode.
## Compound animal-care delivery contract
NPC `feed_animal` remains one task and one exact animal claim across two travel
legs. `ActionTargetResolver` first chooses and reserves the nearest eligible
loaded animal but returns `ActiveWorldAdapter`'s pantry interaction position
when the caretaker lacks the definition cost. Pantry arrival atomically moves
the missing amount into `SimNPC.inventory`; the existing inventory signal makes
the carried-food prop visible. The resolver then looks up only the claimed
animal and requests its current loaded interaction position.
The second leg is derived from carried inventory rather than a separate saved
phase. Pickup occurs only when the completed saved waypoint still matches the
current pantry waypoint. A compatible older route with empty inventory and a
goat destination is first redirected to the pantry instead of withdrawing
remotely. An older already-working feed state with no carried food recovers the
same route on its first resumed tick and preserves its work progress. Before a
successful pickup publishes inventory, event, or village signals, the saved
destination already names the exact goat; synchronous observers therefore
cannot capture carried food with a stale pantry waypoint. At the goat,
completion removes the exact cost from the matching NPC inventory, mutates only
that animal, releases its claim, and records the inventory-to-animal fact. If
pantry contention loses, no partial transfer is allowed, one `task_blocked`
fact names the pantry shortfall, and only that caretaker's goat claim is
released. Player feeding remains the bounded direct pantry-to-animal path
because player inventory is still deferred.
## Active-position contract
NpcVisual emits active position changes after successful movement.
+2 -2
View File
@@ -39,8 +39,8 @@ would otherwise obscure that lifecycle:
and keeps village resource summaries synchronized;
- `simulation/animals/animal_care_system.gd` owns animal records, hunger
advancement, deterministic routine selection, loaded binding, feed
reservations, and the exact conserved pantry-to-animal operation used by
NPCs and the player;
reservations, exact NPC pantry-to-inventory-to-animal delivery, and the
bounded direct player pantry-to-animal operation;
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
history queries, and rate calculations;
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
+15 -8
View File
@@ -801,9 +801,9 @@ Completed:
40. `Jajce Goat 07`: Dunja is the first identity-backed animal, with a stable
goat ID, simulation-owned position and hunger, and a loaded cozy
presentation with an honest hungry cue. NPC and player feeding share one
exact transaction that withdraws pantry food, changes only Dunja's state,
and emits one economic fact. World-schema v10 save/restore preserves the
result without replaying the transient feed response.
exact target mutation backed by real food that changes only Dunja's state
and emits one exact `animal_fed` fact. World-schema v10 save/restore
preserves the result without replaying the transient feed response.
41. `Jajce Dunja Habitat 08`: a self-contained timber shelter and clover
pasture now expose two stable routine sites in an open south-village
clearing. Dunja alternates between them without RNG, follows the real
@@ -823,14 +823,21 @@ Completed:
availability, reports success or blockage, remains visible in cinematic
mode, and also covers existing resource, pantry, guard, and study actions.
Restore discards transient feedback and re-derives context from live facts.
44. `Jajce Care Delivery 11`: NPC animal care now keeps one exact named-goat
claim while walking first to the real pantry and then to that goat. Pantry
pickup creates authoritative carried inventory and reveals the existing
food sack; delivery consumes that unit and records the inventory-to-animal
fact. One-unit contention, both route legs, and post-pickup continuation
prove conservation without adding a saved phase field.
Next:
1. Make NPC feeding visibly collect one real pantry food into the caretaker's
existing inventory/carried-food presentation before delivery to the exact
reserved goat.
2. Prove conservation, claim continuity, and save/restore during both travel
legs; keep herd, product, indexing, and LOD systems deferred.
1. Give the player one restrained nearby-villager inspection context backed by
the authoritative task, exact target, carried item, and recorded decision
reason when it currently exists.
2. Prove that the field note follows live task changes and, after restore,
re-derives the persistent facts while honestly awaiting the next decision
reason. It must not become dialogue, quest, or saved presentation state.
Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already
+16 -11
View File
@@ -18,10 +18,12 @@ simulation tick, actor ID, source ID, destination ID, item ID, and transferred
amount, plus the authoritative world position captured when the fact is
recorded. NPC events preserve their authoritative interaction target (or NPC
position when no target applies); player extraction/depletion events preserve
the ResourceNode interaction position. `animal_fed` records one food moving
from `village_pantry` to a stable animal ID at the animal's authoritative
position. Actor `-1` identifies a player-triggered extraction or feeding until
persistent player identity is introduced.
the ResourceNode interaction position. NPC animal care records a
`storage_withdrawn` move from `village_pantry` to the matching caretaker
inventory at pickup, then an `animal_fed` move from that inventory to the
stable animal ID at delivery. Player `animal_fed` remains a direct
pantry-to-animal fact; actor `-1` identifies that player-triggered feeding or
extraction until persistent player identity is introduced.
Events are immutable facts about completed transfers. They do not perform the
transaction and are not replayed to reconstruct current state. Resource,
@@ -31,9 +33,9 @@ inventory, and storage records remain authoritative.
history/rate queries, including exact lookup through `get_by_id()`.
`VillageEconomy` performs inventory and storage transactions and requests event
records only after state changes succeed. `AnimalCareSystem` composes the
pantry withdrawal with animal hunger relief and requests `animal_fed` only
after both succeed. `SimulationManager` remains the scene-tree signal boundary
used by presentation.
exact carried-inventory removal with animal hunger relief and requests
`animal_fed` only after both succeed. `SimulationManager` remains the
scene-tree signal boundary used by presentation.
An action whose definition-backed completion cost becomes unavailable records
a zero-amount `task_blocked` narrative fact with stable action, source storage,
@@ -56,10 +58,13 @@ references, impossible communicator sources, relationship causes the observer
does not know, and next IDs that could collide with restored history.
Animal-feed facts receive an additional cross-record check: actor is either an
existing NPC or the player sentinel, source is the real pantry, destination is
an existing animal, item and definition cost are exactly one food, event tick
is not in the future, and the animal's `last_fed_tick` agrees with its latest
feed fact.
existing NPC or the player sentinel, destination is an existing animal, item
and definition cost are exactly one food, event tick is not in the future, and
the animal's `last_fed_tick` agrees with its latest feed fact. New NPC facts
emitted at runtime name that actor's matching inventory; player facts name the
pantry. Pantry-sourced NPC facts remain accepted as legacy-compatible v11
history rather than being silently rewritten into an invented carried
transfer.
Positive food deposits, positive NPC food withdrawals from `village_pantry`
into that actor's matching inventory, and definition-backed patrol/study
+24 -7
View File
@@ -16,12 +16,22 @@ ResourceStateRecord (berry bush)
Food is conserved across source, carried inventory, and pantry. It leaves the
world only when consumed.
Animal care extends the same ownership chain without a parallel counter:
```text
village_pantry
-> feed_animal pickup moves one food into exact caretaker inventory
-> existing carried-food prop derives from that inventory
-> delivery consumes caretaker inventory into exact named animal
```
## Authority
- `StorageStateRecord` owns pantry and woodpile contents and capacity.
- `SimNPC.inventory` owns carried item amounts.
- `VillageEconomy` performs deposit, withdrawal, consumption, and
definition-backed completion-cost transactions.
definition-backed completion-cost transactions, including exact
all-or-nothing animal-care pickup and inventory removal.
- `SimulationManager` coordinates action lifecycle and exposes the transaction
results to presentation.
- `village.food` and `village.wood` are synchronized aggregate views used by
@@ -40,6 +50,8 @@ Successful extraction, deposit, withdrawal, and consumption also append
structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md).
Active NPCs display a small food sack while their authoritative inventory
contains food; this is presentation derived from state, not a second inventory.
NPC animal care therefore uses the same visible prop between pantry pickup and
the claimed goat.
## Failure behavior
@@ -50,23 +62,28 @@ Transactions apply the amount actually available:
- player extraction is limited to storage capacity until player inventory
exists, so overflow remains at the source;
- withdrawal cannot exceed pantry contents;
- animal-care pickup succeeds only when the complete missing cost is available;
- eating succeeds only when the NPC carries one food.
If another NPC empties the pantry first, withdrawal returns zero and the NPC
replans on its next idle tick.
If another caretaker empties the pantry first, animal-care pickup records the
definition-backed shortfall, transfers nothing, releases only the losing
caretaker's exact animal claim, and replans. A caretaker that already carries
the unit never consults pantry stock again before delivery.
## Deliberate limits
This slice contains one authored pantry and one carried item type. It does not
yet add:
The current bounded economy has one authored pantry and one woodpile with
simple food/wood stacks. It does not yet add:
- player inventory;
- storage reservations or access capacity;
- multiple households or ownership;
- spoilage, item quality, weight, or stack definitions.
Economic transfer events exist, but event retention, denial/depletion facts,
witnesses, and social interpretation are deliberately deferred.
Objective event history, depletion and blocked-task facts, bounded per-NPC
knowledge, proximity witnesses, and the first social consequences now exist.
Long-run event archival, generalized denial facts, wider witness models, and
broader social interpretation remain deferred.
Those should extend the transaction boundary rather than mutate counters
directly.
+25 -9
View File
@@ -926,9 +926,10 @@ code proven by those two consumers.
The first identity-backed livestock slice is complete. Dunja has a stable
animal ID, simulation-owned position and hunger/feed state, and a loaded cozy
presentation whose persistent cue derives from that state. One exact operation
serves both NPC and player feeding, withdraws real pantry food, mutates only the
target animal, and appends one economic fact. World-schema v10 save/restore
preserves the result without replaying the transient response.
serves both NPC and player feeding, consumes real food from the actor's current
source, mutates only the target animal, and appends one economic fact.
World-schema v10 save/restore preserves the result without replaying the
transient response.
The first loaded-animal routine slice is complete. Dunja alternates between
stable shelter and pasture IDs without RNG; her exact target and current
@@ -955,15 +956,30 @@ shows the action and real availability, remains visible in cinematic mode, and
briefly reports success or blockage. Restore clears transient feedback and
re-derives the current prompt without adding saved state or drawing RNG.
The next bounded slice should make NPC animal care physically honest:
`village_pantry -> NPC inventory -> exact named goat`. A caretaker should keep
the same animal claim, collect one real food into the existing carried-food
presentation, and deliver it without duplication across contention or
mid-route restore. Keep breeding, products, herd scheduling, animal indexing,
and active/abstract animal LOD deferred.
NPC animal care is now physically honest:
`village_pantry -> NPC inventory -> exact named goat`. A caretaker reserves the
goat before visiting the pantry, keeps that stable claim through both travel
legs, visibly carries the withdrawn unit, and consumes only carried inventory
at delivery. One-unit contention records the late shortfall and releases only
the losing claim. A post-pickup restore preserves the inventory, exact goat,
destination, event count, and final checksum without a new saved phase field
or RNG draw.
The next bounded slice should advance Milestone 9 with player-facing villager
inspection. One nearby-person context should show the authoritative name,
current action, exact target, carried item, and current recorded decision reason
when available. After restore it should honestly say that it is awaiting the
next decision while the other facts re-derive. Do not add dialogue, trade,
reputation, quest state, breeding, products, herd scheduling, animal indexing,
or active/abstract animal LOD in that slice.
Recently completed:
- `Jajce Care Delivery 11`: NPC `feed_animal` is now one compound, two-leg
action from the real pantry through caretaker inventory to the exact reserved
goat. Carried presentation, ordered transfer facts, late contention,
conservation, and post-pickup deterministic continuation are proven without
a schema bump or generic job framework.
- `Jajce Context Action 10`: one ephemeral result now keeps the displayed `E`
prompt and executed stable target aligned across existing player
interactions. Named goat care shows exact pantry availability and transient
+19 -7
View File
@@ -642,6 +642,10 @@ These are expected prototype constraints, not necessarily isolated bugs:
- Food and wood now move through finite sources, NPC inventory, and typed
village storage. Other village metrics remain aggregate values rather than
located items.
- NPC animal care keeps one exact named-goat claim through a real pantry pickup
and carried-inventory delivery. The existing persisted task, target,
destination, inventory, and animal reservation encode both legs without a
separate saved phase.
- NPCs have home positions, schedule periods, carried food/wood, and directed
familiarity/trust. They retain direct or one-hop communicated knowledge of a
food deposit or NPC pantry withdrawal, plus direct missing-wood blocked-work
@@ -895,10 +899,12 @@ The location-based food loop is complete and traceable: gathering creates
carried food, depositing fills the pantry, withdrawal retrieves one unit, and
eating consumes it. Each successful transfer creates a persisted structured
event, and active NPCs visibly carry food through presentation derived from
their inventory. The bounded save-slot slice is also complete, including
validation, atomic replacement recovery, and active-visual rebuilding.
Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
simulation-garden runtime capture are complete.
their inventory. Animal care now uses that same ownership path: the caretaker
withdraws at the pantry, carries the unit to the exact claimed goat, and pays
from inventory at delivery. The bounded save-slot slice is also complete,
including validation, atomic replacement recovery, and active-visual
rebuilding. Terrain3D runtime integration, the bounded Jajce beauty pass, and
the first simulation-garden runtime capture are complete.
The first relationship and knowledge consequences are also complete:
food-deposit events become separate known facts for their actor and nearby
@@ -1001,7 +1007,7 @@ decorative density from simulation authority, support discovery beyond the
first 24 m range, and reconstruct sparse/depleted visuals from authoritative
amount. Dunja now proves the first identity-backed animal contract: stable
animal identity, simulation-owned position and hunger, a loaded visual binding,
and the same exact pantry-conserving feed operation for NPC and player.
and the same exact food-conserving target mutation for NPC and player.
World-schema v10 restore reconstructs her persistent state without replaying
the transient response. Dunja now also alternates between a stable shelter and
pasture through real loaded navigation. World-schema v11 preserves exact
@@ -1014,8 +1020,14 @@ preserves both claim pairings without serializing shelter ownership. Do not
generalize animal discovery or introduce herd scheduling, products, breeding,
or active/abstract animal LOD. The named-pair gate instead produced one
Milestone 9 context-action result shared by prompt and execution, with exact
pantry feedback and no persistent UI state. The next animal-facing proof should
make NPC care visibly carry one real pantry food to its exact reserved goat.
pantry feedback and no persistent UI state. NPC care now completes that
physical proof with one compound pantry-to-inventory-to-goat action, exact
claim continuity, visible carrying, late-contention safety, and post-pickup
deterministic continuation. The next bounded proof should let the player
inspect one nearby villager's real action, target, carried item, and recorded
decision reason when available. After restore it should re-derive persistent
facts and honestly await the next decision reason without introducing dialogue
or saved UI state.
The remaining simulation-garden target still aims for:
+4 -2
View File
@@ -35,8 +35,10 @@ both require one stored wood without duplicating that rule.
`feed_animal` is the first animal-targeted action. Its definition prefers the
farmer profession and declares an exact one-food completion cost. Selection,
animal target reservation, and completion remain separate; NPC and player
completion both use `AnimalCareSystem`'s same pantry-to-animal transaction.
animal target reservation, supply pickup, and completion remain separate. NPC
care pays that definition cost from carried inventory after a real pantry
pickup; player care pays it directly from the pantry while player inventory is
deferred. Both paths use `AnimalCareSystem` and mutate only the exact target.
`SimulationIds` names Dunja and Zora, their private shelter anchors, and the
shared pasture anchor. These IDs are saved routine vocabulary; their loaded
positions and private/shared ownership come from `AnimalRoutineSite` rather
+42 -18
View File
@@ -76,19 +76,37 @@ target selection or deterministic RNG state.
NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
StorageStateRecord entries; world-schema v1 migrates legacy village food and
wood into the stable `village_pantry` and `village_woodpile` records. Parsed
storage values are canonicalized so save/restore continuation retains
byte-stable checksums. Current parsing rejects empty item IDs, negative or
non-finite amounts, negative or non-finite capacity, and contents above
capacity. Runtime deposit and withdrawal requests also reject empty item IDs
and non-finite amounts without mutating authoritative stock. When the authored
pantry or woodpile is present, current world parsing also requires its
authoritative food or wood amount to match the duplicated village summary;
wood into the stable `village_pantry` and `village_woodpile` records. NPC
inventory IDs and amounts are canonicalized to strings and floats; empty or
colliding normalized IDs, non-numeric values, and negative or non-finite
amounts are rejected.
Parsed storage values are likewise canonicalized so save/restore continuation
retains byte-stable checksums. Current storage parsing rejects empty item IDs,
negative or non-finite amounts, negative or non-finite capacity, and contents
above capacity. Runtime deposit and withdrawal requests also reject empty item
IDs and non-finite amounts without mutating authoritative stock. When the
authored pantry or woodpile is present, current world parsing also requires
its authoritative food or wood amount to match the duplicated village summary;
contradictory saves are rejected instead of silently changing checksum during
restore. World-v1 migration expands a storage's inferred capacity when needed
so valid legacy stock above the current default is preserved rather than
discarded.
The compound NPC `feed_animal` route needs no additional saved phase. An
in-progress record already contains the exact animal `target_id`, matching
animal reservation, carried inventory, task state, and active travel
destination. Insufficient carried food plus a pantry destination is the pickup
leg; the definition cost in inventory plus the animal destination is the
delivery leg. Restore emits only the saved travel request and never repeats
pickup. A compatible pre-delivery v11 feed record may instead have empty
inventory and the claimed animal as its destination; arrival redirects that
same claim through the current pantry waypoint before any withdrawal. If that
older record is already working, its first resumed tick performs the same
reroute and carries its saved work progress back to the goat. Successful
pickup changes the saved destination to the exact animal before publishing
synchronous inventory, event, or village signals, so signal-time saves cannot
encode carried food with a stale pantry destination.
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
stream. Event records use stable source/destination/item IDs, reject invalid or
@@ -199,10 +217,12 @@ selection establishes the first real target later.
Current-schema parsing requires unique animal IDs that do not collide with
resource or storage IDs. An animal reservation must belong to an existing NPC
whose active feed task targets that exact animal. Every `animal_fed` event
must name the pantry as its source, an existing animal as its destination,
food as its item, the exact feed action cost, and a valid NPC actor or the
player sentinel. The latest matching feed event tick must agree with the
animal's `last_fed_tick`.
must name an existing animal as its destination, food as its item, the exact
feed action cost, and a valid NPC actor or the player sentinel. New NPC events
name the matching `npc_<id>_inventory`; player events name the pantry. Direct
pantry-sourced NPC facts remain accepted as legacy-compatible v11 history,
while runtime emits matching-inventory sources for new NPC deliveries. The
latest matching feed event tick must agree with the animal's `last_fed_tick`.
The bounded opportunity family accepts at most one open record globally.
Opportunity, trigger-event, and resolution-event IDs are unique and the next
@@ -279,10 +299,12 @@ while the node is loaded or absent.
Animals are not `ResourceNode` instances and are not inserted into the
resource discovery grid. The first bounded slice uses a deterministic linear
loaded-animal query. NPC and player feeding resolve through the same atomic
care operation: reserve or select the exact animal, withdraw one real food
from `village_pantry`, reduce that animal's hunger, and append one exact
`animal_fed` fact. Failed withdrawal changes neither animal nor history.
loaded-animal query. NPC and player feeding resolve through the same exact
animal mutation but pay from their real current source. Player care withdraws
directly from `village_pantry`. NPC care reserves the animal, performs an exact
pantry-to-inventory pickup at the authored storage waypoint, carries that unit
to the same claim, and consumes inventory only at delivery. Failed or partial
pickup changes neither inventory nor animal history.
The first routine consumers alternate Dunja and Zora between stable-ID private
shelters and one shared pasture without drawing RNG. `AnimalRoutineSite`
@@ -299,8 +321,10 @@ Hunger cancels travel before care targeting.
Multiple animal records remain independently authoritative. A schema-v11 save
may contain concurrent feed reservations only when each reserving NPC targets
that exact animal in a valid travel or work state. Restoring Dunja and Zora
preserves both claim pairings and the checksum; completing one feed releases
and mutates only its destination record.
preserves both claim pairings and the checksum. The same existing NPC fields
preserve either pantry or goat travel plus carried food, so a post-pickup
restore continues without another withdrawal or RNG draw. Completing one feed
releases and mutates only its destination record.
The persistent hungry/content presentation derives from authoritative hunger.
The short feed response is transient and is reset rather than serialized or
+2 -1
View File
@@ -101,10 +101,11 @@ func set_task(action_id: StringName, duration: float = -1.0) -> void:
has_travel_target = false
func start_working() -> void:
func start_working(preserve_progress: bool = false) -> void:
if task_state != TASK_STATE_TRAVELING:
return
task_state = TASK_STATE_WORKING
if not preserve_progress:
task_progress = 0.0
task_complete = false
+55 -73
View File
@@ -3,6 +3,7 @@ extends Node
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd")
const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
@@ -187,6 +188,13 @@ func _simulate_npc_tick(npc: SimNPC) -> bool:
var was_complete := npc.task_complete
var was_dead := npc.is_dead
if animal_care.requires_working_feed_recovery(npc):
npc.task_state = SimNPC.TASK_STATE_TRAVELING
npc.task_complete = false
if not _continue_animal_feed_delivery(npc):
_redirect_npc_to_wander(npc)
return false
action_executor.advance_npc(npc, village)
_population_view.refresh_npc(npc)
_select_action_if_idle(npc, previous_state)
@@ -205,7 +213,8 @@ func _simulate_npc_tick(npc: SimNPC) -> bool:
_complete_current_action(npc)
_population_view.refresh_npc(npc)
_debug_npc_tick(npc, previous_state)
if debug_logs:
NpcTickDebugLog.print_tick(npc, previous_state)
return completed
@@ -333,45 +342,6 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
release_npc_reservation(npc.id)
func _debug_npc_tick(npc: SimNPC, previous_state: StringName) -> void:
if not debug_logs:
return
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if previous_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
previous_state,
" -> ",
npc.task_state
)
func release_npc_reservation(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
@@ -406,13 +376,7 @@ func notify_npc_arrived(npc_id: int) -> void:
or resource_state.get_reserved_by() != npc.id
):
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but target ",
npc.target_id,
" is unavailable, replanning"
)
NpcTickDebugLog.print_unavailable_resource(npc)
notify_npc_navigation_failed(npc.id)
return
if (
@@ -422,18 +386,19 @@ func notify_npc_arrived(npc_id: int) -> void:
):
if not animal_care.can_complete_feed(npc.target_id, npc.id):
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but animal ",
npc.target_id,
" no longer needs feeding, replanning"
)
NpcTickDebugLog.print_unavailable_animal(npc)
notify_npc_navigation_failed(npc.id)
return
if animal_care.requires_feed_pickup(npc):
if _continue_animal_feed_delivery(npc):
return
_redirect_npc_to_wander(npc)
return
npc.has_travel_target = false
npc.start_working()
npc.start_working(
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
)
var working_speakers: Array[SimNPC] = []
if npc.target_id != &"":
@@ -455,15 +420,42 @@ func notify_npc_arrived(npc_id: int) -> void:
break
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived and started working on: ",
npc.current_task
)
NpcTickDebugLog.print_started_work(npc)
return
func _continue_animal_feed_delivery(npc: SimNPC) -> bool:
var animal_target := target_resolver.resolve_claimed_animal(npc, self, active_world_adapter)
var pantry_target := target_resolver.resolve_storage_target(
SimulationIds.STORAGE_VILLAGE_PANTRY, active_world_adapter
)
if animal_target.is_empty() or pantry_target.is_empty():
return false
if not npc.travel_target_position.is_equal_approx(pantry_target["position"]):
npc.travel_target_position = pantry_target["position"]
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
var pickup_target := npc.travel_target_position
npc.travel_target_position = animal_target["position"]
npc.has_travel_target = true
if not animal_care.collect_feed_supply(npc):
npc.travel_target_position = pickup_target
return false
village_changed.emit(village)
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
func _redirect_npc_to_wander(npc: SimNPC) -> void:
var failed_task := npc.current_task
release_npc_reservation(npc.id)
npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
func notify_npc_navigation_failed(npc_id: int) -> void:
for npc in npcs:
if npc.id != npc_id:
@@ -472,20 +464,10 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
return
var failed_task := npc.current_task
release_npc_reservation(npc.id)
npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
_redirect_npc_to_wander(npc)
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" could not navigate for ",
failed_task,
" and is trying a wander target"
)
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
return
@@ -227,9 +227,48 @@ func _resolve_animal(
var target_id := StringName(best["target_id"])
if not simulation_manager.animal_care.reserve(target_id, npc.id):
return {}
if simulation_manager.animal_care.requires_feed_pickup(npc):
var pantry_target := resolve_storage_target(
SimulationIds.STORAGE_VILLAGE_PANTRY, active_world_adapter
)
if pantry_target.is_empty():
simulation_manager.animal_care.release(target_id, npc.id)
return {}
best["position"] = pantry_target["position"]
return best
func resolve_storage_target(storage_id: StringName, active_world_adapter: Node) -> Dictionary:
if active_world_adapter == null or not active_world_adapter.has_method("get_storage_target"):
return {}
return active_world_adapter.get_storage_target(storage_id)
func resolve_claimed_animal(
npc: SimNPC, simulation_manager: Node, active_world_adapter: Node
) -> Dictionary:
if (
npc == null
or npc.target_id.is_empty()
or not active_world_adapter.has_method("get_animal_candidates")
):
return {}
var animal_state: AnimalStateRecord = simulation_manager.animal_care.get_state(npc.target_id)
if (
animal_state == null
or not animal_state.needs_feed()
or animal_state.get_reserved_by() != npc.id
):
return {}
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
SimulationIds.ACTION_FEED_ANIMAL
)
for candidate in candidates:
if StringName(candidate["target_id"]) == npc.target_id:
return candidate
return {}
func score_resource_candidate(
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
) -> float:
+104 -28
View File
@@ -119,6 +119,61 @@ func can_complete_feed(animal_id: StringName, agent_id: int) -> bool:
)
func requires_feed_pickup(npc: SimNPC) -> bool:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
return (
npc != null
and definition != null
and definition.has_completion_cost()
and (
npc.get_inventory_amount(definition.completion_cost_resource_id)
< definition.completion_cost_amount
)
)
func requires_working_feed_recovery(npc: SimNPC) -> bool:
return (
npc != null
and npc.current_task == SimulationIds.ACTION_FEED_ANIMAL
and npc.task_state == SimNPC.TASK_STATE_WORKING
and requires_feed_pickup(npc)
and can_complete_feed(npc.target_id, npc.id)
)
func collect_feed_supply(npc: SimNPC) -> bool:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if (
npc == null
or npc.current_task != SimulationIds.ACTION_FEED_ANIMAL
or npc.target_id.is_empty()
or not can_complete_feed(npc.target_id, npc.id)
or definition == null
or not definition.has_completion_cost()
or economy == null
):
return false
var resource_id := definition.completion_cost_resource_id
var required_amount := definition.completion_cost_amount
var carried_amount := npc.get_inventory_amount(resource_id)
var pickup_amount := maxf(required_amount - carried_amount, 0.0)
if pickup_amount <= 0.0:
return true
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
if pantry == null or available < pickup_amount:
_emit_feed_blocked(
npc.id, pantry.get_storage_id() if pantry != null else &"", definition, available
)
return false
var withdrawn: float = economy.withdraw_exact_to_inventory(npc, resource_id, pickup_amount)
if not is_equal_approx(withdrawn, pickup_amount):
_emit_feed_blocked(npc.id, pantry.get_storage_id(), definition, available)
return false
return true
func has_available_feed_target(npc_id: int) -> bool:
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
return false
@@ -158,11 +213,13 @@ func find_node_for_player(from_position: Vector3, max_distance: float) -> Animal
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
var animal_state := get_state(animal_id)
if animal_state == null or not animal_state.needs_feed():
if animal_state == null or not animal_state.needs_feed() or current_tick < 0:
return false
var actor_npc: SimNPC
if actor_id >= 0:
actor_npc = _find_npc(actor_id, npcs)
if (
_find_npc(actor_id, npcs) == null
actor_npc == null
or not animal_state.can_npc_feed()
or animal_state.get_reserved_by() != actor_id
):
@@ -176,36 +233,28 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
var resource_id := definition.completion_cost_resource_id
var required_amount := definition.completion_cost_amount
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
var source_id := SimulationIds.STORAGE_VILLAGE_PANTRY
var consumed := 0.0
if actor_npc != null:
var carried_amount := actor_npc.get_inventory_amount(resource_id)
if carried_amount < required_amount:
_emit_feed_blocked(
actor_id, SimulationIds.npc_inventory_id(actor_id), definition, carried_amount
)
return false
consumed = economy.remove_exact_from_inventory(actor_npc, resource_id, required_amount)
source_id = SimulationIds.npc_inventory_id(actor_id)
else:
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
if pantry == null or available < required_amount:
if actor_id >= 0:
(
narrative_event_requested
. emit(
SimulationIds.EVENT_TASK_BLOCKED,
actor_id,
pantry.get_storage_id() if pantry != null else &"",
(
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
required_amount,
String(resource_id).capitalize(),
available,
]
),
definition.action_id,
resource_id,
required_amount
)
)
return false
var consumed: float = economy.withdraw_resource(resource_id, required_amount)
if consumed < required_amount:
economy.deposit_resource(resource_id, consumed)
consumed = economy.withdraw_resource(resource_id, required_amount)
if not is_equal_approx(consumed, required_amount):
return false
if not animal_state.feed(actor_id, current_tick):
if actor_npc != null:
economy.restore_to_inventory(actor_npc, resource_id, consumed)
else:
economy.deposit_resource(resource_id, consumed)
return false
if animal_state.has_active_travel_target():
@@ -216,7 +265,7 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
economic_event_requested.emit(
SimulationIds.EVENT_ANIMAL_FED,
actor_id,
pantry.get_storage_id(),
source_id,
animal_state.get_animal_id(),
resource_id,
consumed,
@@ -225,6 +274,33 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
return true
func _emit_feed_blocked(
actor_id: int, source_id: StringName, definition: ActionDefinition, available_amount: float
) -> void:
if actor_id < 0:
return
(
narrative_event_requested
. emit(
SimulationIds.EVENT_TASK_BLOCKED,
actor_id,
source_id,
(
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(),
available_amount,
]
),
definition.action_id,
definition.completion_cost_resource_id,
definition.completion_cost_amount
)
)
func append_state_records(record: SimulationStateRecord) -> void:
var animal_ids: Array = states.keys()
animal_ids.sort()
+74
View File
@@ -0,0 +1,74 @@
extends RefCounted
static func print_tick(npc: SimNPC, previous_state: StringName) -> void:
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if previous_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
previous_state,
" -> ",
npc.task_state
)
static func print_unavailable_resource(npc: SimNPC) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but target ",
npc.target_id,
" is unavailable, replanning"
)
static func print_unavailable_animal(npc: SimNPC) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but animal ",
npc.target_id,
" no longer needs feeding, replanning"
)
static func print_started_work(npc: SimNPC) -> void:
print(
"[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task
)
static func print_navigation_failure(npc: SimNPC, failed_task: StringName) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" could not navigate for ",
failed_task,
" and is trying a wander target"
)
@@ -0,0 +1 @@
uid://d3nnthqd1b7d3
+32
View File
@@ -141,6 +141,38 @@ func withdraw_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> f
return withdrawn
func withdraw_exact_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> float:
if npc == null or not is_finite(amount) or amount <= 0.0:
return 0.0
var storage := get_storage_for_resource(item_id)
if storage == null or storage.get_amount(item_id) < amount:
return 0.0
return withdraw_to_inventory(npc, item_id, amount)
func remove_exact_from_inventory(npc: SimNPC, item_id: StringName, amount: float) -> float:
if (
npc == null
or not is_finite(amount)
or amount <= 0.0
or npc.get_inventory_amount(item_id) < amount
):
return 0.0
var removed := npc.remove_inventory(item_id, amount)
if not is_equal_approx(removed, amount):
npc.add_inventory(item_id, removed)
return 0.0
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
return removed
func restore_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> void:
if npc == null or not is_finite(amount) or amount <= 0.0:
return
npc.add_inventory(item_id, amount)
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
func consume_npc_food(npc: SimNPC) -> bool:
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
npc.hunger = minf(npc.hunger + 5.0, 100.0)
+18 -1
View File
@@ -98,6 +98,21 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
return null
if not record_data["inventory"] is Dictionary:
return null
var normalized_inventory := {}
for raw_item_id in record_data["inventory"]:
var item_id := str(raw_item_id)
var raw_amount = record_data["inventory"][raw_item_id]
if typeof(raw_amount) not in [TYPE_INT, TYPE_FLOAT]:
return null
var amount := float(raw_amount)
if (
item_id.is_empty()
or normalized_inventory.has(item_id)
or not is_finite(amount)
or amount < 0.0
):
return null
normalized_inventory[item_id] = amount
var profession_id := StringName(record_data["profession"])
if SimulationDefinitions.get_profession(profession_id) == null:
return null
@@ -110,7 +125,9 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
var last_action_id := StringName(record_data["last_task"])
if not last_action_id.is_empty() and SimulationDefinitions.get_action(last_action_id) == null:
return null
return NPCStateRecord.new(record_data)
var normalized := record_data.duplicate(true)
normalized["inventory"] = normalized_inventory
return NPCStateRecord.new(normalized)
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
+9 -1
View File
@@ -473,11 +473,19 @@ static func _is_valid_animal_feed_event(
var animal := (
animal_records_by_id.get(StringName(event.data["destination_id"])) as AnimalStateRecord
)
var source_id := StringName(event.data["source_id"])
var has_valid_source := source_id == SimulationIds.STORAGE_VILLAGE_PANTRY
if actor_id >= 0:
# Existing v11 saves contain direct pantry-to-animal NPC feed facts.
# New physical deliveries name the matching carried inventory instead.
has_valid_source = (
has_valid_source or source_id == SimulationIds.npc_inventory_id(actor_id)
)
return (
(actor_id == -1 or npc_ids.has(actor_id))
and int(event.data["tick"]) <= current_tick
and animal != null
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and has_valid_source
and storage_records_by_id.has(SimulationIds.STORAGE_VILLAGE_PANTRY)
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and definition != null
+20 -4
View File
@@ -18,6 +18,7 @@ func _run() -> void:
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
var player := main_scene.get_node("Player") as Node3D
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var pantry: StorageStateRecord = manager.get_pantry()
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(goat != null, "Jajce should load Dunja's named presentation")
@@ -81,9 +82,23 @@ func _run() -> void:
(
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
and state.get_reserved_by() == caretaker.id
and pantry_node != null
and caretaker.travel_target_position.is_equal_approx(
pantry_node.get_interaction_position()
)
),
"Animal target resolution should retain Dunja's exact claim while routing to the pantry"
)
manager.notify_npc_arrived(caretaker.id)
_check(
(
is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and caretaker.target_id == SimulationIds.ANIMAL_DUNJA
and state.get_reserved_by() == caretaker.id
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.travel_target_position.is_equal_approx(goat.get_interaction_position())
),
"Animal target resolution should claim Dunja's loaded interaction point by stable ID"
"Pantry arrival should carry one real food toward the same reserved Dunja",
)
manager.notify_npc_arrived(caretaker.id)
manager.simulate_tick()
@@ -115,14 +130,15 @@ func _run() -> void:
npc_feed_event != null
and int(npc_feed_event.data["actor_id"]) == caretaker.id
and (
StringName(npc_feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
StringName(npc_feed_event.data["source_id"])
== SimulationIds.npc_inventory_id(caretaker.id)
)
and (StringName(npc_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(npc_feed_event.data["amount"]), 1.0)
and npc_feed_event.get_world_position().is_equal_approx(state.get_position())
),
"NPC feeding should record one exact pantry-to-animal transfer fact"
"NPC feeding should record one exact carried-inventory-to-animal transfer fact"
)
_check(
(
@@ -172,7 +188,7 @@ func _run() -> void:
and int(player_feed_event.data["actor_id"]) == -1
and (StringName(player_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
),
"The player should use the same one-food feed operation and event contract"
"The player should use the same exact one-food animal mutation"
)
var player_feed_json: String = manager.serialize_state()
_check(
+864
View File
@@ -0,0 +1,864 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
await _test_single_two_leg_delivery()
await _test_one_unit_contention()
await _test_legacy_pre_pickup_restore_reroutes()
await _test_legacy_working_restore_reroutes()
await _test_post_pickup_restore_continuation()
_finish()
func _test_single_two_leg_delivery() -> void:
var fixture := await _load_world()
var main_scene: Node = fixture["main"]
var manager: Node = fixture["manager"]
var view: Node = fixture["view"]
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var dunja_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA)
var pantry: StorageStateRecord = manager.get_pantry()
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
var dunja_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(
(
pantry_node != null
and zora_node != null
and dunja_node != null
and pantry != null
and zora_state != null
and dunja_state != null
),
"The delivery proof needs the authored pantry and both bound named goats",
)
if (
pantry_node == null
or zora_node == null
or dunja_node == null
or pantry == null
or zora_state == null
or dunja_state == null
):
await _unload_world(main_scene)
return
_prepare_population(manager, view)
_set_pantry_food(manager, 3.0)
zora_state.set_hunger(72.0)
dunja_state.set_hunger(30.0)
var caretaker: SimNPC = manager.npcs[0]
var origin := zora_node.get_interaction_position()
_prepare_feed_task(manager, view, caretaker, origin)
_check(
manager.resolve_npc_target(caretaker.id, origin),
"A caretaker should resolve a physical route for hungry Zora",
)
_check(
(
caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL
and caretaker.target_id == SimulationIds.ANIMAL_ZORA
and zora_state.get_reserved_by() == caretaker.id
and caretaker.has_travel_target
and caretaker.travel_target_position.is_equal_approx(
pantry_node.get_interaction_position()
)
),
"The first feed leg should retain exact Zora authority while targeting the pantry",
)
var pantry_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
var observed_villages := []
var observed_food := []
var observed_states := []
manager.village_changed.connect(
func(changed_village):
observed_villages.append(changed_village)
observed_food.append(changed_village.food)
observed_states.append(manager.serialize_state())
)
_arrive_at_current_target(manager, view, caretaker)
var withdrawal_events := _events_for(
manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker.id
)
var observed_record: SimulationStateRecord
var observed_npc: SimNPC
if observed_states.size() == 1:
observed_record = SimulationStateRecord.from_json(String(observed_states[0]))
if observed_record != null:
var observed_npc_record := _find_npc_record(observed_record.npcs, caretaker.id)
if observed_npc_record != null:
observed_npc = observed_npc_record.restore(false)
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.target_id == SimulationIds.ANIMAL_ZORA
and zora_state.get_reserved_by() == caretaker.id
and caretaker.has_travel_target
and caretaker.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
and observed_villages.size() == 1
and observed_villages[0] == manager.village
and is_equal_approx(float(observed_food[0]), pantry_before - 1.0)
and observed_record != null
and observed_npc != null
and is_equal_approx(observed_npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and observed_npc.target_id == SimulationIds.ANIMAL_ZORA
and observed_npc.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
),
(
"Pantry arrival should atomically expose synchronized food, carried inventory, "
+ "and the same claimed Zora delivery waypoint"
),
)
_check(
(
withdrawal_events.size() == 1
and _is_exact_withdrawal(
withdrawal_events[0],
caretaker.id,
pantry_node.get_interaction_position(),
)
),
"The pickup leg should record one exact pantry-to-caretaker transfer at the pantry",
)
var carried_food := _get_carried_food(view, caretaker.id)
_check(
carried_food != null and carried_food.visible,
"Authoritative caretaker inventory should reveal the existing carried-food prop",
)
_check(
is_equal_approx(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
+ caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
),
pantry_before,
),
"Food should be conserved across the pantry pickup boundary",
)
var zora_hunger_before := zora_state.get_hunger()
var dunja_hunger_before := dunja_state.get_hunger()
_arrive_at_current_target(manager, view, caretaker)
_check(
caretaker.task_state == SimNPC.TASK_STATE_WORKING,
"Arrival at exact Zora should begin the feed work without another withdrawal",
)
var work_ticks := int(ceil(caretaker.task_duration))
for _tick in range(work_ticks):
manager.simulate_tick()
var feed_events := _events_for(manager, SimulationIds.EVENT_ANIMAL_FED, caretaker.id)
_check(
(
is_equal_approx(
zora_state.get_hunger(),
(
zora_hunger_before
+ AnimalStateRecord.HUNGER_PER_TICK * work_ticks
- AnimalStateRecord.HUNGER_RELIEF
),
)
and is_equal_approx(
dunja_state.get_hunger(),
dunja_hunger_before + AnimalStateRecord.HUNGER_PER_TICK * work_ticks,
)
and zora_state.get_last_fed_tick() == manager.tick_count
and zora_state.get_reserved_by() == -1
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
),
"Delivery completion should consume carried food and mutate only Zora beyond natural hunger",
)
_check(
(
feed_events.size() == 1
and _is_exact_feed(feed_events[0], caretaker.id, SimulationIds.ANIMAL_ZORA, zora_state)
and (
_events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker.id).size()
== 1
)
),
"The complete delivery should retain one withdrawal followed by one exact inventory feed",
)
_check(
(
feed_events.size() == 1
and is_equal_approx(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
+ caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
+ float(feed_events[0].data["amount"])
),
pantry_before,
)
),
"Exactly one delivered food should account for the unit that leaves tracked holdings",
)
carried_food = _get_carried_food(view, caretaker.id)
_check(
carried_food != null and not carried_food.visible,
"The carried-food prop should disappear after the exact goat receives it",
)
await _unload_world(main_scene)
func _test_one_unit_contention() -> void:
var fixture := await _load_world()
var main_scene: Node = fixture["main"]
var manager: Node = fixture["manager"]
var view: Node = fixture["view"]
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var dunja_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA)
var pantry: StorageStateRecord = manager.get_pantry()
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
var dunja_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
if (
pantry_node == null
or zora_node == null
or dunja_node == null
or pantry == null
or zora_state == null
or dunja_state == null
):
_check(false, "The contention proof needs the authored pantry and paired goats")
await _unload_world(main_scene)
return
_prepare_population(manager, view)
_set_pantry_food(manager, 1.0)
zora_state.set_hunger(72.0)
dunja_state.set_hunger(72.0)
var winner: SimNPC = manager.npcs[0]
var loser: SimNPC = manager.npcs[1]
var shared_origin := zora_node.get_interaction_position()
_prepare_feed_task(manager, view, winner, shared_origin)
_prepare_feed_task(manager, view, loser, shared_origin)
var winner_resolved: bool = manager.resolve_npc_target(winner.id, shared_origin)
var loser_resolved: bool = manager.resolve_npc_target(loser.id, shared_origin)
_check(
(
winner_resolved
and loser_resolved
and winner.target_id == SimulationIds.ANIMAL_ZORA
and loser.target_id == SimulationIds.ANIMAL_DUNJA
and zora_state.get_reserved_by() == winner.id
and dunja_state.get_reserved_by() == loser.id
and winner.travel_target_position.is_equal_approx(
pantry_node.get_interaction_position()
)
and loser.travel_target_position.is_equal_approx(pantry_node.get_interaction_position())
),
"Two caretakers should retain independent exact goat claims while sharing the pantry leg",
)
_arrive_at_current_target(manager, view, winner)
_arrive_at_current_target(manager, view, loser)
var all_withdrawals := _events_of_type(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN)
var loser_blocked := _events_for(manager, SimulationIds.EVENT_TASK_BLOCKED, loser.id)
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and is_equal_approx(winner.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and is_equal_approx(loser.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and all_withdrawals.size() == 1
and int(all_withdrawals[0].data["actor_id"]) == winner.id
),
"One pantry unit should produce exactly one winning pickup without duplication",
)
_check(
(
winner.current_task == SimulationIds.ACTION_FEED_ANIMAL
and winner.target_id == SimulationIds.ANIMAL_ZORA
and zora_state.get_reserved_by() == winner.id
and loser.current_task != SimulationIds.ACTION_FEED_ANIMAL
and loser.get_inventory_amount(SimulationIds.RESOURCE_FOOD) == 0.0
and dunja_state.get_reserved_by() == -1
and loser_blocked.size() == 1
and (
StringName(loser_blocked[0].data["source_id"])
== SimulationIds.STORAGE_VILLAGE_PANTRY
)
and (StringName(loser_blocked[0].data["action_id"]) == SimulationIds.ACTION_FEED_ANIMAL)
and is_equal_approx(float(loser_blocked[0].data["required_amount"]), 1.0)
),
"The losing pantry arrival should record the shortfall, release only its claim, and not feed",
)
var zora_hunger_before := zora_state.get_hunger()
var dunja_hunger_before := dunja_state.get_hunger()
_arrive_at_current_target(manager, view, winner)
var work_ticks := int(ceil(winner.task_duration))
for _tick in range(work_ticks):
manager.simulate_tick()
var feed_events := _events_of_type(manager, SimulationIds.EVENT_ANIMAL_FED)
_check(
(
feed_events.size() == 1
and _is_exact_feed(feed_events[0], winner.id, SimulationIds.ANIMAL_ZORA, zora_state)
and is_equal_approx(
zora_state.get_hunger(),
(
zora_hunger_before
+ AnimalStateRecord.HUNGER_PER_TICK * work_ticks
- AnimalStateRecord.HUNGER_RELIEF
),
)
and is_equal_approx(
dunja_state.get_hunger(),
dunja_hunger_before + AnimalStateRecord.HUNGER_PER_TICK * work_ticks,
)
),
"Contention should complete only the winning caretaker's exact Zora delivery",
)
_check(
(
_events_of_type(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN).size() == 1
and is_equal_approx(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
+ winner.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
+ loser.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
+ float(feed_events[0].data["amount"])
),
1.0,
)
),
"One-unit contention should conserve exactly one food through final delivery",
)
await _unload_world(main_scene)
func _test_legacy_pre_pickup_restore_reroutes() -> void:
var fixture := await _load_world()
var main_scene: Node = fixture["main"]
var manager: Node = fixture["manager"]
var view: Node = fixture["view"]
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var pantry: StorageStateRecord = manager.get_pantry()
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
if pantry_node == null or zora_node == null or pantry == null or zora_state == null:
_check(false, "The legacy-route proof needs the authored pantry and Zora")
await _unload_world(main_scene)
return
_prepare_population(manager, view)
_set_pantry_food(manager, 2.0)
zora_state.set_hunger(72.0)
var caretaker: SimNPC = manager.npcs[0]
var caretaker_id := caretaker.id
var origin := zora_node.get_interaction_position()
_prepare_feed_task(manager, view, caretaker, origin)
_check(
manager.resolve_npc_target(caretaker.id, origin),
"The legacy-route caretaker should establish an exact Zora claim",
)
# Schema v11 predates the pantry leg, so its valid active feed records can
# retain an empty inventory while traveling directly to the claimed goat.
caretaker.travel_target_position = zora_node.get_interaction_position()
caretaker.has_travel_target = true
var legacy_route_json: String = manager.serialize_state()
var legacy_route_checksum: String = manager.get_state_checksum()
var pantry_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
_check(
manager.restore_state_from_json(legacy_route_json),
"A pre-delivery schema-v11 feed route should remain restorable",
)
_freeze_visuals(view)
caretaker = _find_npc(manager.npcs, caretaker_id)
pantry = manager.get_pantry()
zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
_check(
(
caretaker != null
and manager.get_state_checksum() == legacy_route_checksum
and caretaker.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and zora_state.get_reserved_by() == caretaker_id
),
"Restore should first preserve the valid legacy route and exact claim",
)
if caretaker == null:
await _unload_world(main_scene)
return
_arrive_at_current_target(manager, view, caretaker)
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and _events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id).is_empty()
and caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.target_id == SimulationIds.ANIMAL_ZORA
and zora_state.get_reserved_by() == caretaker_id
and caretaker.travel_target_position.is_equal_approx(
pantry_node.get_interaction_position()
)
),
"Legacy goat arrival should reroute to the pantry without a remote withdrawal",
)
_arrive_at_current_target(manager, view, caretaker)
var withdrawal_events := _events_for(
manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id
)
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and withdrawal_events.size() == 1
and _is_exact_withdrawal(
withdrawal_events[0],
caretaker_id,
pantry_node.get_interaction_position(),
)
and caretaker.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
and zora_state.get_reserved_by() == caretaker_id
),
"Legacy continuation should withdraw only after physical pantry arrival",
)
await _unload_world(main_scene)
func _test_legacy_working_restore_reroutes() -> void:
var fixture := await _load_world()
var main_scene: Node = fixture["main"]
var manager: Node = fixture["manager"]
var view: Node = fixture["view"]
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var pantry: StorageStateRecord = manager.get_pantry()
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
if pantry_node == null or zora_node == null or pantry == null or zora_state == null:
_check(false, "The legacy-working proof needs the authored pantry and Zora")
await _unload_world(main_scene)
return
_prepare_population(manager, view)
_set_pantry_food(manager, 2.0)
zora_state.set_hunger(72.0)
var caretaker: SimNPC = manager.npcs[0]
var caretaker_id := caretaker.id
var origin := zora_node.get_interaction_position()
_prepare_feed_task(manager, view, caretaker, origin)
_check(
manager.resolve_npc_target(caretaker.id, origin),
"The legacy-working caretaker should establish an exact Zora claim",
)
# The old one-leg flow could also be saved after goat arrival, while work
# was already in progress but before pantry-backed completion.
caretaker.task_state = SimNPC.TASK_STATE_WORKING
caretaker.task_progress = 2.0
caretaker.task_complete = false
caretaker.has_travel_target = false
caretaker.travel_target_position = zora_node.get_interaction_position()
var legacy_working_json: String = manager.serialize_state()
var legacy_working_checksum: String = manager.get_state_checksum()
var pantry_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
_check(
manager.restore_state_from_json(legacy_working_json),
"A working pre-delivery schema-v11 feed state should remain restorable",
)
_freeze_visuals(view)
caretaker = _find_npc(manager.npcs, caretaker_id)
pantry = manager.get_pantry()
zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
_check(
(
caretaker != null
and manager.get_state_checksum() == legacy_working_checksum
and caretaker.task_state == SimNPC.TASK_STATE_WORKING
and is_equal_approx(caretaker.task_progress, 2.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and zora_state.get_reserved_by() == caretaker_id
),
"Restore should initially preserve the legacy working state and checksum",
)
if caretaker == null:
await _unload_world(main_scene)
return
manager.simulate_tick()
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and _events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id).is_empty()
and caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.target_id == SimulationIds.ANIMAL_ZORA
and caretaker.has_travel_target
and caretaker.travel_target_position.is_equal_approx(
pantry_node.get_interaction_position()
)
and is_equal_approx(caretaker.task_progress, 2.0)
and zora_state.get_reserved_by() == caretaker_id
),
"The first resumed tick should preserve work progress and reroute to the pantry",
)
_arrive_at_current_target(manager, view, caretaker)
_check(
(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before - 1.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
and is_equal_approx(caretaker.task_progress, 2.0)
and zora_state.get_reserved_by() == caretaker_id
),
"Recovered work should pick up at the pantry and return to the same Zora claim",
)
_arrive_at_current_target(manager, view, caretaker)
_check(
(
caretaker.task_state == SimNPC.TASK_STATE_WORKING
and is_equal_approx(caretaker.task_progress, 2.0)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and zora_state.get_reserved_by() == caretaker_id
),
"Legacy work should resume beside Zora without losing its saved progress",
)
await _unload_world(main_scene)
func _test_post_pickup_restore_continuation() -> void:
var fixture := await _load_world()
var main_scene: Node = fixture["main"]
var manager: Node = fixture["manager"]
var view: Node = fixture["view"]
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA)
var pantry: StorageStateRecord = manager.get_pantry()
var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
if pantry_node == null or zora_node == null or pantry == null or zora_state == null:
_check(false, "The restore proof needs the authored pantry and Zora")
await _unload_world(main_scene)
return
_prepare_population(manager, view)
_set_pantry_food(manager, 2.0)
zora_state.set_hunger(72.0)
var caretaker: SimNPC = manager.npcs[0]
var caretaker_id := caretaker.id
var origin := zora_node.get_interaction_position()
_prepare_feed_task(manager, view, caretaker, origin)
_check(
manager.resolve_npc_target(caretaker.id, origin),
"The restore caretaker should resolve Zora's two-leg delivery",
)
_arrive_at_current_target(manager, view, caretaker)
var midpoint_json: String = manager.serialize_state()
var midpoint_checksum: String = manager.get_state_checksum()
var midpoint_position := caretaker.position
var midpoint_destination := caretaker.travel_target_position
var midpoint_pantry := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
_check(
(
caretaker.target_id == SimulationIds.ANIMAL_ZORA
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.has_travel_target
and caretaker.travel_target_position.is_equal_approx(
zora_node.get_interaction_position()
)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and zora_state.get_reserved_by() == caretaker.id
and (
_events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker.id).size()
== 1
)
and _events_for(manager, SimulationIds.EVENT_ANIMAL_FED, caretaker.id).is_empty()
),
"The save point should be after one real pickup and before the exact goat delivery",
)
_arrive_at_current_target(manager, view, caretaker)
var continuation_ticks := int(ceil(caretaker.task_duration))
for _tick in range(continuation_ticks):
manager.simulate_tick()
var uninterrupted_checksum: String = manager.get_state_checksum()
var uninterrupted_feed_events := _events_for(
manager, SimulationIds.EVENT_ANIMAL_FED, caretaker_id
)
_check(
(
uninterrupted_feed_events.size() == 1
and (
_events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id).size()
== 1
)
),
"The uninterrupted control should finish with one pickup and one feed",
)
_check(
manager.restore_state_from_json(midpoint_json),
"A post-pickup in-progress feed state should pass the complete save schema",
)
_freeze_visuals(view)
caretaker = _find_npc(manager.npcs, caretaker_id)
pantry = manager.get_pantry()
zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA)
var restored_visual := _get_visual(view, caretaker_id)
var restored_carried_food := _get_carried_food(view, caretaker_id)
_check(
(
caretaker != null
and pantry != null
and zora_state != null
and manager.get_state_checksum() == midpoint_checksum
and caretaker.position.is_equal_approx(midpoint_position)
and caretaker.travel_target_position.is_equal_approx(midpoint_destination)
and caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL
and caretaker.task_state == SimNPC.TASK_STATE_TRAVELING
and caretaker.target_id == SimulationIds.ANIMAL_ZORA
and caretaker.has_travel_target
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 1.0)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), midpoint_pantry)
and zora_state.get_reserved_by() == caretaker_id
and restored_visual != null
and restored_visual.global_position.is_equal_approx(midpoint_position)
and restored_visual.get("current_target") == midpoint_destination
and restored_carried_food != null
and restored_carried_food.visible
and (
_events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id).size()
== 1
)
and _events_for(manager, SimulationIds.EVENT_ANIMAL_FED, caretaker_id).is_empty()
),
"Restore should retain exact claim, route, carried food, pickup history, and checksum",
)
if caretaker == null:
await _unload_world(main_scene)
return
_arrive_at_current_target(manager, view, caretaker)
for _tick in range(continuation_ticks):
manager.simulate_tick()
var restored_feed_events := _events_for(manager, SimulationIds.EVENT_ANIMAL_FED, caretaker_id)
_check(
(
manager.get_state_checksum() == uninterrupted_checksum
and (
_events_for(manager, SimulationIds.EVENT_STORAGE_WITHDRAWN, caretaker_id).size()
== 1
)
and restored_feed_events.size() == 1
and _is_exact_feed(
restored_feed_events[0],
caretaker_id,
SimulationIds.ANIMAL_ZORA,
zora_state,
)
and is_equal_approx(caretaker.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), midpoint_pantry)
),
"Restored continuation should match uninterrupted state without replaying either transfer",
)
await _unload_world(main_scene)
func _load_world() -> Dictionary:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 3:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
manager.set_process(false)
var view: Node = main_scene.get_node("WorldViewManager")
_freeze_visuals(view)
return {"main": main_scene, "manager": manager, "view": view}
func _unload_world(main_scene: Node) -> void:
main_scene.queue_free()
await process_frame
await physics_frame
func _prepare_population(manager: Node, view: Node) -> void:
for npc in manager.npcs:
manager.release_npc_reservation(npc.id)
npc.inventory.clear()
npc.hunger = 20.0
npc.energy = 100.0
npc.is_starving = false
npc.starvation_ticks = 0
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
(
manager
. emit_signal(
"npc_inventory_changed",
npc,
SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
)
)
_freeze_visuals(view)
func _prepare_feed_task(manager: Node, view: Node, npc: SimNPC, origin: Vector3) -> void:
manager.release_npc_reservation(npc.id)
npc.inventory.clear()
npc.target_id = &""
npc.has_travel_target = false
npc.position = origin
npc.set_task(SimulationIds.ACTION_FEED_ANIMAL)
var visual := _get_visual(view, npc.id)
if visual != null:
visual.global_position = origin
manager.synchronize_npc_position(npc.id, origin)
func _set_pantry_food(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
(
pantry
. withdraw(
SimulationIds.RESOURCE_FOOD,
pantry.get_amount(SimulationIds.RESOURCE_FOOD),
)
)
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _arrive_at_current_target(manager: Node, view: Node, npc: SimNPC) -> void:
var visual := _get_visual(view, npc.id)
if visual != null:
visual.set_physics_process(false)
visual.global_position = npc.travel_target_position
manager.synchronize_npc_position(npc.id, npc.travel_target_position)
manager.notify_npc_arrived(npc.id)
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
func _get_visual(view: Node, npc_id: int) -> Node3D:
return view.active_npc_visuals.get(npc_id) as Node3D
func _get_carried_food(view: Node, npc_id: int) -> MeshInstance3D:
var visual := _get_visual(view, npc_id)
if visual == null:
return null
return visual.get_node("CarriedFood") as MeshInstance3D
func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
func _find_npc_record(records: Array[NPCStateRecord], npc_id: int) -> NPCStateRecord:
for record in records:
if int(record.data["id"]) == npc_id:
return record
return null
func _events_for(
manager: Node, event_type: StringName, actor_id: int
) -> Array[EconomicEventRecord]:
var matching: Array[EconomicEventRecord] = []
for event in manager.economic_events:
if (
StringName(event.data["event_type"]) == event_type
and int(event.data["actor_id"]) == actor_id
):
matching.append(event)
return matching
func _events_of_type(manager: Node, event_type: StringName) -> Array[EconomicEventRecord]:
var matching: Array[EconomicEventRecord] = []
for event in manager.economic_events:
if StringName(event.data["event_type"]) == event_type:
matching.append(event)
return matching
func _is_exact_withdrawal(
event: EconomicEventRecord, actor_id: int, pantry_position: Vector3
) -> bool:
return (
event != null
and int(event.data["actor_id"]) == actor_id
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and (StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id))
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(event.data["amount"]), 1.0)
and event.get_world_position().is_equal_approx(pantry_position)
)
func _is_exact_feed(
event: EconomicEventRecord,
actor_id: int,
animal_id: StringName,
animal_state: AnimalStateRecord,
) -> bool:
return (
event != null
and animal_state != null
and int(event.data["actor_id"]) == actor_id
and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id)
and StringName(event.data["destination_id"]) == animal_id
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(event.data["amount"]), 1.0)
and event.get_world_position().is_equal_approx(animal_state.get_position())
)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Animal food delivery passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://rx6bphs7qvyq
+11 -4
View File
@@ -192,9 +192,13 @@ func _run() -> void:
var dunja_hunger_before := dunja_state.get_hunger()
var dunja_fed_tick_before := dunja_state.get_last_fed_tick()
var dunja_reservation_before := dunja_state.get_reserved_by()
_check(
manager.animal_care.collect_feed_supply(zora_caretaker),
"The restored caretaker should collect one real pantry food for Zora",
)
_check(
manager.feed_animal(SimulationIds.ANIMAL_ZORA, zora_caretaker_id),
"The restored exact Zora claim should complete one real feed operation",
"The restored exact Zora claim should deliver its carried food",
)
_check(
(
@@ -212,20 +216,23 @@ func _run() -> void:
)
_check(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_food_before - 1.0),
"Feeding exactly one goat should consume exactly one pantry food",
"Collecting for exactly one goat should withdraw exactly one pantry food",
)
var feed_event := _latest_feed_event(manager)
_check(
(
feed_event != null
and int(feed_event.data["actor_id"]) == zora_caretaker_id
and StringName(feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and (
StringName(feed_event.data["source_id"])
== SimulationIds.npc_inventory_id(zora_caretaker_id)
)
and StringName(feed_event.data["destination_id"]) == SimulationIds.ANIMAL_ZORA
and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(feed_event.data["amount"]), 1.0)
and feed_event.get_world_position().is_equal_approx(zora_state.get_position())
),
"The feed event should record the exact caretaker, pantry, Zora destination, and amount",
"The feed event should record the exact caretaker inventory, Zora, and amount",
)
_finish()
+1 -1
View File
@@ -96,7 +96,7 @@ func _test_definition_backed_behavior() -> void:
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
and is_equal_approx(feed.completion_cost_amount, 1.0)
),
"Feed-animal should target an animal and declare its exact pantry-food cost"
"Feed-animal should target an animal and declare its exact one-food cost"
)
@@ -11,6 +11,7 @@ func _run() -> void:
await _test_deterministic_continuation()
await _test_resource_state_round_trip()
_test_legacy_npc_migration()
_test_npc_inventory_schema_rejection()
_test_legacy_resource_migration()
_test_legacy_world_storage_migration()
_test_previous_world_event_migration()
@@ -275,6 +276,43 @@ func _test_legacy_npc_migration() -> void:
)
func _test_npc_inventory_schema_rejection() -> void:
var npc := SimNPC.new(89, "InventoryNPC", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
var valid_inventory := NPCStateRecord.capture(npc).to_dictionary()
var food_id := String(SimulationIds.RESOURCE_FOOD)
var invalid_cases := [
[{food_id: -1.0}, "negative amounts"],
[{food_id: NAN}, "NaN amounts"],
[{food_id: INF}, "infinite amounts"],
[{food_id: "1.0"}, "string amounts"],
[{food_id: true}, "boolean amounts"],
[{food_id: null}, "null amounts"],
[{"": 1.0}, "empty item IDs"],
[{1: 1.0, "1": 2.0}, "item IDs that collide after string normalization"],
]
for invalid_case in invalid_cases:
var invalid_inventory := valid_inventory.duplicate(true)
invalid_inventory["inventory"] = invalid_case[0]
_check(
NPCStateRecord.from_dictionary(invalid_inventory) == null,
"NPC records should reject inventory %s" % invalid_case[1]
)
var normalizable_inventory := valid_inventory.duplicate(true)
normalizable_inventory["inventory"] = {SimulationIds.RESOURCE_FOOD: 2}
var normalized := NPCStateRecord.from_dictionary(normalizable_inventory)
_check(normalized != null, "NPC records should accept normalizable inventory values")
if normalized != null:
var normalized_inventory: Dictionary = normalized.data["inventory"]
_check(
(
normalized_inventory.has(String(SimulationIds.RESOURCE_FOOD))
and typeof(normalized_inventory.keys()[0]) == TYPE_STRING
and typeof(normalized_inventory.values()[0]) == TYPE_FLOAT
),
"NPC records should normalize inventory IDs to strings and amounts to floats"
)
func _test_legacy_world_storage_migration() -> void:
var manager := _create_manager(55)
var legacy_data: Dictionary = manager.create_state_record().to_dictionary()
+10 -4
View File
@@ -133,11 +133,11 @@ func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO)
return best_candidate
match action_id:
SimulationIds.ACTION_EAT:
return _get_storage_target(pantry_storage)
return get_storage_target(SimulationIds.STORAGE_VILLAGE_PANTRY)
SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_WITHDRAW_FOOD:
return _get_storage_target(pantry_storage)
return get_storage_target(SimulationIds.STORAGE_VILLAGE_PANTRY)
SimulationIds.ACTION_DEPOSIT_WOOD:
return _get_storage_target(woodpile_storage)
return get_storage_target(SimulationIds.STORAGE_VILLAGE_WOODPILE)
return {}
@@ -159,7 +159,13 @@ func get_activity_candidates(action_id: StringName) -> Array[Dictionary]:
return candidates
func _get_storage_target(storage_node: StorageNode) -> Dictionary:
func get_storage_target(storage_id: StringName) -> Dictionary:
var storage_node: StorageNode
match storage_id:
SimulationIds.STORAGE_VILLAGE_PANTRY:
storage_node = pantry_storage
SimulationIds.STORAGE_VILLAGE_WOODPILE:
storage_node = woodpile_storage
if storage_node == null:
return {}
return {