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
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
+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
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
+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
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:
+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,
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
+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
`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
+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]:
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
+3 -3
View File
@@ -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(
+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"):
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
+2
View File
@@ -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"
@@ -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"
+3 -3
View File
@@ -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,
+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(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())
+2 -2
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -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)
+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="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")]