feat: introduce identity-backed goat

This commit is contained in:
Rijad Zuzo
2026-07-26 22:26:00 +02:00
parent 1f09ebe6b6
commit f79c2bb630
47 changed files with 1903 additions and 96 deletions
+10
View File
@@ -16,6 +16,7 @@ SimulationClock
-> ActionSelectionSystem chooses an action -> ActionSelectionSystem chooses an action
-> ActionTargetResolver resolves a stable target ID -> ActionTargetResolver resolves a stable target ID
-> VillageEconomy performs inventory/storage transactions -> VillageEconomy performs inventory/storage transactions
-> AnimalCareSystem advances animal needs and composes feeding
-> SimulationEventLog records completed facts -> SimulationEventLog records completed facts
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
-> RelationshipSystem applies evidence-gated social consequences -> RelationshipSystem applies evidence-gated social consequences
@@ -35,6 +36,9 @@ would otherwise obscure that lifecycle:
- `simulation/actions/` owns selection, progress, and target resolution; - `simulation/actions/` owns selection, progress, and target resolution;
- `simulation/economy/VillageEconomy.gd` owns storage/inventory transactions - `simulation/economy/VillageEconomy.gd` owns storage/inventory transactions
and keeps village resource summaries synchronized; and keeps village resource summaries synchronized;
- `simulation/animals/AnimalCareSystem.gd` owns animal records, hunger
advancement, loaded binding, feed reservations, and the exact conserved
pantry-to-animal operation used by NPCs and the player;
- `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
@@ -89,6 +93,7 @@ hard to read.
| --- | --- | | --- | --- |
| `simulation/` | Headless-capable orchestration and core models | | `simulation/` | Headless-capable orchestration and core models |
| `simulation/actions/` | Action decisions, execution, and target queries | | `simulation/actions/` | Action decisions, execution, and target queries |
| `simulation/animals/` | Animal lifecycle, target claims, and feeding transactions |
| `simulation/economy/` | Authoritative inventory and storage transactions | | `simulation/economy/` | Authoritative inventory and storage transactions |
| `simulation/events/` | Immutable event history and derived event queries | | `simulation/events/` | Immutable event history and derived event queries |
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs | | `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
@@ -100,6 +105,7 @@ hard to read.
| `simulation/persistence/` | Validated local save-file storage | | `simulation/persistence/` | Validated local save-file storage |
| `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads | | `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads |
| `world/` | Loaded-world interaction geometry and presentation adapters | | `world/` | Loaded-world interaction geometry and presentation adapters |
| `world/animals/` | Animal presentation binding and interaction geometry |
| `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index | | `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index |
| `world/storage/` | Storage interaction geometry, never stored quantities | | `world/storage/` | Storage interaction geometry, never stored quantities |
| `world/activity/` | Rest/study/patrol interaction sites and capacity facts | | `world/activity/` | Rest/study/patrol interaction sites and capacity facts |
@@ -130,6 +136,10 @@ improving ownership.
reactions are not replayed. reactions are not replayed.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and - Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views. `VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- Animal identity, position, hunger, last feed tick, and reservation live in
`AnimalStateRecord`. `AnimalNode` binds that state and contributes only its
loaded interaction point. Animal candidates are currently scanned linearly;
they do not enter the resource grid.
- New mutable features define serialization and deterministic continuation at - New mutable features define serialization and deterministic continuation at
the same time as their first gameplay use. Cross-record causes use stable the same time as their first gameplay use. Cross-record causes use stable
event IDs rather than object references or prose. event IDs rather than object references or prose.
+13 -6
View File
@@ -798,15 +798,22 @@ Completed:
stone instances stay presentation-only. Both finite trees remain reachable, stone instances stay presentation-only. Both finite trees remain reachable,
deplete into shared stumps, restore from authoritative amount, and keep the deplete into shared stumps, restore from authoritative amount, and keep the
world/save total at eighteen resources. world/save total at eighteen resources.
40. `Jajce Goat 07`: Dunja is the first identity-backed animal, with a stable
goat ID, simulation-owned position and hunger, and a loaded cozy
presentation with an honest hungry cue. NPC and player feeding share one
exact transaction that withdraws pantry food, changes only Dunja's state,
and emits one economic fact. World-schema v10 save/restore preserves the
result without replaying the transient feed response.
Next: Next:
1. Add one named goat as an identity-backed simulation vertical slice: stable 1. Give Dunja one deterministic pasture/shelter routine using
animal ID, authoritative position and hunger/feed state, loaded visual simulation-owned destination and position, real loaded-world navigation,
binding, and an exact feed interaction shared by one NPC and the player. and exact mid-move save/restore continuation.
Feeding must withdraw real pantry food and survive deterministic save/load. 2. Add a second goat only after movement proves that identity remains stable
2. Add a small herd, breeding, or animal products only after that single-animal across care, navigation, unloading, and restore.
identity and conservation contract is proven. 3. Keep breeding, products, herd scheduling, animal spatial indexing, and
active/abstract simulation LOD deferred until two real animals require them.
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
+19 -7
View File
@@ -10,6 +10,7 @@ resource_extracted
storage_deposited storage_deposited
storage_withdrawn storage_withdrawn
item_consumed item_consumed
animal_fed
``` ```
Each record contains a monotonically increasing event ID, event type, Each record contains a monotonically increasing event ID, event type,
@@ -17,8 +18,10 @@ 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. Actor `-1` identifies a player-triggered the ResourceNode interaction position. `animal_fed` records one food moving
extraction until persistent player identity is introduced. from `village_pantry` to a stable animal ID at the animal's authoritative
position. Actor `-1` identifies a player-triggered extraction or feeding until
persistent player identity is introduced.
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,
@@ -26,8 +29,10 @@ inventory, and storage records remain authoritative.
`SimulationEventLog` owns ordered event identity, append/restore behavior, and `SimulationEventLog` owns ordered event identity, append/restore behavior, and
history/rate queries, including exact lookup through `get_by_id()`. history/rate queries, including exact lookup through `get_by_id()`.
`VillageEconomy` performs transactions and requests event records only after `VillageEconomy` performs inventory and storage transactions and requests event
state changes succeed; `SimulationManager` remains the public signal boundary records only after state changes succeed. `AnimalCareSystem` composes the
pantry withdrawal with animal hunger relief and requests `animal_fed` only
after both succeed. `SimulationManager` remains the scene-tree signal boundary
used by presentation. used by presentation.
An action whose definition-backed completion cost becomes unavailable records An action whose definition-backed completion cost becomes unavailable records
@@ -38,17 +43,24 @@ transfer occurred.
## Persistence and determinism ## Persistence and determinism
`SimulationStateRecord` schema v9 stores the ordered event stream, `SimulationStateRecord` schema v10 stores the ordered event stream,
`next_event_id`, directed relationships that may reference an exact event, and `next_event_id`, directed relationships that may reference an exact event, and
per-NPC known-event references with first-acquisition provenance, plus per-NPC known-event references with first-acquisition provenance, plus
opportunity records that reference exact trigger/resolution events. Schema v1 opportunity records that reference exact trigger/resolution events. Schema v1
and v2 saves migrate to an empty stream beginning at ID zero; world schemas and v2 saves migrate to an empty stream beginning at ID zero; world schemas
v1v7 migrate to an empty opportunity list, while world schema v8 preserves and v1v7 migrate to an empty opportunity list, while world schemas v8 and v9
normalizes its pantry opportunity history. Parsing rejects duplicate event preserve and normalize opportunity history. World v9 adds an empty animal list
during migration. Parsing rejects duplicate event
IDs, invalid or duplicate knowledge/opportunity references, impossible IDs, invalid or duplicate knowledge/opportunity references, impossible
communicator sources, relationship causes the observer does not know, and next communicator sources, relationship causes the observer does not know, and next
IDs that could collide with restored history. IDs that could collide with restored history.
Animal-feed facts receive an additional cross-record check: actor is either an
existing NPC or the player sentinel, source is the real pantry, destination is
an existing animal, item and definition cost are exactly one food, event tick
is not in the future, and the animal's `last_fed_tick` agrees with its latest
feed fact.
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
`task_blocked` facts caused by missing wood at `village_woodpile` are currently `task_blocked` facts caused by missing wood at `village_woodpile` are currently
+18 -8
View File
@@ -923,17 +923,27 @@ rebuilds the sparse standing tree from amount alone. The river and forest
clusters now share only the terrain-snap, bounded-instance, and anchor-layout clusters now share only the terrain-snap, bounded-instance, and anchor-layout
code proven by those two consumers. code proven by those two consumers.
The immediate next slice should introduce livestock only through one The first identity-backed livestock slice is complete. Dunja has a stable
identity-backed animal vertical slice: one named goat with a stable animal ID, animal ID, simulation-owned position and hunger/feed state, and a loaded cozy
simulation-owned position and hunger/feed state, a loaded presentation binding, presentation whose persistent cue derives from that state. One exact operation
and one exact feed interaction available to both an NPC and the player. Feeding serves both NPC and player feeding, withdraws real pantry food, mutates only the
must consume real pantry food and save/restore deterministically. Do not make target animal, and appends one economic fact. World-schema v10 save/restore
the animal a `ResourceNode`, add a herd, breeding, products, or a generalized preserves the result without replaying the transient response.
animal spatial index before that first identity and conservation contract is
honest. The immediate next slice should give Dunja one deterministic
pasture/shelter routine. Her destination and position must remain
simulation-owned, loaded movement must use real navigation, and a mid-move
save/restore must continue exactly without consuming new decision RNG. Keep
loaded-animal discovery linear and do not add a second goat, herd scheduling,
breeding, products, or active/abstract simulation LOD until this movement
contract is honest.
Recently completed: Recently completed:
- `Jajce Goat 07`: named goat Dunja proves stable animal identity, exact
pantry-conserving NPC/player feeding, deterministic hunger and save/restore,
loaded target reservation, a state-derived hungry cue, and a transient cozy
response that never enters persisted state.
- `Jajce Forest Edge 06`: two existing deep-forest tree IDs now anchor a - `Jajce Forest Edge 06`: two existing deep-forest tree IDs now anchor a
restrained four-tree silhouette with 24 decorative understory instances. restrained four-tree silhouette with 24 decorative understory instances.
Both targets require expanded loaded-resource discovery, remain reachable, Both targets require expanded loaded-resource discovery, remain reachable,
+11 -8
View File
@@ -616,10 +616,10 @@ These are expected prototype constraints, not necessarily isolated bugs:
- Temporary activity markers have been removed; NPC and player food/wood - Temporary activity markers have been removed; NPC and player food/wood
gathering use `ResourceNode` instances with no fallback, food transfer uses gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, knowledge, relationship, - Current NPC, village, resource, storage, animal, event, knowledge,
opportunity, clock, and RNG state serialize through world schema v9. F5/F9 relationship, opportunity, clock, and RNG state serialize through world
provide one validated local quicksave; a save menu, metadata, and schema v10. F5/F9 provide one validated local quicksave; a save menu,
player-transform persistence remain deferred. metadata, and player-transform persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and - Simulation-owned resource records retain live amounts, reservations, and
usage definitions while ResourceNode scenes are unloaded. usage definitions while ResourceNode scenes are unloaded.
- An explicit fixed-step clock converts frame delta into simulation ticks, but - An explicit fixed-step clock converts frame delta into simulation ticks, but
@@ -987,10 +987,13 @@ visuals, and two bounded foliage/resource placement proofs are complete. The
riverbank and forest edge preserve stable finite-resource IDs, separate riverbank and forest edge preserve stable finite-resource IDs, separate
decorative density from simulation authority, support discovery beyond the 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. The next slice should introduce one named goat only through a stable amount. Dunja now proves the first identity-backed animal contract: stable
simulation identity, authoritative position and feed state, loaded visual animal identity, simulation-owned position and hunger, a loaded visual binding,
binding, exact NPC/player feeding cost, and deterministic save/restore. Do not and the same exact pantry-conserving feed operation for NPC and player.
yet generalize the resource grid or introduce active/abstract simulation LOD. World-schema v10 restore reconstructs her persistent state without replaying
the transient response. The next bounded animal slice should give Dunja one
deterministic pasture/shelter routine before adding another goat. Do not yet
generalize animal discovery or introduce active/abstract simulation LOD.
The remaining simulation-garden target still aims for: The remaining simulation-garden target still aims for:
+9 -4
View File
@@ -19,13 +19,13 @@ Each executable action definition contains:
- display name; - display name;
- default duration; - default duration;
- optional preferred profession ID; - optional preferred profession ID;
- target type: resource, activity, or free movement; - target type: resource, activity, animal, or free movement;
- resource action ID when the action targets a ResourceNode; - resource action ID when the action targets a ResourceNode;
- optional completion-cost resource ID and amount. - optional completion-cost resource ID and amount.
The current actions are gather food, gather wood, deposit food, deposit wood, The current actions are gather food, gather wood, feed animal, deposit food,
withdraw food, patrol, study, eat, rest, sleep, and wander. Idle and dead are deposit wood, withdraw food, patrol, study, eat, rest, sleep, and wander. Idle
stable state sentinels, not executable action definitions. and dead are stable state sentinels, not executable action definitions.
SimNPC reads default duration and preferred-profession metadata from these SimNPC reads default duration and preferred-profession metadata from these
definitions. WorldViewManager reads target type and resource-action metadata definitions. WorldViewManager reads target type and resource-action metadata
@@ -33,6 +33,11 @@ instead of maintaining a separate gather-action map. ActionSelectionSystem and
SimulationManager share the same completion-cost metadata, so patrol and study SimulationManager share the same completion-cost metadata, so patrol and study
both require one stored wood without duplicating that rule. both require one stored wood without duplicating that rule.
`feed_animal` is the first animal-targeted action. Its definition prefers the
farmer profession and declares an exact one-food completion cost. Selection,
animal target reservation, and completion remain separate; NPC and player
completion both use `AnimalCareSystem`'s same pantry-to-animal transaction.
## Current profession contract ## Current profession contract
Each profession definition contains a stable `profession_id`, display name, Each profession definition contains a stable `profession_id`, display name,
+38 -3
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v9 and captures: simulation. The current world schema is v10 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -13,6 +13,8 @@ simulation. The current world schema is v9 and captures:
- controlled per-NPC wander RNG streams; - controlled per-NPC wander RNG streams;
- scene-independent resource state plus the definition facts needed while its - scene-independent resource state plus the definition facts needed while its
ResourceNode is unloaded; ResourceNode is unloaded;
- scene-independent animal identity, position, hunger, feeding history,
reservation, and availability state;
- pantry contents, carried NPC inventory, the ordered economic event stream, - pantry contents, carried NPC inventory, the ordered economic event stream,
and its next stable event ID; and its next stable event ID;
- directed relationship records with familiarity, trust, and the stable event - directed relationship records with familiarity, trust, and the stable event
@@ -29,7 +31,7 @@ The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "schema": "the_steward.simulation",
"schema_version": 9 "schema_version": 10
} }
``` ```
@@ -170,6 +172,21 @@ nested v1/v2 events receive empty/zero defaults, while a new `task_blocked`
event requires its stable action, source storage, resource, and positive event requires its stable action, source storage, resource, and positive
requirement contract. requirement contract.
SimulationStateRecord v10 adds the top-level `animals` array and the first
`AnimalStateRecord` schema. Each animal record stores its stable animal and
species IDs, display name, authoritative world position, hunger,
`last_fed_tick`, reservation owner, and enabled/player/NPC feeding flags.
World schema v9 preserves opportunity history while migrating to an empty
animal list.
Current-schema parsing requires unique animal IDs that do not collide with
resource or storage IDs. An animal reservation must belong to an existing NPC
whose active feed task targets that exact animal. Every `animal_fed` event
must name the pantry as its source, an existing animal as its destination,
food as its item, the exact feed action cost, and a valid NPC actor or the
player sentinel. The latest matching feed event tick must agree with the
animal's `last_fed_tick`.
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
ID must remain above restored history. Referenced NPCs, storage, resources, and ID must remain above restored history. Referenced NPCs, storage, resources, and
@@ -235,6 +252,24 @@ explicitly. Current-schema parsing also canonicalizes numeric, boolean, and ID
variants so a freshly registered finite source retains a byte-stable checksum variants so a freshly registered finite source retains a byte-stable checksum
after a JSON round trip. after a JSON round trip.
## Animal authority
`AnimalCareSystem` owns `AnimalStateRecord` instances independently of the
scene tree and advances their hunger in stable-ID order. `AnimalNode` binds a
loaded presentation and interaction point to that authority; the simulation
position remains canonical while the node is loaded or absent.
Animals are not `ResourceNode` instances and are not inserted into the
resource discovery grid. The first bounded slice uses a deterministic linear
loaded-animal query. NPC and player feeding resolve through the same atomic
care operation: reserve or select the exact animal, withdraw one real food
from `village_pantry`, reduce that animal's hunger, and append one exact
`animal_fed` fact. Failed withdrawal changes neither animal nor history.
The persistent hungry/content presentation derives from authoritative hunger.
The short feed response is transient and is reset rather than serialized or
replayed after restore.
## Local quicksave boundary ## Local quicksave boundary
`SaveSlotStore` writes the existing versioned JSON record to `SaveSlotStore` writes the existing versioned JSON record to
@@ -253,7 +288,7 @@ This phase does not yet provide:
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations from any historical world schema other than the explicitly - migrations from any historical world schema other than the explicitly
supported v1v8 layouts; supported v1v9 layouts;
- player inventory or player relationship records; - player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence, - broader relationship dimensions, line-of-sight/hearing evidence,
continuous or personalized memory decay, reinforcement, false beliefs, or continuous or personalized memory decay, reinforcement, false beliefs, or
+38
View File
@@ -0,0 +1,38 @@
# Jajce Goat 07
Captured 2026-07-26 with the Metal renderer from the same close-up camera:
- [hungry frame](jajce_goat_07_hungry.png)
- [content frame](jajce_goat_07_content.png)
Only authoritative hunger changes between the paired frames. Dunja lowers her
head toward the bowl and shows a small amber empty-bowl cue while hungry; after
feeding she raises her head and the persistent cue clears. The brief green
response is deliberately transient and is not part of the restored frame.
This visual comparison sits on the gameplay proof:
- Dunja has one stable animal ID and is not a resource node;
- NPC and player use the same exact feed operation;
- one feed withdraws one real pantry food and relieves only Dunja;
- world-schema v10 restores position, hunger, and last-fed tick without
replaying presentation effects.
The next animal frame should prove a real pasture/shelter movement routine,
not multiply static livestock.
Capture command:
```bash
HOME="$PWD/logs/quality/godot_profile" \
APPDATA="$PWD/logs/quality/godot_profile" \
LOCALAPPDATA="$PWD/logs/quality/godot_profile" \
/Applications/Godot.app/Contents/MacOS/Godot \
--path "$PWD" --script res://tools/capture_jajce_lookdev.gd -- \
--scene=res://world/jajce/JajceGoatLookdev.tscn \
--hide-debug-labels --animal-hunger=goat_dunja:90 \
--output=res://docs/baselines/jajce_goat_07_hungry.png
```
For the content frame, change the hunger override to `20` and the output
filename to `jajce_goat_07_content.png`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0ldryrvw76q6"
path="res://.godot/imported/jajce_goat_07_content.png-138802d07281bb13d9d6edbbb359f838.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_goat_07_content.png"
dest_files=["res://.godot/imported/jajce_goat_07_content.png-138802d07281bb13d9d6edbbb359f838.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bexlabef1e1xa"
path="res://.godot/imported/jajce_goat_07_hungry.png-2474d252bb9b213aa9e6cc2457baacb4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/baselines/jajce_goat_07_hungry.png"
dest_files=["res://.godot/imported/jajce_goat_07_hungry.png-2474d252bb9b213aa9e6cc2457baacb4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+7
View File
@@ -478,6 +478,11 @@ func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
food.radius = 0.18 food.radius = 0.18
food.height = 0.26 food.height = 0.26
return food return food
SimulationIds.ACTION_FEED_ANIMAL:
var feed := SphereMesh.new()
feed.radius = 0.18
feed.height = 0.26
return feed
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD: SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
var wood := BoxMesh.new() var wood := BoxMesh.new()
wood.size = Vector3(0.18, 0.42, 0.18) wood.size = Vector3(0.18, 0.42, 0.18)
@@ -521,6 +526,8 @@ func _get_task_glyph_color(action_id: StringName) -> Color:
match action_id: match action_id:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD: SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD:
return Color(0.95, 0.42, 0.25, 1.0) return Color(0.95, 0.42, 0.25, 1.0)
SimulationIds.ACTION_FEED_ANIMAL:
return Color(0.95, 0.64, 0.25, 1.0)
SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD: SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_WOOD:
return Color(0.58, 0.34, 0.16, 1.0) return Color(0.58, 0.34, 0.16, 1.0)
SimulationIds.ACTION_DEPOSIT_FOOD: SimulationIds.ACTION_DEPOSIT_FOOD:
+22
View File
@@ -59,6 +59,9 @@ func try_interact() -> void:
if simulation_manager == null: if simulation_manager == null:
return return
if try_feed_animal():
return
if try_harvest_resource_node(): if try_harvest_resource_node():
return return
@@ -73,6 +76,25 @@ func try_interact() -> void:
print("Player ate food from the village supply.") print("Player ate food from the village supply.")
func try_feed_animal() -> bool:
if not ("animal_care" in simulation_manager) or simulation_manager.animal_care == null:
push_error("Player: SimulationManager has no animal-care service")
return false
var node: AnimalNode = simulation_manager.animal_care.find_node_for_player(
global_position, interaction_range
)
if node == null:
return false
if not simulation_manager.has_method("feed_animal"):
push_error("Player: SimulationManager cannot feed AnimalNodes")
return true
if simulation_manager.feed_animal(node.animal_id, -1):
print("Player fed %s one food from the village pantry." % node.display_name)
else:
print("%s is hungry, but the pantry has no food to spare." % node.display_name)
return true
func try_harvest_resource_node() -> bool: func try_harvest_resource_node() -> bool:
if not simulation_manager.has_method("find_resource_node_for_player"): if not simulation_manager.has_method("find_resource_node_for_player"):
push_error("Player: SimulationManager cannot find ResourceNodes") push_error("Player: SimulationManager cannot find ResourceNodes")
+45 -46
View File
@@ -2,6 +2,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/AnimalCareSystem.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")
@@ -38,6 +39,7 @@ var wander_random_sources := {}
var resource_states: Dictionary = {} var resource_states: Dictionary = {}
var event_log := SimulationEventLogScript.new() var event_log := SimulationEventLogScript.new()
var economy := VillageEconomyScript.new() var economy := VillageEconomyScript.new()
var animal_care := AnimalCareSystemScript.new()
var relationship_system := RelationshipSystemScript.new() var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new()
var opportunity_system := VillageOpportunitySystem.new() var opportunity_system := VillageOpportunitySystem.new()
@@ -71,6 +73,8 @@ func _ready() -> void:
economy.inventory_changed.connect(_on_economy_inventory_changed) economy.inventory_changed.connect(_on_economy_inventory_changed)
economy.economic_event_requested.connect(_record_economic_event) economy.economic_event_requested.connect(_record_economic_event)
economy.narrative_event_requested.connect(record_narrative_event) economy.narrative_event_requested.connect(record_narrative_event)
animal_care.economic_event_requested.connect(_record_economic_event_at)
animal_care.narrative_event_requested.connect(record_narrative_event)
action_selector.relationship_system = relationship_system action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate() var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty(): if not definition_errors.is_empty():
@@ -83,12 +87,14 @@ func _ready() -> void:
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval) clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
village.debug_logs = debug_logs village.debug_logs = debug_logs
economy.configure(village, debug_logs) economy.configure(village, debug_logs)
animal_care.configure(economy, active_world_adapter)
economy.initialize_storage() economy.initialize_storage()
village.update_modifiers() village.update_modifiers()
village.update_priorities() village.update_priorities()
generate_npcs() generate_npcs()
relationship_system.initialize_households(npcs) relationship_system.initialize_households(npcs)
call_deferred("register_loaded_resource_nodes") call_deferred("register_loaded_resource_nodes")
animal_care.call_deferred("register_loaded_nodes")
call_deferred("register_loaded_storage_nodes") call_deferred("register_loaded_storage_nodes")
if debug_logs: if debug_logs:
print("--- Simulation started ---") print("--- Simulation started ---")
@@ -156,6 +162,7 @@ func simulate_tick() -> void:
tick_count += 1 tick_count += 1
if debug_logs: if debug_logs:
print("--- Tick ", tick_count, " ---") print("--- Tick ", tick_count, " ---")
animal_care.advance()
var village_was_changed := false var village_was_changed := false
_population_view.rebuild(npcs) _population_view.rebuild(npcs)
for npc in npcs: for npc in npcs:
@@ -210,8 +217,9 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]: if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]:
return return
var helper := get_active_opportunity_helper() var helper := get_active_opportunity_helper()
var animal_feed_name := animal_care.get_available_feed_name(npc.id)
var selection := action_selector.select_action( var selection := action_selector.select_action(
npc, village, clock.time_of_day(), npcs, helper, _population_view npc, village, clock.time_of_day(), npcs, helper, _population_view, animal_feed_name
) )
if selection == null: if selection == null:
return return
@@ -239,7 +247,12 @@ func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target:
func _complete_current_action(npc: SimNPC) -> void: func _complete_current_action(npc: SimNPC) -> void:
var completed_task := npc.current_task var completed_task := npc.current_task
var definition := SimulationDefinitions.get_action(completed_task) var definition := SimulationDefinitions.get_action(completed_task)
if economy.consume_completion_cost(npc, definition): var completion_succeeded := false
if completed_task == SimulationIds.ACTION_FEED_ANIMAL:
completion_succeeded = feed_animal(npc.target_id, npc.id)
else:
completion_succeeded = economy.consume_completion_cost(npc, definition)
if completion_succeeded:
_apply_action_completion(npc, completed_task, definition) _apply_action_completion(npc, completed_task, definition)
elif not npc.target_id.is_empty(): elif not npc.target_id.is_empty():
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -272,6 +285,8 @@ func _apply_action_completion(
npc.energy = minf(npc.energy + 40.0, 100.0) npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position npc.position = npc.home_position
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id) record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
SimulationIds.ACTION_FEED_ANIMAL:
pass
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER: SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
village.apply_npc_task(npc) village.apply_npc_task(npc)
if debug_logs and completed_task == SimulationIds.ACTION_SLEEP: if debug_logs and completed_task == SimulationIds.ACTION_SLEEP:
@@ -364,6 +379,9 @@ func release_npc_reservation(npc_id: int) -> void:
var resource_state := get_resource_state(npc.target_id) var resource_state := get_resource_state(npc.target_id)
if resource_state != null: if resource_state != null:
resource_state.release(npc.id) resource_state.release(npc.id)
var animal_state := animal_care.get_state(npc.target_id)
if animal_state != null:
animal_state.release(npc.id)
npc.target_id = &"" npc.target_id = &""
return return
@@ -397,6 +415,22 @@ func notify_npc_arrived(npc_id: int) -> void:
) )
notify_npc_navigation_failed(npc.id) notify_npc_navigation_failed(npc.id)
return return
if (
npc.target_id != &""
and definition != null
and definition.target_type == SimulationIds.TARGET_ANIMAL
):
if not animal_care.can_complete_feed(npc.target_id, npc.id):
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but animal ",
npc.target_id,
" no longer needs feeding, replanning"
)
notify_npc_navigation_failed(npc.id)
return
npc.has_travel_target = false npc.has_travel_target = false
npc.start_working() npc.start_working()
@@ -903,6 +937,13 @@ func _on_economy_inventory_changed(npc: SimNPC, item_id: StringName, amount: flo
npc_inventory_changed.emit(npc, item_id, amount) npc_inventory_changed.emit(npc, item_id, amount)
func feed_animal(animal_id: StringName, actor_id: int = -1) -> bool:
var succeeded := animal_care.feed(animal_id, actor_id, npcs, tick_count)
if succeeded:
village_changed.emit(village)
return succeeded
func register_loaded_resource_nodes() -> void: func register_loaded_resource_nodes() -> void:
for node in ResourceNode.get_all(): for node in ResourceNode.get_all():
register_resource_node(node) register_resource_node(node)
@@ -1051,50 +1092,6 @@ func get_starving_count() -> int:
return _population_view.starving_count() return _population_view.starving_count()
func get_state_snapshot() -> Dictionary:
var npc_snapshots: Array[Dictionary] = []
for npc in npcs:
npc_snapshots.append(
{
"id": npc.id,
"name": npc.npc_name,
"profession": npc.profession,
"hunger": npc.hunger,
"energy": npc.energy,
"strength": npc.strength,
"intelligence": npc.intelligence,
"task": npc.current_task,
"task_state": npc.task_state,
"task_duration": npc.task_duration,
"task_progress": npc.task_progress,
"target_id": String(npc.target_id),
"position": [npc.position.x, npc.position.y, npc.position.z],
"travel_target_position":
[
npc.travel_target_position.x,
npc.travel_target_position.y,
npc.travel_target_position.z
],
"has_travel_target": npc.has_travel_target,
"starvation_ticks": npc.starvation_ticks,
"is_dead": npc.is_dead
}
)
return {
"seed": simulation_seed,
"tick_count": tick_count,
"village":
{
"food": village.food,
"wood": village.wood,
"safety": village.safety,
"knowledge": village.knowledge
},
"npcs": npc_snapshots
}
func get_state_checksum() -> String: func get_state_checksum() -> String:
return create_state_record().to_json().sha256_text() return create_state_record().to_json().sha256_text()
@@ -1127,6 +1124,7 @@ func create_state_record() -> SimulationStateRecord:
record.village = VillageStateRecord.capture(village) record.village = VillageStateRecord.capture(village)
for npc in npcs: for npc in npcs:
record.npcs.append(NPCStateRecord.capture(npc)) record.npcs.append(NPCStateRecord.capture(npc))
animal_care.append_state_records(record)
var sorted_resource_ids: Array = resource_states.keys() var sorted_resource_ids: Array = resource_states.keys()
sorted_resource_ids.sort() sorted_resource_ids.sort()
for resource_id in sorted_resource_ids: for resource_id in sorted_resource_ids:
@@ -1194,6 +1192,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
for resource_record in record.resources: for resource_record in record.resources:
resource_states[resource_record.get_node_id()] = resource_record resource_states[resource_record.get_node_id()] = resource_record
register_loaded_resource_nodes() register_loaded_resource_nodes()
animal_care.restore_state_records(record.animals)
village_changed.emit(village) village_changed.emit(village)
state_restored.emit() state_restored.emit()
return true return true
+10 -1
View File
@@ -23,7 +23,8 @@ func select_action(
time_of_day: float = 0.5, time_of_day: float = 0.5,
all_npcs: Array = [], all_npcs: Array = [],
opportunity_helper: OpportunityHelperResult = null, opportunity_helper: OpportunityHelperResult = null,
population_view: SimulationPopulationView = null population_view: SimulationPopulationView = null,
animal_feed_name: String = ""
) -> ActionSelectionResult: ) -> ActionSelectionResult:
if npc.is_dead: if npc.is_dead:
return null return null
@@ -126,6 +127,14 @@ func select_action(
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
) )
if not animal_feed_name.is_empty():
var feed_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if _get_cost_rejection(feed_definition, village).is_empty():
return ActionSelectionResult.new(
SimulationIds.ACTION_FEED_ANIMAL,
-1.0,
"%s is hungry; pantry food is available" % animal_feed_name
)
if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null: if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
npc.id, all_npcs, population_view npc.id, all_npcs, population_view
@@ -17,6 +17,8 @@ func resolve(
) )
SimulationIds.TARGET_ACTIVITY: SimulationIds.TARGET_ACTIVITY:
return _resolve_activity(npc, origin, simulation_manager, active_world_adapter) return _resolve_activity(npc, origin, simulation_manager, active_world_adapter)
SimulationIds.TARGET_ANIMAL:
return _resolve_animal(npc, origin, simulation_manager, active_world_adapter)
SimulationIds.TARGET_FREE: SimulationIds.TARGET_FREE:
return { return {
"target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id) "target_id": "", "position": origin + simulation_manager.get_wander_offset(npc.id)
@@ -202,6 +204,32 @@ func _resolve_activity(
return active_world_adapter.get_activity_target(npc.current_task, origin) return active_world_adapter.get_activity_target(npc.current_task, origin)
func _resolve_animal(
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
) -> Dictionary:
if not active_world_adapter.has_method("get_animal_candidates"):
return {}
var best: Dictionary = {}
var best_distance := INF
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(npc.current_task)
for candidate in candidates:
var animal_id := StringName(candidate["target_id"])
var state: AnimalStateRecord = simulation_manager.animal_care.get_state(animal_id)
if state == null or not state.can_npc_feed() or not state.is_available_for(npc.id):
continue
var position: Vector3 = candidate["position"]
var distance := origin.distance_squared_to(position)
if distance < best_distance:
best_distance = distance
best = candidate
if best.is_empty():
return {}
var target_id := StringName(best["target_id"])
if not simulation_manager.animal_care.reserve(target_id, npc.id):
return {}
return best
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:
+225
View File
@@ -0,0 +1,225 @@
class_name AnimalCareSystem
extends RefCounted
signal economic_event_requested(
event_type: StringName,
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float,
world_position: Vector3
)
signal narrative_event_requested(
event_type: StringName,
actor_id: int,
source_id: StringName,
action_display: String,
action_id: StringName,
item_id: StringName,
required_amount: float
)
var states: Dictionary = {}
var economy: RefCounted
var active_world_adapter: Node
func configure(economy_service: RefCounted, world_adapter: Node) -> void:
economy = economy_service
active_world_adapter = world_adapter
func advance() -> void:
var animal_ids: Array = states.keys()
animal_ids.sort()
for animal_id in animal_ids:
var animal_state := states[animal_id] as AnimalStateRecord
if animal_state != null:
animal_state.advance_tick()
func register_loaded_nodes() -> void:
for node in AnimalNode.get_all():
register_node(node)
func register_node(node: AnimalNode) -> bool:
if node == null or node.animal_id.is_empty():
return false
if (
ResourceNode.get_by_id(node.animal_id) != null
or StorageNode.get_by_id(node.animal_id) != null
or ActivitySite.get_by_id(node.animal_id) != null
):
push_error("AnimalCareSystem: Animal ID '%s' collides with a world target" % node.animal_id)
return false
var action_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if (
action_definition == null
or action_definition.target_type != SimulationIds.TARGET_ANIMAL
or node.species_id != SimulationIds.SPECIES_GOAT
):
push_error(
(
"AnimalCareSystem: AnimalNode '%s' has an invalid feed action or species '%s'"
% [node.animal_id, node.species_id]
)
)
return false
var animal_state := get_state(node.animal_id)
if animal_state == null:
animal_state = AnimalStateRecord.create_from_node(node)
states[node.animal_id] = animal_state
elif not animal_state.apply_definition(node):
push_error("AnimalCareSystem: AnimalNode definition mismatch for '%s'" % node.animal_id)
return false
return node.bind_state(animal_state)
func get_state(animal_id: StringName) -> AnimalStateRecord:
return states.get(animal_id) as AnimalStateRecord
func reserve(animal_id: StringName, agent_id: int) -> bool:
var animal_state := get_state(animal_id)
return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id)
func release(animal_id: StringName, agent_id: int) -> void:
var animal_state := get_state(animal_id)
if animal_state != null:
animal_state.release(agent_id)
func can_complete_feed(animal_id: StringName, agent_id: int) -> bool:
var animal_state := get_state(animal_id)
return (
animal_state != null
and animal_state.needs_feed()
and animal_state.get_reserved_by() == agent_id
)
func get_available_feed_name(npc_id: int) -> String:
if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"):
return ""
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
SimulationIds.ACTION_FEED_ANIMAL
)
for candidate in candidates:
var animal_state := get_state(StringName(candidate["target_id"]))
if (
animal_state != null
and animal_state.can_npc_feed()
and animal_state.is_available_for(npc_id)
):
return animal_state.get_display_name()
return ""
func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode:
if (
active_world_adapter == null
or not active_world_adapter.has_method("get_animal_nodes_in_radius")
):
return null
var candidates: Array[AnimalNode] = active_world_adapter.get_animal_nodes_in_radius(
from_position, max_distance
)
for node in candidates:
var animal_state := get_state(node.animal_id)
if (
animal_state != null
and animal_state.can_player_feed_animal()
and animal_state.needs_feed()
):
return node
return null
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
var animal_state := get_state(animal_id)
if animal_state == null or not animal_state.needs_feed():
return false
if actor_id >= 0:
if (
_find_npc(actor_id, npcs) == null
or not animal_state.can_npc_feed()
or animal_state.get_reserved_by() != actor_id
):
return false
elif not animal_state.can_player_feed_animal():
return false
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if 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 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 < required_amount:
if actor_id >= 0:
(
narrative_event_requested
. emit(
SimulationIds.EVENT_TASK_BLOCKED,
actor_id,
pantry.get_storage_id() if pantry != null else &"",
(
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
required_amount,
String(resource_id).capitalize(),
available,
]
),
definition.action_id,
resource_id,
required_amount
)
)
return false
var consumed: float = economy.withdraw_resource(resource_id, required_amount)
if consumed < required_amount:
economy.deposit_resource(resource_id, consumed)
return false
if not animal_state.feed(actor_id, current_tick):
economy.deposit_resource(resource_id, consumed)
return false
economic_event_requested.emit(
SimulationIds.EVENT_ANIMAL_FED,
actor_id,
pantry.get_storage_id(),
animal_state.get_animal_id(),
resource_id,
consumed,
animal_state.get_position()
)
return true
func append_state_records(record: SimulationStateRecord) -> void:
var animal_ids: Array = states.keys()
animal_ids.sort()
for animal_id in animal_ids:
var animal_state := states[animal_id] as AnimalStateRecord
record.animals.append(animal_state)
func restore_state_records(records: Array[AnimalStateRecord]) -> void:
states.clear()
for animal_record in records:
states[animal_record.get_animal_id()] = animal_record
register_loaded_nodes()
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
@@ -0,0 +1 @@
uid://kilnbq2k276w
+4 -1
View File
@@ -22,7 +22,10 @@ func validate() -> Array[String]:
if ( if (
target_type target_type
not in [ not in [
SimulationIds.TARGET_RESOURCE, SimulationIds.TARGET_ACTIVITY, SimulationIds.TARGET_FREE SimulationIds.TARGET_RESOURCE,
SimulationIds.TARGET_ACTIVITY,
SimulationIds.TARGET_ANIMAL,
SimulationIds.TARGET_FREE,
] ]
): ):
errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id]) errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id])
@@ -4,6 +4,7 @@ extends RefCounted
const ACTION_PATHS := [ const ACTION_PATHS := [
"res://simulation/definitions/actions/gather_food.tres", "res://simulation/definitions/actions/gather_food.tres",
"res://simulation/definitions/actions/gather_wood.tres", "res://simulation/definitions/actions/gather_wood.tres",
"res://simulation/definitions/actions/feed_animal.tres",
"res://simulation/definitions/actions/patrol.tres", "res://simulation/definitions/actions/patrol.tres",
"res://simulation/definitions/actions/study.tres", "res://simulation/definitions/actions/study.tres",
"res://simulation/definitions/actions/eat.tres", "res://simulation/definitions/actions/eat.tres",
+6
View File
@@ -6,6 +6,7 @@ const ACTION_DEAD := &"dead"
const ACTION_SLEEP := &"sleep" const ACTION_SLEEP := &"sleep"
const ACTION_GATHER_FOOD := &"gather_food" const ACTION_GATHER_FOOD := &"gather_food"
const ACTION_GATHER_WOOD := &"gather_wood" const ACTION_GATHER_WOOD := &"gather_wood"
const ACTION_FEED_ANIMAL := &"feed_animal"
const ACTION_PATROL := &"patrol" const ACTION_PATROL := &"patrol"
const ACTION_STUDY := &"study" const ACTION_STUDY := &"study"
const ACTION_EAT := &"eat" const ACTION_EAT := &"eat"
@@ -23,11 +24,15 @@ const PROFESSION_WANDERER := &"wanderer"
const TARGET_RESOURCE := &"resource" const TARGET_RESOURCE := &"resource"
const TARGET_ACTIVITY := &"activity" const TARGET_ACTIVITY := &"activity"
const TARGET_ANIMAL := &"animal"
const TARGET_FREE := &"free" const TARGET_FREE := &"free"
const RESOURCE_FOOD := &"food" const RESOURCE_FOOD := &"food"
const RESOURCE_WOOD := &"wood" const RESOURCE_WOOD := &"wood"
const ANIMAL_DUNJA := &"goat_dunja"
const SPECIES_GOAT := &"goat"
const STORAGE_VILLAGE_PANTRY := &"village_pantry" const STORAGE_VILLAGE_PANTRY := &"village_pantry"
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile" const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
@@ -40,6 +45,7 @@ const EVENT_NPC_DIED := &"npc_died"
const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_STARTED := &"task_started"
const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_TASK_BLOCKED := &"task_blocked"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
const EVENT_ANIMAL_FED := &"animal_fed"
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
@@ -0,0 +1,13 @@
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
action_id = &"feed_animal"
display_name = "Feed Dunja"
default_duration = 2.0
preferred_profession_id = &"farmer"
target_type = &"animal"
completion_cost_resource_id = &"food"
completion_cost_amount = 1.0
+223
View File
@@ -0,0 +1,223 @@
class_name AnimalStateRecord
extends RefCounted
signal changed(state: AnimalStateRecord)
signal fed(state: AnimalStateRecord, actor_id: int)
const SCHEMA_VERSION := 1
const HUNGER_PER_TICK := 0.125
const FEED_THRESHOLD := 65.0
const HUNGER_RELIEF := 55.0
const NEVER_FED_TICK := -1
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create_from_node(node: AnimalNode) -> AnimalStateRecord:
return (
AnimalStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"animal_id": String(node.animal_id),
"display_name": node.display_name,
"species_id": String(node.species_id),
"position":
[node.global_position.x, node.global_position.y, node.global_position.z],
"hunger": node.initial_hunger,
"last_fed_tick": NEVER_FED_TICK,
"reserved_by": -1,
"enabled": node.initial_enabled,
"can_npcs_feed": node.can_npcs_feed,
"can_player_feed": node.can_player_feed,
}
)
)
static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not (
record_data
. has_all(
[
"animal_id",
"display_name",
"species_id",
"position",
"hunger",
"last_fed_tick",
"reserved_by",
"enabled",
"can_npcs_feed",
"can_player_feed",
]
)
):
return null
var animal_id := String(record_data["animal_id"])
var display_name := String(record_data["display_name"])
var species_id := String(record_data["species_id"])
var saved_position = record_data["position"]
var hunger := float(record_data["hunger"])
var last_fed_tick := int(record_data["last_fed_tick"])
var reserved_by := int(record_data["reserved_by"])
if animal_id.is_empty() or display_name.is_empty() or species_id.is_empty():
return null
if StringName(species_id) != SimulationIds.SPECIES_GOAT:
return null
if not saved_position is Array or saved_position.size() != 3:
return null
for component in saved_position:
if not is_finite(float(component)):
return null
if not is_finite(hunger) or hunger < 0.0 or hunger > 100.0:
return null
if last_fed_tick < NEVER_FED_TICK or reserved_by < -1:
return null
if reserved_by >= 0 and hunger < FEED_THRESHOLD:
return null
var normalized := record_data.duplicate(true)
normalized["schema_version"] = SCHEMA_VERSION
normalized["animal_id"] = animal_id
normalized["display_name"] = display_name
normalized["species_id"] = species_id
normalized["position"] = [
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
]
normalized["hunger"] = hunger
normalized["last_fed_tick"] = last_fed_tick
normalized["reserved_by"] = reserved_by
normalized["enabled"] = bool(record_data["enabled"])
normalized["can_npcs_feed"] = bool(record_data["can_npcs_feed"])
normalized["can_player_feed"] = bool(record_data["can_player_feed"])
return AnimalStateRecord.new(normalized)
func apply_definition(node: AnimalNode) -> bool:
if node == null or node.animal_id != get_animal_id():
return false
return (
node.display_name == get_display_name()
and node.species_id == get_species_id()
and node.can_npcs_feed == can_npc_feed()
and node.can_player_feed == can_player_feed_animal()
)
func get_animal_id() -> StringName:
return StringName(data["animal_id"])
func get_display_name() -> String:
return String(data["display_name"])
func get_species_id() -> StringName:
return StringName(data["species_id"])
func get_position() -> Vector3:
var saved_position: Array = data["position"]
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
func get_hunger() -> float:
return float(data["hunger"])
func get_last_fed_tick() -> int:
return int(data["last_fed_tick"])
func get_reserved_by() -> int:
return int(data["reserved_by"])
func is_enabled() -> bool:
return bool(data["enabled"])
func can_npc_feed() -> bool:
return bool(data["can_npcs_feed"])
func can_player_feed_animal() -> bool:
return bool(data["can_player_feed"])
func needs_feed() -> bool:
return is_enabled() and get_hunger() >= FEED_THRESHOLD
func is_available_for(agent_id: int) -> bool:
return needs_feed() and (get_reserved_by() == -1 or get_reserved_by() == agent_id)
func reserve(agent_id: int) -> bool:
if agent_id < 0 or not is_available_for(agent_id):
return false
data["reserved_by"] = agent_id
changed.emit(self)
return true
func release(agent_id: int) -> void:
if get_reserved_by() != agent_id:
return
data["reserved_by"] = -1
changed.emit(self)
func advance_tick() -> void:
if not is_enabled():
return
var next_hunger := minf(get_hunger() + HUNGER_PER_TICK, 100.0)
if is_equal_approx(next_hunger, get_hunger()):
return
data["hunger"] = next_hunger
changed.emit(self)
func feed(actor_id: int, current_tick: int) -> bool:
if not needs_feed() or current_tick < 0:
return false
data["hunger"] = maxf(get_hunger() - HUNGER_RELIEF, 0.0)
data["last_fed_tick"] = current_tick
data["reserved_by"] = -1
changed.emit(self)
fed.emit(self, actor_id)
return true
func set_position(value: Vector3) -> void:
if not value.is_finite() or value.is_equal_approx(get_position()):
return
data["position"] = [value.x, value.y, value.z]
changed.emit(self)
func set_hunger(value: float) -> void:
if not is_finite(value):
return
data["hunger"] = clampf(value, 0.0, 100.0)
if not needs_feed():
data["reserved_by"] = -1
changed.emit(self)
func set_enabled(value: bool) -> void:
data["enabled"] = value
if not value:
data["reserved_by"] = -1
changed.emit(self)
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://c8rgw7h25bcmr
+2
View File
@@ -158,5 +158,7 @@ func description(npc_names: Dictionary = {}) -> String:
return "%s could not complete %s" % [actor_name, action_name] return "%s could not complete %s" % [actor_name, action_name]
"resource_depleted": "resource_depleted":
return "%s was depleted" % source return "%s was depleted" % source
"animal_fed":
return "%s fed %s %.0f %s" % [actor_name, destination, amount, item]
_: _:
return "tick %d: %s" % [int(data["tick"]), event_type] return "tick %d: %s" % [int(data["tick"]), event_type]
+109 -3
View File
@@ -2,7 +2,7 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 9 const SCHEMA_VERSION := 10
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const EVENT_LEGACY_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
@@ -10,11 +10,13 @@ const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4
const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 const PROVENANCE_LEGACY_SCHEMA_VERSION := 5
const RETENTION_LEGACY_SCHEMA_VERSION := 6 const RETENTION_LEGACY_SCHEMA_VERSION := 6
const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8
const ANIMAL_LEGACY_SCHEMA_VERSION := 9
const PREVIOUS_SCHEMA_VERSION := 7 const PREVIOUS_SCHEMA_VERSION := 7
var simulation: Dictionary var simulation: Dictionary
var village: VillageStateRecord var village: VillageStateRecord
var npcs: Array[NPCStateRecord] = [] var npcs: Array[NPCStateRecord] = []
var animals: Array[AnimalStateRecord] = []
var resources: Array[ResourceStateRecord] = [] var resources: Array[ResourceStateRecord] = []
var storages: Array[StorageStateRecord] = [] var storages: Array[StorageStateRecord] = []
var economic_events: Array[EconomicEventRecord] = [] var economic_events: Array[EconomicEventRecord] = []
@@ -28,6 +30,9 @@ func to_dictionary() -> Dictionary:
for npc_record in npcs: for npc_record in npcs:
npc_data.append(npc_record.to_dictionary()) npc_data.append(npc_record.to_dictionary())
var animal_data: Array[Dictionary] = []
for animal_record in animals:
animal_data.append(animal_record.to_dictionary())
var resource_data: Array[Dictionary] = [] var resource_data: Array[Dictionary] = []
for resource_record in resources: for resource_record in resources:
resource_data.append(resource_record.to_dictionary()) resource_data.append(resource_record.to_dictionary())
@@ -53,6 +58,7 @@ func to_dictionary() -> Dictionary:
"simulation": simulation.duplicate(true), "simulation": simulation.duplicate(true),
"village": village.to_dictionary(), "village": village.to_dictionary(),
"npcs": npc_data, "npcs": npc_data,
"animals": animal_data,
"resources": resource_data, "resources": resource_data,
"storages": storage_data, "storages": storage_data,
"economic_events": event_data, "economic_events": event_data,
@@ -88,6 +94,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
RETENTION_LEGACY_SCHEMA_VERSION, RETENTION_LEGACY_SCHEMA_VERSION,
PREVIOUS_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION,
OPPORTUNITY_LEGACY_SCHEMA_VERSION, OPPORTUNITY_LEGACY_SCHEMA_VERSION,
ANIMAL_LEGACY_SCHEMA_VERSION,
] ]
): ):
record_data = _migrate_legacy(record_data, version) record_data = _migrate_legacy(record_data, version)
@@ -100,6 +107,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
"simulation", "simulation",
"village", "village",
"npcs", "npcs",
"animals",
"resources", "resources",
"storages", "storages",
"economic_events", "economic_events",
@@ -166,6 +174,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return null return null
var npc_data = record_data["npcs"] var npc_data = record_data["npcs"]
var animal_data = record_data["animals"]
var resource_data = record_data["resources"] var resource_data = record_data["resources"]
var storage_data = record_data["storages"] var storage_data = record_data["storages"]
var event_data = record_data["economic_events"] var event_data = record_data["economic_events"]
@@ -174,6 +183,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var opportunity_data = record_data["opportunities"] var opportunity_data = record_data["opportunities"]
if ( if (
not npc_data is Array not npc_data is Array
or not animal_data is Array
or not resource_data is Array or not resource_data is Array
or not storage_data is Array or not storage_data is Array
or not event_data is Array or not event_data is Array
@@ -217,6 +227,51 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
resource_records_by_id[resource_id] = resource_record resource_records_by_id[resource_id] = resource_record
record.resources.append(resource_record) record.resources.append(resource_record)
var animal_ids := {}
var animal_records_by_id := {}
for item in animal_data:
if not item is Dictionary:
return null
var animal_record := AnimalStateRecord.from_dictionary(item)
if animal_record == null:
return null
var animal_id := String(animal_record.data["animal_id"])
if animal_ids.has(animal_id) or resource_ids.has(animal_id):
return null
var reserved_by := animal_record.get_reserved_by()
if reserved_by >= 0:
var reserving_npc := npc_records_by_id.get(reserved_by) as NPCStateRecord
if (
reserving_npc == null
or (
StringName(reserving_npc.data["current_task"])
!= SimulationIds.ACTION_FEED_ANIMAL
)
or StringName(reserving_npc.data["target_id"]) != animal_record.get_animal_id()
or (
StringName(reserving_npc.data["task_state"])
not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
)
):
return null
animal_ids[animal_id] = true
animal_records_by_id[animal_id] = animal_record
record.animals.append(animal_record)
for npc_record in record.npcs:
if (
StringName(npc_record.data["current_task"]) != SimulationIds.ACTION_FEED_ANIMAL
or (
StringName(npc_record.data["task_state"])
not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
)
):
continue
var target_animal := (
animal_records_by_id.get(StringName(npc_record.data["target_id"])) as AnimalStateRecord
)
if target_animal == null or target_animal.get_reserved_by() != int(npc_record.data["id"]):
return null
var storage_ids := {} var storage_ids := {}
var storage_records_by_id := {} var storage_records_by_id := {}
for item in storage_data: for item in storage_data:
@@ -226,7 +281,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if storage_record == null: if storage_record == null:
return null return null
var storage_id := storage_record.get_storage_id() var storage_id := storage_record.get_storage_id()
if storage_ids.has(storage_id): if storage_ids.has(storage_id) or animal_ids.has(String(storage_id)):
return null return null
storage_ids[storage_id] = true storage_ids[storage_id] = true
storage_records_by_id[storage_id] = storage_record storage_records_by_id[storage_id] = storage_record
@@ -235,6 +290,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var event_ids := {} var event_ids := {}
var event_records_by_id := {} var event_records_by_id := {}
var highest_event_id := -1 var highest_event_id := -1
var latest_feed_ticks := {}
for item in event_data: for item in event_data:
if not item is Dictionary: if not item is Dictionary:
return null return null
@@ -247,9 +303,33 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
event_ids[event_id] = true event_ids[event_id] = true
event_records_by_id[event_id] = event_record event_records_by_id[event_id] = event_record
highest_event_id = maxi(highest_event_id, event_id) highest_event_id = maxi(highest_event_id, event_id)
if StringName(event_record.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
if not _is_valid_animal_feed_event(
event_record,
npc_ids,
animal_records_by_id,
storage_records_by_id,
int(record.simulation["tick_count"])
):
return null
var fed_animal_id := StringName(event_record.data["destination_id"])
latest_feed_ticks[fed_animal_id] = maxi(
int(latest_feed_ticks.get(fed_animal_id, AnimalStateRecord.NEVER_FED_TICK)),
int(event_record.data["tick"])
)
record.economic_events.append(event_record) record.economic_events.append(event_record)
if int(record.simulation["next_event_id"]) <= highest_event_id: if int(record.simulation["next_event_id"]) <= highest_event_id:
return null return null
for animal_record in record.animals:
if (
animal_record.get_last_fed_tick()
!= int(
latest_feed_ticks.get(
animal_record.get_animal_id(), AnimalStateRecord.NEVER_FED_TICK
)
)
):
return null
var knowledge_keys := {} var knowledge_keys := {}
var knowledge_records_by_key := {} var knowledge_records_by_key := {}
@@ -359,6 +439,31 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
return record return record
static func _is_valid_animal_feed_event(
event: EconomicEventRecord,
npc_ids: Dictionary,
animal_records_by_id: Dictionary,
storage_records_by_id: Dictionary,
current_tick: int
) -> bool:
var actor_id := int(event.data["actor_id"])
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
var animal := (
animal_records_by_id.get(StringName(event.data["destination_id"])) as AnimalStateRecord
)
return (
(actor_id == -1 or npc_ids.has(actor_id))
and int(event.data["tick"]) <= current_tick
and animal != null
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and storage_records_by_id.has(SimulationIds.STORAGE_VILLAGE_PANTRY)
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and definition != null
and definition.has_completion_cost()
and is_equal_approx(float(event.data["amount"]), definition.completion_cost_amount)
)
static func _is_valid_knowledge_provenance( static func _is_valid_knowledge_provenance(
known_event: KnownEventStateRecord, known_event: KnownEventStateRecord,
npc_ids: Dictionary, npc_ids: Dictionary,
@@ -571,6 +676,7 @@ static func _is_valid_supply_resolution(
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
var migrated := legacy_data.duplicate(true) var migrated := legacy_data.duplicate(true)
migrated["schema_version"] = SCHEMA_VERSION migrated["schema_version"] = SCHEMA_VERSION
migrated["animals"] = []
if version == LEGACY_SCHEMA_VERSION: if version == LEGACY_SCHEMA_VERSION:
var village_data: Dictionary = legacy_data.get("village", {}) var village_data: Dictionary = legacy_data.get("village", {})
var initial_food := float(village_data.get("food", 0.0)) var initial_food := float(village_data.get("food", 0.0))
@@ -621,7 +727,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
migrated.get("economic_events", []), migrated.get("economic_events", []),
int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0)) int((migrated.get("simulation", {}) as Dictionary).get("tick_count", 0))
) )
if version != OPPORTUNITY_LEGACY_SCHEMA_VERSION: if version not in [OPPORTUNITY_LEGACY_SCHEMA_VERSION, ANIMAL_LEGACY_SCHEMA_VERSION]:
migrated["opportunities"] = [] migrated["opportunities"] = []
var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) var opportunity_simulation_data: Dictionary = migrated.get("simulation", {})
opportunity_simulation_data["next_opportunity_id"] = 0 opportunity_simulation_data["next_opportunity_id"] = 0
+232
View File
@@ -0,0 +1,232 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
var manager: Node = main_scene.get_node("SimulationManager")
manager.set_process(false)
for _frame in 3:
await physics_frame
var goat := main_scene.get_node("JajceWorld/WorldObjects/Animals/Dunja") as AnimalNode
var player := main_scene.get_node("Player") as Node3D
var pantry: StorageStateRecord = manager.get_pantry()
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(goat != null, "Jajce should load one named goat presentation")
_check(
AnimalNode.get_all().size() == 1 and ResourceNode.get_all().size() == 18,
"The goat should be one AnimalNode without changing the finite-resource count"
)
_check(
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
"Dunja must not masquerade as a ResourceNode",
)
_check(
(
goat.animal_id == SimulationIds.ANIMAL_DUNJA
and goat.display_name == "Dunja"
and goat.species_id == SimulationIds.SPECIES_GOAT
and state != null
and goat.state == state
),
"The loaded presentation should bind the stable named-goat identity"
)
if state == null:
_finish()
return
var authored_position := state.get_position()
_check(
goat.global_position.is_equal_approx(authored_position),
"Animal state should own the loaded presentation position"
)
state.set_position(authored_position + Vector3(0.25, 0.0, 0.0))
_check(
goat.global_position.is_equal_approx(state.get_position()),
"A simulation position change should move the bound goat presentation"
)
state.set_position(authored_position)
for index in range(1, manager.npcs.size()):
_park_npc(manager.npcs[index])
var caretaker: SimNPC = manager.npcs[0]
manager.release_npc_reservation(caretaker.id)
caretaker.inventory.clear()
caretaker.hunger = 20.0
caretaker.energy = 90.0
caretaker.current_task = SimulationIds.ACTION_IDLE
caretaker.task_state = SimNPC.TASK_STATE_IDLE
caretaker.task_complete = true
caretaker.target_id = &""
caretaker.has_travel_target = false
state.set_hunger(72.0)
var pantry_before_npc := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
manager.simulate_tick()
_check(
caretaker.current_task == SimulationIds.ACTION_FEED_ANIMAL,
"An available villager should autonomously choose to feed hungry Dunja"
)
if caretaker.target_id.is_empty():
manager.resolve_npc_target(caretaker.id, caretaker.position)
_check(
(
caretaker.target_id == SimulationIds.ANIMAL_DUNJA
and state.get_reserved_by() == caretaker.id
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"
)
manager.notify_npc_arrived(caretaker.id)
manager.simulate_tick()
var hunger_before_completion := state.get_hunger()
manager.simulate_tick()
_check(
is_equal_approx(
state.get_hunger(),
(
hunger_before_completion
+ AnimalStateRecord.HUNGER_PER_TICK
- AnimalStateRecord.HUNGER_RELIEF
)
),
"NPC feeding should apply the exact simulation-owned hunger relief"
)
_check(
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_npc - 1.0),
"NPC feeding should withdraw exactly one real pantry food"
)
_check(
state.get_last_fed_tick() == manager.tick_count and state.get_reserved_by() == -1,
"Completed feeding should retain its deterministic tick and release the claim"
)
var npc_feed_event := _latest_feed_event(manager)
_check(
(
npc_feed_event != null
and int(npc_feed_event.data["actor_id"]) == caretaker.id
and (
StringName(npc_feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
)
and (StringName(npc_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
and StringName(npc_feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and is_equal_approx(float(npc_feed_event.data["amount"]), 1.0)
and npc_feed_event.get_world_position().is_equal_approx(state.get_position())
),
"NPC feeding should record one exact pantry-to-animal transfer fact"
)
_check(
(
not goat.get_node("Visual/HungerCueRoot").visible
and goat.get_node("Visual/FedResponseRoot").visible
),
"Real feeding should clear the stable hunger cue and play one cozy response"
)
var npc_feed_json: String = manager.serialize_state()
var npc_feed_checksum: String = manager.get_state_checksum()
var saved_hunger := state.get_hunger()
var saved_fed_tick := state.get_last_fed_tick()
_check(
manager.restore_state_from_json(npc_feed_json),
"NPC-fed animal state should pass the complete schema and restore"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
pantry = manager.get_pantry()
_check(
(
goat.state == state
and state.get_position().is_equal_approx(authored_position)
and is_equal_approx(state.get_hunger(), saved_hunger)
and state.get_last_fed_tick() == saved_fed_tick
and manager.get_state_checksum() == npc_feed_checksum
),
"Animal position, hunger, feed tick, and checksum should round-trip deterministically"
)
_check(
not goat.get_node("Visual/FedResponseRoot").visible,
"Restore should rebuild stable animal cues without replaying transient praise"
)
state.set_hunger(72.0)
var pantry_before_player := pantry.get_amount(SimulationIds.RESOURCE_FOOD)
player.global_position = goat.get_interaction_position()
player.call("try_interact")
var player_feed_event := _latest_feed_event(manager)
_check(
(
is_equal_approx(
pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_before_player - 1.0
)
and is_equal_approx(state.get_hunger(), 72.0 - AnimalStateRecord.HUNGER_RELIEF)
and player_feed_event != null
and int(player_feed_event.data["actor_id"]) == -1
and (StringName(player_feed_event.data["destination_id"]) == SimulationIds.ANIMAL_DUNJA)
),
"The player should use the same one-food feed operation and event contract"
)
var player_feed_json: String = manager.serialize_state()
_check(
manager.restore_state_from_json(player_feed_json),
"Player-fed animal history should restore through the same schema"
)
state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
pantry = manager.get_pantry()
_check(
not goat.get_node("Visual/FedResponseRoot").visible,
"Player feed feedback should also remain transient across restore"
)
state.set_hunger(72.0)
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
var events_before_failed_feed: int = manager.economic_events.size()
player.global_position = goat.get_interaction_position()
player.call("try_interact")
_check(
(
is_equal_approx(state.get_hunger(), 72.0)
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.economic_events.size() == events_before_failed_feed
),
"An empty pantry should block feeding without inventing food or animal relief"
)
_finish()
func _park_npc(npc: SimNPC) -> void:
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
npc.start_working()
func _latest_feed_event(manager: Node) -> EconomicEventRecord:
for index in range(manager.economic_events.size() - 1, -1, -1):
var event := manager.economic_events[index] as EconomicEventRecord
if StringName(event.data["event_type"]) == SimulationIds.EVENT_ANIMAL_FED:
return event
return null
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Animal feeding vertical slice passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://cgnst4m7bp3tb
+2 -2
View File
@@ -44,8 +44,8 @@ func _run_scenario(seed_value: int) -> String:
if npc.task_state == SimNPC.TASK_STATE_TRAVELING: if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
manager.notify_npc_arrived(npc.id) manager.notify_npc_arrived(npc.id)
var snapshot: Dictionary = manager.get_state_snapshot() var snapshot: Dictionary = manager.create_state_record().to_dictionary()
snapshot.erase("seed") snapshot["simulation"].erase("seed")
var checksum := JSON.stringify(snapshot).sha256_text() var checksum := JSON.stringify(snapshot).sha256_text()
manager.free() manager.free()
return checksum return checksum
+1 -1
View File
@@ -105,7 +105,7 @@ func _run() -> void:
var active_restored := _create_manager(902) var active_restored := _create_manager(902)
_check( _check(
active_restored.restore_state_from_json(active_json), active_restored.restore_state_from_json(active_json),
"An active opportunity should survive schema-v9 save and restore" "An active opportunity should survive schema-v10 save and restore"
) )
_check( _check(
( (
+1 -1
View File
@@ -269,7 +269,7 @@ func _run() -> void:
var old_contributor_visual := contributor_visual var old_contributor_visual := contributor_visual
_check( _check(
simulation_manager.restore_state_from_json(active_need_json), simulation_manager.restore_state_from_json(active_need_json),
"The visible shortage should survive a valid schema-v9 restore" "The visible shortage should survive a valid schema-v10 restore"
) )
await process_frame await process_frame
contributor = simulation_manager.npcs[contributor.id] contributor = simulation_manager.npcs[contributor.id]
+19
View File
@@ -396,6 +396,24 @@ func _run() -> void:
) )
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts") _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts") _check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
var animal_root := world.get_node("WorldObjects/Animals")
var goat := animal_root.get_node("Dunja") as AnimalNode
_check(
animal_root.get_child_count() == 1 and goat != null,
"Jajce should contain one bounded loaded-animal presentation"
)
_check(
(
goat.animal_id == SimulationIds.ANIMAL_DUNJA
and goat.species_id == SimulationIds.SPECIES_GOAT
and goat.display_name == "Dunja"
),
"The authored goat should preserve its stable identity and readable name"
)
_check(
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
"The identity-backed goat should remain separate from finite resources"
)
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
_check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode")
@@ -448,6 +466,7 @@ func _run() -> void:
var destinations: Array[Vector3] = [] var destinations: Array[Vector3] = []
for resource in resources: for resource in resources:
destinations.append((resource as ResourceNode).interaction_point.global_position) destinations.append((resource as ResourceNode).interaction_point.global_position)
destinations.append(goat.get_interaction_position())
destinations.append(pantry.get_interaction_position()) destinations.append(pantry.get_interaction_position())
destinations.append(woodpile.get_interaction_position()) destinations.append(woodpile.get_interaction_position())
for site in activity_root.get_children(): for site in activity_root.get_children():
+11 -1
View File
@@ -35,7 +35,7 @@ func _test_registry_integrity() -> void:
"Action lookup should return '%s'" % definition.action_id "Action lookup should return '%s'" % definition.action_id
) )
_check( _check(
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions" action_ids.size() == 12, "Registry should contain all twelve executable prototype actions"
) )
var profession_ids := SimulationDefinitions.get_profession_ids() var profession_ids := SimulationDefinitions.get_profession_ids()
@@ -88,6 +88,16 @@ func _test_definition_backed_behavior() -> void:
), ),
"Gather-food target metadata should come from its definition" "Gather-food target metadata should come from its definition"
) )
var feed := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
_check(
(
feed.target_type == SimulationIds.TARGET_ANIMAL
and feed.preferred_profession_id == SimulationIds.PROFESSION_FARMER
and feed.completion_cost_resource_id == SimulationIds.RESOURCE_FOOD
and is_equal_approx(feed.completion_cost_amount, 1.0)
),
"Feed-animal should target an animal and declare its exact pantry-food cost"
)
func _test_state_reference_validation() -> void: func _test_state_reference_validation() -> void:
@@ -19,8 +19,10 @@ func _run() -> void:
_test_previous_world_provenance_migration() _test_previous_world_provenance_migration()
_test_previous_world_retention_migration() _test_previous_world_retention_migration()
_test_previous_world_opportunity_migration() _test_previous_world_opportunity_migration()
_test_previous_world_animal_migration()
_test_relationship_schema_rejection() _test_relationship_schema_rejection()
_test_opportunity_schema_rejection() _test_opportunity_schema_rejection()
_test_animal_schema_rejection()
_test_schema_rejection() _test_schema_rejection()
if failures.is_empty(): if failures.is_empty():
@@ -481,6 +483,51 @@ func _test_previous_world_opportunity_migration() -> void:
manager.free() manager.free()
func _test_previous_world_animal_migration() -> void:
var manager := _create_manager(63)
var v9_data: Dictionary = _build_opportunity_world(manager, false)
v9_data["schema_version"] = SimulationStateRecord.ANIMAL_LEGACY_SCHEMA_VERSION
v9_data.erase("animals")
var migrated := SimulationStateRecord.from_dictionary(v9_data)
_check(migrated != null, "World schema v9 should add an empty animal stream")
if migrated != null:
_check(
migrated.animals.is_empty() and migrated.opportunities.size() == 1,
"World v9 migration should preserve opportunity history while adding animals"
)
manager.free()
func _test_animal_schema_rejection() -> void:
var manager := _create_manager(64)
var missing_animals: Dictionary = manager.create_state_record().to_dictionary()
missing_animals.erase("animals")
_check(
SimulationStateRecord.from_dictionary(missing_animals) == null,
"Current world records should require the authoritative animal array"
)
var invalid_position := (
AnimalStateRecord
. from_dictionary(
{
"schema_version": AnimalStateRecord.SCHEMA_VERSION,
"animal_id": "invalid_goat",
"display_name": "Invalid",
"species_id": String(SimulationIds.SPECIES_GOAT),
"position": [NAN, 0.0, 0.0],
"hunger": 70.0,
"last_fed_tick": AnimalStateRecord.NEVER_FED_TICK,
"reserved_by": -1,
"enabled": true,
"can_npcs_feed": true,
"can_player_feed": true,
}
)
)
_check(invalid_position == null, "Animal records should reject non-finite positions")
manager.free()
func _test_relationship_schema_rejection() -> void: func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58) var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
+1 -1
View File
@@ -132,7 +132,7 @@ func _run() -> void:
var active_restored := _create_manager(1202) var active_restored := _create_manager(1202)
_check( _check(
active_restored.restore_state_from_json(active_json), active_restored.restore_state_from_json(active_json),
"An active missing-wood need should survive schema-v9 restore" "An active missing-wood need should survive schema-v10 restore"
) )
var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper() var restored_helper: OpportunityHelperResult = active_restored.get_active_opportunity_helper()
_check( _check(
+29 -1
View File
@@ -77,12 +77,40 @@ func _get_argument_value(prefix: String, default_value: String) -> String:
func _apply_resource_capture_overrides() -> bool: func _apply_resource_capture_overrides() -> bool:
var hide_labels := OS.get_cmdline_user_args().has("--hide-resource-labels") var hide_all_labels := OS.get_cmdline_user_args().has("--hide-debug-labels")
var hide_labels := hide_all_labels or OS.get_cmdline_user_args().has("--hide-resource-labels")
for resource in ResourceNode.get_all(): for resource in ResourceNode.get_all():
if hide_labels: if hide_labels:
resource.set_debug_label_enabled(false) resource.set_debug_label_enabled(false)
var hide_animal_labels := (
hide_all_labels or OS.get_cmdline_user_args().has("--hide-animal-labels")
)
for animal in AnimalNode.get_all():
if hide_animal_labels:
animal.set_debug_label_enabled(false)
if hide_all_labels:
for storage in StorageNode.get_all():
storage.set_debug_label_enabled(false)
for site in ActivitySite.get_all():
site.set_debug_label_enabled(false)
for argument in OS.get_cmdline_user_args(): for argument in OS.get_cmdline_user_args():
if argument.begins_with("--animal-hunger="):
var animal_override := argument.trim_prefix("--animal-hunger=")
var animal_parts := animal_override.split(":", false, 1)
if animal_parts.size() != 2 or not animal_parts[1].is_valid_float():
push_error("Invalid animal hunger override: %s" % animal_override)
return false
var animal := AnimalNode.get_by_id(StringName(animal_parts[0]))
if animal == null:
push_error("Unknown animal hunger override ID: %s" % animal_parts[0])
return false
var animal_state := AnimalStateRecord.create_from_node(animal)
animal_state.set_hunger(animal_parts[1].to_float())
if not animal.bind_state(animal_state):
push_error("Could not bind capture state for animal: %s" % animal_parts[0])
return false
continue
if not argument.begins_with("--resource-amount="): if not argument.begins_with("--resource-amount="):
continue continue
var override_text := argument.trim_prefix("--resource-amount=") var override_text := argument.trim_prefix("--resource-amount=")
+35
View File
@@ -59,6 +59,41 @@ func get_resource_index_stats() -> Dictionary:
return _resource_index.get_stats() return _resource_index.get_stats()
func get_animal_candidates(action_id: StringName) -> Array[Dictionary]:
var candidates: Array[Dictionary] = []
for node in AnimalNode.get_all():
if not node.supports_action(action_id):
continue
(
candidates
. append(
{
"target_id": String(node.animal_id),
"position": node.get_interaction_position(),
"display_name": node.display_name,
}
)
)
return candidates
func get_animal_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[AnimalNode]:
var nodes: Array[AnimalNode] = []
var maximum_distance_squared := maxf(max_distance, 0.0) * maxf(max_distance, 0.0)
for node in AnimalNode.get_all():
if origin.distance_squared_to(node.get_interaction_position()) <= maximum_distance_squared:
nodes.append(node)
nodes.sort_custom(
func(first: AnimalNode, second: AnimalNode) -> bool:
var first_distance := origin.distance_squared_to(first.get_interaction_position())
var second_distance := origin.distance_squared_to(second.get_interaction_position())
if not is_equal_approx(first_distance, second_distance):
return first_distance < second_distance
return String(first.animal_id) < String(second.animal_id)
)
return nodes
func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary: func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary:
var best_candidate: Dictionary = {} var best_candidate: Dictionary = {}
var best_distance := INF var best_distance := INF
+165
View File
@@ -0,0 +1,165 @@
class_name AnimalNode
extends Node3D
signal hunger_changed(animal_id: StringName, hunger: float)
signal fed(animal_id: StringName, actor_id: int)
signal reservation_changed(animal_id: StringName, agent_id: int)
static var _all: Array[AnimalNode] = []
@export var animal_id: StringName
@export var display_name := "Animal"
@export var species_id: StringName = SimulationIds.SPECIES_GOAT
@export_range(0.0, 100.0, 0.5) var initial_hunger := 70.0
@export var initial_enabled := true
@export var can_npcs_feed := true
@export var can_player_feed := true
@export var debug_label_enabled := true
@onready var interaction_point: Marker3D = get_node_or_null("InteractionPoint") as Marker3D
var state: AnimalStateRecord
func _ready() -> void:
if animal_id.is_empty():
push_error("AnimalNode at %s has empty animal_id" % get_path())
initial_enabled = false
_update_presentation()
return
var existing := get_by_id(animal_id)
if existing != null:
push_error(
(
"Duplicate AnimalNode animal_id '%s' at %s; first registered at %s"
% [animal_id, get_path(), existing.get_path()]
)
)
initial_enabled = false
_update_presentation()
return
_all.append(self)
add_to_group("animal_nodes")
_try_register_with_simulation()
_update_presentation()
func _exit_tree() -> void:
_all.erase(self)
_disconnect_state()
state = null
func bind_state(animal_state: AnimalStateRecord) -> bool:
if animal_state == null or animal_state.get_animal_id() != animal_id:
return false
_disconnect_state()
state = animal_state
if not state.changed.is_connected(_on_state_changed):
state.changed.connect(_on_state_changed)
if not state.fed.is_connected(_on_state_fed):
state.fed.connect(_on_state_fed)
global_position = state.get_position()
var visual := get_node_or_null("Visual")
if visual != null and visual.has_method("reset_transient_feedback"):
visual.reset_transient_feedback()
_update_presentation()
hunger_changed.emit(animal_id, state.get_hunger())
return true
func _disconnect_state() -> void:
if state == null:
return
if state.changed.is_connected(_on_state_changed):
state.changed.disconnect(_on_state_changed)
if state.fed.is_connected(_on_state_fed):
state.fed.disconnect(_on_state_fed)
func _try_register_with_simulation() -> void:
var managers := get_tree().get_nodes_in_group("simulation_manager")
if managers.size() != 1:
return
var manager := managers[0]
if "animal_care" in manager and manager.animal_care != null:
manager.animal_care.register_node(self)
func get_interaction_position() -> Vector3:
return interaction_point.global_position if interaction_point != null else global_position
func supports_action(action_id: StringName) -> bool:
return action_id == SimulationIds.ACTION_FEED_ANIMAL
func get_hunger() -> float:
return state.get_hunger() if state != null else initial_hunger
func needs_feed() -> bool:
if state != null:
return state.needs_feed()
return initial_enabled and initial_hunger >= AnimalStateRecord.FEED_THRESHOLD
func is_enabled() -> bool:
return state.is_enabled() if state != null else initial_enabled
func _on_state_changed(changed_state: AnimalStateRecord) -> void:
if changed_state != state:
return
if not global_position.is_equal_approx(state.get_position()):
global_position = state.get_position()
hunger_changed.emit(animal_id, state.get_hunger())
reservation_changed.emit(animal_id, state.get_reserved_by())
_update_presentation()
func _on_state_fed(fed_state: AnimalStateRecord, actor_id: int) -> void:
if fed_state != state:
return
var visual := get_node_or_null("Visual")
if visual != null and visual.has_method("play_fed_response"):
visual.play_fed_response()
fed.emit(animal_id, actor_id)
func _update_presentation() -> void:
var visual := get_node_or_null("Visual")
if visual != null:
visual.visible = is_enabled()
if visual.has_method("set_hungry"):
visual.set_hungry(needs_feed())
if not has_node("DebugLabel"):
return
var label := $DebugLabel as Label3D
label.visible = debug_label_enabled
if not debug_label_enabled:
return
var text := "%s\n%s · hunger %.0f" % [display_name, animal_id, get_hunger()]
if needs_feed():
text += "\nHUNGRY"
if state != null and state.get_reserved_by() >= 0:
text += "\nheld:%d" % state.get_reserved_by()
if state == null:
text += "\nUNBOUND"
label.text = text
func set_debug_label_enabled(is_enabled: bool) -> void:
debug_label_enabled = is_enabled
_update_presentation()
static func get_by_id(search_id: StringName) -> AnimalNode:
for node in _all:
if node.animal_id == search_id:
return node
return null
static func get_all() -> Array[AnimalNode]:
return _all.duplicate()
+1
View File
@@ -0,0 +1 @@
uid://6s3isttop6a6
+4
View File
@@ -359,6 +359,10 @@ func _apply_debug_overlay_visibility() -> void:
if node.has_method("set_debug_label_enabled"): if node.has_method("set_debug_label_enabled"):
node.set_debug_label_enabled(debug_overlay_visible) node.set_debug_label_enabled(debug_overlay_visible)
for node in AnimalNode.get_all():
if node.has_method("set_debug_label_enabled"):
node.set_debug_label_enabled(debug_overlay_visible)
for node in StorageNode.get_all(): for node in StorageNode.get_all():
if node.has_method("set_debug_label_enabled"): if node.has_method("set_debug_label_enabled"):
node.set_debug_label_enabled(debug_overlay_visible) node.set_debug_label_enabled(debug_overlay_visible)
+66
View File
@@ -0,0 +1,66 @@
extends Node3D
const HUNGER_CUE_BASE_Y := 2.05
const FED_RESPONSE_BASE_Y := 1.8
@onready var head_root: Node3D = $HeadRoot
@onready var tail_root: Node3D = $TailRoot
@onready var hunger_cue_root: Node3D = $HungerCueRoot
@onready var fed_response_root: Node3D = $FedResponseRoot
var idle_phase := 0.0
var is_hungry := false
var fed_tween: Tween
var head_base_position: Vector3
func _ready() -> void:
head_base_position = head_root.position
reset_transient_feedback()
func _process(delta: float) -> void:
idle_phase = fmod(idle_phase + delta, TAU)
var breath := sin(idle_phase * 1.7) * 0.025
var hungry_drop := -0.12 if is_hungry else 0.0
head_root.position.y = head_base_position.y + breath + hungry_drop
head_root.rotation_degrees.x = (
8.0 + sin(idle_phase * 1.2) * 2.0 if is_hungry else -3.0 + sin(idle_phase * 1.4) * 2.5
)
tail_root.rotation_degrees.z = sin(idle_phase * 3.1) * (4.0 if is_hungry else 13.0)
if hunger_cue_root.visible:
hunger_cue_root.position.y = HUNGER_CUE_BASE_Y + sin(idle_phase * 2.2) * 0.07
func set_hungry(value: bool) -> void:
is_hungry = value
hunger_cue_root.visible = value
func reset_transient_feedback() -> void:
if fed_tween != null:
fed_tween.kill()
fed_tween = null
fed_response_root.visible = false
fed_response_root.position.y = FED_RESPONSE_BASE_Y
fed_response_root.scale = Vector3(0.2, 0.2, 0.2)
func play_fed_response() -> void:
reset_transient_feedback()
fed_response_root.visible = true
fed_tween = create_tween()
fed_tween.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
fed_tween.tween_property(fed_response_root, "scale", Vector3.ONE, 0.34)
fed_tween.parallel().tween_property(
fed_response_root, "position:y", FED_RESPONSE_BASE_Y + 0.22, 0.34
)
fed_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
fed_tween.tween_interval(0.7)
fed_tween.tween_property(fed_response_root, "scale", Vector3(0.2, 0.2, 0.2), 0.42)
fed_tween.finished.connect(_on_fed_response_finished)
func _on_fed_response_finished() -> void:
fed_response_root.visible = false
fed_tween = null
+1
View File
@@ -0,0 +1 @@
uid://byx6pyisksbtg
+329
View File
@@ -0,0 +1,329 @@
[gd_scene load_steps=26 format=3]
[ext_resource type="Script" path="res://world/animals/AnimalNode.gd" id="1_animal"]
[ext_resource type="Script" path="res://world/jajce/CozyGoat.gd" id="2_visual"]
[sub_resource type="StandardMaterial3D" id="Material_wool"]
albedo_color = Color(0.88, 0.82, 0.67, 1)
roughness = 0.96
[sub_resource type="StandardMaterial3D" id="Material_face"]
albedo_color = Color(0.58, 0.43, 0.29, 1)
roughness = 0.92
[sub_resource type="StandardMaterial3D" id="Material_dark"]
albedo_color = Color(0.13, 0.09, 0.065, 1)
roughness = 0.9
[sub_resource type="StandardMaterial3D" id="Material_horn"]
albedo_color = Color(0.78, 0.68, 0.5, 1)
roughness = 0.86
[sub_resource type="StandardMaterial3D" id="Material_collar"]
albedo_color = Color(0.12, 0.5, 0.5, 1)
roughness = 0.72
[sub_resource type="StandardMaterial3D" id="Material_wood"]
albedo_color = Color(0.35, 0.19, 0.09, 1)
roughness = 0.96
[sub_resource type="StandardMaterial3D" id="Material_hunger"]
transparency = 1
shading_mode = 0
albedo_color = Color(0.98, 0.62, 0.22, 0.9)
emission_enabled = true
emission = Color(0.92, 0.36, 0.08, 1)
emission_energy_multiplier = 0.32
[sub_resource type="StandardMaterial3D" id="Material_fed"]
transparency = 1
shading_mode = 0
albedo_color = Color(0.72, 0.92, 0.5, 0.92)
emission_enabled = true
emission = Color(0.45, 0.78, 0.28, 1)
emission_energy_multiplier = 0.38
[sub_resource type="CapsuleMesh" id="Mesh_body"]
material = SubResource("Material_wool")
radius = 0.48
height = 1.55
radial_segments = 12
rings = 4
[sub_resource type="SphereMesh" id="Mesh_fluff"]
material = SubResource("Material_wool")
radius = 0.42
height = 0.72
radial_segments = 12
rings = 6
[sub_resource type="CapsuleMesh" id="Mesh_leg"]
material = SubResource("Material_face")
radius = 0.105
height = 0.62
radial_segments = 8
rings = 3
[sub_resource type="BoxMesh" id="Mesh_hoof"]
material = SubResource("Material_dark")
size = Vector3(0.2, 0.13, 0.28)
[sub_resource type="SphereMesh" id="Mesh_head"]
material = SubResource("Material_face")
radius = 0.34
height = 0.58
radial_segments = 12
rings = 6
[sub_resource type="SphereMesh" id="Mesh_snout"]
material = SubResource("Material_wool")
radius = 0.21
height = 0.32
radial_segments = 10
rings = 5
[sub_resource type="SphereMesh" id="Mesh_ear"]
material = SubResource("Material_face")
radius = 0.24
height = 0.42
radial_segments = 10
rings = 5
[sub_resource type="SphereMesh" id="Mesh_eye"]
material = SubResource("Material_dark")
radius = 0.045
height = 0.075
radial_segments = 8
rings = 4
[sub_resource type="CylinderMesh" id="Mesh_horn"]
material = SubResource("Material_horn")
top_radius = 0.035
bottom_radius = 0.09
height = 0.42
radial_segments = 8
[sub_resource type="CylinderMesh" id="Mesh_beard"]
material = SubResource("Material_wool")
top_radius = 0.015
bottom_radius = 0.11
height = 0.34
radial_segments = 8
[sub_resource type="CylinderMesh" id="Mesh_tail"]
material = SubResource("Material_wool")
top_radius = 0.07
bottom_radius = 0.12
height = 0.42
radial_segments = 8
[sub_resource type="TorusMesh" id="Mesh_collar"]
material = SubResource("Material_collar")
inner_radius = 0.27
outer_radius = 0.32
rings = 14
ring_segments = 8
[sub_resource type="CylinderMesh" id="Mesh_bowl"]
material = SubResource("Material_wood")
top_radius = 0.36
bottom_radius = 0.28
height = 0.13
radial_segments = 12
[sub_resource type="TorusMesh" id="Mesh_hunger_bowl"]
material = SubResource("Material_hunger")
inner_radius = 0.18
outer_radius = 0.27
rings = 14
ring_segments = 8
[sub_resource type="SphereMesh" id="Mesh_hunger_food"]
material = SubResource("Material_hunger")
radius = 0.09
height = 0.14
radial_segments = 8
rings = 4
[sub_resource type="TorusMesh" id="Mesh_fed_ring"]
material = SubResource("Material_fed")
inner_radius = 0.32
outer_radius = 0.42
rings = 16
ring_segments = 8
[sub_resource type="SphereMesh" id="Mesh_fed_mote"]
material = SubResource("Material_fed")
radius = 0.075
height = 0.13
radial_segments = 8
rings = 4
[node name="CozyGoat" type="Node3D"]
script = ExtResource("1_animal")
animal_id = &"goat_dunja"
display_name = "Dunja"
species_id = &"goat"
initial_hunger = 72.0
[node name="Visual" type="Node3D" parent="."]
script = ExtResource("2_visual")
[node name="Body" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 0.95, 0)
rotation_degrees = Vector3(90, 0, 0)
mesh = SubResource("Mesh_body")
[node name="FluffFront" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 1.02, 0.42)
scale = Vector3(1.08, 1.05, 1)
mesh = SubResource("Mesh_fluff")
[node name="FluffBack" type="MeshInstance3D" parent="Visual"]
position = Vector3(0, 1.02, -0.42)
scale = Vector3(1.08, 1.05, 1)
mesh = SubResource("Mesh_fluff")
[node name="LegFrontLeft" type="MeshInstance3D" parent="Visual"]
position = Vector3(-0.3, 0.43, 0.48)
mesh = SubResource("Mesh_leg")
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontLeft"]
position = Vector3(0, -0.29, 0.035)
mesh = SubResource("Mesh_hoof")
[node name="LegFrontRight" type="MeshInstance3D" parent="Visual"]
position = Vector3(0.3, 0.43, 0.48)
mesh = SubResource("Mesh_leg")
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegFrontRight"]
position = Vector3(0, -0.29, 0.035)
mesh = SubResource("Mesh_hoof")
[node name="LegBackLeft" type="MeshInstance3D" parent="Visual"]
position = Vector3(-0.3, 0.43, -0.48)
mesh = SubResource("Mesh_leg")
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackLeft"]
position = Vector3(0, -0.29, 0.035)
mesh = SubResource("Mesh_hoof")
[node name="LegBackRight" type="MeshInstance3D" parent="Visual"]
position = Vector3(0.3, 0.43, -0.48)
mesh = SubResource("Mesh_leg")
[node name="Hoof" type="MeshInstance3D" parent="Visual/LegBackRight"]
position = Vector3(0, -0.29, 0.035)
mesh = SubResource("Mesh_hoof")
[node name="HeadRoot" type="Node3D" parent="Visual"]
position = Vector3(0, 1.28, 0.88)
rotation_degrees = Vector3(-3, 0, 0)
[node name="Head" type="MeshInstance3D" parent="Visual/HeadRoot"]
mesh = SubResource("Mesh_head")
[node name="Snout" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0, -0.08, 0.31)
scale = Vector3(1.05, 0.72, 0.9)
mesh = SubResource("Mesh_snout")
[node name="EyeLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(-0.18, 0.08, 0.285)
mesh = SubResource("Mesh_eye")
[node name="EyeRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0.18, 0.08, 0.285)
mesh = SubResource("Mesh_eye")
[node name="EarLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(-0.35, 0.13, 0)
rotation_degrees = Vector3(8, 0, 67)
scale = Vector3(0.82, 0.28, 1)
mesh = SubResource("Mesh_ear")
[node name="EarRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0.35, 0.13, 0)
rotation_degrees = Vector3(8, 0, -67)
scale = Vector3(0.82, 0.28, 1)
mesh = SubResource("Mesh_ear")
[node name="HornLeft" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(-0.17, 0.34, -0.06)
rotation_degrees = Vector3(-24, 0, -20)
mesh = SubResource("Mesh_horn")
[node name="HornRight" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0.17, 0.34, -0.06)
rotation_degrees = Vector3(-24, 0, 20)
mesh = SubResource("Mesh_horn")
[node name="Beard" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0, -0.35, 0.08)
mesh = SubResource("Mesh_beard")
[node name="Collar" type="MeshInstance3D" parent="Visual/HeadRoot"]
position = Vector3(0, -0.31, -0.16)
rotation_degrees = Vector3(90, 0, 0)
scale = Vector3(0.85, 0.85, 0.85)
mesh = SubResource("Mesh_collar")
[node name="TailRoot" type="Node3D" parent="Visual"]
position = Vector3(0, 1.16, -0.92)
rotation_degrees = Vector3(60, 0, 0)
[node name="Tail" type="MeshInstance3D" parent="Visual/TailRoot"]
position = Vector3(0, 0.18, 0)
mesh = SubResource("Mesh_tail")
[node name="FeedBowl" type="MeshInstance3D" parent="Visual"]
position = Vector3(0.72, 0.08, 1.43)
mesh = SubResource("Mesh_bowl")
[node name="HungerCueRoot" type="Node3D" parent="Visual"]
position = Vector3(0, 2.05, 0.18)
[node name="EmptyBowl" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
rotation_degrees = Vector3(72, 0, 0)
scale = Vector3(1.1, 0.72, 1.1)
mesh = SubResource("Mesh_hunger_bowl")
[node name="FoodMoteLeft" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
position = Vector3(-0.13, 0.22, 0)
mesh = SubResource("Mesh_hunger_food")
[node name="FoodMoteRight" type="MeshInstance3D" parent="Visual/HungerCueRoot"]
position = Vector3(0.13, 0.29, 0)
scale = Vector3(0.82, 0.82, 0.82)
mesh = SubResource("Mesh_hunger_food")
[node name="FedResponseRoot" type="Node3D" parent="Visual"]
visible = false
position = Vector3(0, 1.8, 0.12)
scale = Vector3(0.2, 0.2, 0.2)
[node name="Halo" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
rotation_degrees = Vector3(74, 0, 0)
mesh = SubResource("Mesh_fed_ring")
[node name="MoteLeft" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
position = Vector3(-0.34, 0.18, 0)
mesh = SubResource("Mesh_fed_mote")
[node name="MoteRight" type="MeshInstance3D" parent="Visual/FedResponseRoot"]
position = Vector3(0.34, 0.28, 0)
scale = Vector3(0.82, 0.82, 0.82)
mesh = SubResource("Mesh_fed_mote")
[node name="InteractionPoint" type="Marker3D" parent="."]
position = Vector3(0, 0, 2.05)
[node name="DebugLabel" type="Label3D" parent="."]
position = Vector3(0, 2.55, 0)
billboard = 1
no_depth_test = true
font_size = 22
outline_size = 5
text = "Dunja"
modulate = Color(1, 0.94, 0.78, 1)
pixel_size = 0.006
+16
View File
@@ -0,0 +1,16 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="1_world"]
[ext_resource type="Script" path="res://world/jajce/beauty_camera.gd" id="2_camera"]
[node name="JajceGoatLookdev" type="Node3D"]
[node name="JajceWorld" parent="." instance=ExtResource("1_world")]
[node name="GoatCamera" type="Camera3D" parent="."]
position = Vector3(0.2, 2.9, -7)
current = true
fov = 32.0
script = ExtResource("2_camera")
focus_point = Vector3(-4, 1.15, -11.5)
animate_orbit = false
+8 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=59 format=3] [gd_scene load_steps=60 format=3]
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"] [ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -27,6 +27,7 @@
[ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"] [ext_resource type="Material" path="res://world/jajce/materials/cozy_grass_process_material.tres" id="25_grass_process"]
[ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"] [ext_resource type="PackedScene" path="res://world/jajce/RiverbankResourceCluster.tscn" id="26_resource_cluster"]
[ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"] [ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"]
[ext_resource type="PackedScene" path="res://world/jajce/CozyGoat.tscn" id="28_goat"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
@@ -458,6 +459,12 @@ phase = 4.4
[node name="WorldObjects" type="Node3D" parent="."] [node name="WorldObjects" type="Node3D" parent="."]
[node name="Animals" type="Node3D" parent="WorldObjects"]
[node name="Dunja" parent="WorldObjects/Animals" instance=ExtResource("28_goat")]
position = Vector3(-4, 0, -11.5)
rotation_degrees = Vector3(0, -18, 0)
[node name="ResourceNodes" type="Node3D" parent="WorldObjects"] [node name="ResourceNodes" type="Node3D" parent="WorldObjects"]
[node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]
+1 -1
View File
@@ -21,7 +21,7 @@ func _initialize_world_presentation() -> void:
groups_to_snap.append(child) groups_to_snap.append(child)
if has_node("WorldObjects"): if has_node("WorldObjects"):
var wo := $WorldObjects var wo := $WorldObjects
for group_name in ["ResourceNodes", "StorageSites", "ActivitySites"]: for group_name in ["Animals", "ResourceNodes", "StorageSites", "ActivitySites"]:
if wo.has_node(group_name): if wo.has_node(group_name):
groups_to_snap.append_array(wo.get_node(group_name).get_children()) groups_to_snap.append_array(wo.get_node(group_name).get_children())