feat: add physical animal care delivery

This commit is contained in:
Rijad Zuzo
2026-07-31 00:19:41 +02:00
parent 7d410d8193
commit ead75fca68
25 changed files with 1469 additions and 191 deletions
+3 -2
View File
@@ -101,10 +101,11 @@ func set_task(action_id: StringName, duration: float = -1.0) -> void:
has_travel_target = false
func start_working() -> void:
func start_working(preserve_progress: bool = false) -> void:
if task_state != TASK_STATE_TRAVELING:
return
task_state = TASK_STATE_WORKING
task_progress = 0.0
if not preserve_progress:
task_progress = 0.0
task_complete = false
+55 -73
View File
@@ -3,6 +3,7 @@ extends Node
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_system.gd")
const NpcTickDebugLog := preload("res://simulation/debug/npc_tick_debug_log.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
@@ -187,6 +188,13 @@ func _simulate_npc_tick(npc: SimNPC) -> bool:
var was_complete := npc.task_complete
var was_dead := npc.is_dead
if animal_care.requires_working_feed_recovery(npc):
npc.task_state = SimNPC.TASK_STATE_TRAVELING
npc.task_complete = false
if not _continue_animal_feed_delivery(npc):
_redirect_npc_to_wander(npc)
return false
action_executor.advance_npc(npc, village)
_population_view.refresh_npc(npc)
_select_action_if_idle(npc, previous_state)
@@ -205,7 +213,8 @@ func _simulate_npc_tick(npc: SimNPC) -> bool:
_complete_current_action(npc)
_population_view.refresh_npc(npc)
_debug_npc_tick(npc, previous_state)
if debug_logs:
NpcTickDebugLog.print_tick(npc, previous_state)
return completed
@@ -333,45 +342,6 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
release_npc_reservation(npc.id)
func _debug_npc_tick(npc: SimNPC, previous_state: StringName) -> void:
if not debug_logs:
return
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if previous_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
previous_state,
" -> ",
npc.task_state
)
func release_npc_reservation(npc_id: int) -> void:
for npc in npcs:
if npc.id == npc_id:
@@ -406,13 +376,7 @@ func notify_npc_arrived(npc_id: int) -> void:
or resource_state.get_reserved_by() != npc.id
):
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but target ",
npc.target_id,
" is unavailable, replanning"
)
NpcTickDebugLog.print_unavailable_resource(npc)
notify_npc_navigation_failed(npc.id)
return
if (
@@ -422,18 +386,19 @@ func notify_npc_arrived(npc_id: int) -> void:
):
if not animal_care.can_complete_feed(npc.target_id, npc.id):
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but animal ",
npc.target_id,
" no longer needs feeding, replanning"
)
NpcTickDebugLog.print_unavailable_animal(npc)
notify_npc_navigation_failed(npc.id)
return
if animal_care.requires_feed_pickup(npc):
if _continue_animal_feed_delivery(npc):
return
_redirect_npc_to_wander(npc)
return
npc.has_travel_target = false
npc.start_working()
npc.start_working(
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
)
var working_speakers: Array[SimNPC] = []
if npc.target_id != &"":
@@ -455,15 +420,42 @@ func notify_npc_arrived(npc_id: int) -> void:
break
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived and started working on: ",
npc.current_task
)
NpcTickDebugLog.print_started_work(npc)
return
func _continue_animal_feed_delivery(npc: SimNPC) -> bool:
var animal_target := target_resolver.resolve_claimed_animal(npc, self, active_world_adapter)
var pantry_target := target_resolver.resolve_storage_target(
SimulationIds.STORAGE_VILLAGE_PANTRY, active_world_adapter
)
if animal_target.is_empty() or pantry_target.is_empty():
return false
if not npc.travel_target_position.is_equal_approx(pantry_target["position"]):
npc.travel_target_position = pantry_target["position"]
npc.has_travel_target = true
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
var pickup_target := npc.travel_target_position
npc.travel_target_position = animal_target["position"]
npc.has_travel_target = true
if not animal_care.collect_feed_supply(npc):
npc.travel_target_position = pickup_target
return false
village_changed.emit(village)
npc_travel_requested.emit(npc, npc.travel_target_position)
return true
func _redirect_npc_to_wander(npc: SimNPC) -> void:
var failed_task := npc.current_task
release_npc_reservation(npc.id)
npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
func notify_npc_navigation_failed(npc_id: int) -> void:
for npc in npcs:
if npc.id != npc_id:
@@ -472,20 +464,10 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
return
var failed_task := npc.current_task
release_npc_reservation(npc.id)
npc.last_task = failed_task
npc.set_task(SimulationIds.ACTION_WANDER, 2.0)
npc_task_changed.emit(npc, failed_task, npc.current_task)
npc_target_requested.emit(npc)
_redirect_npc_to_wander(npc)
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" could not navigate for ",
failed_task,
" and is trying a wander target"
)
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
return
@@ -227,9 +227,48 @@ func _resolve_animal(
var target_id := StringName(best["target_id"])
if not simulation_manager.animal_care.reserve(target_id, npc.id):
return {}
if simulation_manager.animal_care.requires_feed_pickup(npc):
var pantry_target := resolve_storage_target(
SimulationIds.STORAGE_VILLAGE_PANTRY, active_world_adapter
)
if pantry_target.is_empty():
simulation_manager.animal_care.release(target_id, npc.id)
return {}
best["position"] = pantry_target["position"]
return best
func resolve_storage_target(storage_id: StringName, active_world_adapter: Node) -> Dictionary:
if active_world_adapter == null or not active_world_adapter.has_method("get_storage_target"):
return {}
return active_world_adapter.get_storage_target(storage_id)
func resolve_claimed_animal(
npc: SimNPC, simulation_manager: Node, active_world_adapter: Node
) -> Dictionary:
if (
npc == null
or npc.target_id.is_empty()
or not active_world_adapter.has_method("get_animal_candidates")
):
return {}
var animal_state: AnimalStateRecord = simulation_manager.animal_care.get_state(npc.target_id)
if (
animal_state == null
or not animal_state.needs_feed()
or animal_state.get_reserved_by() != npc.id
):
return {}
var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates(
SimulationIds.ACTION_FEED_ANIMAL
)
for candidate in candidates:
if StringName(candidate["target_id"]) == npc.target_id:
return candidate
return {}
func score_resource_candidate(
npc: SimNPC, origin: Vector3, position: Vector3, state: ResourceStateRecord
) -> float:
+107 -31
View File
@@ -119,6 +119,61 @@ func can_complete_feed(animal_id: StringName, agent_id: int) -> bool:
)
func requires_feed_pickup(npc: SimNPC) -> bool:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
return (
npc != null
and definition != null
and definition.has_completion_cost()
and (
npc.get_inventory_amount(definition.completion_cost_resource_id)
< definition.completion_cost_amount
)
)
func requires_working_feed_recovery(npc: SimNPC) -> bool:
return (
npc != null
and npc.current_task == SimulationIds.ACTION_FEED_ANIMAL
and npc.task_state == SimNPC.TASK_STATE_WORKING
and requires_feed_pickup(npc)
and can_complete_feed(npc.target_id, npc.id)
)
func collect_feed_supply(npc: SimNPC) -> bool:
var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL)
if (
npc == null
or npc.current_task != SimulationIds.ACTION_FEED_ANIMAL
or npc.target_id.is_empty()
or not can_complete_feed(npc.target_id, npc.id)
or definition == null
or not definition.has_completion_cost()
or economy == null
):
return false
var resource_id := definition.completion_cost_resource_id
var required_amount := definition.completion_cost_amount
var carried_amount := npc.get_inventory_amount(resource_id)
var pickup_amount := maxf(required_amount - carried_amount, 0.0)
if pickup_amount <= 0.0:
return true
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
if pantry == null or available < pickup_amount:
_emit_feed_blocked(
npc.id, pantry.get_storage_id() if pantry != null else &"", definition, available
)
return false
var withdrawn: float = economy.withdraw_exact_to_inventory(npc, resource_id, pickup_amount)
if not is_equal_approx(withdrawn, pickup_amount):
_emit_feed_blocked(npc.id, pantry.get_storage_id(), definition, available)
return false
return true
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 false
@@ -158,11 +213,13 @@ func find_node_for_player(from_position: Vector3, max_distance: float) -> Animal
func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool:
var animal_state := get_state(animal_id)
if animal_state == null or not animal_state.needs_feed():
if animal_state == null or not animal_state.needs_feed() or current_tick < 0:
return false
var actor_npc: SimNPC
if actor_id >= 0:
actor_npc = _find_npc(actor_id, npcs)
if (
_find_npc(actor_id, npcs) == null
actor_npc == null
or not animal_state.can_npc_feed()
or animal_state.get_reserved_by() != actor_id
):
@@ -176,37 +233,29 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
var resource_id := definition.completion_cost_resource_id
var required_amount := definition.completion_cost_amount
var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id)
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
if pantry == null or available < required_amount:
if actor_id >= 0:
(
narrative_event_requested
. emit(
SimulationIds.EVENT_TASK_BLOCKED,
actor_id,
pantry.get_storage_id() if pantry != null else &"",
(
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
required_amount,
String(resource_id).capitalize(),
available,
]
),
definition.action_id,
resource_id,
required_amount
)
var source_id := SimulationIds.STORAGE_VILLAGE_PANTRY
var consumed := 0.0
if actor_npc != null:
var carried_amount := actor_npc.get_inventory_amount(resource_id)
if carried_amount < required_amount:
_emit_feed_blocked(
actor_id, SimulationIds.npc_inventory_id(actor_id), definition, carried_amount
)
return false
var consumed: float = economy.withdraw_resource(resource_id, required_amount)
if consumed < required_amount:
economy.deposit_resource(resource_id, consumed)
return false
consumed = economy.remove_exact_from_inventory(actor_npc, resource_id, required_amount)
source_id = SimulationIds.npc_inventory_id(actor_id)
else:
var available := pantry.get_amount(resource_id) if pantry != null else 0.0
if pantry == null or available < required_amount:
return false
consumed = economy.withdraw_resource(resource_id, required_amount)
if not is_equal_approx(consumed, required_amount):
return false
if not animal_state.feed(actor_id, current_tick):
economy.deposit_resource(resource_id, consumed)
if actor_npc != null:
economy.restore_to_inventory(actor_npc, resource_id, consumed)
else:
economy.deposit_resource(resource_id, consumed)
return false
if animal_state.has_active_travel_target():
animal_state.cancel_travel(current_tick + ROUTINE_STAY_TICKS)
@@ -216,7 +265,7 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
economic_event_requested.emit(
SimulationIds.EVENT_ANIMAL_FED,
actor_id,
pantry.get_storage_id(),
source_id,
animal_state.get_animal_id(),
resource_id,
consumed,
@@ -225,6 +274,33 @@ func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tic
return true
func _emit_feed_blocked(
actor_id: int, source_id: StringName, definition: ActionDefinition, available_amount: float
) -> void:
if actor_id < 0:
return
(
narrative_event_requested
. emit(
SimulationIds.EVENT_TASK_BLOCKED,
actor_id,
source_id,
(
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(),
available_amount,
]
),
definition.action_id,
definition.completion_cost_resource_id,
definition.completion_cost_amount
)
)
func append_state_records(record: SimulationStateRecord) -> void:
var animal_ids: Array = states.keys()
animal_ids.sort()
+74
View File
@@ -0,0 +1,74 @@
extends RefCounted
static func print_tick(npc: SimNPC, previous_state: StringName) -> void:
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if previous_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
previous_state,
" -> ",
npc.task_state
)
static func print_unavailable_resource(npc: SimNPC) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but target ",
npc.target_id,
" is unavailable, replanning"
)
static func print_unavailable_animal(npc: SimNPC) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" arrived but animal ",
npc.target_id,
" no longer needs feeding, replanning"
)
static func print_started_work(npc: SimNPC) -> void:
print(
"[SimulationManager] ", npc.npc_name, " arrived and started working on: ", npc.current_task
)
static func print_navigation_failure(npc: SimNPC, failed_task: StringName) -> void:
print(
"[SimulationManager] ",
npc.npc_name,
" could not navigate for ",
failed_task,
" and is trying a wander target"
)
@@ -0,0 +1 @@
uid://d3nnthqd1b7d3
+32
View File
@@ -141,6 +141,38 @@ func withdraw_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> f
return withdrawn
func withdraw_exact_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> float:
if npc == null or not is_finite(amount) or amount <= 0.0:
return 0.0
var storage := get_storage_for_resource(item_id)
if storage == null or storage.get_amount(item_id) < amount:
return 0.0
return withdraw_to_inventory(npc, item_id, amount)
func remove_exact_from_inventory(npc: SimNPC, item_id: StringName, amount: float) -> float:
if (
npc == null
or not is_finite(amount)
or amount <= 0.0
or npc.get_inventory_amount(item_id) < amount
):
return 0.0
var removed := npc.remove_inventory(item_id, amount)
if not is_equal_approx(removed, amount):
npc.add_inventory(item_id, removed)
return 0.0
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
return removed
func restore_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> void:
if npc == null or not is_finite(amount) or amount <= 0.0:
return
npc.add_inventory(item_id, amount)
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
func consume_npc_food(npc: SimNPC) -> bool:
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
npc.hunger = minf(npc.hunger + 5.0, 100.0)
+18 -1
View File
@@ -98,6 +98,21 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
return null
if not record_data["inventory"] is Dictionary:
return null
var normalized_inventory := {}
for raw_item_id in record_data["inventory"]:
var item_id := str(raw_item_id)
var raw_amount = record_data["inventory"][raw_item_id]
if typeof(raw_amount) not in [TYPE_INT, TYPE_FLOAT]:
return null
var amount := float(raw_amount)
if (
item_id.is_empty()
or normalized_inventory.has(item_id)
or not is_finite(amount)
or amount < 0.0
):
return null
normalized_inventory[item_id] = amount
var profession_id := StringName(record_data["profession"])
if SimulationDefinitions.get_profession(profession_id) == null:
return null
@@ -110,7 +125,9 @@ static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
var last_action_id := StringName(record_data["last_task"])
if not last_action_id.is_empty() and SimulationDefinitions.get_action(last_action_id) == null:
return null
return NPCStateRecord.new(record_data)
var normalized := record_data.duplicate(true)
normalized["inventory"] = normalized_inventory
return NPCStateRecord.new(normalized)
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
+9 -1
View File
@@ -473,11 +473,19 @@ static func _is_valid_animal_feed_event(
var animal := (
animal_records_by_id.get(StringName(event.data["destination_id"])) as AnimalStateRecord
)
var source_id := StringName(event.data["source_id"])
var has_valid_source := source_id == SimulationIds.STORAGE_VILLAGE_PANTRY
if actor_id >= 0:
# Existing v11 saves contain direct pantry-to-animal NPC feed facts.
# New physical deliveries name the matching carried inventory instead.
has_valid_source = (
has_valid_source or source_id == SimulationIds.npc_inventory_id(actor_id)
)
return (
(actor_id == -1 or npc_ids.has(actor_id))
and int(event.data["tick"]) <= current_tick
and animal != null
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY
and has_valid_source
and storage_records_by_id.has(SimulationIds.STORAGE_VILLAGE_PANTRY)
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and definition != null