From d950c3da681a1a32142269cf5a0ad8346c4e0ba7 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 30 Jul 2026 23:03:15 +0200 Subject: [PATCH] feat: add paired goat care --- docs/BUILD_IN_PUBLIC_PLAN.md | 15 +- docs/LEARNING_ROADMAP.md | 26 +- docs/PROJECT_CONTEXT.md | 12 +- docs/SIMULATION_DEFINITIONS.md | 8 +- docs/SIMULATION_STATE_SCHEMA.md | 24 +- simulation/SimulationManager.gd | 4 +- simulation/actions/ActionSelectionSystem.gd | 6 +- simulation/animals/animal_care_system.gd | 10 +- simulation/definitions/SimulationIds.gd | 2 + .../definitions/actions/feed_animal.tres | 2 +- tests/animal_feeding_vertical_slice_test.gd | 6 +- tests/animal_pair_vertical_slice_test.gd | 280 ++++++++++++++++++ tests/animal_pair_vertical_slice_test.gd.uid | 1 + tests/jajce_world_scaffold_test.gd | 54 ++-- world/active_world_adapter.gd | 4 +- world/animals/animal_routine_site.gd | 8 + world/animals/goat/dunja_habitat.tscn | 1 + world/animals/goat/zora_shelter.tscn | 123 ++++++++ world/jajce/JajceWorld.tscn | 16 +- 19 files changed, 541 insertions(+), 61 deletions(-) create mode 100644 tests/animal_pair_vertical_slice_test.gd create mode 100644 tests/animal_pair_vertical_slice_test.gd.uid create mode 100644 world/animals/goat/zora_shelter.tscn diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 61704c1..89700a6 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -811,15 +811,20 @@ Completed: exact mid-route state through schema-v11 restore and presentation unload/reload. The animal feature assets now live together under `world/animals/goat/`, while ignored docs no longer create import sidecars. +42. `Jajce Goat Pair 09`: Zora now joins Dunja as a second named, stable animal + with an authored private shelter and deterministic access to the shared + pasture. Simultaneous hunger selects the nearest available loaded goat, + preserves two exact reservations through save/restore, and feeds only the + named target. Private shelter ownership remains loaded-world context; no + herd system, animal index, or schema bump was introduced. Next: -1. Add one second named goat with its own stable state, authored shelter - context, and deterministic use of the shared pasture. Prove nearest - feed-target selection and independent reservations when both animals are - hungry. +1. Playtest the named pair and record one concrete gameplay or scaling need + before extending the animal model. 2. Keep breeding, products, herd scheduling, animal spatial indexing, and - active/abstract simulation LOD deferred until two real animals require them. + active/abstract simulation LOD deferred unless that evidence requires one + of them; otherwise return to the broader milestone sequence. 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/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 9d8a9c8..6a492d9 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -938,16 +938,28 @@ destination, and every NPC RNG stream. Unloading and re-instantiating the goat scene retains that same travel state, arrival is exact, and hunger stops movement before feeding. -The immediate next slice should add one second named goat with its own stable -record and authored shelter context while sharing the existing pasture. -Selection and reservation tests must prove that simultaneous hunger still -chooses the nearest real animal and mutates only the exact target. Keep -loaded-animal discovery linear and defer herd scheduling, breeding, products, -animal indexing, and active/abstract simulation LOD until two identities -demonstrate a concrete need. +The first multi-animal care slice is complete. Zora adds a second stable record, +loaded presentation, and private shelter while sharing Dunja's pasture. When +both goats are hungry, loaded target resolution chooses the nearest available +interaction point, two villagers can retain independent exact claims, and +feeding Zora leaves Dunja's hunger, feed history, and reservation untouched. +Schema-v11 restore preserves both claim pairings and checksum. Loaded routine +sites now distinguish private shelter ownership from shared species context +without adding that authored-world fact to saved simulation state. + +The immediate next step is a playtest-and-measure gate, not a herd abstraction. +Keep loaded-animal discovery linear and use the named pair to identify one +concrete gameplay need before adding breeding, products, herd scheduling, +animal indexing, or active/abstract animal LOD. If no such need appears, resume +the broader milestone sequence instead of adding animal framework by momentum. Recently completed: +- `Jajce Goat Pair 09`: named goat Zora adds a second independent animal + record and private shelter while sharing Dunja's pasture. Stable nearest + targeting, simultaneous exact reservations, isolated feed mutation and + events, deterministic routine ownership, and active-claim save/restore prove + the multi-animal contract without a schema bump or herd system. - `Jajce Dunja Habitat 08`: the first deterministic animal routine alternates shelter and pasture through real loaded navigation, preserves exact mid-route state across save/restore and presentation unload/reload, stops diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9b824ad..40463de 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -999,10 +999,14 @@ World-schema v10 restore reconstructs her persistent state without replaying the transient response. Dunja now also alternates between a stable shelter and pasture through real loaded navigation. World-schema v11 preserves exact mid-route position, destination, and schedule through restore or presentation -unload/reload without consuming decision RNG. The next bounded animal slice -should add one second named goat and prove independent nearest-target care -before any herd abstraction. Do not yet generalize animal discovery or -introduce active/abstract simulation LOD. +unload/reload without consuming decision RNG. Zora now proves the bounded +multi-animal contract: her independent stable record and private shelter share +Dunja's pasture, nearest loaded care targeting supports two simultaneous exact +claims, and feeding one goat cannot mutate the other. World-schema v11 +preserves both claim pairings without serializing shelter ownership. Do not +generalize animal discovery or introduce herd scheduling, products, breeding, +or active/abstract animal LOD until playtest or measurement exposes a concrete +need. The remaining simulation-garden target still aims for: diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index ee9a2f4..b80cdd7 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -37,10 +37,10 @@ both require one stored wood without duplicating that rule. 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. -`SimulationIds` also names Dunja's shelter and pasture anchors. These IDs are -saved routine vocabulary; their loaded positions come from -`AnimalRoutineSite` rather than entering an action definition or resource -registry. +`SimulationIds` names Dunja and Zora, their private shelter anchors, and the +shared pasture anchor. These IDs are saved routine vocabulary; their loaded +positions and private/shared ownership come from `AnimalRoutineSite` rather +than entering an action definition or resource registry. ## Current profession contract diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index 7bc4e00..07a6b51 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -284,13 +284,23 @@ 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 first routine consumer alternates Dunja between stable-ID shelter and -pasture markers without drawing RNG. `ActiveWorldAdapter` supplies the loaded -marker positions, the selected ID and exact position enter state, and -`AnimalNode` follows a real `NavigationServer3D` path while reporting factual -position changes. A mid-route save restores the same position and target; -unloading the scene node pauses presentation only, and reloading it resumes -from those facts. Hunger cancels travel before care targeting. +The first routine consumers alternate Dunja and Zora between stable-ID private +shelters and one shared pasture without drawing RNG. `AnimalRoutineSite` +declares optional loaded-world resident ownership: an empty resident ID is +shared species context, while a named resident makes a shelter private. +`ActiveWorldAdapter` filters those loaded markers for each stable animal; site +ownership is not persistent state and does not require a schema bump. The +selected site ID and exact position do enter state, and each `AnimalNode` +follows a real `NavigationServer3D` path while reporting factual position +changes. A mid-route save restores the same position and target; unloading a +scene node pauses presentation only, and reloading it resumes from those facts. +Hunger cancels travel before care targeting. + +Multiple animal records remain independently authoritative. A schema-v11 save +may contain concurrent feed reservations only when each reserving NPC targets +that exact animal in a valid travel or work state. Restoring Dunja and Zora +preserves both claim pairings and the checksum; completing one feed releases +and mutates only its destination record. The persistent hungry/content presentation derives from authoritative hunger. The short feed response is transient and is reset rather than serialized or diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 98a3b78..b7ceca7 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -217,9 +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 animal_feed_available := animal_care.has_available_feed_target(npc.id) var selection := action_selector.select_action( - npc, village, clock.time_of_day(), npcs, helper, _population_view, animal_feed_name + npc, village, clock.time_of_day(), npcs, helper, _population_view, animal_feed_available ) if selection == null: return diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 4400b99..4b9f75a 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -24,7 +24,7 @@ func select_action( all_npcs: Array = [], opportunity_helper: OpportunityHelperResult = null, population_view: SimulationPopulationView = null, - animal_feed_name: String = "" + animal_feed_available: bool = false ) -> ActionSelectionResult: if npc.is_dead: return null @@ -127,13 +127,13 @@ func select_action( return ActionSelectionResult.new( SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" ) - if not animal_feed_name.is_empty(): + if animal_feed_available: 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 + "A hungry animal is available; pantry food is available" ) if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null: var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( diff --git a/simulation/animals/animal_care_system.gd b/simulation/animals/animal_care_system.gd index e765c32..e166f42 100644 --- a/simulation/animals/animal_care_system.gd +++ b/simulation/animals/animal_care_system.gd @@ -119,9 +119,9 @@ func can_complete_feed(animal_id: StringName, agent_id: int) -> bool: ) -func get_available_feed_name(npc_id: int) -> String: +func has_available_feed_target(npc_id: int) -> bool: if active_world_adapter == null or not active_world_adapter.has_method("get_animal_candidates"): - return "" + return false var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates( SimulationIds.ACTION_FEED_ANIMAL ) @@ -132,8 +132,8 @@ func get_available_feed_name(npc_id: int) -> String: and animal_state.can_npc_feed() and animal_state.is_available_for(npc_id) ): - return animal_state.get_display_name() - return "" + return true + return false func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode: @@ -250,7 +250,7 @@ func _begin_due_routine(animal_state: AnimalStateRecord) -> bool: if AnimalNode.get_by_id(animal_state.get_animal_id()) == null: return false var candidates: Array[Dictionary] = active_world_adapter.get_animal_routine_sites( - animal_state.get_species_id() + animal_state.get_species_id(), animal_state.get_animal_id() ) if candidates.is_empty(): return false diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index ada1e12..e523384 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -31,9 +31,11 @@ const RESOURCE_FOOD := &"food" const RESOURCE_WOOD := &"wood" const ANIMAL_DUNJA := &"goat_dunja" +const ANIMAL_ZORA := &"goat_zora" const SPECIES_GOAT := &"goat" const ANIMAL_SITE_DUNJA_PASTURE := &"dunja_pasture" const ANIMAL_SITE_DUNJA_SHELTER := &"dunja_shelter" +const ANIMAL_SITE_ZORA_SHELTER := &"zora_shelter" const STORAGE_VILLAGE_PANTRY := &"village_pantry" const STORAGE_VILLAGE_WOODPILE := &"village_woodpile" diff --git a/simulation/definitions/actions/feed_animal.tres b/simulation/definitions/actions/feed_animal.tres index d6e6f97..9555ca3 100644 --- a/simulation/definitions/actions/feed_animal.tres +++ b/simulation/definitions/actions/feed_animal.tres @@ -5,7 +5,7 @@ [resource] script = ExtResource("1") action_id = &"feed_animal" -display_name = "Feed Dunja" +display_name = "Feed animal" default_duration = 2.0 preferred_profession_id = &"farmer" target_type = &"animal" diff --git a/tests/animal_feeding_vertical_slice_test.gd b/tests/animal_feeding_vertical_slice_test.gd index 3ce4b57..c331847 100644 --- a/tests/animal_feeding_vertical_slice_test.gd +++ b/tests/animal_feeding_vertical_slice_test.gd @@ -20,10 +20,10 @@ func _run() -> void: 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(goat != null, "Jajce should load Dunja's named 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" + AnimalNode.get_all().size() == 2 and ResourceNode.get_all().size() == 18, + "Named goats should remain AnimalNodes without changing the finite-resource count" ) _check( not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null, diff --git a/tests/animal_pair_vertical_slice_test.gd b/tests/animal_pair_vertical_slice_test.gd new file mode 100644 index 0000000..02990d1 --- /dev/null +++ b/tests/animal_pair_vertical_slice_test.gd @@ -0,0 +1,280 @@ +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 dunja_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_DUNJA) + var zora_node := AnimalNode.get_by_id(SimulationIds.ANIMAL_ZORA) + var dunja_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA) + var zora_state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA) + var shared_pasture := AnimalRoutineSite.get_by_id(SimulationIds.ANIMAL_SITE_DUNJA_PASTURE) + var dunja_shelter := AnimalRoutineSite.get_by_id(SimulationIds.ANIMAL_SITE_DUNJA_SHELTER) + var zora_shelter := AnimalRoutineSite.get_by_id(SimulationIds.ANIMAL_SITE_ZORA_SHELTER) + _check( + ( + AnimalNode.get_all().size() == 2 + and manager.animal_care.states.size() == 2 + and dunja_node != null + and zora_node != null + and dunja_state != null + and zora_state != null + and dunja_node.state == dunja_state + and zora_node.state == zora_state + ), + "The real main scene should bind exactly two stable named-goat records", + ) + _check( + shared_pasture != null and dunja_shelter != null and zora_shelter != null, + "The pair proof needs one shared pasture and one authored shelter per goat", + ) + if ( + dunja_node == null + or zora_node == null + or dunja_state == null + or zora_state == null + or shared_pasture == null + or dunja_shelter == null + or zora_shelter == null + ): + _finish() + return + + for npc in manager.npcs: + _park_npc(npc) + + var adapter := manager.active_world_adapter as ActiveWorldAdapter + var dunja_sites: Array[Dictionary] = adapter.get_animal_routine_sites( + SimulationIds.SPECIES_GOAT, SimulationIds.ANIMAL_DUNJA + ) + var zora_sites: Array[Dictionary] = adapter.get_animal_routine_sites( + SimulationIds.SPECIES_GOAT, SimulationIds.ANIMAL_ZORA + ) + _check( + ( + ( + _site_ids(dunja_sites) + == [ + SimulationIds.ANIMAL_SITE_DUNJA_PASTURE, + SimulationIds.ANIMAL_SITE_DUNJA_SHELTER, + ] + ) + and ( + _site_ids(zora_sites) + == [ + SimulationIds.ANIMAL_SITE_DUNJA_PASTURE, + SimulationIds.ANIMAL_SITE_ZORA_SHELTER, + ] + ) + ), + "Routine discovery should expose the shared pasture plus only each goat's own shelter", + ) + + zora_state.set_hunger(30.0) + _check( + zora_state.schedule_routine_at(manager.tick_count), + "Zora's routine should be schedulable immediately from her authored shelter", + ) + manager.simulate_tick() + _check( + ( + zora_state.has_active_travel_target() + and zora_state.get_routine_site_id() == SimulationIds.ANIMAL_SITE_ZORA_SHELTER + and (zora_state.get_travel_target_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE) + and (zora_state.get_travel_target_position().is_equal_approx( + shared_pasture.get_routine_position() + )) + ), + "Zora should deterministically choose the shared pasture from her own shelter", + ) + _check( + zora_state.complete_travel(shared_pasture.get_routine_position(), manager.tick_count), + "The routine proof should be able to complete Zora's pasture leg directly", + ) + _check( + zora_state.schedule_routine_at(manager.tick_count), + "Zora's return leg should be schedulable without waiting for long navigation", + ) + manager.simulate_tick() + _check( + ( + zora_state.has_active_travel_target() + and (zora_state.get_routine_site_id() == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE) + and (zora_state.get_travel_target_site_id() == SimulationIds.ANIMAL_SITE_ZORA_SHELTER) + and (zora_state.get_travel_target_position().is_equal_approx( + zora_shelter.get_routine_position() + )) + ), + "Zora should return to her own shelter rather than entering Dunja's shelter", + ) + zora_state.cancel_travel(manager.tick_count + AnimalCareSystem.ROUTINE_RETRY_TICKS) + + dunja_state.set_hunger(72.0) + zora_state.set_hunger(72.0) + var zora_caretaker: SimNPC = manager.npcs[0] + var dunja_caretaker: SimNPC = manager.npcs[1] + _prepare_feed_task(manager, zora_caretaker) + _prepare_feed_task(manager, dunja_caretaker) + var shared_origin := zora_node.get_interaction_position() + zora_caretaker.position = shared_origin + dunja_caretaker.position = shared_origin + _check( + manager.resolve_npc_target(zora_caretaker.id, shared_origin), + "The first caretaker should resolve a real hungry animal target", + ) + _check( + ( + zora_caretaker.target_id == SimulationIds.ANIMAL_ZORA + and zora_state.get_reserved_by() == zora_caretaker.id + and dunja_state.get_reserved_by() == -1 + ), + "The first caretaker should claim the nearest hungry goat, Zora, by exact ID", + ) + _check( + manager.resolve_npc_target(dunja_caretaker.id, shared_origin), + "The second caretaker should resolve the remaining available hungry goat", + ) + _check( + ( + dunja_caretaker.target_id == SimulationIds.ANIMAL_DUNJA + and dunja_state.get_reserved_by() == dunja_caretaker.id + and zora_state.get_reserved_by() == zora_caretaker.id + ), + "Simultaneous hunger should retain two independent exact animal reservations", + ) + + var claimed_json: String = manager.serialize_state() + var claimed_checksum: String = manager.get_state_checksum() + var zora_caretaker_id := zora_caretaker.id + var dunja_caretaker_id := dunja_caretaker.id + _check( + manager.restore_state_from_json(claimed_json), + "A save containing both exact feed claims should pass the complete schema", + ) + dunja_state = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA) + zora_state = manager.animal_care.get_state(SimulationIds.ANIMAL_ZORA) + zora_caretaker = _find_npc(manager.npcs, zora_caretaker_id) + dunja_caretaker = _find_npc(manager.npcs, dunja_caretaker_id) + _check( + ( + manager.get_state_checksum() == claimed_checksum + and zora_node.state == zora_state + and dunja_node.state == dunja_state + and zora_caretaker != null + and dunja_caretaker != null + and zora_caretaker.target_id == SimulationIds.ANIMAL_ZORA + and dunja_caretaker.target_id == SimulationIds.ANIMAL_DUNJA + and zora_state.get_reserved_by() == zora_caretaker_id + and dunja_state.get_reserved_by() == dunja_caretaker_id + ), + "Restore should retain both claims, bindings, target IDs, and the exact checksum", + ) + if zora_caretaker == null or dunja_caretaker == null: + _finish() + return + + var pantry: StorageStateRecord = manager.get_pantry() + var pantry_food_before := pantry.get_amount(SimulationIds.RESOURCE_FOOD) + var zora_hunger_before := zora_state.get_hunger() + var zora_fed_tick_before := zora_state.get_last_fed_tick() + var dunja_hunger_before := dunja_state.get_hunger() + var dunja_fed_tick_before := dunja_state.get_last_fed_tick() + var dunja_reservation_before := dunja_state.get_reserved_by() + _check( + manager.feed_animal(SimulationIds.ANIMAL_ZORA, zora_caretaker_id), + "The restored exact Zora claim should complete one real feed operation", + ) + _check( + ( + is_equal_approx( + zora_state.get_hunger(), zora_hunger_before - AnimalStateRecord.HUNGER_RELIEF + ) + and zora_state.get_last_fed_tick() == manager.tick_count + and zora_state.get_last_fed_tick() != zora_fed_tick_before + and zora_state.get_reserved_by() == -1 + and is_equal_approx(dunja_state.get_hunger(), dunja_hunger_before) + and dunja_state.get_last_fed_tick() == dunja_fed_tick_before + and dunja_state.get_reserved_by() == dunja_reservation_before + ), + "Feeding Zora should mutate only Zora's hunger, feed tick, and reservation", + ) + _check( + is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), pantry_food_before - 1.0), + "Feeding exactly one goat should consume exactly one pantry food", + ) + var feed_event := _latest_feed_event(manager) + _check( + ( + feed_event != null + and int(feed_event.data["actor_id"]) == zora_caretaker_id + and StringName(feed_event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY + and StringName(feed_event.data["destination_id"]) == SimulationIds.ANIMAL_ZORA + and StringName(feed_event.data["item_id"]) == SimulationIds.RESOURCE_FOOD + and is_equal_approx(float(feed_event.data["amount"]), 1.0) + and feed_event.get_world_position().is_equal_approx(zora_state.get_position()) + ), + "The feed event should record the exact caretaker, pantry, Zora destination, and amount", + ) + + _finish() + + +func _park_npc(npc: SimNPC) -> void: + npc.set_task(SimulationIds.ACTION_WANDER, 1000.0) + npc.start_working() + + +func _prepare_feed_task(manager: Node, npc: SimNPC) -> void: + manager.release_npc_reservation(npc.id) + npc.target_id = &"" + npc.has_travel_target = false + npc.set_task(SimulationIds.ACTION_FEED_ANIMAL) + + +func _site_ids(candidates: Array[Dictionary]) -> Array[StringName]: + var ids: Array[StringName] = [] + for candidate in candidates: + ids.append(StringName(candidate["site_id"])) + return ids + + +func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC: + for npc in npcs: + if npc.id == npc_id: + return npc + return null + + +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] Two-goat vertical slice passed") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) diff --git a/tests/animal_pair_vertical_slice_test.gd.uid b/tests/animal_pair_vertical_slice_test.gd.uid new file mode 100644 index 0000000..c3e4cb8 --- /dev/null +++ b/tests/animal_pair_vertical_slice_test.gd.uid @@ -0,0 +1 @@ +uid://drh502csu7pj6 diff --git a/tests/jajce_world_scaffold_test.gd b/tests/jajce_world_scaffold_test.gd index 90c272d..e54390f 100644 --- a/tests/jajce_world_scaffold_test.gd +++ b/tests/jajce_world_scaffold_test.gd @@ -397,29 +397,39 @@ 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 + var dunja := animal_root.get_node("Dunja") as AnimalNode + var zora := animal_root.get_node("Zora") as AnimalNode _check( - animal_root.get_child_count() == 1 and goat != null, - "Jajce should contain one bounded loaded-animal presentation" + animal_root.get_child_count() == 2 and dunja != null and zora != null, + "Jajce should contain the bounded pair of loaded-animal presentations" ) _check( ( - goat.animal_id == SimulationIds.ANIMAL_DUNJA - and goat.species_id == SimulationIds.SPECIES_GOAT - and goat.display_name == "Dunja" + dunja.animal_id == SimulationIds.ANIMAL_DUNJA + and dunja.species_id == SimulationIds.SPECIES_GOAT + and dunja.display_name == "Dunja" + and zora.animal_id == SimulationIds.ANIMAL_ZORA + and zora.species_id == SimulationIds.SPECIES_GOAT + and zora.display_name == "Zora" ), - "The authored goat should preserve its stable identity and readable name" + "The authored goats should preserve unique stable identities and readable names" ) _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" + ( + not dunja.is_in_group("resource_nodes") + and not zora.is_in_group("resource_nodes") + and ResourceNode.get_by_id(dunja.animal_id) == null + and ResourceNode.get_by_id(zora.animal_id) == null + ), + "The identity-backed goats should remain separate from finite resources" ) _check( - goat.is_in_group("grass_interactors"), - "Dunja should bend camera-local grass through the shared presentation group" + dunja.is_in_group("grass_interactors") and zora.is_in_group("grass_interactors"), + "Both goats should bend camera-local grass through the shared presentation group" ) var habitat_root := world.get_node("WorldObjects/AnimalHabitats") - var habitat := habitat_root.get_node("DunjaHabitat") + var dunja_habitat := habitat_root.get_node("DunjaHabitat") + var zora_shelter_context := habitat_root.get_node("ZoraShelter") var routine_sites: Array[AnimalRoutineSite] = [] for site in AnimalRoutineSite.get_all(): routine_sites.append(site) @@ -428,16 +438,25 @@ func _run() -> void: return String(first.site_id) < String(second.site_id) ) _check( - habitat_root.get_child_count() == 1 and habitat != null and routine_sites.size() == 2, - "Jajce should keep Dunja's two routine anchors inside one self-contained habitat scene" + ( + habitat_root.get_child_count() == 2 + and dunja_habitat != null + and zora_shelter_context != null + and routine_sites.size() == 3 + ), + "Jajce should author one shared pasture and a private shelter context for each goat" ) _check( ( - routine_sites.size() == 2 + routine_sites.size() == 3 and routine_sites[0].site_id == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE and routine_sites[1].site_id == SimulationIds.ANIMAL_SITE_DUNJA_SHELTER + and routine_sites[2].site_id == SimulationIds.ANIMAL_SITE_ZORA_SHELTER + and routine_sites[0].resident_animal_id.is_empty() + and routine_sites[1].resident_animal_id == SimulationIds.ANIMAL_DUNJA + and routine_sites[2].resident_animal_id == SimulationIds.ANIMAL_ZORA ), - "Dunja's pasture and shelter should expose deterministic stable IDs" + "Routine sites should expose stable IDs with shared pasture and exact shelter ownership" ) _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode @@ -491,7 +510,8 @@ 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(dunja.get_interaction_position()) + destinations.append(zora.get_interaction_position()) for site in routine_sites: destinations.append(site.get_routine_position()) destinations.append(pantry.get_interaction_position()) diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index 8703140..55473b6 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -81,10 +81,10 @@ func get_animal_candidates(action_id: StringName) -> Array[Dictionary]: return candidates -func get_animal_routine_sites(species_id: StringName) -> Array[Dictionary]: +func get_animal_routine_sites(species_id: StringName, animal_id: StringName) -> Array[Dictionary]: var candidates: Array[Dictionary] = [] for site in AnimalRoutineSite.get_all(): - if not site.supports_species(species_id): + if not site.supports_animal(animal_id, species_id): continue ( candidates diff --git a/world/animals/animal_routine_site.gd b/world/animals/animal_routine_site.gd index 98f381c..4c15467 100644 --- a/world/animals/animal_routine_site.gd +++ b/world/animals/animal_routine_site.gd @@ -7,6 +7,7 @@ static var _all: Array[AnimalRoutineSite] = [] @export var site_id: StringName @export var display_name := "Animal site" @export var species_id: StringName = SimulationIds.SPECIES_GOAT +@export var resident_animal_id: StringName @export var debug_label_enabled := false @@ -48,6 +49,13 @@ func supports_species(candidate_species_id: StringName) -> bool: return candidate_species_id == species_id +func supports_animal(candidate_animal_id: StringName, candidate_species_id: StringName) -> bool: + return ( + supports_species(candidate_species_id) + and (resident_animal_id.is_empty() or resident_animal_id == candidate_animal_id) + ) + + func get_routine_position() -> Vector3: return global_position diff --git a/world/animals/goat/dunja_habitat.tscn b/world/animals/goat/dunja_habitat.tscn index 9671968..1be5519 100644 --- a/world/animals/goat/dunja_habitat.tscn +++ b/world/animals/goat/dunja_habitat.tscn @@ -137,6 +137,7 @@ script = ExtResource("1_site") site_id = &"dunja_shelter" display_name = "Warm shelter" species_id = &"goat" +resident_animal_id = &"goat_dunja" [node name="DebugLabel" type="Label3D" parent="ShelterSite"] position = Vector3(0, 2.45, 0) diff --git a/world/animals/goat/zora_shelter.tscn b/world/animals/goat/zora_shelter.tscn new file mode 100644 index 0000000..df3753f --- /dev/null +++ b/world/animals/goat/zora_shelter.tscn @@ -0,0 +1,123 @@ +[gd_scene load_steps=13 format=3] + +[ext_resource type="Script" path="res://world/animals/animal_routine_site.gd" id="1_site"] + +[sub_resource type="StandardMaterial3D" id="Material_timber"] +albedo_color = Color(0.39, 0.22, 0.11, 1) +roughness = 0.96 + +[sub_resource type="StandardMaterial3D" id="Material_roof"] +albedo_color = Color(0.48, 0.3, 0.2, 1) +roughness = 0.98 + +[sub_resource type="StandardMaterial3D" id="Material_bowl"] +albedo_color = Color(0.58, 0.31, 0.19, 1) +roughness = 0.82 + +[sub_resource type="StandardMaterial3D" id="Material_straw"] +albedo_color = Color(0.72, 0.54, 0.27, 1) +roughness = 0.98 + +[sub_resource type="StandardMaterial3D" id="Material_glow"] +shading_mode = 0 +albedo_color = Color(1, 0.65, 0.28, 0.9) +emission_enabled = true +emission = Color(1, 0.38, 0.1, 1) +emission_energy_multiplier = 0.42 + +[sub_resource type="BoxMesh" id="Mesh_post"] +material = SubResource("Material_timber") +size = Vector3(0.18, 2.05, 0.18) + +[sub_resource type="BoxMesh" id="Mesh_beam"] +material = SubResource("Material_timber") +size = Vector3(2.75, 0.16, 0.18) + +[sub_resource type="BoxMesh" id="Mesh_roof"] +material = SubResource("Material_roof") +size = Vector3(1.65, 0.13, 2.55) + +[sub_resource type="CylinderMesh" id="Mesh_bowl"] +material = SubResource("Material_bowl") +top_radius = 0.34 +bottom_radius = 0.27 +height = 0.13 +radial_segments = 12 + +[sub_resource type="BoxMesh" id="Mesh_straw"] +material = SubResource("Material_straw") +size = Vector3(1.65, 0.09, 1.15) + +[sub_resource type="SphereMesh" id="Mesh_glow"] +material = SubResource("Material_glow") +radius = 0.08 +height = 0.13 +radial_segments = 8 +rings = 4 + +[node name="ZoraShelter" type="Node3D"] + +[node name="Shelter" type="Node3D" parent="."] + +[node name="PostFrontLeft" type="MeshInstance3D" parent="Shelter"] +position = Vector3(-1.18, 1.02, 2.2) +mesh = SubResource("Mesh_post") + +[node name="PostFrontRight" type="MeshInstance3D" parent="Shelter"] +position = Vector3(1.18, 1.02, 2.2) +mesh = SubResource("Mesh_post") + +[node name="PostBackLeft" type="MeshInstance3D" parent="Shelter"] +position = Vector3(-1.18, 1.02, 0.25) +mesh = SubResource("Mesh_post") + +[node name="PostBackRight" type="MeshInstance3D" parent="Shelter"] +position = Vector3(1.18, 1.02, 0.25) +mesh = SubResource("Mesh_post") + +[node name="FrontBeam" type="MeshInstance3D" parent="Shelter"] +position = Vector3(0, 2.02, 2.2) +mesh = SubResource("Mesh_beam") + +[node name="BackBeam" type="MeshInstance3D" parent="Shelter"] +position = Vector3(0, 2.02, 0.25) +mesh = SubResource("Mesh_beam") + +[node name="RoofLeft" type="MeshInstance3D" parent="Shelter"] +position = Vector3(-0.72, 2.31, 1.22) +rotation_degrees = Vector3(0, 0, -19) +mesh = SubResource("Mesh_roof") + +[node name="RoofRight" type="MeshInstance3D" parent="Shelter"] +position = Vector3(0.72, 2.31, 1.22) +rotation_degrees = Vector3(0, 0, 19) +mesh = SubResource("Mesh_roof") + +[node name="StrawBed" type="MeshInstance3D" parent="Shelter"] +position = Vector3(0, 0.05, 1.15) +mesh = SubResource("Mesh_straw") + +[node name="FeedBowl" type="MeshInstance3D" parent="Shelter"] +position = Vector3(-0.72, 0.08, 2.52) +mesh = SubResource("Mesh_bowl") + +[node name="Lantern" type="MeshInstance3D" parent="Shelter"] +position = Vector3(0, 1.87, 2.1) +mesh = SubResource("Mesh_glow") + +[node name="ShelterSite" type="Marker3D" parent="."] +position = Vector3(0, 0, 1.5) +script = ExtResource("1_site") +site_id = &"zora_shelter" +display_name = "Zora's shelter" +species_id = &"goat" +resident_animal_id = &"goat_zora" + +[node name="DebugLabel" type="Label3D" parent="ShelterSite"] +position = Vector3(0, 2.45, 0) +billboard = 1 +no_depth_test = true +font_size = 20 +outline_size = 5 +modulate = Color(1, 0.72, 0.5, 1) +pixel_size = 0.006 diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index 287393a..7efc063 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=61 format=3] +[gd_scene load_steps=62 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"] @@ -29,6 +29,7 @@ [ext_resource type="PackedScene" path="res://world/jajce/ForestEdgeResourceCluster.tscn" id="27_forest_cluster"] [ext_resource type="PackedScene" path="res://world/animals/goat/cozy_goat.tscn" id="28_goat"] [ext_resource type="PackedScene" path="res://world/animals/goat/dunja_habitat.tscn" id="29_habitat"] +[ext_resource type="PackedScene" path="res://world/animals/goat/zora_shelter.tscn" id="30_zora_shelter"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] _shader_parameters = { @@ -466,11 +467,24 @@ phase = 4.4 position = Vector3(0, 0, -15.5) rotation_degrees = Vector3(0, 150, 0) +[node name="Zora" parent="WorldObjects/Animals" instance=ExtResource("28_goat")] +position = Vector3(-8, 0, -15.5) +rotation_degrees = Vector3(0, 210, 0) +scale = Vector3(0.92, 0.92, 0.92) +animal_id = &"goat_zora" +display_name = "Zora" +initial_hunger = 42.0 +initial_routine_site_id = &"zora_shelter" +initial_next_routine_tick = 12 + [node name="AnimalHabitats" type="Node3D" parent="WorldObjects"] [node name="DunjaHabitat" parent="WorldObjects/AnimalHabitats" instance=ExtResource("29_habitat")] position = Vector3(0, 0, -17) +[node name="ZoraShelter" parent="WorldObjects/AnimalHabitats" instance=ExtResource("30_zora_shelter")] +position = Vector3(-8, 0, -17) + [node name="ResourceNodes" type="Node3D" parent="WorldObjects"] [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]