feat: add physical animal care delivery
This commit is contained in:
@@ -53,6 +53,8 @@ SimulationManager tick
|
|||||||
compatibility path for isolated adapters;
|
compatibility path for isolated adapters;
|
||||||
- checks simulation-owned ResourceStateRecord availability;
|
- checks simulation-owned ResourceStateRecord availability;
|
||||||
- reserves and returns stable resource target IDs;
|
- 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;
|
- skips full-capacity activity sites based on authoritative NPC target claims;
|
||||||
- returns typed activity-site, storage, or wander positions without owning
|
- returns typed activity-site, storage, or wander positions without owning
|
||||||
presentation.
|
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
|
UI does not recompute decisions. Each idle selection re-derives the capable
|
||||||
helper instead of consulting persisted assignment state. At completion it
|
helper instead of consulting persisted assignment state. At completion it
|
||||||
atomically pays any definition-backed stored-resource cost before applying the
|
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
|
`task_blocked` fact. The manager also captures actor/nearby knowledge before
|
||||||
forwarding newly recorded events into `RelationshipSystem`. When an NPC arrives
|
forwarding newly recorded events into `RelationshipSystem`. When an NPC
|
||||||
beside a worker at the same non-storage activity site, the manager may transfer
|
arrives beside a worker at the same non-storage activity site, the manager may
|
||||||
one direct known fact and applies its consequence only to that newly informed
|
transfer one direct known fact and applies its consequence only to that newly
|
||||||
listener. An open opportunity's exact trigger receives bounded priority in
|
informed listener. An open opportunity's exact trigger receives bounded
|
||||||
that conversation before normal lasting/recent ranking. It exposes
|
priority in that conversation before normal lasting/recent ranking. It exposes
|
||||||
knowledge, provenance, relationship, and cause queries without moving social
|
knowledge, provenance, relationship, and cause queries without moving social
|
||||||
authority into UI. After consequences are known, it protects current causal
|
authority into UI. After consequences are known, it protects current causal
|
||||||
facts, enforces the recent-memory cap, and runs age review from authoritative
|
facts, enforces the recent-memory cap, and runs age review from authoritative
|
||||||
@@ -103,6 +108,8 @@ simulation ticks.
|
|||||||
### VillageEconomy
|
### VillageEconomy
|
||||||
|
|
||||||
- owns storage and NPC-inventory transfer operations;
|
- 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;
|
- keeps `village.food` and `village.wood` synchronized as aggregate views;
|
||||||
- validates and pays definition-backed completion costs;
|
- validates and pays definition-backed completion costs;
|
||||||
- emits completed transaction facts without owning their history.
|
- 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
|
simulation and loaded-world facts. The HUD is separate from development
|
||||||
overlays so the embodied action remains legible in cinematic mode.
|
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
|
## Active-position contract
|
||||||
|
|
||||||
NpcVisual emits active position changes after successful movement.
|
NpcVisual emits active position changes after successful movement.
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ would otherwise obscure that lifecycle:
|
|||||||
and keeps village resource summaries synchronized;
|
and keeps village resource summaries synchronized;
|
||||||
- `simulation/animals/animal_care_system.gd` owns animal records, hunger
|
- `simulation/animals/animal_care_system.gd` owns animal records, hunger
|
||||||
advancement, deterministic routine selection, loaded binding, feed
|
advancement, deterministic routine selection, loaded binding, feed
|
||||||
reservations, and the exact conserved pantry-to-animal operation used by
|
reservations, exact NPC pantry-to-inventory-to-animal delivery, and the
|
||||||
NPCs and the player;
|
bounded direct player pantry-to-animal operation;
|
||||||
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||||
history queries, and rate calculations;
|
history queries, and rate calculations;
|
||||||
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
- `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
|
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
|
goat ID, simulation-owned position and hunger, and a loaded cozy
|
||||||
presentation with an honest hungry cue. NPC and player feeding share one
|
presentation with an honest hungry cue. NPC and player feeding share one
|
||||||
exact transaction that withdraws pantry food, changes only Dunja's state,
|
exact target mutation backed by real food that changes only Dunja's state
|
||||||
and emits one economic fact. World-schema v10 save/restore preserves the
|
and emits one exact `animal_fed` fact. World-schema v10 save/restore
|
||||||
result without replaying the transient feed response.
|
preserves the result without replaying the transient feed response.
|
||||||
41. `Jajce Dunja Habitat 08`: a self-contained timber shelter and clover
|
41. `Jajce Dunja Habitat 08`: a self-contained timber shelter and clover
|
||||||
pasture now expose two stable routine sites in an open south-village
|
pasture now expose two stable routine sites in an open south-village
|
||||||
clearing. Dunja alternates between them without RNG, follows the real
|
clearing. Dunja alternates between them without RNG, follows the real
|
||||||
@@ -823,14 +823,21 @@ Completed:
|
|||||||
availability, reports success or blockage, remains visible in cinematic
|
availability, reports success or blockage, remains visible in cinematic
|
||||||
mode, and also covers existing resource, pantry, guard, and study actions.
|
mode, and also covers existing resource, pantry, guard, and study actions.
|
||||||
Restore discards transient feedback and re-derives context from live facts.
|
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:
|
Next:
|
||||||
|
|
||||||
1. Make NPC feeding visibly collect one real pantry food into the caretaker's
|
1. Give the player one restrained nearby-villager inspection context backed by
|
||||||
existing inventory/carried-food presentation before delivery to the exact
|
the authoritative task, exact target, carried item, and recorded decision
|
||||||
reserved goat.
|
reason when it currently exists.
|
||||||
2. Prove conservation, claim continuity, and save/restore during both travel
|
2. Prove that the field note follows live task changes and, after restore,
|
||||||
legs; keep herd, product, indexing, and LOD systems deferred.
|
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
|
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
|
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
|
amount, plus the authoritative world position captured when the fact is
|
||||||
recorded. NPC events preserve their authoritative interaction target (or NPC
|
recorded. NPC events preserve their authoritative interaction target (or NPC
|
||||||
position when no target applies); player extraction/depletion events preserve
|
position when no target applies); player extraction/depletion events preserve
|
||||||
the ResourceNode interaction position. `animal_fed` records one food moving
|
the ResourceNode interaction position. NPC animal care records a
|
||||||
from `village_pantry` to a stable animal ID at the animal's authoritative
|
`storage_withdrawn` move from `village_pantry` to the matching caretaker
|
||||||
position. Actor `-1` identifies a player-triggered extraction or feeding until
|
inventory at pickup, then an `animal_fed` move from that inventory to the
|
||||||
persistent player identity is introduced.
|
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
|
Events are immutable facts about completed transfers. They do not perform the
|
||||||
transaction and are not replayed to reconstruct current state. Resource,
|
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()`.
|
history/rate queries, including exact lookup through `get_by_id()`.
|
||||||
`VillageEconomy` performs inventory and storage transactions and requests event
|
`VillageEconomy` performs inventory and storage transactions and requests event
|
||||||
records only after state changes succeed. `AnimalCareSystem` composes the
|
records only after state changes succeed. `AnimalCareSystem` composes the
|
||||||
pantry withdrawal with animal hunger relief and requests `animal_fed` only
|
exact carried-inventory removal with animal hunger relief and requests
|
||||||
after both succeed. `SimulationManager` remains the scene-tree signal boundary
|
`animal_fed` only after both succeed. `SimulationManager` remains the
|
||||||
used by presentation.
|
scene-tree signal boundary used by presentation.
|
||||||
|
|
||||||
An action whose definition-backed completion cost becomes unavailable records
|
An action whose definition-backed completion cost becomes unavailable records
|
||||||
a zero-amount `task_blocked` narrative fact with stable action, source storage,
|
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.
|
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
|
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
|
existing NPC or the player sentinel, destination is an existing animal, item
|
||||||
an existing animal, item and definition cost are exactly one food, event tick
|
and definition cost are exactly one food, event tick is not in the future, and
|
||||||
is not in the future, and the animal's `last_fed_tick` agrees with its latest
|
the animal's `last_fed_tick` agrees with its latest feed fact. New NPC facts
|
||||||
feed fact.
|
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`
|
Positive food deposits, positive NPC food withdrawals from `village_pantry`
|
||||||
into that actor's matching inventory, and definition-backed patrol/study
|
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
|
Food is conserved across source, carried inventory, and pantry. It leaves the
|
||||||
world only when consumed.
|
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
|
## Authority
|
||||||
|
|
||||||
- `StorageStateRecord` owns pantry and woodpile contents and capacity.
|
- `StorageStateRecord` owns pantry and woodpile contents and capacity.
|
||||||
- `SimNPC.inventory` owns carried item amounts.
|
- `SimNPC.inventory` owns carried item amounts.
|
||||||
- `VillageEconomy` performs deposit, withdrawal, consumption, and
|
- `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
|
- `SimulationManager` coordinates action lifecycle and exposes the transaction
|
||||||
results to presentation.
|
results to presentation.
|
||||||
- `village.food` and `village.wood` are synchronized aggregate views used by
|
- `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).
|
structured economic facts. See [the economic event stream](ECONOMIC_EVENTS.md).
|
||||||
Active NPCs display a small food sack while their authoritative inventory
|
Active NPCs display a small food sack while their authoritative inventory
|
||||||
contains food; this is presentation derived from state, not a second 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
|
## Failure behavior
|
||||||
|
|
||||||
@@ -50,23 +62,28 @@ Transactions apply the amount actually available:
|
|||||||
- player extraction is limited to storage capacity until player inventory
|
- player extraction is limited to storage capacity until player inventory
|
||||||
exists, so overflow remains at the source;
|
exists, so overflow remains at the source;
|
||||||
- withdrawal cannot exceed pantry contents;
|
- 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.
|
- eating succeeds only when the NPC carries one food.
|
||||||
|
|
||||||
If another NPC empties the pantry first, withdrawal returns zero and the NPC
|
If another caretaker empties the pantry first, animal-care pickup records the
|
||||||
replans on its next idle tick.
|
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
|
## Deliberate limits
|
||||||
|
|
||||||
This slice contains one authored pantry and one carried item type. It does not
|
The current bounded economy has one authored pantry and one woodpile with
|
||||||
yet add:
|
simple food/wood stacks. It does not yet add:
|
||||||
|
|
||||||
- player inventory;
|
- player inventory;
|
||||||
- storage reservations or access capacity;
|
- storage reservations or access capacity;
|
||||||
- multiple households or ownership;
|
- multiple households or ownership;
|
||||||
- spoilage, item quality, weight, or stack definitions.
|
- spoilage, item quality, weight, or stack definitions.
|
||||||
|
|
||||||
Economic transfer events exist, but event retention, denial/depletion facts,
|
Objective event history, depletion and blocked-task facts, bounded per-NPC
|
||||||
witnesses, and social interpretation are deliberately deferred.
|
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
|
Those should extend the transaction boundary rather than mutate counters
|
||||||
directly.
|
directly.
|
||||||
|
|||||||
@@ -926,9 +926,10 @@ code proven by those two consumers.
|
|||||||
The first identity-backed livestock slice is complete. Dunja has a stable
|
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
|
animal ID, simulation-owned position and hunger/feed state, and a loaded cozy
|
||||||
presentation whose persistent cue derives from that state. One exact operation
|
presentation whose persistent cue derives from that state. One exact operation
|
||||||
serves both NPC and player feeding, withdraws real pantry food, mutates only the
|
serves both NPC and player feeding, consumes real food from the actor's current
|
||||||
target animal, and appends one economic fact. World-schema v10 save/restore
|
source, mutates only the target animal, and appends one economic fact.
|
||||||
preserves the result without replaying the transient response.
|
World-schema v10 save/restore preserves the result without replaying the
|
||||||
|
transient response.
|
||||||
|
|
||||||
The first loaded-animal routine slice is complete. Dunja alternates between
|
The first loaded-animal routine slice is complete. Dunja alternates between
|
||||||
stable shelter and pasture IDs without RNG; her exact target and current
|
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
|
briefly reports success or blockage. Restore clears transient feedback and
|
||||||
re-derives the current prompt without adding saved state or drawing RNG.
|
re-derives the current prompt without adding saved state or drawing RNG.
|
||||||
|
|
||||||
The next bounded slice should make NPC animal care physically honest:
|
NPC animal care is now physically honest:
|
||||||
`village_pantry -> NPC inventory -> exact named goat`. A caretaker should keep
|
`village_pantry -> NPC inventory -> exact named goat`. A caretaker reserves the
|
||||||
the same animal claim, collect one real food into the existing carried-food
|
goat before visiting the pantry, keeps that stable claim through both travel
|
||||||
presentation, and deliver it without duplication across contention or
|
legs, visibly carries the withdrawn unit, and consumes only carried inventory
|
||||||
mid-route restore. Keep breeding, products, herd scheduling, animal indexing,
|
at delivery. One-unit contention records the late shortfall and releases only
|
||||||
and active/abstract animal LOD deferred.
|
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:
|
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`
|
- `Jajce Context Action 10`: one ephemeral result now keeps the displayed `E`
|
||||||
prompt and executed stable target aligned across existing player
|
prompt and executed stable target aligned across existing player
|
||||||
interactions. Named goat care shows exact pantry availability and transient
|
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
|
- Food and wood now move through finite sources, NPC inventory, and typed
|
||||||
village storage. Other village metrics remain aggregate values rather than
|
village storage. Other village metrics remain aggregate values rather than
|
||||||
located items.
|
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
|
- NPCs have home positions, schedule periods, carried food/wood, and directed
|
||||||
familiarity/trust. They retain direct or one-hop communicated knowledge of a
|
familiarity/trust. They retain direct or one-hop communicated knowledge of a
|
||||||
food deposit or NPC pantry withdrawal, plus direct missing-wood blocked-work
|
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
|
carried food, depositing fills the pantry, withdrawal retrieves one unit, and
|
||||||
eating consumes it. Each successful transfer creates a persisted structured
|
eating consumes it. Each successful transfer creates a persisted structured
|
||||||
event, and active NPCs visibly carry food through presentation derived from
|
event, and active NPCs visibly carry food through presentation derived from
|
||||||
their inventory. The bounded save-slot slice is also complete, including
|
their inventory. Animal care now uses that same ownership path: the caretaker
|
||||||
validation, atomic replacement recovery, and active-visual rebuilding.
|
withdraws at the pantry, carries the unit to the exact claimed goat, and pays
|
||||||
Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
|
from inventory at delivery. The bounded save-slot slice is also complete,
|
||||||
simulation-garden runtime capture are 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:
|
The first relationship and knowledge consequences are also complete:
|
||||||
food-deposit events become separate known facts for their actor and nearby
|
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
|
first 24 m range, and reconstruct sparse/depleted visuals from authoritative
|
||||||
amount. Dunja now proves the first identity-backed animal contract: stable
|
amount. Dunja now proves the first identity-backed animal contract: stable
|
||||||
animal identity, simulation-owned position and hunger, a loaded visual binding,
|
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
|
World-schema v10 restore reconstructs her persistent state without replaying
|
||||||
the transient response. Dunja now also alternates between a stable shelter and
|
the transient response. Dunja now also alternates between a stable shelter and
|
||||||
pasture through real loaded navigation. World-schema v11 preserves exact
|
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,
|
generalize animal discovery or introduce herd scheduling, products, breeding,
|
||||||
or active/abstract animal LOD. The named-pair gate instead produced one
|
or active/abstract animal LOD. The named-pair gate instead produced one
|
||||||
Milestone 9 context-action result shared by prompt and execution, with exact
|
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
|
pantry feedback and no persistent UI state. NPC care now completes that
|
||||||
make NPC care visibly carry one real pantry food to its exact reserved goat.
|
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:
|
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
|
`feed_animal` is the first animal-targeted action. Its definition prefers the
|
||||||
farmer profession and declares an exact one-food completion cost. Selection,
|
farmer profession and declares an exact one-food completion cost. Selection,
|
||||||
animal target reservation, and completion remain separate; NPC and player
|
animal target reservation, supply pickup, and completion remain separate. NPC
|
||||||
completion both use `AnimalCareSystem`'s same pantry-to-animal transaction.
|
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
|
`SimulationIds` names Dunja and Zora, their private shelter anchors, and the
|
||||||
shared pasture anchor. These IDs are saved routine vocabulary; their loaded
|
shared pasture anchor. These IDs are saved routine vocabulary; their loaded
|
||||||
positions and private/shared ownership come from `AnimalRoutineSite` rather
|
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
|
NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
|
||||||
StorageStateRecord entries; world-schema v1 migrates legacy village food and
|
StorageStateRecord entries; world-schema v1 migrates legacy village food and
|
||||||
wood into the stable `village_pantry` and `village_woodpile` records. Parsed
|
wood into the stable `village_pantry` and `village_woodpile` records. NPC
|
||||||
storage values are canonicalized so save/restore continuation retains
|
inventory IDs and amounts are canonicalized to strings and floats; empty or
|
||||||
byte-stable checksums. Current parsing rejects empty item IDs, negative or
|
colliding normalized IDs, non-numeric values, and negative or non-finite
|
||||||
non-finite amounts, negative or non-finite capacity, and contents above
|
amounts are rejected.
|
||||||
capacity. Runtime deposit and withdrawal requests also reject empty item IDs
|
Parsed storage values are likewise canonicalized so save/restore continuation
|
||||||
and non-finite amounts without mutating authoritative stock. When the authored
|
retains byte-stable checksums. Current storage parsing rejects empty item IDs,
|
||||||
pantry or woodpile is present, current world parsing also requires its
|
negative or non-finite amounts, negative or non-finite capacity, and contents
|
||||||
authoritative food or wood amount to match the duplicated village summary;
|
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
|
contradictory saves are rejected instead of silently changing checksum during
|
||||||
restore. World-v1 migration expands a storage's inferred capacity when needed
|
restore. World-v1 migration expands a storage's inferred capacity when needed
|
||||||
so valid legacy stock above the current default is preserved rather than
|
so valid legacy stock above the current default is preserved rather than
|
||||||
discarded.
|
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
|
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
||||||
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
`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
|
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
|
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
|
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
|
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,
|
must name an existing animal as its destination, food as its item, the exact
|
||||||
food as its item, the exact feed action cost, and a valid NPC actor or the
|
feed action cost, and a valid NPC actor or the player sentinel. New NPC events
|
||||||
player sentinel. The latest matching feed event tick must agree with the
|
name the matching `npc_<id>_inventory`; player events name the pantry. Direct
|
||||||
animal's `last_fed_tick`.
|
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.
|
The bounded opportunity family accepts at most one open record globally.
|
||||||
Opportunity, trigger-event, and resolution-event IDs are unique and the next
|
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
|
Animals are not `ResourceNode` instances and are not inserted into the
|
||||||
resource discovery grid. The first bounded slice uses a deterministic linear
|
resource discovery grid. The first bounded slice uses a deterministic linear
|
||||||
loaded-animal query. NPC and player feeding resolve through the same atomic
|
loaded-animal query. NPC and player feeding resolve through the same exact
|
||||||
care operation: reserve or select the exact animal, withdraw one real food
|
animal mutation but pay from their real current source. Player care withdraws
|
||||||
from `village_pantry`, reduce that animal's hunger, and append one exact
|
directly from `village_pantry`. NPC care reserves the animal, performs an exact
|
||||||
`animal_fed` fact. Failed withdrawal changes neither animal nor history.
|
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
|
The first routine consumers alternate Dunja and Zora between stable-ID private
|
||||||
shelters and one shared pasture without drawing RNG. `AnimalRoutineSite`
|
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
|
Multiple animal records remain independently authoritative. A schema-v11 save
|
||||||
may contain concurrent feed reservations only when each reserving NPC targets
|
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
|
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
|
preserves both claim pairings and the checksum. The same existing NPC fields
|
||||||
and mutates only its destination record.
|
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 persistent hungry/content presentation derives from authoritative hunger.
|
||||||
The short feed response is transient and is reset rather than serialized or
|
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
|
has_travel_target = false
|
||||||
|
|
||||||
|
|
||||||
func start_working() -> void:
|
func start_working(preserve_progress: bool = false) -> void:
|
||||||
if task_state != TASK_STATE_TRAVELING:
|
if task_state != TASK_STATE_TRAVELING:
|
||||||
return
|
return
|
||||||
|
|
||||||
task_state = TASK_STATE_WORKING
|
task_state = TASK_STATE_WORKING
|
||||||
task_progress = 0.0
|
if not preserve_progress:
|
||||||
|
task_progress = 0.0
|
||||||
task_complete = false
|
task_complete = false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ extends Node
|
|||||||
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||||
const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.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 RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||||
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.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_complete := npc.task_complete
|
||||||
var was_dead := npc.is_dead
|
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)
|
action_executor.advance_npc(npc, village)
|
||||||
_population_view.refresh_npc(npc)
|
_population_view.refresh_npc(npc)
|
||||||
_select_action_if_idle(npc, previous_state)
|
_select_action_if_idle(npc, previous_state)
|
||||||
@@ -205,7 +213,8 @@ func _simulate_npc_tick(npc: SimNPC) -> bool:
|
|||||||
_complete_current_action(npc)
|
_complete_current_action(npc)
|
||||||
_population_view.refresh_npc(npc)
|
_population_view.refresh_npc(npc)
|
||||||
|
|
||||||
_debug_npc_tick(npc, previous_state)
|
if debug_logs:
|
||||||
|
NpcTickDebugLog.print_tick(npc, previous_state)
|
||||||
return completed
|
return completed
|
||||||
|
|
||||||
|
|
||||||
@@ -333,45 +342,6 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
|||||||
release_npc_reservation(npc.id)
|
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:
|
func release_npc_reservation(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
if npc.id == npc_id:
|
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
|
or resource_state.get_reserved_by() != npc.id
|
||||||
):
|
):
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
NpcTickDebugLog.print_unavailable_resource(npc)
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" arrived but target ",
|
|
||||||
npc.target_id,
|
|
||||||
" is unavailable, replanning"
|
|
||||||
)
|
|
||||||
notify_npc_navigation_failed(npc.id)
|
notify_npc_navigation_failed(npc.id)
|
||||||
return
|
return
|
||||||
if (
|
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 not animal_care.can_complete_feed(npc.target_id, npc.id):
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
NpcTickDebugLog.print_unavailable_animal(npc)
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" arrived but animal ",
|
|
||||||
npc.target_id,
|
|
||||||
" no longer needs feeding, replanning"
|
|
||||||
)
|
|
||||||
notify_npc_navigation_failed(npc.id)
|
notify_npc_navigation_failed(npc.id)
|
||||||
return
|
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.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] = []
|
var working_speakers: Array[SimNPC] = []
|
||||||
if npc.target_id != &"":
|
if npc.target_id != &"":
|
||||||
@@ -455,15 +420,42 @@ func notify_npc_arrived(npc_id: int) -> void:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
NpcTickDebugLog.print_started_work(npc)
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" arrived and started working on: ",
|
|
||||||
npc.current_task
|
|
||||||
)
|
|
||||||
return
|
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:
|
func notify_npc_navigation_failed(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
if npc.id != npc_id:
|
if npc.id != npc_id:
|
||||||
@@ -472,20 +464,10 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
var failed_task := npc.current_task
|
var failed_task := npc.current_task
|
||||||
release_npc_reservation(npc.id)
|
_redirect_npc_to_wander(npc)
|
||||||
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)
|
|
||||||
|
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" could not navigate for ",
|
|
||||||
failed_task,
|
|
||||||
" and is trying a wander target"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -227,9 +227,48 @@ func _resolve_animal(
|
|||||||
var target_id := StringName(best["target_id"])
|
var target_id := StringName(best["target_id"])
|
||||||
if not simulation_manager.animal_care.reserve(target_id, npc.id):
|
if not simulation_manager.animal_care.reserve(target_id, npc.id):
|
||||||
return {}
|
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
|
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(
|
func score_resource_candidate(
|
||||||
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
|
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
|
||||||
) -> float:
|
) -> 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:
|
func has_available_feed_target(npc_id: int) -> bool:
|
||||||
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
|
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
|
||||||
return false
|
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:
|
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
|
||||||
var animal_state := get_state(animal_id)
|
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
|
return false
|
||||||
|
var actor_npc: SimNPC
|
||||||
if actor_id >= 0:
|
if actor_id >= 0:
|
||||||
|
actor_npc = _find_npc(actor_id, npcs)
|
||||||
if (
|
if (
|
||||||
_find_npc(actor_id, npcs) == null
|
actor_npc == null
|
||||||
or not animal_state.can_npc_feed()
|
or not animal_state.can_npc_feed()
|
||||||
or animal_state.get_reserved_by() != actor_id
|
or animal_state.get_reserved_by() != actor_id
|
||||||
):
|
):
|
||||||
@@ -176,37 +233,29 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
|
|||||||
var resource_id := definition.completion_cost_resource_id
|
var resource_id := definition.completion_cost_resource_id
|
||||||
var required_amount := definition.completion_cost_amount
|
var required_amount := definition.completion_cost_amount
|
||||||
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
|
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
|
||||||
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
|
var source_id := SimulationIds.STORAGE_VILLAGE_PANTRY
|
||||||
if pantry == null or available < required_amount:
|
var consumed := 0.0
|
||||||
if actor_id >= 0:
|
if actor_npc != null:
|
||||||
(
|
var carried_amount := actor_npc.get_inventory_amount(resource_id)
|
||||||
narrative_event_requested
|
if carried_amount < required_amount:
|
||||||
. emit(
|
_emit_feed_blocked(
|
||||||
SimulationIds.EVENT_TASK_BLOCKED,
|
actor_id, SimulationIds.npc_inventory_id(actor_id), definition, carried_amount
|
||||||
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
|
return false
|
||||||
|
consumed = economy.remove_exact_from_inventory(actor_npc, resource_id, required_amount)
|
||||||
var consumed: float = economy.withdraw_resource(resource_id, required_amount)
|
source_id = SimulationIds.npc_inventory_id(actor_id)
|
||||||
if consumed < required_amount:
|
else:
|
||||||
economy.deposit_resource(resource_id, consumed)
|
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
|
||||||
|
if pantry == null or available < required_amount:
|
||||||
|
return false
|
||||||
|
consumed = economy.withdraw_resource(resource_id, required_amount)
|
||||||
|
if not is_equal_approx(consumed, required_amount):
|
||||||
return false
|
return false
|
||||||
if not animal_state.feed(actor_id, current_tick):
|
if not animal_state.feed(actor_id, current_tick):
|
||||||
economy.deposit_resource(resource_id, consumed)
|
if actor_npc != null:
|
||||||
|
economy.restore_to_inventory(actor_npc, resource_id, consumed)
|
||||||
|
else:
|
||||||
|
economy.deposit_resource(resource_id, consumed)
|
||||||
return false
|
return false
|
||||||
if animal_state.has_active_travel_target():
|
if animal_state.has_active_travel_target():
|
||||||
animal_state.cancel_travel(current_tick + ROUTINE_STAY_TICKS)
|
animal_state.cancel_travel(current_tick + ROUTINE_STAY_TICKS)
|
||||||
@@ -216,7 +265,7 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
|
|||||||
economic_event_requested.emit(
|
economic_event_requested.emit(
|
||||||
SimulationIds.EVENT_ANIMAL_FED,
|
SimulationIds.EVENT_ANIMAL_FED,
|
||||||
actor_id,
|
actor_id,
|
||||||
pantry.get_storage_id(),
|
source_id,
|
||||||
animal_state.get_animal_id(),
|
animal_state.get_animal_id(),
|
||||||
resource_id,
|
resource_id,
|
||||||
consumed,
|
consumed,
|
||||||
@@ -225,6 +274,33 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
|
|||||||
return true
|
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:
|
func append_state_records(record: SimulationStateRecord) -> void:
|
||||||
var animal_ids: Array = states.keys()
|
var animal_ids: Array = states.keys()
|
||||||
animal_ids.sort()
|
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
|
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:
|
func consume_npc_food(npc: SimNPC) -> bool:
|
||||||
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
||||||
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
||||||
|
|||||||
@@ -98,6 +98,21 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
|
|||||||
return null
|
return null
|
||||||
if not record_data["inventory"] is Dictionary:
|
if not record_data["inventory"] is Dictionary:
|
||||||
return null
|
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"])
|
var profession_id := StringName(record_data["profession"])
|
||||||
if SimulationDefinitions.get_profession(profession_id) == null:
|
if SimulationDefinitions.get_profession(profession_id) == null:
|
||||||
return null
|
return null
|
||||||
@@ -110,7 +125,9 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
|
|||||||
var last_action_id := StringName(record_data["last_task"])
|
var last_action_id := StringName(record_data["last_task"])
|
||||||
if not last_action_id.is_empty() and SimulationDefinitions.get_action(last_action_id) == null:
|
if not last_action_id.is_empty() and SimulationDefinitions.get_action(last_action_id) == null:
|
||||||
return 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:
|
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
|
||||||
|
|||||||
@@ -473,11 +473,19 @@ static func _is_valid_animal_feed_event(
|
|||||||
var animal := (
|
var animal := (
|
||||||
animal_records_by_id.get(StringName(event.data["destination_id"])) as AnimalStateRecord
|
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 (
|
return (
|
||||||
(actor_id == -1 or npc_ids.has(actor_id))
|
(actor_id == -1 or npc_ids.has(actor_id))
|
||||||
and int(event.data["tick"]) <= current_tick
|
and int(event.data["tick"]) <= current_tick
|
||||||
and animal != null
|
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 storage_records_by_id.has(SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||||
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||||
and definition != null
|
and definition != null
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
|
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
|
||||||
var player := main_scene.get_node("Player") as Node3D
|
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 pantry: StorageStateRecord = manager.get_pantry()
|
||||||
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
|
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
|
||||||
_check(goat != null, "Jajce should load Dunja's named presentation")
|
_check(goat != null, "Jajce should load Dunja's named presentation")
|
||||||
@@ -81,9 +82,23 @@ func _run() -> void:
|
|||||||
(
|
(
|
||||||
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
|
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
|
||||||
and state.get_reserved_by() == caretaker.id
|
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())
|
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.notify_npc_arrived(caretaker.id)
|
||||||
manager.simulate_tick()
|
manager.simulate_tick()
|
||||||
@@ -115,14 +130,15 @@ func _run() -> void:
|
|||||||
npc_feed_event != null
|
npc_feed_event != null
|
||||||
and int(npc_feed_event.data["actor_id"]) == caretaker.id
|
and int(npc_feed_event.data["actor_id"]) == caretaker.id
|
||||||
and (
|
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["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
|
||||||
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||||
and is_equal_approx(float(npc_feed_event.data["amount"]), 1.0)
|
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())
|
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(
|
_check(
|
||||||
(
|
(
|
||||||
@@ -172,7 +188,7 @@ func _run() -> void:
|
|||||||
and int(player_feed_event.data["actor_id"]) == -1
|
and int(player_feed_event.data["actor_id"]) == -1
|
||||||
and (StringName(player_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
|
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()
|
var player_feed_json: String = manager.serialize_state()
|
||||||
_check(
|
_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_hunger_before := dunja_state.get_hunger()
|
||||||
var dunja_fed_tick_before := dunja_state.get_last_fed_tick()
|
var dunja_fed_tick_before := dunja_state.get_last_fed_tick()
|
||||||
var dunja_reservation_before := dunja_state.get_reserved_by()
|
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(
|
_check(
|
||||||
manager.feed_animal(SimulationIds.ANIMAL_ZORA, zora_caretaker_id),
|
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(
|
_check(
|
||||||
(
|
(
|
||||||
@@ -212,20 +216,23 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_food_before - 1.0),
|
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)
|
var feed_event := _latest_feed_event(manager)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
feed_event != null
|
feed_event != null
|
||||||
and int(feed_event.data["actor_id"]) == zora_caretaker_id
|
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["destination_id"]) == SimulationIds.ANIMAL_ZORA
|
||||||
and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||||
and is_equal_approx(float(feed_event.data["amount"]), 1.0)
|
and is_equal_approx(float(feed_event.data["amount"]), 1.0)
|
||||||
and feed_event.get_world_position().is_equal_approx(zora_state.get_position())
|
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()
|
_finish()
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func _test_definition_backed_behavior() -> void:
|
|||||||
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
|
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
|
||||||
and is_equal_approx(feed.completion_cost_amount, 1.0)
|
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_deterministic_continuation()
|
||||||
await _test_resource_state_round_trip()
|
await _test_resource_state_round_trip()
|
||||||
_test_legacy_npc_migration()
|
_test_legacy_npc_migration()
|
||||||
|
_test_npc_inventory_schema_rejection()
|
||||||
_test_legacy_resource_migration()
|
_test_legacy_resource_migration()
|
||||||
_test_legacy_world_storage_migration()
|
_test_legacy_world_storage_migration()
|
||||||
_test_previous_world_event_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:
|
func _test_legacy_world_storage_migration() -> void:
|
||||||
var manager := _create_manager(55)
|
var manager := _create_manager(55)
|
||||||
var legacy_data: Dictionary = manager.create_state_record().to_dictionary()
|
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
|
return best_candidate
|
||||||
match action_id:
|
match action_id:
|
||||||
SimulationIds.ACTION_EAT:
|
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:
|
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:
|
SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||||
return _get_storage_target(woodpile_storage)
|
return get_storage_target(SimulationIds.STORAGE_VILLAGE_WOODPILE)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -159,7 +159,13 @@ func get_activity_candidates(action_id: StringName) -> Array[Dictionary]:
|
|||||||
return candidates
|
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:
|
if storage_node == null:
|
||||||
return {}
|
return {}
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user