feat: add paired goat care

This commit is contained in:
Rijad Zuzo
2026-07-30 23:03:15 +02:00
parent 5511c295a5
commit d950c3da68
19 changed files with 541 additions and 61 deletions
+10 -5
View File
@@ -811,15 +811,20 @@ Completed:
exact mid-route state through schema-v11 restore and presentation exact mid-route state through schema-v11 restore and presentation
unload/reload. The animal feature assets now live together under unload/reload. The animal feature assets now live together under
`world/animals/goat/`, while ignored docs no longer create import sidecars. `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: Next:
1. Add one second named goat with its own stable state, authored shelter 1. Playtest the named pair and record one concrete gameplay or scaling need
context, and deterministic use of the shared pasture. Prove nearest before extending the animal model.
feed-target selection and independent reservations when both animals are
hungry.
2. Keep breeding, products, herd scheduling, animal spatial indexing, and 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 Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+19 -7
View File
@@ -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 scene retains that same travel state, arrival is exact, and hunger stops
movement before feeding. movement before feeding.
The immediate next slice should add one second named goat with its own stable The first multi-animal care slice is complete. Zora adds a second stable record,
record and authored shelter context while sharing the existing pasture. loaded presentation, and private shelter while sharing Dunja's pasture. When
Selection and reservation tests must prove that simultaneous hunger still both goats are hungry, loaded target resolution chooses the nearest available
chooses the nearest real animal and mutates only the exact target. Keep interaction point, two villagers can retain independent exact claims, and
loaded-animal discovery linear and defer herd scheduling, breeding, products, feeding Zora leaves Dunja's hunger, feed history, and reservation untouched.
animal indexing, and active/abstract simulation LOD until two identities Schema-v11 restore preserves both claim pairings and checksum. Loaded routine
demonstrate a concrete need. 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: 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 - `Jajce Dunja Habitat 08`: the first deterministic animal routine alternates
shelter and pasture through real loaded navigation, preserves exact shelter and pasture through real loaded navigation, preserves exact
mid-route state across save/restore and presentation unload/reload, stops mid-route state across save/restore and presentation unload/reload, stops
+8 -4
View File
@@ -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 the transient response. Dunja now also alternates between a stable shelter and
pasture through real loaded navigation. World-schema v11 preserves exact pasture through real loaded navigation. World-schema v11 preserves exact
mid-route position, destination, and schedule through restore or presentation mid-route position, destination, and schedule through restore or presentation
unload/reload without consuming decision RNG. The next bounded animal slice unload/reload without consuming decision RNG. Zora now proves the bounded
should add one second named goat and prove independent nearest-target care multi-animal contract: her independent stable record and private shelter share
before any herd abstraction. Do not yet generalize animal discovery or Dunja's pasture, nearest loaded care targeting supports two simultaneous exact
introduce active/abstract simulation LOD. 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: The remaining simulation-garden target still aims for:
+4 -4
View File
@@ -37,10 +37,10 @@ both require one stored wood without duplicating that rule.
farmer profession and declares an exact one-food completion cost. Selection, farmer profession and declares an exact one-food completion cost. Selection,
animal target reservation, and completion remain separate; NPC and player animal target reservation, and completion remain separate; NPC and player
completion both use `AnimalCareSystem`'s same pantry-to-animal transaction. completion both use `AnimalCareSystem`'s same pantry-to-animal transaction.
`SimulationIds` also names Dunja's shelter and pasture anchors. These IDs are `SimulationIds` names Dunja and Zora, their private shelter anchors, and the
saved routine vocabulary; their loaded positions come from shared pasture anchor. These IDs are saved routine vocabulary; their loaded
`AnimalRoutineSite` rather than entering an action definition or resource positions and private/shared ownership come from `AnimalRoutineSite` rather
registry. than entering an action definition or resource registry.
## Current profession contract ## Current profession contract
+17 -7
View File
@@ -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 from `village_pantry`, reduce that animal's hunger, and append one exact
`animal_fed` fact. Failed withdrawal changes neither animal nor history. `animal_fed` fact. Failed withdrawal changes neither animal nor history.
The first routine consumer alternates Dunja between stable-ID shelter and The first routine consumers alternate Dunja and Zora between stable-ID private
pasture markers without drawing RNG. `ActiveWorldAdapter` supplies the loaded shelters and one shared pasture without drawing RNG. `AnimalRoutineSite`
marker positions, the selected ID and exact position enter state, and declares optional loaded-world resident ownership: an empty resident ID is
`AnimalNode` follows a real `NavigationServer3D` path while reporting factual shared species context, while a named resident makes a shelter private.
position changes. A mid-route save restores the same position and target; `ActiveWorldAdapter` filters those loaded markers for each stable animal; site
unloading the scene node pauses presentation only, and reloading it resumes ownership is not persistent state and does not require a schema bump. The
from those facts. Hunger cancels travel before care targeting. 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 persistent hungry/content presentation derives from authoritative hunger.
The short feed response is transient and is reset rather than serialized or The short feed response is transient and is reset rather than serialized or
+2 -2
View File
@@ -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]: if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]:
return return
var helper := get_active_opportunity_helper() var helper := get_active_opportunity_helper()
var animal_feed_name := animal_care.get_available_feed_name(npc.id) var animal_feed_available := animal_care.has_available_feed_target(npc.id)
var selection := action_selector.select_action( 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: if selection == null:
return return
+3 -3
View File
@@ -24,7 +24,7 @@ func select_action(
all_npcs: Array = [], all_npcs: Array = [],
opportunity_helper: OpportunityHelperResult = null, opportunity_helper: OpportunityHelperResult = null,
population_view: SimulationPopulationView = null, population_view: SimulationPopulationView = null,
animal_feed_name: String = "" animal_feed_available: bool = false
) -> ActionSelectionResult: ) -> ActionSelectionResult:
if npc.is_dead: if npc.is_dead:
return null return null
@@ -127,13 +127,13 @@ func select_action(
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
) )
if not animal_feed_name.is_empty(): if animal_feed_available:
var feed_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) var feed_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if _get_cost_rejection(feed_definition, village).is_empty(): if _get_cost_rejection(feed_definition, village).is_empty():
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_FEED_ANIMAL, SimulationIds.ACTION_FEED_ANIMAL,
-1.0, -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: if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
+5 -5
View File
@@ -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"): 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( var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
SimulationIds.ACTION_FEED_ANIMAL 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.can_npc_feed()
and animal_state.is_available_for(npc_id) and animal_state.is_available_for(npc_id)
): ):
return animal_state.get_display_name() return true
return "" return false
func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode: 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: if AnimalNode.get_by_id(animal_state.get_animal_id()) == null:
return false return false
var candidates: Array[Dictionary] = active_world_adapter.get_animal_routine_sites( 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(): if candidates.is_empty():
return false return false
+2
View File
@@ -31,9 +31,11 @@ const RESOURCE_FOOD := &"food"
const RESOURCE_WOOD := &"wood" const RESOURCE_WOOD := &"wood"
const ANIMAL_DUNJA := &"goat_dunja" const ANIMAL_DUNJA := &"goat_dunja"
const ANIMAL_ZORA := &"goat_zora"
const SPECIES_GOAT := &"goat" const SPECIES_GOAT := &"goat"
const ANIMAL_SITE_DUNJA_PASTURE := &"dunja_pasture" const ANIMAL_SITE_DUNJA_PASTURE := &"dunja_pasture"
const ANIMAL_SITE_DUNJA_SHELTER := &"dunja_shelter" const ANIMAL_SITE_DUNJA_SHELTER := &"dunja_shelter"
const ANIMAL_SITE_ZORA_SHELTER := &"zora_shelter"
const STORAGE_VILLAGE_PANTRY := &"village_pantry" const STORAGE_VILLAGE_PANTRY := &"village_pantry"
const STORAGE_VILLAGE_WOODPILE := &"village_woodpile" const STORAGE_VILLAGE_WOODPILE := &"village_woodpile"
@@ -5,7 +5,7 @@
[resource] [resource]
script = ExtResource("1") script = ExtResource("1")
action_id = &"feed_animal" action_id = &"feed_animal"
display_name = "Feed Dunja" display_name = "Feed animal"
default_duration = 2.0 default_duration = 2.0
preferred_profession_id = &"farmer" preferred_profession_id = &"farmer"
target_type = &"animal" target_type = &"animal"
+3 -3
View File
@@ -20,10 +20,10 @@ func _run() -> void:
var player := main_scene.get_node("Player") as Node3D var player := main_scene.get_node("Player") as Node3D
var pantry: StorageStateRecord = manager.get_pantry() var pantry: StorageStateRecord = manager.get_pantry()
var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA) var state: AnimalStateRecord = manager.animal_care.get_state(SimulationIds.ANIMAL_DUNJA)
_check(goat != null, "Jajce should load one named goat presentation") _check(goat != null, "Jajce should load Dunja's named presentation")
_check( _check(
AnimalNode.get_all().size() == 1 and ResourceNode.get_all().size() == 18, AnimalNode.get_all().size() == 2 and ResourceNode.get_all().size() == 18,
"The goat should be one AnimalNode without changing the finite-resource count" "Named goats should remain AnimalNodes without changing the finite-resource count"
) )
_check( _check(
not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null, not goat.is_in_group("resource_nodes") and ResourceNode.get_by_id(goat.animal_id) == null,
+280
View File
@@ -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)
@@ -0,0 +1 @@
uid://drh502csu7pj6
+37 -17
View File
@@ -397,29 +397,39 @@ func _run() -> void:
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts") _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts") _check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
var animal_root := world.get_node("WorldObjects/Animals") var 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( _check(
animal_root.get_child_count() == 1 and goat != null, animal_root.get_child_count() == 2 and dunja != null and zora != null,
"Jajce should contain one bounded loaded-animal presentation" "Jajce should contain the bounded pair of loaded-animal presentations"
) )
_check( _check(
( (
goat.animal_id == SimulationIds.ANIMAL_DUNJA dunja.animal_id == SimulationIds.ANIMAL_DUNJA
and goat.species_id == SimulationIds.SPECIES_GOAT and dunja.species_id == SimulationIds.SPECIES_GOAT
and goat.display_name == "Dunja" 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( _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( _check(
goat.is_in_group("grass_interactors"), dunja.is_in_group("grass_interactors") and zora.is_in_group("grass_interactors"),
"Dunja should bend camera-local grass through the shared presentation group" "Both goats should bend camera-local grass through the shared presentation group"
) )
var habitat_root := world.get_node("WorldObjects/AnimalHabitats") 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] = [] var routine_sites: Array[AnimalRoutineSite] = []
for site in AnimalRoutineSite.get_all(): for site in AnimalRoutineSite.get_all():
routine_sites.append(site) routine_sites.append(site)
@@ -428,16 +438,25 @@ func _run() -> void:
return String(first.site_id) < String(second.site_id) return String(first.site_id) < String(second.site_id)
) )
_check( _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( _check(
( (
routine_sites.size() == 2 routine_sites.size() == 3
and routine_sites[0].site_id == SimulationIds.ANIMAL_SITE_DUNJA_PASTURE 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[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") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := world.get_node("WorldObjects/StorageSites/VillagePantry") as StorageNode
@@ -491,7 +510,8 @@ func _run() -> void:
var destinations: Array[Vector3] = [] var destinations: Array[Vector3] = []
for resource in resources: for resource in resources:
destinations.append((resource as ResourceNode).interaction_point.global_position) destinations.append((resource as ResourceNode).interaction_point.global_position)
destinations.append(goat.get_interaction_position()) destinations.append(dunja.get_interaction_position())
destinations.append(zora.get_interaction_position())
for site in routine_sites: for site in routine_sites:
destinations.append(site.get_routine_position()) destinations.append(site.get_routine_position())
destinations.append(pantry.get_interaction_position()) destinations.append(pantry.get_interaction_position())
+2 -2
View File
@@ -81,10 +81,10 @@ func get_animal_candidates(action_id: StringName) -> Array[Dictionary]:
return candidates 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] = [] var candidates: Array[Dictionary] = []
for site in AnimalRoutineSite.get_all(): for site in AnimalRoutineSite.get_all():
if not site.supports_species(species_id): if not site.supports_animal(animal_id, species_id):
continue continue
( (
candidates candidates
+8
View File
@@ -7,6 +7,7 @@ static var _all: Array[AnimalRoutineSite] = []
@export var site_id: StringName @export var site_id: StringName
@export var display_name := "Animal site" @export var display_name := "Animal site"
@export var species_id: StringName = SimulationIds.SPECIES_GOAT @export var species_id: StringName = SimulationIds.SPECIES_GOAT
@export var resident_animal_id: StringName
@export var debug_label_enabled := false @export var debug_label_enabled := false
@@ -48,6 +49,13 @@ func supports_species(candidate_species_id: StringName) -> bool:
return candidate_species_id == species_id 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: func get_routine_position() -> Vector3:
return global_position return global_position
+1
View File
@@ -137,6 +137,7 @@ script = ExtResource("1_site")
site_id = &"dunja_shelter" site_id = &"dunja_shelter"
display_name = "Warm shelter" display_name = "Warm shelter"
species_id = &"goat" species_id = &"goat"
resident_animal_id = &"goat_dunja"
[node name="DebugLabel" type="Label3D" parent="ShelterSite"] [node name="DebugLabel" type="Label3D" parent="ShelterSite"]
position = Vector3(0, 2.45, 0) position = Vector3(0, 2.45, 0)
+123
View File
@@ -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
+15 -1
View File
@@ -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="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -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/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/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/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"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
@@ -466,11 +467,24 @@ phase = 4.4
position = Vector3(0, 0, -15.5) position = Vector3(0, 0, -15.5)
rotation_degrees = Vector3(0, 150, 0) 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="AnimalHabitats" type="Node3D" parent="WorldObjects"]
[node name="DunjaHabitat" parent="WorldObjects/AnimalHabitats" instance=ExtResource("29_habitat")] [node name="DunjaHabitat" parent="WorldObjects/AnimalHabitats" instance=ExtResource("29_habitat")]
position = Vector3(0, 0, -17) 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="ResourceNodes" type="Node3D" parent="WorldObjects"]
[node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")] [node name="BerryBush_01" parent="WorldObjects/ResourceNodes" instance=ExtResource("2_resource")]