diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 49166ea..10d12fa 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -16,6 +16,7 @@ SimulationClock -> ActionSelectionSystem chooses an action -> ActionTargetResolver resolves a stable target ID -> VillageEconomy performs inventory/storage transactions + -> AnimalCareSystem advances animal needs and composes feeding -> SimulationEventLog records completed facts -> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge -> RelationshipSystem applies evidence-gated social consequences @@ -35,6 +36,9 @@ would otherwise obscure that lifecycle: - `simulation/actions/` owns selection, progress, and target resolution; - `simulation/economy/VillageEconomy.gd` owns storage/inventory transactions 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, history queries, and rate calculations; - `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to @@ -89,6 +93,7 @@ hard to read. | --- | --- | | `simulation/` | Headless-capable orchestration and core models | | `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/events/` | Immutable event history and derived event queries | | `simulation/knowledge/` | Per-NPC knowledge of objective event IDs | @@ -100,6 +105,7 @@ hard to read. | `simulation/persistence/` | Validated local save-file storage | | `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads | | `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/storage/` | Storage interaction geometry, never stored quantities | | `world/activity/` | Rest/study/patrol interaction sites and capacity facts | @@ -130,6 +136,10 @@ improving ownership. reactions are not replayed. - Resource changes go through `ResourceStateRecord`, NPC inventory, and `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 the same time as their first gameplay use. Cross-record causes use stable event IDs rather than object references or prose. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 6532315..620b953 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -798,15 +798,22 @@ Completed: stone instances stay presentation-only. Both finite trees remain reachable, deplete into shared stumps, restore from authoritative amount, and keep the 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: -1. Add one named goat as an identity-backed simulation vertical slice: stable - animal ID, authoritative position and hunger/feed state, loaded visual - binding, and an exact feed interaction shared by one NPC and the player. - Feeding must withdraw real pantry food and survive deterministic save/load. -2. Add a small herd, breeding, or animal products only after that single-animal - identity and conservation contract is proven. +1. Give Dunja one deterministic pasture/shelter routine using + simulation-owned destination and position, real loaded-world navigation, + and exact mid-move save/restore continuation. +2. Add a second goat only after movement proves that identity remains stable + across care, navigation, unloading, and restore. +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 mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 0d34b6f..c326255 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.md @@ -10,6 +10,7 @@ resource_extracted storage_deposited storage_withdrawn item_consumed +animal_fed ``` 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 recorded. NPC events preserve their authoritative interaction target (or NPC position when no target applies); player extraction/depletion events preserve -the ResourceNode interaction position. Actor `-1` identifies a player-triggered -extraction until persistent player identity is introduced. +the ResourceNode interaction position. `animal_fed` records one food moving +from `village_pantry` to a stable animal ID at the animal's authoritative +position. Actor `-1` identifies a player-triggered extraction or feeding until +persistent player identity is introduced. Events are immutable facts about completed transfers. They do not perform the 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 history/rate queries, including exact lookup through `get_by_id()`. -`VillageEconomy` performs transactions and requests event records only after -state changes succeed; `SimulationManager` remains the public signal boundary +`VillageEconomy` performs inventory and storage transactions and requests event +records only after state changes succeed. `AnimalCareSystem` composes the +pantry withdrawal with animal hunger relief and requests `animal_fed` only +after both succeed. `SimulationManager` remains the scene-tree signal boundary used by presentation. An action whose definition-backed completion cost becomes unavailable records @@ -38,17 +43,24 @@ transfer occurred. ## 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 per-NPC known-event references with first-acquisition provenance, plus opportunity records that reference exact trigger/resolution events. Schema v1 and v2 saves migrate to an empty stream beginning at ID zero; world schemas -v1–v7 migrate to an empty opportunity list, while world schema v8 preserves and -normalizes its pantry opportunity history. Parsing rejects duplicate event +v1–v7 migrate to an empty opportunity list, while world schemas v8 and v9 +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 communicator sources, relationship causes the observer does not know, and next IDs that could collide with restored history. +Animal-feed facts receive an additional cross-record check: actor is either an +existing NPC or the player sentinel, source is the real pantry, destination is +an existing animal, item and definition cost are exactly one food, event tick +is not in the future, and the animal's `last_fed_tick` agrees with its latest +feed fact. + Positive food deposits, positive NPC food withdrawals from `village_pantry` into that actor's matching inventory, and definition-backed patrol/study `task_blocked` facts caused by missing wood at `village_woodpile` are currently diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 60f252c..8a1c784 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -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 code proven by those two consumers. -The immediate next slice should introduce livestock only through one -identity-backed animal vertical slice: one named goat with a stable animal ID, -simulation-owned position and hunger/feed state, a loaded presentation binding, -and one exact feed interaction available to both an NPC and the player. Feeding -must consume real pantry food and save/restore deterministically. Do not make -the animal a `ResourceNode`, add a herd, breeding, products, or a generalized -animal spatial index before that first identity and conservation contract is -honest. +The first identity-backed livestock slice is complete. Dunja has a stable +animal ID, simulation-owned position and hunger/feed state, and a loaded cozy +presentation whose persistent cue derives from that state. One exact operation +serves both NPC and player feeding, withdraws real pantry food, mutates only the +target animal, and appends one economic fact. World-schema v10 save/restore +preserves the result without replaying the transient response. + +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: +- `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 restrained four-tree silhouette with 24 decorative understory instances. Both targets require expanded loaded-resource discovery, remain reachable, diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 98b57d8..223932e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -616,10 +616,10 @@ These are expected prototype constraints, not necessarily isolated bugs: - Temporary activity markers have been removed; NPC and player food/wood gathering use `ResourceNode` instances with no fallback, food transfer uses the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. -- Current NPC, village, resource, storage, event, knowledge, relationship, - opportunity, clock, and RNG state serialize through world schema v9. F5/F9 - provide one validated local quicksave; a save menu, metadata, and - player-transform persistence remain deferred. +- Current NPC, village, resource, storage, animal, event, knowledge, + relationship, opportunity, clock, and RNG state serialize through world + schema v10. F5/F9 provide one validated local quicksave; a save menu, + metadata, and player-transform persistence remain deferred. - Simulation-owned resource records retain live amounts, reservations, and usage definitions while ResourceNode scenes are unloaded. - 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 decorative density from simulation authority, support discovery beyond the first 24 m range, and reconstruct sparse/depleted visuals from authoritative -amount. The next slice should introduce one named goat only through a stable -simulation identity, authoritative position and feed state, loaded visual -binding, exact NPC/player feeding cost, and deterministic save/restore. Do not -yet generalize the resource grid or introduce active/abstract simulation LOD. +amount. Dunja now proves the first identity-backed animal contract: stable +animal identity, simulation-owned position and hunger, a loaded visual binding, +and the same exact pantry-conserving feed operation for NPC and player. +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: diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index a7ac89d..f34f45d 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -19,13 +19,13 @@ Each executable action definition contains: - display name; - default duration; - 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; - optional completion-cost resource ID and amount. -The current actions are gather food, gather wood, deposit food, deposit wood, -withdraw food, patrol, study, eat, rest, sleep, and wander. Idle and dead are -stable state sentinels, not executable action definitions. +The current actions are gather food, gather wood, feed animal, deposit food, +deposit wood, withdraw food, patrol, study, eat, rest, sleep, and wander. Idle +and dead are stable state sentinels, not executable action definitions. SimNPC reads default duration and preferred-profession metadata from these 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 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 Each profession definition contains a stable `profession_id`, display name, diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 4decc5b..d0983dd 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -3,7 +3,7 @@ ## Current contract `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 clock ticks; @@ -13,6 +13,8 @@ simulation. The current world schema is v9 and captures: - controlled per-NPC wander RNG streams; - scene-independent resource state plus the definition facts needed while its 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, and its next stable event ID; - directed relationship records with familiarity, trust, and the stable event @@ -29,7 +31,7 @@ The top-level identity is: ```json { "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 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. Opportunity, trigger-event, and resolution-event IDs are unique and the next 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 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 `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; - migrations from any historical world schema other than the explicitly - supported v1–v8 layouts; + supported v1–v9 layouts; - player inventory or player relationship records; - broader relationship dimensions, line-of-sight/hearing evidence, continuous or personalized memory decay, reinforcement, false beliefs, or diff --git a/docs/baselines/JAJCE_GOAT_07.md b/docs/baselines/JAJCE_GOAT_07.md new file mode 100644 index 0000000..7049328 --- /dev/null +++ b/docs/baselines/JAJCE_GOAT_07.md @@ -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`. diff --git a/docs/baselines/jajce_goat_07_content.png b/docs/baselines/jajce_goat_07_content.png new file mode 100644 index 0000000..378a361 Binary files /dev/null and b/docs/baselines/jajce_goat_07_content.png differ diff --git a/docs/baselines/jajce_goat_07_content.png.import b/docs/baselines/jajce_goat_07_content.png.import new file mode 100644 index 0000000..2337830 --- /dev/null +++ b/docs/baselines/jajce_goat_07_content.png.import @@ -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 diff --git a/docs/baselines/jajce_goat_07_hungry.png b/docs/baselines/jajce_goat_07_hungry.png new file mode 100644 index 0000000..e992872 Binary files /dev/null and b/docs/baselines/jajce_goat_07_hungry.png differ diff --git a/docs/baselines/jajce_goat_07_hungry.png.import b/docs/baselines/jajce_goat_07_hungry.png.import new file mode 100644 index 0000000..195fadc --- /dev/null +++ b/docs/baselines/jajce_goat_07_hungry.png.import @@ -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 diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index ca74c92..7469605 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -478,6 +478,11 @@ func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh: food.radius = 0.18 food.height = 0.26 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: var wood := BoxMesh.new() 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: SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_EAT, SimulationIds.ACTION_WITHDRAW_FOOD: 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: return Color(0.58, 0.34, 0.16, 1.0) SimulationIds.ACTION_DEPOSIT_FOOD: diff --git a/player/player.gd b/player/player.gd index c4ff3cd..c249ca2 100644 --- a/player/player.gd +++ b/player/player.gd @@ -59,6 +59,9 @@ func try_interact() -> void: if simulation_manager == null: return + if try_feed_animal(): + return + if try_harvest_resource_node(): return @@ -73,6 +76,25 @@ func try_interact() -> void: 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: if not simulation_manager.has_method("find_resource_node_for_player"): push_error("Player: SimulationManager cannot find ResourceNodes") diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index f1d2e25..246310b 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -2,6 +2,7 @@ extends Node const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") +const AnimalCareSystemScript := preload("res://simulation/animals/AnimalCareSystem.gd") const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd") const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd") @@ -38,6 +39,7 @@ var wander_random_sources := {} var resource_states: Dictionary = {} var event_log := SimulationEventLogScript.new() var economy := VillageEconomyScript.new() +var animal_care := AnimalCareSystemScript.new() var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() @@ -71,6 +73,8 @@ func _ready() -> void: economy.inventory_changed.connect(_on_economy_inventory_changed) economy.economic_event_requested.connect(_record_economic_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 var definition_errors := SimulationDefinitions.validate() if not definition_errors.is_empty(): @@ -83,12 +87,14 @@ func _ready() -> void: clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval) village.debug_logs = debug_logs economy.configure(village, debug_logs) + animal_care.configure(economy, active_world_adapter) economy.initialize_storage() village.update_modifiers() village.update_priorities() generate_npcs() relationship_system.initialize_households(npcs) call_deferred("register_loaded_resource_nodes") + animal_care.call_deferred("register_loaded_nodes") call_deferred("register_loaded_storage_nodes") if debug_logs: print("--- Simulation started ---") @@ -156,6 +162,7 @@ func simulate_tick() -> void: tick_count += 1 if debug_logs: print("--- Tick ", tick_count, " ---") + animal_care.advance() var village_was_changed := false _population_view.rebuild(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]: return var helper := get_active_opportunity_helper() + var animal_feed_name := animal_care.get_available_feed_name(npc.id) 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: return @@ -239,7 +247,12 @@ func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: func _complete_current_action(npc: SimNPC) -> void: var completed_task := npc.current_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) elif not npc.target_id.is_empty(): release_npc_reservation(npc.id) @@ -272,6 +285,8 @@ func _apply_action_completion( npc.energy = minf(npc.energy + 40.0, 100.0) npc.position = npc.home_position 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: village.apply_npc_task(npc) 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) if resource_state != null: 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 = &"" return @@ -397,6 +415,22 @@ func notify_npc_arrived(npc_id: int) -> void: ) notify_npc_navigation_failed(npc.id) 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.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) +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: for node in ResourceNode.get_all(): register_resource_node(node) @@ -1051,50 +1092,6 @@ func get_starving_count() -> int: 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: return create_state_record().to_json().sha256_text() @@ -1127,6 +1124,7 @@ func create_state_record() -> SimulationStateRecord: record.village = VillageStateRecord.capture(village) for npc in npcs: record.npcs.append(NPCStateRecord.capture(npc)) + animal_care.append_state_records(record) var sorted_resource_ids: Array = resource_states.keys() sorted_resource_ids.sort() for resource_id in sorted_resource_ids: @@ -1194,6 +1192,7 @@ func restore_state(record: SimulationStateRecord) -> bool: for resource_record in record.resources: resource_states[resource_record.get_node_id()] = resource_record register_loaded_resource_nodes() + animal_care.restore_state_records(record.animals) village_changed.emit(village) state_restored.emit() return true diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 869f4fd..4400b99 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -23,7 +23,8 @@ func select_action( time_of_day: float = 0.5, all_npcs: Array = [], opportunity_helper: OpportunityHelperResult = null, - population_view: SimulationPopulationView = null + population_view: SimulationPopulationView = null, + animal_feed_name: String = "" ) -> ActionSelectionResult: if npc.is_dead: return null @@ -126,6 +127,14 @@ func select_action( return ActionSelectionResult.new( 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: var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( npc.id, all_npcs, population_view diff --git a/simulation/actions/ActionTargetResolver.gd b/simulation/actions/ActionTargetResolver.gd index f651a06..6ca864c 100644 --- a/simulation/actions/ActionTargetResolver.gd +++ b/simulation/actions/ActionTargetResolver.gd @@ -17,6 +17,8 @@ func resolve( ) SimulationIds.TARGET_ACTIVITY: 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: return { "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) +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( npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord ) -> float: diff --git a/simulation/animals/AnimalCareSystem.gd b/simulation/animals/AnimalCareSystem.gd new file mode 100644 index 0000000..ef7958e --- /dev/null +++ b/simulation/animals/AnimalCareSystem.gd @@ -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 diff --git a/simulation/animals/AnimalCareSystem.gd.uid b/simulation/animals/AnimalCareSystem.gd.uid new file mode 100644 index 0000000..1be1492 --- /dev/null +++ b/simulation/animals/AnimalCareSystem.gd.uid @@ -0,0 +1 @@ +uid://kilnbq2k276w diff --git a/simulation/definitions/ActionDefinition.gd b/simulation/definitions/ActionDefinition.gd index 94e7643..c5eb962 100644 --- a/simulation/definitions/ActionDefinition.gd +++ b/simulation/definitions/ActionDefinition.gd @@ -22,7 +22,10 @@ func validate() -> Array[String]: if ( target_type 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]) diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index a45a03f..b4e0afe 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -4,6 +4,7 @@ extends RefCounted const ACTION_PATHS := [ "res://simulation/definitions/actions/gather_food.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/study.tres", "res://simulation/definitions/actions/eat.tres", diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 79bd724..dd65c38 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -6,6 +6,7 @@ const ACTION_DEAD := &"dead" const ACTION_SLEEP := &"sleep" const ACTION_GATHER_FOOD := &"gather_food" const ACTION_GATHER_WOOD := &"gather_wood" +const ACTION_FEED_ANIMAL := &"feed_animal" const ACTION_PATROL := &"patrol" const ACTION_STUDY := &"study" const ACTION_EAT := &"eat" @@ -23,11 +24,15 @@ const PROFESSION_WANDERER := &"wanderer" const TARGET_RESOURCE := &"resource" const TARGET_ACTIVITY := &"activity" +const TARGET_ANIMAL := &"animal" const TARGET_FREE := &"free" const RESOURCE_FOOD := &"food" const RESOURCE_WOOD := &"wood" +const ANIMAL_DUNJA := &"goat_dunja" +const SPECIES_GOAT := &"goat" + const STORAGE_VILLAGE_PANTRY := &"village_pantry" 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_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" +const EVENT_ANIMAL_FED := &"animal_fed" const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry" const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood" diff --git a/simulation/definitions/actions/feed_animal.tres b/simulation/definitions/actions/feed_animal.tres new file mode 100644 index 0000000..d6e6f97 --- /dev/null +++ b/simulation/definitions/actions/feed_animal.tres @@ -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 diff --git a/simulation/state/AnimalStateRecord.gd b/simulation/state/AnimalStateRecord.gd new file mode 100644 index 0000000..7532924 --- /dev/null +++ b/simulation/state/AnimalStateRecord.gd @@ -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) diff --git a/simulation/state/AnimalStateRecord.gd.uid b/simulation/state/AnimalStateRecord.gd.uid new file mode 100644 index 0000000..12525d2 --- /dev/null +++ b/simulation/state/AnimalStateRecord.gd.uid @@ -0,0 +1 @@ +uid://c8rgw7h25bcmr diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd index c0ae6d5..556927c 100644 --- a/simulation/state/EconomicEventRecord.gd +++ b/simulation/state/EconomicEventRecord.gd @@ -158,5 +158,7 @@ func description(npc_names: Dictionary = {}) -> String: return "%s could not complete %s" % [actor_name, action_name] "resource_depleted": 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] diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 4cbdafd..7f4f284 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,7 +2,7 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 9 +const SCHEMA_VERSION := 10 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -10,11 +10,13 @@ const KNOWLEDGE_LEGACY_SCHEMA_VERSION := 4 const PROVENANCE_LEGACY_SCHEMA_VERSION := 5 const RETENTION_LEGACY_SCHEMA_VERSION := 6 const OPPORTUNITY_LEGACY_SCHEMA_VERSION := 8 +const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary var village: VillageStateRecord var npcs: Array[NPCStateRecord] = [] +var animals: Array[AnimalStateRecord] = [] var resources: Array[ResourceStateRecord] = [] var storages: Array[StorageStateRecord] = [] var economic_events: Array[EconomicEventRecord] = [] @@ -28,6 +30,9 @@ func to_dictionary() -> Dictionary: for npc_record in npcs: 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] = [] for resource_record in resources: resource_data.append(resource_record.to_dictionary()) @@ -53,6 +58,7 @@ func to_dictionary() -> Dictionary: "simulation": simulation.duplicate(true), "village": village.to_dictionary(), "npcs": npc_data, + "animals": animal_data, "resources": resource_data, "storages": storage_data, "economic_events": event_data, @@ -88,6 +94,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: RETENTION_LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, OPPORTUNITY_LEGACY_SCHEMA_VERSION, + ANIMAL_LEGACY_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -100,6 +107,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "simulation", "village", "npcs", + "animals", "resources", "storages", "economic_events", @@ -166,6 +174,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: return null var npc_data = record_data["npcs"] + var animal_data = record_data["animals"] var resource_data = record_data["resources"] var storage_data = record_data["storages"] 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"] if ( not npc_data is Array + or not animal_data is Array or not resource_data is Array or not storage_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 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_records_by_id := {} for item in storage_data: @@ -226,7 +281,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if storage_record == null: return null 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 storage_ids[storage_id] = true 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_records_by_id := {} var highest_event_id := -1 + var latest_feed_ticks := {} for item in event_data: if not item is Dictionary: return null @@ -247,9 +303,33 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: event_ids[event_id] = true event_records_by_id[event_id] = event_record 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) if int(record.simulation["next_event_id"]) <= highest_event_id: 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_records_by_key := {} @@ -359,6 +439,31 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: 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( known_event: KnownEventStateRecord, npc_ids: Dictionary, @@ -571,6 +676,7 @@ static func _is_valid_supply_resolution( static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary: var migrated := legacy_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION + migrated["animals"] = [] if version == LEGACY_SCHEMA_VERSION: var village_data: Dictionary = legacy_data.get("village", {}) 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", []), 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"] = [] var opportunity_simulation_data: Dictionary = migrated.get("simulation", {}) opportunity_simulation_data["next_opportunity_id"] = 0 diff --git a/tests/animal_feeding_vertical_slice_test.gd b/tests/animal_feeding_vertical_slice_test.gd new file mode 100644 index 0000000..3ce4b57 --- /dev/null +++ b/tests/animal_feeding_vertical_slice_test.gd @@ -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) diff --git a/tests/animal_feeding_vertical_slice_test.gd.uid b/tests/animal_feeding_vertical_slice_test.gd.uid new file mode 100644 index 0000000..2222169 --- /dev/null +++ b/tests/animal_feeding_vertical_slice_test.gd.uid @@ -0,0 +1 @@ +uid://cgnst4m7bp3tb diff --git a/tests/deterministic_simulation_test.gd b/tests/deterministic_simulation_test.gd index b63188f..dd3d6cf 100644 --- a/tests/deterministic_simulation_test.gd +++ b/tests/deterministic_simulation_test.gd @@ -44,8 +44,8 @@ func _run_scenario(seed_value: int) -> String: if npc.task_state == SimNPC.TASK_STATE_TRAVELING: manager.notify_npc_arrived(npc.id) - var snapshot: Dictionary = manager.get_state_snapshot() - snapshot.erase("seed") + var snapshot: Dictionary = manager.create_state_record().to_dictionary() + snapshot["simulation"].erase("seed") var checksum := JSON.stringify(snapshot).sha256_text() manager.free() return checksum diff --git a/tests/food_shortage_opportunity_test.gd b/tests/food_shortage_opportunity_test.gd index 95eb920..6eaf92b 100644 --- a/tests/food_shortage_opportunity_test.gd +++ b/tests/food_shortage_opportunity_test.gd @@ -105,7 +105,7 @@ func _run() -> void: var active_restored := _create_manager(902) _check( 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( ( diff --git a/tests/jajce_runtime_integration_test.gd b/tests/jajce_runtime_integration_test.gd index 09dcc62..6e626d5 100644 --- a/tests/jajce_runtime_integration_test.gd +++ b/tests/jajce_runtime_integration_test.gd @@ -269,7 +269,7 @@ func _run() -> void: var old_contributor_visual := contributor_visual _check( 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 contributor = simulation_manager.npcs[contributor.id] diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 207304a..509227e 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -396,6 +396,24 @@ func _run() -> void: ) _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river 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") var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode _check(pantry != null, "JajceWorld should include a typed VillagePantry StorageNode") @@ -448,6 +466,7 @@ func _run() -> void: var destinations: Array[Vector3] = [] for resource in resources: destinations.append((resource as ResourceNode).interaction_point.global_position) + destinations.append(goat.get_interaction_position()) destinations.append(pantry.get_interaction_position()) destinations.append(woodpile.get_interaction_position()) for site in activity_root.get_children(): diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index f705cf4..f9acb8a 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -35,7 +35,7 @@ func _test_registry_integrity() -> void: "Action lookup should return '%s'" % definition.action_id ) _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() @@ -88,6 +88,16 @@ func _test_definition_backed_behavior() -> void: ), "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: diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index 8c7193d..62df186 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -19,8 +19,10 @@ func _run() -> void: _test_previous_world_provenance_migration() _test_previous_world_retention_migration() _test_previous_world_opportunity_migration() + _test_previous_world_animal_migration() _test_relationship_schema_rejection() _test_opportunity_schema_rejection() + _test_animal_schema_rejection() _test_schema_rejection() if failures.is_empty(): @@ -481,6 +483,51 @@ func _test_previous_world_opportunity_migration() -> void: 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: var manager := _create_manager(58) var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary() diff --git a/tests/wood_shortage_opportunity_test.gd b/tests/wood_shortage_opportunity_test.gd index fdc0a9e..3d23aa9 100644 --- a/tests/wood_shortage_opportunity_test.gd +++ b/tests/wood_shortage_opportunity_test.gd @@ -132,7 +132,7 @@ func _run() -> void: var active_restored := _create_manager(1202) _check( 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() _check( diff --git a/tools/capture_jajce_lookdev.gd b/tools/capture_jajce_lookdev.gd index 5b843d8..baef70e 100644 --- a/tools/capture_jajce_lookdev.gd +++ b/tools/capture_jajce_lookdev.gd @@ -77,12 +77,40 @@ func _get_argument_value(prefix: String, default_value: String) -> String: 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(): if hide_labels: 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(): + 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="): continue var override_text := argument.trim_prefix("--resource-amount=") diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index 54af670..7372db9 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -59,6 +59,41 @@ func get_resource_index_stats() -> Dictionary: 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: var best_candidate: Dictionary = {} var best_distance := INF diff --git a/world/animals/AnimalNode.gd b/world/animals/AnimalNode.gd new file mode 100644 index 0000000..7d76bb8 --- /dev/null +++ b/world/animals/AnimalNode.gd @@ -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() diff --git a/world/animals/AnimalNode.gd.uid b/world/animals/AnimalNode.gd.uid new file mode 100644 index 0000000..46db8f1 --- /dev/null +++ b/world/animals/AnimalNode.gd.uid @@ -0,0 +1 @@ +uid://6s3isttop6a6 diff --git a/world/demo/DemoController.gd b/world/demo/DemoController.gd index 771ec9b..f966d85 100644 --- a/world/demo/DemoController.gd +++ b/world/demo/DemoController.gd @@ -359,6 +359,10 @@ func _apply_debug_overlay_visibility() -> void: if node.has_method("set_debug_label_enabled"): 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(): if node.has_method("set_debug_label_enabled"): node.set_debug_label_enabled(debug_overlay_visible) diff --git a/world/jajce/CozyGoat.gd b/world/jajce/CozyGoat.gd new file mode 100644 index 0000000..6b87518 --- /dev/null +++ b/world/jajce/CozyGoat.gd @@ -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 diff --git a/world/jajce/CozyGoat.gd.uid b/world/jajce/CozyGoat.gd.uid new file mode 100644 index 0000000..7928ca9 --- /dev/null +++ b/world/jajce/CozyGoat.gd.uid @@ -0,0 +1 @@ +uid://byx6pyisksbtg diff --git a/world/jajce/CozyGoat.tscn b/world/jajce/CozyGoat.tscn new file mode 100644 index 0000000..2dfb1b2 --- /dev/null +++ b/world/jajce/CozyGoat.tscn @@ -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 diff --git a/world/jajce/JajceGoatLookdev.tscn b/world/jajce/JajceGoatLookdev.tscn new file mode 100644 index 0000000..278f0a3 --- /dev/null +++ b/world/jajce/JajceGoatLookdev.tscn @@ -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 diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index daaa214..3baeb2f 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -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="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="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/CozyGoat.tscn" id="28_goat"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { @@ -458,6 +459,12 @@ phase = 4.4 [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="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] diff --git a/world/jajce/jajce_world.gd b/world/jajce/jajce_world.gd index c572f2c..996d51b 100644 --- a/world/jajce/jajce_world.gd +++ b/world/jajce/jajce_world.gd @@ -21,7 +21,7 @@ func _initialize_world_presentation() -> void: groups_to_snap.append(child) if has_node("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): groups_to_snap.append_array(wo.get_node(group_name).get_children())