class_name VillageOpportunitySystem extends RefCounted const CARE_HUNGER_THRESHOLD := 80.0 const WEAK_ENERGY_THRESHOLD := 25.0 var opportunities: Array[OpportunityStateRecord] = [] var next_opportunity_id := 0 func consider_event( event: EconomicEventRecord, current_tick: int, pantry: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem, resource_states: Dictionary, woodpile: StorageStateRecord = null ) -> OpportunityStateRecord: if event == null or pantry == null or current_tick < 0: return null var event_tick := int(event.data["tick"]) if event_tick < 0 or event_tick > current_tick: return null var resolved := _try_resolve(event, pantry, woodpile, npcs, resource_states) if resolved != null: return resolved var opened := _try_open_food(event, current_tick, pantry, npcs, knowledge_system) if opened != null: return opened if woodpile == null: return null opened = _try_open_wood(event, current_tick, woodpile, npcs, knowledge_system) if opened != null: return opened opened = _try_open_weak(event, current_tick, pantry, npcs, knowledge_system) if opened != null: return opened return _try_open_roof(event, current_tick, woodpile, npcs, knowledge_system) func maintain_open_opportunity( current_tick: int, max_age: int, npcs: Array[SimNPC], pantry: StorageStateRecord, woodpile: StorageStateRecord ) -> OpportunityStateRecord: var opportunity := get_open_opportunity() if opportunity == null or current_tick < opportunity.get_created_tick(): return null if ( opportunity.get_opportunity_type() not in [ SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER, SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF, ] ): return null var interested := _find_npc(opportunity.get_interested_npc_id(), npcs) if interested == null or interested.is_dead: return ( opportunity if opportunity.invalidate( SimulationIds.OPPORTUNITY_INVALIDATED_INTERESTED_DIED, current_tick ) else null ) var condition_is_stale := current_tick - opportunity.get_created_tick() >= maxi(max_age, 1) if opportunity.get_opportunity_type() != SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: var target_storage := _get_target_storage(opportunity, pantry, woodpile) condition_is_stale = ( condition_is_stale or target_storage == null or ( target_storage.get_amount(opportunity.get_resource_id()) >= opportunity.get_target_amount() ) ) if not condition_is_stale: return null return ( opportunity if opportunity.invalidate( SimulationIds.OPPORTUNITY_INVALIDATED_EVIDENCE_STALE, current_tick ) else null ) func restore(records: Array[OpportunityStateRecord], restored_next_id: int) -> void: opportunities = records.duplicate() opportunities.sort_custom(_sort_by_id) next_opportunity_id = maxi(restored_next_id, 0) func get_all_sorted() -> Array[OpportunityStateRecord]: var sorted := opportunities.duplicate() sorted.sort_custom(_sort_by_id) return sorted func get_open_sorted() -> Array[OpportunityStateRecord]: var open_records: Array[OpportunityStateRecord] = [] for opportunity in opportunities: if opportunity.is_open(): open_records.append(opportunity) open_records.sort_custom(_sort_by_id) return open_records func get_open_opportunity() -> OpportunityStateRecord: var open_records := get_open_sorted() return open_records[0] if not open_records.is_empty() else null func get_open_trigger_event_id() -> int: var opportunity := get_open_opportunity() return ( opportunity.get_trigger_event_id() if opportunity != null else OpportunityStateRecord.NO_EVENT_ID ) func get_latest() -> OpportunityStateRecord: var sorted := get_all_sorted() return sorted[-1] if not sorted.is_empty() else null func get_latest_for_npc(npc_id: int) -> OpportunityStateRecord: for index in range(opportunities.size() - 1, -1, -1): var opportunity := opportunities[index] if opportunity.get_interested_npc_id() == npc_id: return opportunity return null func find_capable_helper( opportunity: OpportunityStateRecord, target_storage: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem, relationship_system: RefCounted, resource_states: Dictionary ) -> OpportunityHelperResult: if ( opportunity == null or not opportunity.is_open() or target_storage == null or knowledge_system == null or relationship_system == null ): return null var interested := _find_npc(opportunity.get_interested_npc_id(), npcs) if interested == null or interested.is_dead: return null var remaining := maxf( opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()), 0.0 ) if remaining <= 0.0 or target_storage.get_available_capacity() < remaining: return null var action_ids := _get_supply_action_ids(opportunity.get_resource_id()) if action_ids.is_empty(): return null var gather_definition := SimulationDefinitions.get_action(action_ids["gather"]) var deposit_definition := SimulationDefinitions.get_action(action_ids["deposit"]) if gather_definition == null or deposit_definition == null: return null var candidates: Array[Dictionary] = [] for npc in npcs: var candidate := _build_helper_candidate( npc, opportunity, remaining, gather_definition, deposit_definition, interested.npc_name, knowledge_system, relationship_system, resource_states ) if not candidate.is_empty(): candidates.append(candidate) if candidates.is_empty(): return null candidates.sort_custom(_sort_helper_candidates) var best := candidates[0] return ( OpportunityHelperResult . new( { "opportunity_id": opportunity.get_opportunity_id(), "helper_npc_id": (best["npc"] as SimNPC).id, "action_id": best["action_id"], "source_id": best["source_id"], "resource_id": opportunity.get_resource_id(), "trigger_event_id": opportunity.get_trigger_event_id(), "trust": best["trust"], "familiarity": best["familiarity"], "uses_inventory": best["uses_inventory"], "available_source_count": best["available_source_count"], "profession_match": best["profession_match"], "reason": best["reason"], } ) ) func find_player_response( opportunity: OpportunityStateRecord, target_storage: StorageStateRecord, resource_states: Dictionary, has_capable_helper: bool ) -> OpportunityPlayerResponseResult: if ( opportunity == null or not opportunity.is_open() or target_storage == null or target_storage.get_storage_id() != opportunity.get_target_id() or has_capable_helper ): return null var remaining := maxf( opportunity.get_target_amount() - target_storage.get_amount(opportunity.get_resource_id()), 0.0 ) if remaining <= 0.0 or target_storage.get_available_capacity() < remaining: return null var action_ids := _get_supply_action_ids(opportunity.get_resource_id()) if action_ids.is_empty(): return null var gather_definition := SimulationDefinitions.get_action(action_ids["gather"]) if gather_definition == null: return null var available_source_count := _count_player_resource_sources( remaining, opportunity.get_resource_id(), gather_definition, resource_states ) if available_source_count == 0: return null return ( OpportunityPlayerResponseResult . new( { "opportunity_id": opportunity.get_opportunity_id(), "trigger_event_id": opportunity.get_trigger_event_id(), "action_id": gather_definition.action_id, "resource_id": opportunity.get_resource_id(), "target_id": target_storage.get_storage_id(), "available_source_count": available_source_count, "reason": ( "No capable helper; %d player-usable finite %s source%s can supply %s" % [ available_source_count, String(opportunity.get_resource_id()).capitalize(), "" if available_source_count == 1 else "s", String(target_storage.get_storage_id()), ] ), } ) ) func _build_helper_candidate( npc: SimNPC, opportunity: OpportunityStateRecord, remaining: float, gather_definition: ActionDefinition, deposit_definition: ActionDefinition, interested_name: String, knowledge_system: EventKnowledgeSystem, relationship_system: RefCounted, resource_states: Dictionary ) -> Dictionary: if npc.is_dead or npc.id == opportunity.get_interested_npc_id(): return {} if not knowledge_system.knows_event(npc.id, opportunity.get_trigger_event_id()): return {} var relationship: RelationshipStateRecord = relationship_system.get_relationship( npc.id, opportunity.get_interested_npc_id() ) if ( relationship == null or not relationship_system.is_trusted_for_help(npc.id, opportunity.get_interested_npc_id()) ): return {} var carried := npc.get_inventory_amount(opportunity.get_resource_id()) var uses_inventory := carried >= remaining var available_source_count := 0 if not uses_inventory: available_source_count = _count_available_resource_sources( npc.id, remaining, opportunity.get_resource_id(), gather_definition, resource_states ) if available_source_count == 0: return {} var action_definition := deposit_definition if uses_inventory else gather_definition var source_id := SimulationIds.npc_inventory_id(npc.id) if uses_inventory else &"" var profession_match := ( not gather_definition.preferred_profession_id.is_empty() and npc.profession == gather_definition.preferred_profession_id ) var supply_fact := ( "carries %.1f %s" % [carried, String(opportunity.get_resource_id()).capitalize()] if uses_inventory else ( "has %d available finite %s source%s" % [ available_source_count, String(opportunity.get_resource_id()).capitalize(), "" if available_source_count == 1 else "s", ] ) ) return { "npc": npc, "action_id": action_definition.action_id, "source_id": source_id, "trust": relationship.get_trust(), "familiarity": relationship.get_familiarity(), "uses_inventory": uses_inventory, "available_source_count": available_source_count, "profession_match": profession_match, "reason": ( "Knows the need; trust %.2f toward %s; %s" % [relationship.get_trust(), interested_name, supply_fact] ), } static func _get_supply_action_ids(resource_id: StringName) -> Dictionary: match resource_id: SimulationIds.RESOURCE_FOOD: return { "gather": SimulationIds.ACTION_GATHER_FOOD, "deposit": SimulationIds.ACTION_DEPOSIT_FOOD, } SimulationIds.RESOURCE_WOOD: return { "gather": SimulationIds.ACTION_GATHER_WOOD, "deposit": SimulationIds.ACTION_DEPOSIT_WOOD, } return {} static func _count_available_resource_sources( npc_id: int, remaining: float, resource_id: StringName, gather_definition: ActionDefinition, resource_states: Dictionary ) -> int: var count := 0 for value in resource_states.values(): var state := value as ResourceStateRecord if ( state == null or state.get_action_id() != gather_definition.resource_action_id or state.get_resource_id() != resource_id or not state.can_npc_use() or not state.is_available_for(npc_id) or state.get_yield_per_action() <= 0.0 or state.get_amount_remaining() < remaining ): continue count += 1 return count static func _count_player_resource_sources( remaining: float, resource_id: StringName, gather_definition: ActionDefinition, resource_states: Dictionary ) -> int: var count := 0 for value in resource_states.values(): var state := value as ResourceStateRecord if ( state == null or state.get_action_id() != gather_definition.resource_action_id or state.get_resource_id() != resource_id or not state.can_player_use_resource() or not state.can_extract() or state.get_yield_per_action() <= 0.0 or state.get_amount_remaining() < remaining ): continue count += 1 return count static func _sort_helper_candidates(first: Dictionary, second: Dictionary) -> bool: if first["uses_inventory"] != second["uses_inventory"]: return bool(first["uses_inventory"]) if first["trust"] != second["trust"]: return float(first["trust"]) > float(second["trust"]) if first["profession_match"] != second["profession_match"]: return bool(first["profession_match"]) if first["familiarity"] != second["familiarity"]: return float(first["familiarity"]) > float(second["familiarity"]) return (first["npc"] as SimNPC).id < (second["npc"] as SimNPC).id func _try_open_food( event: EconomicEventRecord, current_tick: int, pantry: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem ) -> OpportunityStateRecord: if knowledge_system == null or get_open_opportunity() != null: return null if pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0: return null if not _is_food_withdrawal(event): return null var interested_npc := _select_interested_npc( int(event.data["event_id"]), npcs, knowledge_system ) if interested_npc == null: return null var opportunity := OpportunityStateRecord.create( next_opportunity_id, current_tick, int(event.data["event_id"]), interested_npc.id ) next_opportunity_id += 1 opportunities.append(opportunity) return opportunity func _try_open_wood( event: EconomicEventRecord, current_tick: int, woodpile: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem ) -> OpportunityStateRecord: if knowledge_system == null or get_open_opportunity() != null: return null if not _is_missing_wood_block(event): return null var target_amount := float(event.data.get("required_amount", 0.0)) if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) >= target_amount: return null var actor := _find_npc(int(event.data["actor_id"]), npcs) if ( actor == null or actor.is_dead or not knowledge_system.knows_event(actor.id, int(event.data["event_id"])) ): return null var opportunity := OpportunityStateRecord.create( next_opportunity_id, current_tick, int(event.data["event_id"]), actor.id, target_amount, SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD, SimulationIds.STORAGE_VILLAGE_WOODPILE, SimulationIds.RESOURCE_WOOD ) next_opportunity_id += 1 opportunities.append(opportunity) return opportunity func _try_open_weak( event: EconomicEventRecord, current_tick: int, pantry: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem ) -> OpportunityStateRecord: if knowledge_system == null or get_open_opportunity() != null: return null if not _is_weak_trigger(event): return null if pantry.get_amount(SimulationIds.RESOURCE_FOOD) > 0.0: return null var actor := _find_npc(int(event.data["actor_id"]), npcs) if ( actor == null or actor.is_dead or actor.hunger < CARE_HUNGER_THRESHOLD or actor.energy >= WEAK_ENERGY_THRESHOLD or not knowledge_system.knows_event(actor.id, int(event.data["event_id"])) ): return null var opportunity := OpportunityStateRecord.create( next_opportunity_id, current_tick, int(event.data["event_id"]), actor.id, OpportunityStateRecord.DEFAULT_TARGET_AMOUNT, SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER, SimulationIds.STORAGE_VILLAGE_PANTRY, SimulationIds.RESOURCE_FOOD ) next_opportunity_id += 1 opportunities.append(opportunity) return opportunity func _try_open_roof( event: EconomicEventRecord, current_tick: int, woodpile: StorageStateRecord, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem ) -> OpportunityStateRecord: if knowledge_system == null or get_open_opportunity() != null: return null if not _is_home_damaged_trigger(event): return null if ( woodpile.get_amount(SimulationIds.RESOURCE_WOOD) >= OpportunityStateRecord.DEFAULT_TARGET_AMOUNT ): return null var actor := _find_npc(int(event.data["actor_id"]), npcs) if ( actor == null or actor.is_dead or not knowledge_system.knows_event(actor.id, int(event.data["event_id"])) ): return null var opportunity := OpportunityStateRecord.create( next_opportunity_id, current_tick, int(event.data["event_id"]), actor.id, OpportunityStateRecord.DEFAULT_TARGET_AMOUNT, SimulationIds.OPPORTUNITY_REPAIR_HOME_ROOF, SimulationIds.STORAGE_VILLAGE_WOODPILE, SimulationIds.RESOURCE_WOOD ) next_opportunity_id += 1 opportunities.append(opportunity) return opportunity static func _is_weak_trigger(event: EconomicEventRecord) -> bool: return ( StringName(event.data["event_type"]) == SimulationIds.EVENT_VILLAGER_WEAK and int(event.data["actor_id"]) >= 0 ) static func _is_home_damaged_trigger(event: EconomicEventRecord) -> bool: return ( StringName(event.data["event_type"]) == SimulationIds.EVENT_HOME_DAMAGED and int(event.data["actor_id"]) >= 0 ) func _try_resolve( event: EconomicEventRecord, pantry: StorageStateRecord, woodpile: StorageStateRecord, npcs: Array[SimNPC], resource_states: Dictionary ) -> OpportunityStateRecord: var opportunity := get_open_opportunity() if opportunity == null: return null if ( opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER and _is_valid_supply(event, opportunity, npcs, resource_states) ): if int(event.data["tick"]) < opportunity.get_created_tick(): return null if int(event.data["event_id"]) <= opportunity.get_trigger_event_id(): return null if not opportunity.resolve(int(event.data["event_id"]), int(event.data["tick"])): return null return opportunity var target_storage := _get_target_storage(opportunity, pantry, woodpile) if ( target_storage == null or ( target_storage.get_amount(opportunity.get_resource_id()) < opportunity.get_target_amount() ) ): return null if not _is_valid_supply(event, opportunity, npcs, resource_states): return null if int(event.data["tick"]) < opportunity.get_created_tick(): return null if int(event.data["event_id"]) <= opportunity.get_trigger_event_id(): return null if not opportunity.resolve(int(event.data["event_id"]), int(event.data["tick"])): return null return opportunity func _select_interested_npc( event_id: int, npcs: Array[SimNPC], knowledge_system: EventKnowledgeSystem ) -> SimNPC: var candidates: Array[SimNPC] = [] for npc in npcs: if npc.is_dead or npc.hunger < CARE_HUNGER_THRESHOLD: continue if not knowledge_system.knows_event(npc.id, event_id): continue candidates.append(npc) candidates.sort_custom(_sort_by_care) return candidates[0] if not candidates.is_empty() else null static func _is_food_withdrawal(event: EconomicEventRecord) -> bool: var actor_id := int(event.data["actor_id"]) return ( StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_WITHDRAWN and actor_id >= 0 and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_PANTRY and StringName(event.data["destination_id"]) == SimulationIds.npc_inventory_id(actor_id) and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and float(event.data["amount"]) > 0.0 ) static func _is_missing_wood_block(event: EconomicEventRecord) -> bool: return ( StringName(event.data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED and int(event.data["actor_id"]) >= 0 and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_WOOD and ( StringName(event.data.get("action_id", &"")) in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY] ) and float(event.data.get("required_amount", 0.0)) > 0.0 ) static func _is_valid_supply( event: EconomicEventRecord, opportunity: OpportunityStateRecord, npcs: Array[SimNPC], resource_states: Dictionary ) -> bool: if opportunity.get_opportunity_type() == SimulationIds.OPPORTUNITY_FEED_WEAK_VILLAGER: return ( StringName(event.data["event_type"]) == SimulationIds.EVENT_ITEM_CONSUMED and int(event.data["actor_id"]) == opportunity.get_interested_npc_id() and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and float(event.data["amount"]) > 0.0 ) if ( StringName(event.data["item_id"]) != opportunity.get_resource_id() or StringName(event.data["destination_id"]) != opportunity.get_target_id() or float(event.data["amount"]) <= 0.0 ): return false var event_type := StringName(event.data["event_type"]) var actor_id := int(event.data["actor_id"]) if event_type == SimulationIds.EVENT_STORAGE_DEPOSITED: if actor_id == SimulationIds.PLAYER_ACTOR_ID: return ( StringName(event.data["source_id"]) in [SimulationIds.PLAYER_INVENTORY_ID, &"player_carry"] ) return ( _find_npc(actor_id, npcs) != null and StringName(event.data["source_id"]) == SimulationIds.npc_inventory_id(actor_id) ) if event_type != SimulationIds.EVENT_RESOURCE_EXTRACTED or actor_id != -1: return false var resource_state := ( resource_states.get(StringName(event.data["source_id"])) as ResourceStateRecord ) return ( resource_state != null and resource_state.get_resource_id() == opportunity.get_resource_id() and resource_state.can_player_use_resource() ) static func _get_target_storage( opportunity: OpportunityStateRecord, pantry: StorageStateRecord, woodpile: StorageStateRecord ) -> StorageStateRecord: match opportunity.get_target_id(): SimulationIds.STORAGE_VILLAGE_PANTRY: return pantry SimulationIds.STORAGE_VILLAGE_WOODPILE: return woodpile return null static func _sort_by_care(first: SimNPC, second: SimNPC) -> bool: if first.hunger != second.hunger: return first.hunger > second.hunger return first.id < second.id static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC: for npc in npcs: if npc.id == npc_id: return npc return null static func _sort_by_id(first: OpportunityStateRecord, second: OpportunityStateRecord) -> bool: return first.get_opportunity_id() < second.get_opportunity_id()