class_name AnimalCareSystem extends RefCounted signal economic_event_requested( event_type: StringName, actor_id: int, source_id: StringName, destination_id: StringName, item_id: StringName, amount: float, world_position: Vector3 ) signal narrative_event_requested( event_type: StringName, actor_id: int, source_id: StringName, action_display: String, action_id: StringName, item_id: StringName, required_amount: float ) signal routine_started(animal_id: StringName, site_id: StringName, target_position: Vector3) signal routine_completed(animal_id: StringName, site_id: StringName, position: Vector3) const ROUTINE_STAY_TICKS := 40 const ROUTINE_RETRY_TICKS := 10 var states: Dictionary = {} var economy: RefCounted var active_world_adapter: Node var current_tick := 0 var _animal_catalog: AnimalCatalog func configure(economy_service: RefCounted, world_adapter: Node) -> void: economy = economy_service active_world_adapter = world_adapter func configure_animal_catalog(catalog: AnimalCatalog) -> bool: if catalog == null or not catalog.rebuild().is_empty(): return false _animal_catalog = catalog return true func get_animal_catalog() -> AnimalCatalog: if _animal_catalog == null: _animal_catalog = AnimalCatalog.create_core() return _animal_catalog func advance(simulation_tick: int) -> void: current_tick = simulation_tick var animal_ids: Array = states.keys() animal_ids.sort() for animal_id in animal_ids: var animal_state := states[animal_id] as AnimalStateRecord if animal_state == null: continue animal_state.advance_tick() if animal_state.has_active_travel_target() and animal_state.needs_feed(): animal_state.cancel_travel(current_tick + ROUTINE_RETRY_TICKS) elif animal_state.is_routine_due(current_tick): _begin_due_routine(animal_state) func register_loaded_nodes() -> void: for node in AnimalNode.get_all(): register_node(node) func register_node(node: AnimalNode) -> bool: if node == null or node.animal_id.is_empty(): return false if ( ResourceNode.get_by_id(node.animal_id) != null or StorageNode.get_by_id(node.animal_id) != null or ActivitySite.get_by_id(node.animal_id) != null ): push_error("AnimalCareSystem: Animal ID '%s' collides with a world target" % node.animal_id) return false var action_definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) var animal_catalog := get_animal_catalog() var animal_definition := ( animal_catalog.get_definition(node.animal_definition_id) if animal_catalog != null else null ) if ( action_definition == null or action_definition.target_type != SimulationIds.TARGET_ANIMAL or animal_definition == null or animal_definition.behavior_id != AnimalDefinition.BEHAVIOR_GRAZER_ROUTINE or animal_definition.species_id != node.species_id or not animal_definition.supports_action(SimulationIds.ACTION_FEED_ANIMAL) or not node.supports_action(SimulationIds.ACTION_FEED_ANIMAL) ): push_error( ( "AnimalCareSystem: AnimalNode '%s' has invalid definition '%s' or species '%s'" % [node.animal_id, node.animal_definition_id, node.species_id] ) ) return false var animal_state := get_state(node.animal_id) if animal_state == null: animal_state = AnimalStateRecord.create_from_node(node) states[node.animal_id] = animal_state elif not animal_state.apply_definition(node): push_error("AnimalCareSystem: AnimalNode definition mismatch for '%s'" % node.animal_id) return false if not node.position_changed.is_connected(_on_node_position_changed): node.position_changed.connect(_on_node_position_changed) if not node.routine_arrived.is_connected(_on_node_routine_arrived): node.routine_arrived.connect(_on_node_routine_arrived) if not node.navigation_failed.is_connected(_on_node_navigation_failed): node.navigation_failed.connect(_on_node_navigation_failed) return node.bind_state(animal_state) func get_state(animal_id: StringName) -> AnimalStateRecord: return states.get(animal_id) as AnimalStateRecord func reserve(animal_id: StringName, agent_id: int) -> bool: var animal_state := get_state(animal_id) return animal_state != null and animal_state.can_npc_feed() and animal_state.reserve(agent_id) func release(animal_id: StringName, agent_id: int) -> void: var animal_state := get_state(animal_id) if animal_state != null: animal_state.release(agent_id) func can_complete_feed(animal_id: StringName, agent_id: int) -> bool: var animal_state := get_state(animal_id) return ( animal_state != null and animal_state.needs_feed() and animal_state.get_reserved_by() == agent_id ) func 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 var candidates: Array[Dictionary] = active_world_adapter.get_animal_candidates( SimulationIds.ACTION_FEED_ANIMAL ) for candidate in candidates: var animal_state := get_state(StringName(candidate["target_id"])) if ( animal_state != null and animal_state.can_npc_feed() and animal_state.is_available_for(npc_id) ): return true return false func find_node_for_player(from_position: Vector3, max_distance: float) -> AnimalNode: if ( active_world_adapter == null or not active_world_adapter.has_method("get_animal_nodes_in_radius") ): return null var candidates: Array[AnimalNode] = active_world_adapter.get_animal_nodes_in_radius( from_position, max_distance ) for node in candidates: var animal_state := get_state(node.animal_id) if ( animal_state != null and animal_state.can_player_feed_animal() and animal_state.needs_feed() ): return node return null func feed(animal_id: StringName, actor_id: int, npcs: Array[SimNPC], current_tick: int) -> bool: var animal_state := get_state(animal_id) if animal_state == null or not animal_state.needs_feed() or current_tick < 0: return false var actor_npc: SimNPC if actor_id >= 0: actor_npc = _find_npc(actor_id, npcs) if ( actor_npc == null or not animal_state.can_npc_feed() or animal_state.get_reserved_by() != actor_id ): return false elif not animal_state.can_player_feed_animal(): return false var definition := SimulationDefinitions.get_action(SimulationIds.ACTION_FEED_ANIMAL) if definition == null or not definition.has_completion_cost() or economy == null: return false var resource_id := definition.completion_cost_resource_id var required_amount := definition.completion_cost_amount var pantry: StorageStateRecord = economy.get_storage_for_resource(resource_id) var 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 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): 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) else: animal_state.schedule_routine_at(current_tick + ROUTINE_STAY_TICKS) economic_event_requested.emit( SimulationIds.EVENT_ANIMAL_FED, actor_id, source_id, animal_state.get_animal_id(), resource_id, consumed, animal_state.get_position() ) 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() for animal_id in animal_ids: var animal_state := states[animal_id] as AnimalStateRecord record.animals.append(animal_state) func restore_state_records(records: Array[AnimalStateRecord], restored_tick: int) -> void: current_tick = restored_tick states.clear() for animal_record in records: states[animal_record.get_animal_id()] = animal_record register_loaded_nodes() func _begin_due_routine(animal_state: AnimalStateRecord) -> bool: if ( active_world_adapter == null or not active_world_adapter.has_method("get_animal_routine_sites") ): return false 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_animal_id() ) if candidates.is_empty(): return false var current_site_id := animal_state.get_routine_site_id() for candidate in candidates: var candidate_id := StringName(candidate["site_id"]) if candidate_id == current_site_id: continue var target_position: Vector3 = candidate["position"] if not animal_state.begin_travel(candidate_id, target_position): return false routine_started.emit(animal_state.get_animal_id(), candidate_id, target_position) return true return false func _on_node_position_changed(animal_id: StringName, active_position: Vector3) -> void: var animal_state := get_state(animal_id) if animal_state != null and animal_state.has_active_travel_target(): animal_state.set_position(active_position) func _on_node_routine_arrived( animal_id: StringName, site_id: StringName, arrival_position: Vector3 ) -> void: var animal_state := get_state(animal_id) if ( animal_state == null or not animal_state.has_active_travel_target() or animal_state.get_travel_target_site_id() != site_id ): return if animal_state.complete_travel(arrival_position, current_tick + ROUTINE_STAY_TICKS): routine_completed.emit(animal_id, site_id, arrival_position) func _on_node_navigation_failed(animal_id: StringName, site_id: StringName) -> void: var animal_state := get_state(animal_id) if ( animal_state != null and animal_state.has_active_travel_target() and animal_state.get_travel_target_site_id() == site_id ): animal_state.cancel_travel(current_tick + ROUTINE_RETRY_TICKS) static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC: for npc in npcs: if npc.id == npc_id: return npc return null