diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 9d1752c..1302446 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -62,6 +62,14 @@ func _init( func die_from_starvation() -> void: + _apply_death() + + +func die_from_combat() -> void: + _apply_death() + + +func _apply_death() -> void: is_dead = true current_task = SimulationIds.ACTION_DEAD task_state = TASK_STATE_IDLE @@ -71,7 +79,7 @@ func die_from_starvation() -> void: has_travel_target = false if debug_logs: - print("[SimNPC] ", npc_name, " died from starvation.") + print("[SimNPC] ", npc_name, " died.") func get_inventory_amount(item_id: StringName) -> float: diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 8234452..cf95045 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -10,6 +10,7 @@ const AnimalCareSystemScript := preload("res://simulation/animals/animal_care_sy 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") +const ConflictSystemScript := preload("res://simulation/conflict/ConflictSystem.gd") signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName) signal village_changed(village: SimVillage) @@ -27,6 +28,11 @@ signal event_knowledge_forgotten(knower_id: int, event: EconomicEventRecord) signal opportunity_opened(opportunity: OpportunityStateRecord) signal opportunity_resolved(opportunity: OpportunityStateRecord, cause_event: EconomicEventRecord) signal opportunity_invalidated(opportunity: OpportunityStateRecord) +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 var village := SimVillage.new() var player_system := PlayerCitizenSystem.new() @@ -49,6 +55,7 @@ var animal_care := AnimalCareSystemScript.new() var relationship_system := RelationshipSystemScript.new() var event_knowledge_system := EventKnowledgeSystemScript.new() var opportunity_system := VillageOpportunitySystem.new() +var conflict_system := ConflictSystemScript.new() var storage_states: Dictionary: get: return economy.storage_states @@ -85,6 +92,12 @@ func _ready() -> void: economy.narrative_event_requested.connect(record_narrative_event) animal_care.economic_event_requested.connect(_record_economic_event_at) animal_care.narrative_event_requested.connect(record_narrative_event) + conflict_system.narrative_event_requested.connect(record_narrative_event) + conflict_system.combatant_spawned.connect(_on_conflict_combatant_spawned) + conflict_system.combatant_died.connect(_on_conflict_combatant_died) + conflict_system.raid_started.connect(func(raider_ids): raid_started.emit(raider_ids)) + conflict_system.war_resolved.connect(func(outcome): war_resolved.emit(outcome)) + conflict_system.war_aborted.connect(func(): war_aborted.emit()) action_selector.relationship_system = relationship_system var definition_errors := SimulationDefinitions.validate() if not definition_errors.is_empty(): @@ -99,11 +112,14 @@ func _ready() -> void: economy.configure(village, debug_logs) animal_care.configure(economy, active_world_adapter) player_system.configure(economy, _record_player_event_at) + conflict_system.configure(economy) economy.initialize_storage() village.update_modifiers() village.update_priorities() generate_npcs() relationship_system.initialize_households(npcs) + conflict_system.initialize_factions() + conflict_system.register_npc_combatants(npcs) call_deferred("register_loaded_resource_nodes") animal_care.call_deferred("register_loaded_nodes") call_deferred("register_loaded_storage_nodes") @@ -182,6 +198,7 @@ func simulate_tick() -> void: village_was_changed = _simulate_npc_tick(npc) or village_was_changed if village_was_changed: village_changed.emit(village) + conflict_system.advance(tick_count) var invalidated: OpportunityStateRecord = opportunity_system.maintain_open_opportunity( tick_count, get_knowledge_review_interval(), npcs, get_pantry(), get_woodpile() ) @@ -266,6 +283,55 @@ func _is_weak_villager_state(npc: SimNPC) -> bool: ) +func _on_conflict_combatant_spawned(combatant_id: StringName) -> void: + combatant_spawned.emit(combatant_id) + + +func _on_conflict_combatant_died(combatant_id: StringName) -> void: + combatant_died.emit(combatant_id) + var combatant := conflict_system.get_combatant(combatant_id) + if combatant == null or combatant.get_npc_id() < 0: + return + for npc in npcs: + if npc.id != combatant.get_npc_id() or npc.is_dead: + continue + _handle_npc_death(npc, npc.current_task, npc.target_id) + return + + +func player_attack(combatant_id: StringName) -> float: + return conflict_system.player_attack(combatant_id) + + +func spawn_wolf(position: Vector3) -> StringName: + return conflict_system.spawn_wolf(position) + + +func get_combatant(combatant_id: StringName) -> CombatantStateRecord: + return conflict_system.get_combatant(combatant_id) + + +func get_faction(faction_id: StringName) -> FactionStateRecord: + return conflict_system.get_faction(faction_id) + + +func get_living_hostile_combatants() -> Array[CombatantStateRecord]: + return conflict_system.get_living_hostiles() + + +func get_village_defense() -> float: + return conflict_system.village_defense() + + +func get_village_defender_count() -> int: + return conflict_system.village_defender_count() + + +func get_tribe_war_plan() -> StringName: + var tribe := conflict_system.get_faction(SimulationIds.FACTION_TRIBE) + return tribe.get_war_plan() if tribe != null else SimulationIds.WAR_PLAN_NONE + + func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void: if not previous_target.is_empty(): release_npc_reservation(npc.id) @@ -1203,6 +1269,7 @@ func create_state_record() -> SimulationStateRecord: for opportunity in opportunity_system.get_all_sorted(): record.opportunities.append(opportunity) record.player = player_system.player_state + conflict_system.append_state_records(record) return record @@ -1255,6 +1322,8 @@ func restore_state(record: SimulationStateRecord) -> bool: animal_care.restore_state_records(record.animals, tick_count) if record.player != null: player_system.player_state = record.player + conflict_system.restore_state_records(record.combatants, record.factions, tick_count) + conflict_system.register_npc_combatants(npcs) village_changed.emit(village) state_restored.emit() return true diff --git a/simulation/conflict/ConflictSystem.gd b/simulation/conflict/ConflictSystem.gd new file mode 100644 index 0000000..6bc73ee --- /dev/null +++ b/simulation/conflict/ConflictSystem.gd @@ -0,0 +1,625 @@ +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 + +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 RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0) +const RAID_SPEED := 1.2 +const RAID_SIZE := 4 +const RAIDER_HEALTH := 60.0 +const WOLF_HEALTH := 40.0 +const RAIDER_REACH_FACTOR := 1.6 + +var combatants: Dictionary = {} +var factions: Dictionary = {} +var economy: RefCounted +var npcs: Array[SimNPC] = [] +var next_combatant_id := 0 +var next_wolf_id := 0 +var current_tick := 0 +var village_center := Vector3.ZERO +var last_war_resolved_tick := -1 + + +func configure(economy_service: RefCounted) -> void: + economy = economy_service + + +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 + ) + + +func register_npc_combatants(npc_list: Array[SimNPC]) -> void: + npcs = npc_list + 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 + 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 combatant := get_combatant(combatant_id) + if combatant == null or not combatant.is_alive(): + return 0.0 + var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD) + var damage := sword.damage if sword != null else 24.0 + return _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID) + + +func spawn_wolf(position: Vector3) -> StringName: + var wolf_id := StringName("wolf_%d" % next_wolf_id) + next_wolf_id += 1 + var wolf := CombatantStateRecord.create( + wolf_id, + SimulationIds.COMBATANT_KIND_WOLF, + SimulationIds.FACTION_TRIBE, + "Wolf", + position, + WOLF_HEALTH, + SimulationIds.ITEM_CLAW, + CombatantStateRecord.NO_NPC_ID, + true + ) + combatants[wolf_id] = wolf + combatant_spawned.emit(wolf_id) + return wolf_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 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() + _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()) + if npc_id >= 0: + var npc := _find_npc(npc_id) + if npc != null and not npc.is_dead: + npc.die_from_combat() + + +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 + if combatant.get_kind() != SimulationIds.COMBATANT_KIND_WOLF: + 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) + _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 raider_id := StringName("raider_%d" % next_combatant_id) + next_combatant_id += 1 + var offset := Vector3(float(index * 2 - tribe.get_warriors()), 0.0, 0.0) + var raider := CombatantStateRecord.create( + raider_id, + SimulationIds.COMBATANT_KIND_RAIDER, + SimulationIds.FACTION_TRIBE, + "Raider", + village_center + RAID_SPAWN_OFFSET + offset, + RAIDER_HEALTH, + SimulationIds.ITEM_SWORD, + CombatantStateRecord.NO_NPC_ID, + true + ) + combatants[raider_id] = raider + 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 := minf(village.get_food() * 0.6, 15.0) + village.set_food(maxf(village.get_food() - stolen, 0.0)) + 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() + + +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 * RAIDER_REACH_FACTOR + if combatant.get_kind() == SimulationIds.COMBATANT_KIND_WOLF + else weapon.reach + ) + + +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: + var keys: Array[String] = [] + for combatant_id in combatants.keys(): + keys.append(String(combatant_id)) + keys.sort() + var ids: Array = [] + for key in keys: + ids.append(StringName(key)) + return ids + + +func _find_npc(npc_id: int) -> SimNPC: + for npc in npcs: + if npc.id == npc_id: + return npc + return null + + +func _get_faction(faction_id: StringName) -> FactionStateRecord: + return factions.get(faction_id) as FactionStateRecord + + +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(): + 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 + combatants.clear() + for combatant in combatant_records: + combatants[combatant.get_combatant_id()] = combatant + factions.clear() + for faction in faction_records: + factions[faction.get_faction_id()] = faction + initialize_factions() diff --git a/simulation/conflict/ConflictSystem.gd.uid b/simulation/conflict/ConflictSystem.gd.uid new file mode 100644 index 0000000..f4c2e6d --- /dev/null +++ b/simulation/conflict/ConflictSystem.gd.uid @@ -0,0 +1 @@ +uid://d33ic0xovdv53 diff --git a/simulation/definitions/ItemDefinition.gd b/simulation/definitions/ItemDefinition.gd new file mode 100644 index 0000000..b074839 --- /dev/null +++ b/simulation/definitions/ItemDefinition.gd @@ -0,0 +1,25 @@ +class_name ItemDefinition +extends Resource + +@export var item_id: StringName +@export var display_name: String +@export var category: StringName = SimulationIds.ITEM_CATEGORY_WEAPON +@export var equip_slot: StringName = SimulationIds.EQUIP_SLOT_HAND +@export_range(0.0, 1000.0, 0.1) var damage := 0.0 +@export_range(0.0, 50.0, 0.1) var reach := 1.5 +@export_range(0.0, 5.0, 0.05) var attack_cooldown := 0.6 + + +func validate() -> Array[String]: + var errors: Array[String] = [] + if item_id.is_empty(): + errors.append("item_id is empty") + if display_name.is_empty(): + errors.append("display_name is empty for '%s'" % item_id) + if damage <= 0.0: + errors.append("damage must be positive for '%s'" % item_id) + return errors + + +func is_weapon() -> bool: + return category == SimulationIds.ITEM_CATEGORY_WEAPON diff --git a/simulation/definitions/ItemDefinition.gd.uid b/simulation/definitions/ItemDefinition.gd.uid new file mode 100644 index 0000000..d9c5c98 --- /dev/null +++ b/simulation/definitions/ItemDefinition.gd.uid @@ -0,0 +1 @@ +uid://bf722tp11qt7j diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index c4a672c..fe3d821 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -71,6 +71,36 @@ const KNOWLEDGE_ACQUISITION_WITNESSED := &"witnessed" const KNOWLEDGE_ACQUISITION_COMMUNICATED := &"communicated" const KNOWLEDGE_ACQUISITION_LEGACY := &"legacy" +const ITEM_SWORD := &"item_sword" +const ITEM_CLAW := &"item_claw" +const ITEM_CATEGORY_WEAPON := &"weapon" +const EQUIP_SLOT_HAND := &"hand" + +const FACTION_VILLAGE := &"faction_village" +const FACTION_TRIBE := &"faction_tribe" +const STANCE_NEUTRAL := &"neutral" +const STANCE_HOSTILE := &"hostile" +const WAR_PLAN_NONE := &"none" +const WAR_PLAN_PLANNED := &"planned" +const WAR_PLAN_RAIDING := &"raiding" +const WAR_PLAN_ABORTED := &"aborted" + +const COMBATANT_KIND_NPC := &"npc" +const COMBATANT_KIND_RAIDER := &"raider" +const COMBATANT_KIND_WOLF := &"wolf" +const COMBATANT_KIND_PLAYER := &"player" + +const EVENT_COMBATANT_HURT := &"combatant_hurt" +const EVENT_COMBATANT_KILLED := &"combatant_killed" +const EVENT_RAID_STARTED := &"raid_started" +const EVENT_WAR_RESOLVED := &"war_resolved" +const EVENT_WAR_ABORTED := &"war_aborted" +const EVENT_WOLF_HUNT := &"wolf_hunt" + static func npc_inventory_id(npc_id: int) -> StringName: return StringName("npc_%d_inventory" % npc_id) + + +static func npc_combatant_id(npc_id: int) -> StringName: + return StringName("npc_%d_combatant" % npc_id) diff --git a/simulation/definitions/SimulationItems.gd b/simulation/definitions/SimulationItems.gd new file mode 100644 index 0000000..0f3d98a --- /dev/null +++ b/simulation/definitions/SimulationItems.gd @@ -0,0 +1,50 @@ +class_name SimulationItems +extends RefCounted + +static var _items: Dictionary = {} +static var _cache_valid := false + + +static func _ensure_cache() -> void: + if _cache_valid: + return + _items = {} + _add_weapon(SimulationIds.ITEM_SWORD, "Sword", 24.0, 2.4, 0.55) + _add_weapon(SimulationIds.ITEM_CLAW, "Claw", 10.0, 1.2, 0.4) + _cache_valid = true + + +static func _add_weapon( + item_id: StringName, display_name: String, damage: float, reach: float, cooldown: float +) -> void: + var item := ItemDefinition.new() + item.item_id = item_id + item.display_name = display_name + item.category = SimulationIds.ITEM_CATEGORY_WEAPON + item.equip_slot = SimulationIds.EQUIP_SLOT_HAND + item.damage = damage + item.reach = reach + item.attack_cooldown = cooldown + _items[item_id] = item + + +static func invalidate_cache() -> void: + _cache_valid = false + + +static func get_item(item_id: StringName) -> ItemDefinition: + _ensure_cache() + return _items.get(item_id) as ItemDefinition + + +static func get_items() -> Array[ItemDefinition]: + _ensure_cache() + var values: Array[ItemDefinition] = [] + for item in _items.values(): + values.append(item) + return values + + +static func get_weapon(item_id: StringName) -> ItemDefinition: + var item := get_item(item_id) + return item if item != null and item.is_weapon() else null diff --git a/simulation/definitions/SimulationItems.gd.uid b/simulation/definitions/SimulationItems.gd.uid new file mode 100644 index 0000000..50766bb --- /dev/null +++ b/simulation/definitions/SimulationItems.gd.uid @@ -0,0 +1 @@ +uid://doogu4fg3ysgi diff --git a/simulation/state/CombatantStateRecord.gd b/simulation/state/CombatantStateRecord.gd new file mode 100644 index 0000000..496e948 --- /dev/null +++ b/simulation/state/CombatantStateRecord.gd @@ -0,0 +1,225 @@ +class_name CombatantStateRecord +extends RefCounted + +signal changed(state: CombatantStateRecord) + +const SCHEMA_VERSION := 1 +const NO_NPC_ID := -1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func _snap(value: float) -> float: + return snappedf(value, 1.0 / 1024.0) + + +static func create( + combatant_id: StringName, + kind: StringName, + faction_id: StringName, + display_name: String, + position: Vector3, + max_health: float, + weapon_id: StringName, + npc_id: int = NO_NPC_ID, + hostile: bool = false +) -> CombatantStateRecord: + var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z)) + return ( + CombatantStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "combatant_id": String(combatant_id), + "kind": String(kind), + "faction_id": String(faction_id), + "display_name": display_name, + "npc_id": npc_id, + "position": [snapped_position.x, snapped_position.y, snapped_position.z], + "health": max_health, + "max_health": max_health, + "weapon_id": String(weapon_id), + "alive": true, + "hostile": hostile, + "attack_cooldown": 0, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "combatant_id", + "kind", + "faction_id", + "display_name", + "npc_id", + "position", + "health", + "max_health", + "weapon_id", + "alive", + "hostile", + "attack_cooldown", + ] + ) + ): + return null + var combatant_id := String(record_data["combatant_id"]) + var display_name := String(record_data["display_name"]) + if combatant_id.is_empty() or display_name.is_empty(): + return null + if ( + StringName(record_data["kind"]) + not in [ + SimulationIds.COMBATANT_KIND_NPC, + SimulationIds.COMBATANT_KIND_RAIDER, + SimulationIds.COMBATANT_KIND_WOLF, + SimulationIds.COMBATANT_KIND_PLAYER, + ] + ): + return null + var saved_position = record_data["position"] + if not saved_position is Array or saved_position.size() != 3: + return null + for component in saved_position: + if not is_finite(float(component)): + return null + var health := float(record_data["health"]) + var max_health := float(record_data["max_health"]) + var npc_id := int(record_data["npc_id"]) + if ( + not is_finite(health) + or not is_finite(max_health) + or health < 0.0 + or max_health <= 0.0 + or health > max_health + or npc_id < NO_NPC_ID + or int(record_data["attack_cooldown"]) < 0 + ): + return null + var normalized := record_data.duplicate(true) + normalized["schema_version"] = SCHEMA_VERSION + normalized["combatant_id"] = combatant_id + normalized["kind"] = String(record_data["kind"]) + normalized["faction_id"] = String(record_data["faction_id"]) + normalized["display_name"] = display_name + normalized["npc_id"] = npc_id + normalized["position"] = [ + _snap(float(saved_position[0])), + _snap(float(saved_position[1])), + _snap(float(saved_position[2])) + ] + normalized["health"] = health + normalized["max_health"] = max_health + normalized["weapon_id"] = String(record_data["weapon_id"]) + normalized["alive"] = bool(record_data["alive"]) + normalized["hostile"] = bool(record_data["hostile"]) + normalized["attack_cooldown"] = int(record_data["attack_cooldown"]) + return CombatantStateRecord.new(normalized) + + +func get_combatant_id() -> StringName: + return StringName(data["combatant_id"]) + + +func get_kind() -> StringName: + return StringName(data["kind"]) + + +func get_faction_id() -> StringName: + return StringName(data["faction_id"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_npc_id() -> int: + return int(data["npc_id"]) + + +func get_position() -> Vector3: + var saved_position: Array = data["position"] + return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2])) + + +func get_health() -> float: + return float(data["health"]) + + +func get_max_health() -> float: + return float(data["max_health"]) + + +func get_weapon_id() -> StringName: + return StringName(data["weapon_id"]) + + +func is_alive() -> bool: + return bool(data["alive"]) and get_health() > 0.0 + + +func is_hostile() -> bool: + return bool(data["hostile"]) + + +func get_attack_cooldown() -> int: + return int(data["attack_cooldown"]) + + +func is_attack_ready() -> bool: + return is_alive() and get_attack_cooldown() <= 0 + + +func set_position(value: Vector3) -> void: + if not value.is_finite() or value.is_equal_approx(get_position()): + return + data["position"] = [_snap(value.x), _snap(value.y), _snap(value.z)] + changed.emit(self) + + +func set_hostile(value: bool) -> void: + if value == is_hostile(): + return + data["hostile"] = value + changed.emit(self) + + +func tick_cooldown() -> void: + if get_attack_cooldown() > 0: + data["attack_cooldown"] = get_attack_cooldown() - 1 + + +func take_damage(amount: float) -> float: + if not is_alive() or not is_finite(amount) or amount <= 0.0: + return 0.0 + var previous := get_health() + data["health"] = maxf(previous - amount, 0.0) + if get_health() <= 0.0: + data["alive"] = false + changed.emit(self) + return previous - get_health() + + +func mark_attacked() -> void: + data["attack_cooldown"] = _weapon_cooldown_ticks() + + +func _weapon_cooldown_ticks() -> int: + var weapon := SimulationItems.get_weapon(get_weapon_id()) + var seconds := weapon.attack_cooldown if weapon != null else 0.6 + return maxi(int(ceil(seconds / 1.2)), 1) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/CombatantStateRecord.gd.uid b/simulation/state/CombatantStateRecord.gd.uid new file mode 100644 index 0000000..80ed322 --- /dev/null +++ b/simulation/state/CombatantStateRecord.gd.uid @@ -0,0 +1 @@ +uid://d0tknjfogljjg diff --git a/simulation/state/FactionStateRecord.gd b/simulation/state/FactionStateRecord.gd new file mode 100644 index 0000000..c6e96fb --- /dev/null +++ b/simulation/state/FactionStateRecord.gd @@ -0,0 +1,179 @@ +class_name FactionStateRecord +extends RefCounted + +signal changed(state: FactionStateRecord) + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func _snap_food(value: float) -> float: + return snappedf(value, 1.0 / 64.0) + + +static func create( + faction_id: StringName, + display_name: String, + food: float, + warriors: int, + aggression: float, + morale: float +) -> FactionStateRecord: + return ( + FactionStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "faction_id": String(faction_id), + "display_name": display_name, + "food": _snap_food(food), + "warriors": warriors, + "aggression": clampf(aggression, 0.0, 1.0), + "morale": clampf(morale, 0.0, 1.0), + "stance": String(SimulationIds.STANCE_NEUTRAL), + "war_plan": String(SimulationIds.WAR_PLAN_NONE), + "plan_tick": -1, + "confidence": 0.0, + } + ) + ) + + +static func from_dictionary(record_data: Dictionary) -> FactionStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "faction_id", + "display_name", + "food", + "warriors", + "aggression", + "morale", + "stance", + "war_plan", + "plan_tick", + "confidence", + ] + ) + ): + return null + var faction_id := String(record_data["faction_id"]) + var display_name := String(record_data["display_name"]) + if faction_id.is_empty() or display_name.is_empty(): + return null + var food := float(record_data["food"]) + var warriors := int(record_data["warriors"]) + var aggression := float(record_data["aggression"]) + var morale := float(record_data["morale"]) + var confidence := float(record_data["confidence"]) + if ( + not is_finite(food) + or not is_finite(aggression) + or not is_finite(morale) + or not is_finite(confidence) + or food < 0.0 + or warriors < 0 + or aggression < 0.0 + or aggression > 1.0 + or morale < 0.0 + or morale > 1.0 + or confidence < 0.0 + or confidence > 1.0 + ): + return null + var normalized := record_data.duplicate(true) + normalized["schema_version"] = SCHEMA_VERSION + normalized["faction_id"] = faction_id + normalized["display_name"] = display_name + normalized["food"] = _snap_food(food) + normalized["warriors"] = warriors + normalized["aggression"] = aggression + normalized["morale"] = morale + normalized["stance"] = String(record_data["stance"]) + normalized["war_plan"] = String(record_data["war_plan"]) + normalized["plan_tick"] = int(record_data["plan_tick"]) + normalized["confidence"] = confidence + return FactionStateRecord.new(normalized) + + +func get_faction_id() -> StringName: + return StringName(data["faction_id"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_food() -> float: + return maxf(float(data["food"]), 0.0) + + +func get_warriors() -> int: + return int(data["warriors"]) + + +func get_aggression() -> float: + return clampf(float(data["aggression"]), 0.0, 1.0) + + +func get_morale() -> float: + return clampf(float(data["morale"]), 0.0, 1.0) + + +func get_stance() -> StringName: + return StringName(data["stance"]) + + +func get_war_plan() -> StringName: + return StringName(data["war_plan"]) + + +func get_plan_tick() -> int: + return int(data["plan_tick"]) + + +func get_confidence() -> float: + return clampf(float(data["confidence"]), 0.0, 1.0) + + +func set_food(value: float) -> void: + var next := _snap_food(maxf(value, 0.0)) + if is_equal_approx(next, get_food()): + return + data["food"] = next + changed.emit(self) + + +func set_morale(value: float) -> void: + var next := clampf(value, 0.0, 1.0) + if is_equal_approx(next, get_morale()): + return + data["morale"] = next + changed.emit(self) + + +func set_stance(value: StringName) -> void: + if String(value) == String(get_stance()): + return + data["stance"] = String(value) + changed.emit(self) + + +func set_war_plan(value: StringName, current_tick: int, confidence: float = -1.0) -> void: + data["war_plan"] = String(value) + data["plan_tick"] = current_tick + if confidence >= 0.0: + data["confidence"] = clampf(confidence, 0.0, 1.0) + changed.emit(self) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/FactionStateRecord.gd.uid b/simulation/state/FactionStateRecord.gd.uid new file mode 100644 index 0000000..05e4916 --- /dev/null +++ b/simulation/state/FactionStateRecord.gd.uid @@ -0,0 +1 @@ +uid://dm6nxs26kblf7 diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index 1025a57..4ab7ec4 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -2,7 +2,7 @@ class_name SimulationStateRecord extends RefCounted const SCHEMA_NAME := "the_steward.simulation" -const SCHEMA_VERSION := 13 +const SCHEMA_VERSION := 14 const LEGACY_SCHEMA_VERSION := 1 const EVENT_LEGACY_SCHEMA_VERSION := 2 const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3 @@ -14,6 +14,7 @@ const ANIMAL_LEGACY_SCHEMA_VERSION := 9 const ROUTINE_LEGACY_SCHEMA_VERSION := 10 const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11 const PLAYER_NEEDS_SCHEMA_VERSION := 12 +const CONFLICT_SCHEMA_VERSION := 13 const PREVIOUS_SCHEMA_VERSION := 7 var simulation: Dictionary @@ -27,6 +28,8 @@ var relationships: Array[RelationshipStateRecord] = [] var event_knowledge: Array[KnownEventStateRecord] = [] var opportunities: Array[OpportunityStateRecord] = [] var player: PlayerStateRecord +var combatants: Array[CombatantStateRecord] = [] +var factions: Array[FactionStateRecord] = [] func to_dictionary() -> Dictionary: @@ -55,6 +58,12 @@ func to_dictionary() -> Dictionary: var opportunity_data: Array[Dictionary] = [] for opportunity_record in opportunities: opportunity_data.append(opportunity_record.to_dictionary()) + var combatant_data: Array[Dictionary] = [] + for combatant_record in combatants: + combatant_data.append(combatant_record.to_dictionary()) + var faction_data: Array[Dictionary] = [] + for faction_record in factions: + faction_data.append(faction_record.to_dictionary()) return { "schema": SCHEMA_NAME, @@ -69,7 +78,9 @@ func to_dictionary() -> Dictionary: "relationships": relationship_data, "event_knowledge": knowledge_data, "opportunities": opportunity_data, - "player": player.to_dictionary() + "player": player.to_dictionary(), + "combatants": combatant_data, + "factions": faction_data } @@ -103,6 +114,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: ROUTINE_LEGACY_SCHEMA_VERSION, PLAYER_RELATIONSHIP_SCHEMA_VERSION, PLAYER_NEEDS_SCHEMA_VERSION, + CONFLICT_SCHEMA_VERSION, ] ): record_data = _migrate_legacy(record_data, version) @@ -123,6 +135,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: "event_knowledge", "opportunities", "player", + "combatants", + "factions", ] ) ): @@ -212,6 +226,35 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: if player_record == null: return null record.player = player_record + + var combatant_data = record_data["combatants"] + var faction_data = record_data["factions"] + if not combatant_data is Array or not faction_data is Array: + return null + var combatant_ids := {} + for item in combatant_data: + if not item is Dictionary: + return null + var combatant_record := CombatantStateRecord.from_dictionary(item) + if combatant_record == null: + return null + var combatant_id := String(combatant_record.get_combatant_id()) + if combatant_ids.has(combatant_id): + return null + combatant_ids[combatant_id] = true + record.combatants.append(combatant_record) + var faction_ids := {} + for item in faction_data: + if not item is Dictionary: + return null + var faction_record := FactionStateRecord.from_dictionary(item) + if faction_record == null: + return null + var faction_id := String(faction_record.get_faction_id()) + if faction_ids.has(faction_id): + return null + faction_ids[faction_id] = true + record.factions.append(faction_record) var npc_ids := {} var npc_records_by_id := {} @@ -227,6 +270,11 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord: npc_ids[npc_id] = true npc_records_by_id[npc_id] = npc_record record.npcs.append(npc_record) + for combatant_record in record.combatants: + if combatant_record.get_kind() != SimulationIds.COMBATANT_KIND_NPC: + continue + if not npc_ids.has(combatant_record.get_npc_id()): + return null var resource_ids := {} var resource_records_by_id := {} @@ -802,6 +850,20 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary migrated["schema_version"] = SCHEMA_VERSION if not migrated.has("player"): migrated["player"] = PlayerStateRecord.create_default().to_dictionary() + if version < CONFLICT_SCHEMA_VERSION: + migrated["combatants"] = [] + migrated["factions"] = [ + ( + FactionStateRecord + . create(SimulationIds.FACTION_VILLAGE, "Village", 20.0, 0, 0.2, 0.8) + . to_dictionary() + ), + ( + FactionStateRecord + . create(SimulationIds.FACTION_TRIBE, "Hill Tribe", 4.0, 4, 0.7, 0.7) + . to_dictionary() + ), + ] if version <= ANIMAL_LEGACY_SCHEMA_VERSION: migrated["animals"] = [] if version == LEGACY_SCHEMA_VERSION: @@ -864,6 +926,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary ROUTINE_LEGACY_SCHEMA_VERSION, PLAYER_RELATIONSHIP_SCHEMA_VERSION, PLAYER_NEEDS_SCHEMA_VERSION, + CONFLICT_SCHEMA_VERSION, ] ): migrated["opportunities"] = [] diff --git a/tests/conflict_war_system_test.gd b/tests/conflict_war_system_test.gd new file mode 100644 index 0000000..d33bd10 --- /dev/null +++ b/tests/conflict_war_system_test.gd @@ -0,0 +1,192 @@ +extends SceneTree + +var failures: Array[String] = [] +var war_outcomes: Array[StringName] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_combat_and_wolf() + _test_war_motivation() + _test_player_combat_and_restore() + _finish() + + +func _test_combat_and_wolf() -> void: + var manager := _create_manager(981) + var npc: SimNPC = manager.npcs[0] + npc.position = Vector3.ZERO + var npc_combatant: CombatantStateRecord = manager.get_combatant( + SimulationIds.npc_combatant_id(npc.id) + ) + _check( + ( + npc_combatant != null + and is_equal_approx(npc_combatant.get_health(), 100.0) + and npc_combatant.get_faction_id() == SimulationIds.FACTION_VILLAGE + ), + "Each living villager should gain a village combatant record" + ) + var wolf_id: StringName = manager.spawn_wolf(Vector3(1.0, 0.0, 0.0)) + _check(wolf_id != &"", "A hungry wolf should spawn as a hostile combatant") + var wolf: CombatantStateRecord = manager.get_combatant(wolf_id) + _check(wolf.is_hostile(), "A spawned wolf should be hostile") + manager.simulate_tick() + _check( + wolf.get_health() == 40.0 and npc_combatant.get_health() < 100.0, + "A nearby wolf should wound the villager with its claw" + ) + var hurt_found := _has_event(manager, SimulationIds.EVENT_COMBATANT_HURT) + _check(hurt_found, "The wolf's attack should record a combatant_hurt fact") + for _tick in 40: + manager.simulate_tick() + _check( + npc.is_dead or not wolf.is_alive(), + "A prolonged wolf fight should resolve with a death on one side" + ) + manager.free() + + +func _test_war_motivation() -> void: + var strong_manager := _create_manager(982) + _set_all_strength(strong_manager, 9.0) + strong_manager.war_resolved.connect(_on_war_resolved) + for _tick in 400: + strong_manager.simulate_tick() + var strong_plan: StringName = strong_manager.get_tribe_war_plan() + var strong_raiders := _count_raiders(strong_manager) + _check( + ( + strong_plan in [SimulationIds.WAR_PLAN_PLANNED, SimulationIds.WAR_PLAN_NONE] + and strong_raiders == 0 + ), + "A strong village should make the hungry tribe plan but never dare to raid" + ) + strong_manager.free() + + var weak_manager := _create_manager(983) + _set_all_strength(weak_manager, 1.0) + var weak_outcomes: Array[StringName] = [] + weak_manager.war_resolved.connect(func(outcome): weak_outcomes.append(outcome)) + for _tick in 200: + weak_manager.simulate_tick() + _check( + weak_manager.get_tribe_war_plan() == SimulationIds.WAR_PLAN_PLANNED, + "A hungry tribe should first watch a weak food-rich village before striking" + ) + for _tick in 200: + weak_manager.simulate_tick() + _check( + weak_manager.get_tribe_war_plan() == SimulationIds.WAR_PLAN_RAIDING, + "A weak village with plenty of food should be raided once the tribe commits" + ) + _check(_count_raiders(weak_manager) > 0, "The raid should spawn hostile raiders") + var resolved := false + for _tick in 600: + weak_manager.simulate_tick() + if weak_manager.get_tribe_war_plan() != SimulationIds.WAR_PLAN_RAIDING: + resolved = true + break + _check( + resolved and not weak_outcomes.is_empty(), + "The raid should resolve into a war outcome with recorded consequences" + ) + weak_manager.free() + + +func _test_player_combat_and_restore() -> void: + var manager := _create_manager(984) + var wolf_id: StringName = manager.spawn_wolf(Vector3(0.0, 0.0, 0.0)) + var wolf: CombatantStateRecord = manager.get_combatant(wolf_id) + _check( + is_equal_approx(manager.player_attack(wolf_id), 24.0), + "The player's sword strike should deal its configured damage" + ) + _check(is_equal_approx(wolf.get_health(), 16.0), "The wolf should survive one sword strike") + manager.player_attack(wolf_id) + _check(not wolf.is_alive(), "A second sword strike should kill the wolf") + _check( + _has_event(manager, SimulationIds.EVENT_COMBATANT_KILLED), + "The player's kill should record a combatant_killed fact" + ) + _check( + manager.get_living_hostile_combatants().is_empty(), + "The dead wolf should leave the living-hostile set" + ) + + var saved_json: String = manager.serialize_state() + var restored := _create_manager(985) + _check( + restored.restore_state_from_json(saved_json), + "Combatants and factions should survive schema-v14 save and restore" + ) + _check( + ( + restored.get_state_checksum() == manager.get_state_checksum() + and restored.get_faction(SimulationIds.FACTION_TRIBE) != null + and not restored.get_combatant(wolf_id).is_alive() + ), + "Restore should preserve combatant health, factions, and checksum" + ) + restored.free() + manager.free() + + +func _set_all_strength(manager: Node, strength: float) -> void: + for npc in manager.npcs: + npc.strength = strength + + +func _count_raiders(manager: Node) -> int: + var count := 0 + for combatant in manager.conflict_system.get_all_combatants(): + if combatant.get_kind() == SimulationIds.COMBATANT_KIND_RAIDER: + count += 1 + return count + + +func _has_event(manager: Node, event_type: StringName) -> bool: + for event in manager.economic_events: + if StringName(event.data["event_type"]) == event_type: + return true + return false + + +func _on_war_resolved(_outcome: StringName) -> void: + war_outcomes.append(_outcome) + + +func _create_manager(seed_value: int = 981) -> Node: + var manager: Node = load("res://simulation/SimulationManager.gd").new() + manager.simulation_seed = seed_value + manager.debug_logs = false + var home_positions: Array[Vector3] = [ + Vector3(0.0, 0.0, 0.0), + Vector3(2.0, 0.0, 0.0), + Vector3(10.0, 0.0, 0.0), + Vector3(12.0, 0.0, 0.0), + Vector3(30.0, 0.0, 30.0), + Vector3(40.0, 0.0, 40.0), + ] + manager.home_positions = home_positions + root.add_child(manager) + manager.set_process(false) + return manager + + +func _finish() -> void: + if failures.is_empty(): + print("[TEST] Conflict and war passed: combat, wolves, motivation, raids") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/conflict_war_system_test.gd.uid b/tests/conflict_war_system_test.gd.uid new file mode 100644 index 0000000..b4ce929 --- /dev/null +++ b/tests/conflict_war_system_test.gd.uid @@ -0,0 +1 @@ +uid://ch6e6vuiek3ju