class_name ActionSelectionSystem extends RefCounted enum SchedulePeriod { WORK, SLEEP, MEAL, DISCRETIONARY } const SLEEP_BEGIN := 0.88 const SLEEP_END := 0.15 const BREAKFAST_BEGIN := 0.25 const BREAKFAST_END := 0.35 const DINNER_BEGIN := 0.7 const DINNER_END := 0.8 const WORK_BEGIN := 0.28 const WORK_END := 0.85 const UNAVAILABLE_ACTION_SCORE := -1000000.0 const RELATIONSHIP_AID_PANTRY_THRESHOLD := 25.0 var relationship_system: RefCounted func select_action( npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = [], opportunity_helper: OpportunityHelperResult = null, population_view: SimulationPopulationView = null ) -> ActionSelectionResult: if npc.is_dead: return null if npc.is_starving: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food" ) if village.food > 0: return ActionSelectionResult.new( SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food" ) return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Starving; must find food" ) if npc.mourning_ticks > 0: if npc.energy < 40.0: return ActionSelectionResult.new( SimulationIds.ACTION_REST, -1.0, "Mourning; seeking rest" ) return ActionSelectionResult.new( SimulationIds.ACTION_WANDER, -1.0, "Mourning; wandering near home" ) if npc.hunger > 80.0: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( SimulationIds.ACTION_EAT, -1.0, "Critically hungry and carrying food" ) if village.food > 0: return ActionSelectionResult.new( SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Critically hungry; pantry has food" ) return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Critically hungry; must find food" ) var profession_def := SimulationDefinitions.get_profession(npc.profession) var offset: float = profession_def.schedule_offset if profession_def != null else 0.0 var local_time := fmod(time_of_day + offset + 1.0, 1.0) var period := _get_period(local_time) if period == SchedulePeriod.SLEEP: if npc.energy < 60.0: return ActionSelectionResult.new( SimulationIds.ACTION_SLEEP, -1.0, "Night-time sleep; energy is low" ) if npc.hunger > 75.0 and npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory" ) return ActionSelectionResult.new(SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home") if period == SchedulePeriod.MEAL: if npc.hunger > 50.0: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( SimulationIds.ACTION_EAT, -1.0, "Meal-time hunger; eating from inventory" ) if village.food > 0: return ActionSelectionResult.new( SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Meal-time; pantry has food" ) return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty" ) if npc.hunger > 75.0: if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( SimulationIds.ACTION_EAT, -1.0, "Hungry and carrying food" ) if village.food > 0: return ActionSelectionResult.new( SimulationIds.ACTION_WITHDRAW_FOOD, -1.0, "Hungry; pantry has food" ) return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Hungry; pantry is empty" ) var is_opportunity_helper := _is_current_opportunity_helper(npc, opportunity_helper) if is_opportunity_helper and npc.energy < 25.0: return ActionSelectionResult.new( SimulationIds.ACTION_REST, -1.0, "Energy is low; resting before helping" ) if is_opportunity_helper: return ActionSelectionResult.new( opportunity_helper.action_id, -1.0, "Responding to village need: %s" % opportunity_helper.reason ) if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) > 0.0: return ActionSelectionResult.new( SimulationIds.ACTION_DEPOSIT_FOOD, -1.0, "Carrying food for storage" ) if npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD) > 0.0: return ActionSelectionResult.new( SimulationIds.ACTION_DEPOSIT_WOOD, -1.0, "Carrying wood for storage" ) if npc.energy < 25.0: return ActionSelectionResult.new( SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" ) if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null: var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject( npc.id, all_npcs, population_view ) if trusted_starving_npc != null: return ActionSelectionResult.new( SimulationIds.ACTION_GATHER_FOOD, -1.0, "Helping %s, a trusted acquaintance who is starving" % trusted_starving_npc.npc_name ) if period == SchedulePeriod.DISCRETIONARY: var roll := npc.random_source.randf() if roll < 0.3: return ActionSelectionResult.new( SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander" ) return _choose_best_work_action(npc, village) func _is_current_opportunity_helper( npc: SimNPC, opportunity_helper: OpportunityHelperResult ) -> bool: return ( opportunity_helper != null and opportunity_helper.helper_npc_id == npc.id and ( opportunity_helper.action_id in [ SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_DEPOSIT_FOOD, SimulationIds.ACTION_DEPOSIT_WOOD, ] ) ) func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult: var scores := { SimulationIds.ACTION_GATHER_FOOD: _calculate_score( npc, SimulationIds.ACTION_GATHER_FOOD, village.food_priority, village.food, 15.0, 30.0, 3.0, 1.5 ), SimulationIds.ACTION_GATHER_WOOD: _calculate_score( npc, SimulationIds.ACTION_GATHER_WOOD, village.wood_priority, village.wood, 10.0, 20.0, 2.5, 1.0 ), SimulationIds.ACTION_PATROL: _calculate_score( npc, SimulationIds.ACTION_PATROL, village.safety_priority, village.safety, 30.0, 50.0, 3.0, 1.5 ), SimulationIds.ACTION_STUDY: _calculate_score( npc, SimulationIds.ACTION_STUDY, village.knowledge_priority, village.knowledge, 10.0, 25.0, 1.5, 0.75 ) } var rejections := {} for action_id in scores: var definition := SimulationDefinitions.get_action(action_id) var rejection := _get_cost_rejection(definition, village) if not rejection.is_empty(): scores[action_id] = UNAVAILABLE_ACTION_SCORE rejections[action_id] = rejection var best_action := SimulationIds.ACTION_GATHER_FOOD var best_score: float = scores[best_action] for action_id in [ SimulationIds.ACTION_GATHER_WOOD, SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY ]: var score: float = scores[action_id] if score > best_score: best_action = action_id best_score = score return ActionSelectionResult.new( best_action, -1.0, "Highest current work utility", scores, rejections ) func _get_cost_rejection(definition: ActionDefinition, village: SimVillage) -> String: if definition == null or not definition.has_completion_cost(): return "" var available := _get_village_resource_amount(village, definition.completion_cost_resource_id) if available >= definition.completion_cost_amount: return "" return ( "Needs %.0f %s (%.1f available)" % [ definition.completion_cost_amount, String(definition.completion_cost_resource_id).capitalize(), available, ] ) func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float: match resource_id: SimulationIds.RESOURCE_FOOD: return village.food SimulationIds.RESOURCE_WOOD: return village.wood return 0.0 func _calculate_score( npc: SimNPC, action_id: StringName, base_priority: float, resource_amount: float, critical_threshold: float, low_threshold: float, critical_bonus: float, low_bonus: float ) -> float: var score := base_priority if resource_amount < critical_threshold: score += critical_bonus elif resource_amount < low_threshold: score += low_bonus var definition := SimulationDefinitions.get_action(action_id) if ( definition != null and not definition.preferred_profession_id.is_empty() and npc.profession == definition.preferred_profession_id ): score += 2.0 if action_id == npc.last_task: score -= 1.5 score += npc.random_source.randf_range(0.0, 0.5) if npc.debug_logs: print("[ActionSelection] ", npc.npc_name, " score ", action_id, " = ", score) return score static func _get_period(time_of_day: float) -> int: if _in_wrap_range(time_of_day, SLEEP_BEGIN, SLEEP_END): return SchedulePeriod.SLEEP if _in_range(time_of_day, BREAKFAST_BEGIN, BREAKFAST_END): return SchedulePeriod.MEAL if _in_range(time_of_day, DINNER_BEGIN, DINNER_END): return SchedulePeriod.MEAL if _in_range(time_of_day, WORK_BEGIN, WORK_END): return SchedulePeriod.WORK return SchedulePeriod.DISCRETIONARY static func _in_range(value: float, begin: float, end: float) -> bool: return value >= begin and value <= end static func _in_wrap_range(value: float, begin: float, end: float) -> bool: return value >= begin or value <= end