class_name ConflictSystem extends RefCounted 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 combatant_spawned(combatant_id: StringName) signal combatant_died(combatant_id: StringName) signal raid_started(raider_ids: Array[StringName]) signal war_resolved(outcome: StringName) signal war_aborted signal player_damaged(amount: float) signal player_downed const NPC_MAX_HEALTH := 100.0 const STRONG_STRENGTH := 5.0 const DEFENSE_DECISION_INTERVAL := 200 const TRIBE_FOOD_DECAY := 0.05 const WOLF_HUNT_RADIUS := 18.0 const DEFAULT_TICK_INTERVAL := 1.2 const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0) const RAID_SPEED := 1.2 const RAID_SIZE := 4 const PLAYER_COMBATANT_ID := &"player_combatant" var combatants: Dictionary = {} var factions: Dictionary = {} var economy: RefCounted var npcs: Array[SimNPC] = [] var _npcs_by_id: Dictionary = {} var combatant_factory := CombatantFactory.new() var current_tick := 0 var village_center := Vector3.ZERO var last_war_resolved_tick := -1 var tick_interval := DEFAULT_TICK_INTERVAL var player_attack_cooldown_remaining := 0.0 var _sorted_combatant_ids_cache: Array[StringName] = [] var _combatant_ids_dirty := true func configure( economy_service: RefCounted, simulation_tick_interval: float = DEFAULT_TICK_INTERVAL ) -> void: economy = economy_service tick_interval = maxf(simulation_tick_interval, 0.0001) func initialize_factions() -> void: if not factions.has(SimulationIds.FACTION_VILLAGE): factions[SimulationIds.FACTION_VILLAGE] = FactionStateRecord.create( SimulationIds.FACTION_VILLAGE, "Village", 20.0, 0, 0.2, 0.8 ) if not factions.has(SimulationIds.FACTION_TRIBE): factions[SimulationIds.FACTION_TRIBE] = FactionStateRecord.create( SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, RAID_SIZE, 0.7, 0.7 ) _ensure_player_combatant() func _ensure_player_combatant() -> void: if combatants.has(PLAYER_COMBATANT_ID): return var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD) combatants[PLAYER_COMBATANT_ID] = CombatantStateRecord.create( PLAYER_COMBATANT_ID, SimulationIds.COMBATANT_KIND_PLAYER, SimulationIds.FACTION_VILLAGE, "Player", village_center, 100.0, sword.item_id if sword != null else SimulationIds.ITEM_SWORD, CombatantStateRecord.NO_NPC_ID ) _combatant_ids_dirty = true func set_player_combatant_position(position: Vector3) -> void: var player_combatant := get_combatant(PLAYER_COMBATANT_ID) if player_combatant == null: _ensure_player_combatant() player_combatant = get_combatant(PLAYER_COMBATANT_ID) if player_combatant != null: player_combatant.set_position(position) func set_player_health(health: float, downed: bool) -> void: var player_combatant := get_combatant(PLAYER_COMBATANT_ID) if player_combatant == null: _ensure_player_combatant() player_combatant = get_combatant(PLAYER_COMBATANT_ID) if player_combatant == null: return player_combatant.set_health_and_alive(health, not downed) if downed: player_attack_cooldown_remaining = 0.0 func is_player_downed() -> bool: var player_combatant := get_combatant(PLAYER_COMBATANT_ID) return player_combatant != null and not player_combatant.is_alive() func advance_realtime(delta: float) -> void: if not is_finite(delta) or delta <= 0.0: return player_attack_cooldown_remaining = maxf(player_attack_cooldown_remaining - delta, 0.0) func is_player_attack_ready() -> bool: var player_combatant := get_combatant(PLAYER_COMBATANT_ID) return ( player_combatant != null and player_combatant.is_alive() and player_attack_cooldown_remaining <= 0.0 ) func get_player_attack_cooldown_remaining() -> float: return player_attack_cooldown_remaining func register_npc_combatants(npc_list: Array[SimNPC]) -> void: npcs = npc_list _npcs_by_id.clear() for npc in npcs: _npcs_by_id[npc.id] = npc var existing_ids := {} for combatant in combatants.values(): var candidate := combatant as CombatantStateRecord if candidate != null and candidate.get_kind() == SimulationIds.COMBATANT_KIND_NPC: existing_ids[candidate.get_npc_id()] = true for npc in npcs: if npc.is_dead: continue if existing_ids.has(npc.id): continue _create_npc_combatant(npc) func _create_npc_combatant(npc: SimNPC) -> CombatantStateRecord: var weapon := ( SimulationIds.ITEM_SWORD if npc.profession == SimulationIds.PROFESSION_GUARD else SimulationIds.ITEM_CLAW ) var combatant := CombatantStateRecord.create( SimulationIds.npc_combatant_id(npc.id), SimulationIds.COMBATANT_KIND_NPC, SimulationIds.FACTION_VILLAGE, npc.npc_name, npc.position, NPC_MAX_HEALTH, weapon, npc.id ) combatants[combatant.get_combatant_id()] = combatant _combatant_ids_dirty = true return combatant func advance(simulation_tick: int) -> void: current_tick = simulation_tick _sync_npc_positions() _tick_cooldowns() _advance_raider_positions() _advance_wolves() _advance_war(simulation_tick) _run_battle() func _advance_raider_positions() -> void: if not is_raiding(): return for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if ( combatant == null or not combatant.is_alive() or combatant.get_kind() != SimulationIds.COMBATANT_KIND_RAIDER ): continue var target := _find_nearest_defender(combatant.get_position(), 100000.0) var target_position := village_center if target != null: target_position = target.get_position() var to_target := target_position - combatant.get_position() to_target.y = 0.0 if to_target.length() <= 0.5: continue var step := to_target.normalized() * minf(RAID_SPEED, to_target.length()) combatant.set_position(combatant.get_position() + step) func player_attack(combatant_id: StringName) -> float: var player_combatant := get_combatant(PLAYER_COMBATANT_ID) var combatant := get_combatant(combatant_id) if ( not is_player_attack_ready() or combatant == null or not combatant.is_alive() or not combatant.is_hostile() or combatant.get_faction_id() == player_combatant.get_faction_id() or ( _horizontal_distance(player_combatant.get_position(), combatant.get_position()) > _reach(player_combatant) ) ): return 0.0 var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD) var damage := sword.damage if sword != null else 24.0 var applied := _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID) if applied > 0.0: player_attack_cooldown_remaining = sword.attack_cooldown if sword != null else 0.6 return applied func spawn_wolf(position: Vector3) -> StringName: return spawn_enemy(&"enemy_wolf", position) func spawn_enemy(enemy_definition_id: StringName, position: Vector3) -> StringName: var enemy := combatant_factory.create_enemy(enemy_definition_id, position) if enemy == null: return &"" var combatant_id := enemy.get_combatant_id() combatants[combatant_id] = enemy _combatant_ids_dirty = true combatant_spawned.emit(combatant_id) return combatant_id func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -> float: if target == null or not target.is_alive() or damage <= 0.0: return 0.0 var applied := target.take_damage(damage) if applied <= 0.0: return 0.0 _narrative_event_requested( SimulationIds.EVENT_COMBATANT_HURT, actor_id, target.get_combatant_id(), "", "", target.get_weapon_id(), applied ) if target.get_kind() == SimulationIds.COMBATANT_KIND_PLAYER: player_damaged.emit(applied) if not target.is_alive(): player_downed.emit() return applied if not target.is_alive(): _kill_combatant(target, actor_id) return applied func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void: var npc_id := target.get_npc_id() if npc_id >= 0: var npc := _find_npc(npc_id) if npc != null and not npc.is_dead: npc.last_task = npc.current_task npc.die_from_combat() _narrative_event_requested( SimulationIds.EVENT_COMBATANT_KILLED, killer_id, target.get_combatant_id(), target.get_display_name(), "", target.get_weapon_id(), 0.0 ) combatant_died.emit(target.get_combatant_id()) func _advance_wolves() -> void: for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or not combatant.is_alive(): continue var definition := _enemy_definition_for(combatant) if ( definition == null or definition.behavior_profile_id != EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT ): continue var target := _find_nearest_defender(combatant.get_position(), WOLF_HUNT_RADIUS) if target == null: continue if not combatant.is_hostile(): combatant.set_hostile(true) var reach := _reach(combatant) var to_target := target.get_position() - combatant.get_position() to_target.y = 0.0 if to_target.length() > reach: var speed := definition.move_speed var step_distance := minf(speed * tick_interval, to_target.length() - reach) combatant.set_position( combatant.get_position() + to_target.normalized() * maxf(step_distance, 0.0) ) continue _attack_target(combatant, target) func _advance_war(simulation_tick: int) -> void: var tribe := _get_faction(SimulationIds.FACTION_TRIBE) var village := _get_faction(SimulationIds.FACTION_VILLAGE) if tribe == null: return if economy != null: var pantry: StorageStateRecord = economy.get_pantry() if pantry != null: if village != null: village.set_food(pantry.get_amount(SimulationIds.RESOURCE_FOOD)) tribe.set_food(tribe.get_food() - TRIBE_FOOD_DECAY) if tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING: return if ( last_war_resolved_tick >= 0 and simulation_tick - last_war_resolved_tick < DEFENSE_DECISION_INTERVAL ): return if simulation_tick % DEFENSE_DECISION_INTERVAL != 0: return _evaluate_war(simulation_tick) func _evaluate_war(simulation_tick: int) -> void: var tribe := _get_faction(SimulationIds.FACTION_TRIBE) var village := _get_faction(SimulationIds.FACTION_VILLAGE) if tribe == null or village == null: return var desire := _tribe_desire(tribe, village) var confidence := _tribe_confidence(tribe) if desire < 0.5: tribe.set_war_plan(SimulationIds.WAR_PLAN_NONE, simulation_tick, confidence) return if confidence >= 0.55: if tribe.get_war_plan() == SimulationIds.WAR_PLAN_PLANNED: _start_raid(tribe, simulation_tick) else: tribe.set_war_plan(SimulationIds.WAR_PLAN_PLANNED, simulation_tick, confidence) return if confidence < 0.35: var had_plan := ( tribe.get_war_plan() in [ SimulationIds.WAR_PLAN_PLANNED, SimulationIds.WAR_PLAN_RAIDING, ] ) tribe.set_war_plan(SimulationIds.WAR_PLAN_ABORTED, simulation_tick, confidence) tribe.set_stance(SimulationIds.STANCE_NEUTRAL) if had_plan: war_aborted.emit() _narrative_event_requested( SimulationIds.EVENT_WAR_ABORTED, SimulationIds.PLAYER_ACTOR_ID, SimulationIds.FACTION_TRIBE, "The hill tribe read the village's strength and turned away.", "", "", 0.0 ) return if tribe.get_war_plan() != SimulationIds.WAR_PLAN_PLANNED: tribe.set_war_plan(SimulationIds.WAR_PLAN_PLANNED, simulation_tick, confidence) func _tribe_desire(tribe: FactionStateRecord, village: FactionStateRecord) -> float: var hunger_need := clampf((30.0 - tribe.get_food()) / 30.0, 0.0, 1.0) var surplus_lure := clampf(village.get_food() / 30.0, 0.0, 1.0) return clampf((hunger_need + surplus_lure * 0.5) / 1.5, 0.0, 1.0) * tribe.get_aggression() func _tribe_confidence(tribe: FactionStateRecord) -> float: var morale_factor := 0.6 + 0.4 * tribe.get_morale() var tribe_strength := float(tribe.get_warriors()) * morale_factor var defense := village_defense() if defense <= 0.0: return 1.0 return clampf(tribe_strength / (defense * 1.1), 0.0, 1.0) func village_defense() -> float: var defense := 0.0 var eligible := 0 for npc in npcs: if npc.is_dead: continue var is_guard := npc.profession == SimulationIds.PROFESSION_GUARD if npc.strength < STRONG_STRENGTH and not is_guard: continue eligible += 1 defense += ( 1.0 + (0.5 if is_guard else 0.0) + maxf(npc.strength - STRONG_STRENGTH, 0.0) * 0.1 ) var village := _get_faction(SimulationIds.FACTION_VILLAGE) if village != null: defense += village.get_food() / 100.0 return defense func village_defender_count() -> int: var count := 0 for npc in npcs: if npc.is_dead: continue if npc.strength >= STRONG_STRENGTH or npc.profession == SimulationIds.PROFESSION_GUARD: count += 1 return count func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void: tribe.set_war_plan(SimulationIds.WAR_PLAN_RAIDING, simulation_tick) tribe.set_stance(SimulationIds.STANCE_HOSTILE) var raider_ids: Array[StringName] = [] for index in range(tribe.get_warriors()): var offset := Vector3(float(index * 2 - tribe.get_warriors()), 0.0, 0.0) var raider := combatant_factory.create_enemy( &"enemy_raider", village_center + RAID_SPAWN_OFFSET + offset ) if raider == null: continue var raider_id := raider.get_combatant_id() combatants[raider_id] = raider _combatant_ids_dirty = true raider_ids.append(raider_id) combatant_spawned.emit(raider_id) raid_started.emit(raider_ids) _narrative_event_requested( SimulationIds.EVENT_RAID_STARTED, SimulationIds.PLAYER_ACTOR_ID, SimulationIds.FACTION_TRIBE, "The hill tribe, short of food, raids the village.", "", "", 0.0 ) func _run_battle() -> void: var raid_active := is_raiding() var living_hostiles: Array[CombatantStateRecord] = [] for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or not combatant.is_alive() or not combatant.is_hostile(): continue living_hostiles.append(combatant) if not raid_active and living_hostiles.is_empty(): return for hostile in living_hostiles: var target := _find_nearest_defender(hostile.get_position(), _reach(hostile)) if target != null and hostile.is_attack_ready(): _attack_target(hostile, target) if raid_active: for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if ( combatant == null or not combatant.is_alive() or combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC ): continue var hostile := _find_nearest_hostile(combatant.get_position(), _reach(combatant)) if hostile != null and combatant.is_attack_ready(): _attack_target(combatant, hostile) _resolve_raid_outcome() func _resolve_raid_outcome() -> void: var tribe := _get_faction(SimulationIds.FACTION_TRIBE) if tribe == null or tribe.get_war_plan() != SimulationIds.WAR_PLAN_RAIDING: return var living_raiders := 0 for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if ( combatant != null and combatant.is_alive() and combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER ): living_raiders += 1 var living_defenders := _living_village_npc_count() if living_raiders == 0: _finish_war(SimulationIds.WAR_PLAN_NONE, &"village_won") elif living_defenders <= 0: _finish_war(SimulationIds.WAR_PLAN_ABORTED, &"village_lost") func _finish_war(next_plan: StringName, outcome: StringName) -> void: var tribe := _get_faction(SimulationIds.FACTION_TRIBE) var village := _get_faction(SimulationIds.FACTION_VILLAGE) if tribe != null: tribe.set_war_plan(next_plan, current_tick) tribe.set_stance(SimulationIds.STANCE_NEUTRAL) if village != null and outcome == &"village_lost": var stolen := 0.0 if economy != null: var pantry: StorageStateRecord = economy.get_pantry() if pantry != null: var requested := minf(pantry.get_amount(SimulationIds.RESOURCE_FOOD) * 0.6, 15.0) stolen = economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, requested) village.set_food(pantry.get_amount(SimulationIds.RESOURCE_FOOD)) if tribe != null: tribe.set_food(tribe.get_food() + stolen) elif tribe != null: tribe.set_food(tribe.get_food() + 2.0) tribe.set_morale(tribe.get_morale() * 0.6) last_war_resolved_tick = current_tick war_resolved.emit(outcome) _narrative_event_requested( SimulationIds.EVENT_WAR_RESOLVED, SimulationIds.PLAYER_ACTOR_ID, SimulationIds.FACTION_TRIBE, ( "The village drove off the raiders." if outcome == &"village_won" else "The raiders plundered the village." ), "", "", 0.0 ) func _attack_target(attacker: CombatantStateRecord, target: CombatantStateRecord) -> void: if not attacker.is_attack_ready() or target == null: return var damage := _attack_damage(attacker) var applied := _apply_damage(target, damage, _actor_id_for(attacker)) if applied > 0.0: attacker.mark_attacked(tick_interval) func _attack_damage(attacker: CombatantStateRecord) -> float: var weapon := SimulationItems.get_weapon(attacker.get_weapon_id()) var base := weapon.damage if weapon != null else 5.0 if attacker.get_kind() != SimulationIds.COMBATANT_KIND_NPC: return base var npc := _find_npc(attacker.get_npc_id()) return base * (1.0 + (npc.strength * 0.04 if npc != null else 0.0)) func _find_nearest_defender(origin: Vector3, max_distance: float) -> CombatantStateRecord: var best: CombatantStateRecord var best_distance := max_distance * max_distance for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or not combatant.is_alive() or combatant.is_hostile(): continue if ( combatant.get_kind() not in [SimulationIds.COMBATANT_KIND_NPC, SimulationIds.COMBATANT_KIND_PLAYER] ): continue var distance := origin.distance_squared_to(combatant.get_position()) if distance > best_distance: continue best = combatant best_distance = distance return best func _find_nearest_hostile(origin: Vector3, max_distance: float) -> CombatantStateRecord: var best: CombatantStateRecord var best_distance := max_distance * max_distance for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or not combatant.is_alive() or not combatant.is_hostile(): continue var distance := origin.distance_squared_to(combatant.get_position()) if distance > best_distance: continue best = combatant best_distance = distance return best func _reach(combatant: CombatantStateRecord) -> float: var weapon := SimulationItems.get_weapon(combatant.get_weapon_id()) if weapon == null: return 1.5 return weapon.reach static func _horizontal_distance(first: Vector3, second: Vector3) -> float: return Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z)) func _actor_id_for(combatant: CombatantStateRecord) -> int: if combatant.get_npc_id() >= 0: return combatant.get_npc_id() if combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER: return SimulationIds.PLAYER_ACTOR_ID - 1 return SimulationIds.PLAYER_ACTOR_ID - 2 func _living_village_npc_count() -> int: var count := 0 for npc in npcs: if not npc.is_dead: count += 1 return count func is_raiding() -> bool: var tribe := _get_faction(SimulationIds.FACTION_TRIBE) return tribe != null and tribe.get_war_plan() == SimulationIds.WAR_PLAN_RAIDING func _sync_npc_positions() -> void: for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC: continue var npc := _find_npc(combatant.get_npc_id()) if npc == null: continue if npc.is_dead: if combatant.is_alive(): combatant.take_damage(combatant.get_health()) continue combatant.set_position(npc.position) func _tick_cooldowns() -> void: for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant != null: combatant.tick_cooldown() func get_combatant(combatant_id: StringName) -> CombatantStateRecord: return combatants.get(combatant_id) as CombatantStateRecord func get_faction(faction_id: StringName) -> FactionStateRecord: return factions.get(faction_id) as FactionStateRecord func get_all_combatants() -> Array[CombatantStateRecord]: var values: Array[CombatantStateRecord] = [] for combatant in combatants.values(): values.append(combatant) return values func get_living_hostiles() -> Array[CombatantStateRecord]: var hostiles: Array[CombatantStateRecord] = [] for combatant_id in _sorted_combatant_ids(): var combatant := combatants[combatant_id] as CombatantStateRecord if combatant != null and combatant.is_alive() and combatant.is_hostile(): hostiles.append(combatant) return hostiles func _sorted_combatant_ids() -> Array[StringName]: if not _combatant_ids_dirty: return _sorted_combatant_ids_cache var keys: Array[String] = [] for combatant_id in combatants.keys(): keys.append(String(combatant_id)) keys.sort() _sorted_combatant_ids_cache.clear() for key in keys: _sorted_combatant_ids_cache.append(StringName(key)) _combatant_ids_dirty = false return _sorted_combatant_ids_cache func _find_npc(npc_id: int) -> SimNPC: return _npcs_by_id.get(npc_id) as SimNPC func _get_faction(faction_id: StringName) -> FactionStateRecord: return factions.get(faction_id) as FactionStateRecord func _enemy_definition_for(combatant: CombatantStateRecord) -> EnemyDefinition: if combatant == null or combatant.get_enemy_definition_id().is_empty(): return null return SimulationEnemies.get_enemy(combatant.get_enemy_definition_id()) func _narrative_event_requested( event_type: StringName, actor_id: int, source_id: StringName, action_display: String, action_id: StringName, item_id: StringName, required_amount: float ) -> void: narrative_event_requested.emit( event_type, actor_id, source_id, action_display, action_id, item_id, required_amount ) func append_state_records(record: SimulationStateRecord) -> void: var combatant_keys: Array[String] = [] for combatant_id in combatants.keys(): if String(combatant_id) == String(PLAYER_COMBATANT_ID): continue combatant_keys.append(String(combatant_id)) combatant_keys.sort() for combatant_key in combatant_keys: var combatant := combatants[StringName(combatant_key)] as CombatantStateRecord record.combatants.append(combatant) var faction_keys: Array[String] = [] for faction_id in factions.keys(): faction_keys.append(String(faction_id)) faction_keys.sort() for faction_key in faction_keys: var faction := factions[StringName(faction_key)] as FactionStateRecord record.factions.append(faction) func restore_state_records( combatant_records: Array[CombatantStateRecord], faction_records: Array[FactionStateRecord], restored_tick: int ) -> void: current_tick = restored_tick player_attack_cooldown_remaining = 0.0 combatants.clear() for combatant in combatant_records: combatants[combatant.get_combatant_id()] = combatant combatant_factory.restore_allocator(combatant_records) _combatant_ids_dirty = true factions.clear() for faction in faction_records: factions[faction.get_faction_id()] = faction initialize_factions()