feat: add physical animal care delivery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://rx6bphs7qvyq
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user