diff --git a/simulation/conflict/CombatantFactory.gd b/simulation/conflict/CombatantFactory.gd new file mode 100644 index 0000000..cad1617 --- /dev/null +++ b/simulation/conflict/CombatantFactory.gd @@ -0,0 +1,81 @@ +class_name CombatantFactory +extends RefCounted + +var _occupied_ids: Dictionary = {} +var _next_suffix_by_prefix: Dictionary = {} + + +func reset_allocator() -> void: + _occupied_ids.clear() + _next_suffix_by_prefix.clear() + + +func restore_allocator(records: Array[CombatantStateRecord]) -> void: + reset_allocator() + for record in records: + if record != null: + reserve_id(record.get_combatant_id()) + + +func reserve_id(combatant_id: StringName) -> bool: + if combatant_id.is_empty() or _occupied_ids.has(combatant_id): + return false + _occupied_ids[combatant_id] = true + _observe_numeric_suffix(combatant_id) + return true + + +func create_enemy(enemy_definition_id: StringName, position: Vector3) -> CombatantStateRecord: + return create_from_definition(SimulationEnemies.get_enemy(enemy_definition_id), position) + + +func create_from_definition(definition: EnemyDefinition, position: Vector3) -> CombatantStateRecord: + if definition == null or not definition.validate().is_empty() or not position.is_finite(): + return null + var combatant_id := _allocate_id(definition.combatant_id_prefix) + return CombatantStateRecord.create( + combatant_id, + definition.kind, + definition.faction_id, + definition.display_name, + position, + definition.health, + definition.weapon_id, + CombatantStateRecord.NO_NPC_ID, + definition.hostile, + definition.enemy_id + ) + + +func get_next_suffix(prefix: String) -> int: + return int(_next_suffix_by_prefix.get(prefix, 0)) + + +func is_reserved(combatant_id: StringName) -> bool: + return _occupied_ids.has(combatant_id) + + +func _allocate_id(prefix: String) -> StringName: + var suffix := get_next_suffix(prefix) + var candidate := StringName("%s_%d" % [prefix, suffix]) + while _occupied_ids.has(candidate): + suffix += 1 + candidate = StringName("%s_%d" % [prefix, suffix]) + _occupied_ids[candidate] = true + _next_suffix_by_prefix[prefix] = suffix + 1 + return candidate + + +func _observe_numeric_suffix(combatant_id: StringName) -> void: + var text_id := String(combatant_id) + var separator_index := text_id.rfind("_") + if separator_index <= 0 or separator_index >= text_id.length() - 1: + return + var suffix_text := text_id.substr(separator_index + 1) + if not suffix_text.is_valid_int(): + return + var suffix := int(suffix_text) + if suffix < 0: + return + var prefix := text_id.substr(0, separator_index) + _next_suffix_by_prefix[prefix] = maxi(get_next_suffix(prefix), suffix + 1) diff --git a/simulation/conflict/CombatantFactory.gd.uid b/simulation/conflict/CombatantFactory.gd.uid new file mode 100644 index 0000000..3e9f8a8 --- /dev/null +++ b/simulation/conflict/CombatantFactory.gd.uid @@ -0,0 +1 @@ +uid://dfxudn0m53uai diff --git a/simulation/conflict/ConflictSystem.gd b/simulation/conflict/ConflictSystem.gd index 9b66dc5..b880e12 100644 --- a/simulation/conflict/ConflictSystem.gd +++ b/simulation/conflict/ConflictSystem.gd @@ -34,8 +34,7 @@ var factions: Dictionary = {} var economy: RefCounted var npcs: Array[SimNPC] = [] var _npcs_by_id: Dictionary = {} -var next_combatant_id := 0 -var next_wolf_id := 0 +var combatant_factory := CombatantFactory.new() var current_tick := 0 var village_center := Vector3.ZERO var last_war_resolved_tick := -1 @@ -222,24 +221,18 @@ func player_attack(combatant_id: StringName) -> float: func spawn_wolf(position: Vector3) -> StringName: - var definition := SimulationEnemies.get_enemy(&"enemy_wolf") - 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, - definition.display_name if definition != null else "Wolf", - position, - definition.health if definition != null else 40.0, - definition.weapon_id if definition != null else SimulationIds.ITEM_CLAW, - CombatantStateRecord.NO_NPC_ID, - true - ) - combatants[wolf_id] = wolf + 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(wolf_id) - return wolf_id + combatant_spawned.emit(combatant_id) + return combatant_id func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -> float: @@ -291,7 +284,11 @@ func _advance_wolves() -> void: var combatant := combatants[combatant_id] as CombatantStateRecord if combatant == null or not combatant.is_alive(): continue - if combatant.get_kind() != SimulationIds.COMBATANT_KIND_WOLF: + 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: @@ -302,8 +299,7 @@ func _advance_wolves() -> void: var to_target := target.get_position() - combatant.get_position() to_target.y = 0.0 if to_target.length() > reach: - var definition := SimulationEnemies.get_enemy(&"enemy_wolf") - var speed := definition.move_speed if definition != null else 3.2 + 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) @@ -422,25 +418,17 @@ func village_defender_count() -> int: func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void: - var definition := SimulationEnemies.get_enemy(&"enemy_raider") 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, - definition.display_name if definition != null else "Raider", - village_center + RAID_SPAWN_OFFSET + offset, - definition.health if definition != null else 60.0, - definition.weapon_id if definition != null else SimulationIds.ITEM_SWORD, - CombatantStateRecord.NO_NPC_ID, - true + 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) @@ -695,6 +683,12 @@ 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, @@ -738,6 +732,7 @@ func restore_state_records( 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: diff --git a/simulation/definitions/EnemyDefinition.gd b/simulation/definitions/EnemyDefinition.gd index e39d7a5..0d3b60d 100644 --- a/simulation/definitions/EnemyDefinition.gd +++ b/simulation/definitions/EnemyDefinition.gd @@ -1,9 +1,20 @@ class_name EnemyDefinition extends Resource +const BEHAVIOR_PROFILE_RAID_ASSAULT := &"raid_assault" +const BEHAVIOR_PROFILE_WOLF_HUNT := &"wolf_hunt" +const VALID_BEHAVIOR_PROFILE_IDS := [ + BEHAVIOR_PROFILE_RAID_ASSAULT, + BEHAVIOR_PROFILE_WOLF_HUNT, +] + @export var enemy_id: StringName @export var display_name: String @export var kind: StringName = SimulationIds.COMBATANT_KIND_RAIDER +@export var behavior_profile_id: StringName = BEHAVIOR_PROFILE_RAID_ASSAULT +@export var combatant_id_prefix := "enemy" +@export var faction_id: StringName = SimulationIds.FACTION_TRIBE +@export var hostile := true @export var weapon_id: StringName = SimulationIds.ITEM_SWORD @export var health := 60.0 @export var move_speed := 3.5 @@ -18,6 +29,18 @@ func validate() -> Array[String]: errors.append("enemy_id is empty") if display_name.is_empty(): errors.append("display_name is empty for '%s'" % enemy_id) + if behavior_profile_id not in VALID_BEHAVIOR_PROFILE_IDS: + errors.append( + "behavior_profile_id '%s' is invalid for '%s'" % [behavior_profile_id, enemy_id] + ) + if combatant_id_prefix.is_empty(): + errors.append("combatant_id_prefix is empty for '%s'" % enemy_id) + elif not combatant_id_prefix.is_valid_identifier(): + errors.append( + "combatant_id_prefix '%s' is invalid for '%s'" % [combatant_id_prefix, enemy_id] + ) + if faction_id.is_empty(): + errors.append("faction_id is empty for '%s'" % enemy_id) if ( kind not in [ diff --git a/simulation/definitions/enemies/boar.tres b/simulation/definitions/enemies/boar.tres new file mode 100644 index 0000000..4f03bcd --- /dev/null +++ b/simulation/definitions/enemies/boar.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://simulation/definitions/EnemyDefinition.gd" id="1"] + +[resource] +script = ExtResource("1") +enemy_id = &"enemy_boar" +display_name = "Wild Boar" +kind = &"wolf" +behavior_profile_id = &"wolf_hunt" +combatant_id_prefix = "boar" +faction_id = &"faction_tribe" +hostile = true +weapon_id = &"item_claw" +health = 55.0 +move_speed = 3.6 +body_color = Color(0.28, 0.16, 0.08, 1) +accent_color = Color(0.78, 0.68, 0.5, 1) +visual_scale = Vector3(1.05, 0.9, 1.15) diff --git a/simulation/definitions/enemies/raider.tres b/simulation/definitions/enemies/raider.tres index 44df45c..2eae6ad 100644 --- a/simulation/definitions/enemies/raider.tres +++ b/simulation/definitions/enemies/raider.tres @@ -7,6 +7,10 @@ script = ExtResource("1") enemy_id = &"enemy_raider" display_name = "Raider" kind = &"raider" +behavior_profile_id = &"raid_assault" +combatant_id_prefix = "raider" +faction_id = &"faction_tribe" +hostile = true weapon_id = &"item_sword" health = 60.0 move_speed = 3.5 diff --git a/simulation/definitions/enemies/wolf.tres b/simulation/definitions/enemies/wolf.tres index 096574d..9f3a06f 100644 --- a/simulation/definitions/enemies/wolf.tres +++ b/simulation/definitions/enemies/wolf.tres @@ -7,6 +7,10 @@ script = ExtResource("1") enemy_id = &"enemy_wolf" display_name = "Wolf" kind = &"wolf" +behavior_profile_id = &"wolf_hunt" +combatant_id_prefix = "wolf" +faction_id = &"faction_tribe" +hostile = true weapon_id = &"item_claw" health = 40.0 move_speed = 4.2 diff --git a/simulation/definitions/packs/core.tres b/simulation/definitions/packs/core.tres index 2e4bb0b..29b3756 100644 --- a/simulation/definitions/packs/core.tres +++ b/simulation/definitions/packs/core.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=27 format=3] +[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=28 format=3] [ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"] [ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"] @@ -25,6 +25,7 @@ [ext_resource type="Resource" path="res://simulation/definitions/items/wood.tres" id="23_wood"] [ext_resource type="Resource" path="res://simulation/definitions/enemies/raider.tres" id="24_raider"] [ext_resource type="Resource" path="res://simulation/definitions/enemies/wolf.tres" id="25_wolf"] +[ext_resource type="Resource" path="res://simulation/definitions/enemies/boar.tres" id="26_boar"] [resource] script = ExtResource("1_pack") @@ -33,4 +34,4 @@ display_name = "The Steward Core" actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")] professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")] items = [ExtResource("20_claw"), ExtResource("21_food"), ExtResource("22_sword"), ExtResource("23_wood")] -enemies = [ExtResource("24_raider"), ExtResource("25_wolf")] +enemies = [ExtResource("24_raider"), ExtResource("25_wolf"), ExtResource("26_boar")] diff --git a/simulation/state/CombatantStateRecord.gd b/simulation/state/CombatantStateRecord.gd index 2c786bb..b6b5987 100644 --- a/simulation/state/CombatantStateRecord.gd +++ b/simulation/state/CombatantStateRecord.gd @@ -3,8 +3,12 @@ extends RefCounted signal changed(state: CombatantStateRecord) -const SCHEMA_VERSION := 1 +const SCHEMA_VERSION := 2 +const LEGACY_SCHEMA_VERSION := 1 const NO_NPC_ID := -1 +const NO_ENEMY_DEFINITION_ID := &"" +const LEGACY_RAIDER_DEFINITION_ID := &"enemy_raider" +const LEGACY_WOLF_DEFINITION_ID := &"enemy_wolf" var data: Dictionary @@ -26,9 +30,13 @@ static func create( max_health: float, weapon_id: StringName, npc_id: int = NO_NPC_ID, - hostile: bool = false + hostile: bool = false, + enemy_definition_id: StringName = NO_ENEMY_DEFINITION_ID ) -> CombatantStateRecord: var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z)) + var canonical_enemy_definition_id := enemy_definition_id + if canonical_enemy_definition_id.is_empty(): + canonical_enemy_definition_id = _legacy_definition_id_for_kind(kind) return ( CombatantStateRecord . new( @@ -39,6 +47,7 @@ static func create( "faction_id": String(faction_id), "display_name": display_name, "npc_id": npc_id, + "enemy_definition_id": String(canonical_enemy_definition_id), "position": [snapped_position.x, snapped_position.y, snapped_position.z], "health": max_health, "max_health": max_health, @@ -52,10 +61,17 @@ static func create( static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: - if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + var version := int(record_data.get("schema_version", -1)) + var normalized := record_data.duplicate(true) + if version == LEGACY_SCHEMA_VERSION: + normalized["schema_version"] = SCHEMA_VERSION + normalized["enemy_definition_id"] = String( + _legacy_definition_id_for_kind(StringName(normalized.get("kind", &""))) + ) + elif version != SCHEMA_VERSION: return null if not ( - record_data + normalized . has_all( [ "combatant_id", @@ -63,6 +79,7 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: "faction_id", "display_name", "npc_id", + "enemy_definition_id", "position", "health", "max_health", @@ -74,12 +91,12 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: ) ): return null - var combatant_id := String(record_data["combatant_id"]) - var display_name := String(record_data["display_name"]) + var combatant_id := String(normalized["combatant_id"]) + var display_name := String(normalized["display_name"]) if combatant_id.is_empty() or display_name.is_empty(): return null if ( - StringName(record_data["kind"]) + StringName(normalized["kind"]) not in [ SimulationIds.COMBATANT_KIND_NPC, SimulationIds.COMBATANT_KIND_RAIDER, @@ -88,15 +105,20 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: ] ): return null - var saved_position = record_data["position"] + var kind := StringName(normalized["kind"]) + var enemy_definition_id := StringName(normalized["enemy_definition_id"]) + var is_enemy := kind in [SimulationIds.COMBATANT_KIND_RAIDER, SimulationIds.COMBATANT_KIND_WOLF] + if is_enemy == enemy_definition_id.is_empty(): + return null + var saved_position = normalized["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"]) + var health := float(normalized["health"]) + var max_health := float(normalized["max_health"]) + var npc_id := int(normalized["npc_id"]) if ( not is_finite(health) or not is_finite(max_health) @@ -104,16 +126,16 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: or max_health <= 0.0 or health > max_health or npc_id < NO_NPC_ID - or int(record_data["attack_cooldown"]) < 0 + or int(normalized["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["kind"] = String(kind) + normalized["faction_id"] = String(normalized["faction_id"]) normalized["display_name"] = display_name normalized["npc_id"] = npc_id + normalized["enemy_definition_id"] = String(enemy_definition_id) normalized["position"] = [ _snap(float(saved_position[0])), _snap(float(saved_position[1])), @@ -121,10 +143,10 @@ static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: ] 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"]) + normalized["weapon_id"] = String(normalized["weapon_id"]) + normalized["alive"] = bool(normalized["alive"]) + normalized["hostile"] = bool(normalized["hostile"]) + normalized["attack_cooldown"] = int(normalized["attack_cooldown"]) return CombatantStateRecord.new(normalized) @@ -148,6 +170,10 @@ func get_npc_id() -> int: return int(data["npc_id"]) +func get_enemy_definition_id() -> StringName: + return StringName(data["enemy_definition_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])) @@ -235,3 +261,11 @@ func _weapon_cooldown_ticks(tick_interval: float) -> int: func to_dictionary() -> Dictionary: return data.duplicate(true) + + +static func _legacy_definition_id_for_kind(kind: StringName) -> StringName: + if kind == SimulationIds.COMBATANT_KIND_RAIDER: + return LEGACY_RAIDER_DEFINITION_ID + if kind == SimulationIds.COMBATANT_KIND_WOLF: + return LEGACY_WOLF_DEFINITION_ID + return NO_ENEMY_DEFINITION_ID diff --git a/tests/combat_patterns_test.gd b/tests/combat_patterns_test.gd index 829572b..ec8ae00 100644 --- a/tests/combat_patterns_test.gd +++ b/tests/combat_patterns_test.gd @@ -111,6 +111,7 @@ func _test_defend_selection() -> void: "A strong idle villager should rally to defend during an active raid" ) var worker: SimNPC = manager.npcs[1] + worker.profession = SimulationIds.PROFESSION_FARMER worker.strength = 1.0 worker.hunger = 20.0 worker.energy = 90.0 diff --git a/tests/content_catalog_test.gd b/tests/content_catalog_test.gd index 1b1afa0..cb05873 100644 --- a/tests/content_catalog_test.gd +++ b/tests/content_catalog_test.gd @@ -51,7 +51,7 @@ func _test_core_pack() -> void: "Core items should include food, wood, sword, and claw in stable-ID order" ) _check( - _enemy_ids(catalog.get_enemies()) == [&"enemy_raider", &"enemy_wolf"], + _enemy_ids(catalog.get_enemies()) == [&"enemy_boar", &"enemy_raider", &"enemy_wolf"], "Core enemies should enumerate by stable ID" ) var food := catalog.get_item(SimulationIds.RESOURCE_FOOD) @@ -69,10 +69,15 @@ func _test_core_pack() -> void: "Claw should preserve its weapon contract" ) var wolf := catalog.get_enemy(&"enemy_wolf") + var boar := catalog.get_enemy(&"enemy_boar") _check( wolf != null and wolf.weapon_id == SimulationIds.ITEM_CLAW, "Enemy references should resolve against catalog items" ) + _check( + boar != null and boar.behavior_profile_id == wolf.behavior_profile_id, + "New enemy types should be able to reuse an existing behavior profile" + ) func _test_deterministic_enumeration_and_dependencies() -> void: diff --git a/tests/unit/test_combatant_factory.gd b/tests/unit/test_combatant_factory.gd new file mode 100644 index 0000000..71a6e2d --- /dev/null +++ b/tests/unit/test_combatant_factory.gd @@ -0,0 +1,107 @@ +extends GutTest + + +func test_core_boar_reuses_wolf_behavior_and_spawns_from_data() -> void: + SimulationEnemies.invalidate_cache() + var wolf := SimulationEnemies.get_enemy(&"enemy_wolf") + var boar := SimulationEnemies.get_enemy(&"enemy_boar") + + assert_not_null(wolf) + assert_not_null(boar) + assert_eq(boar.behavior_profile_id, wolf.behavior_profile_id) + assert_eq(boar.behavior_profile_id, EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT) + assert_eq(boar.combatant_id_prefix, "boar") + var factory := CombatantFactory.new() + var combatant := factory.create_enemy(boar.enemy_id, Vector3(1.0, 2.0, 3.0)) + assert_not_null(combatant) + assert_eq(combatant.get_combatant_id(), &"boar_0") + assert_eq(combatant.get_enemy_definition_id(), boar.enemy_id) + assert_eq(combatant.get_kind(), boar.kind) + assert_eq(combatant.get_faction_id(), boar.faction_id) + assert_eq(combatant.get_weapon_id(), boar.weapon_id) + assert_eq(combatant.get_max_health(), boar.health) + assert_eq(combatant.is_hostile(), boar.hostile) + + +func test_factory_accepts_a_definition_without_enemy_specific_code() -> void: + var definition := EnemyDefinition.new() + definition.enemy_id = &"enemy_test_hound" + definition.display_name = "Test Hound" + definition.kind = SimulationIds.COMBATANT_KIND_WOLF + definition.behavior_profile_id = EnemyDefinition.BEHAVIOR_PROFILE_WOLF_HUNT + definition.combatant_id_prefix = "test_hound" + definition.faction_id = SimulationIds.FACTION_TRIBE + definition.hostile = true + definition.weapon_id = SimulationIds.ITEM_CLAW + definition.health = 37.0 + var factory := CombatantFactory.new() + + assert_true(definition.validate().is_empty()) + var combatant := factory.create_from_definition(definition, Vector3.ZERO) + assert_not_null(combatant) + assert_eq(combatant.get_combatant_id(), &"test_hound_0") + assert_eq(combatant.get_enemy_definition_id(), definition.enemy_id) + assert_eq(combatant.get_max_health(), 37.0) + + +func test_enemy_definition_id_round_trips_and_v1_infers_legacy_ids() -> void: + var current := CombatantStateRecord.create( + &"boar_7", + SimulationIds.COMBATANT_KIND_WOLF, + SimulationIds.FACTION_TRIBE, + "Wild Boar", + Vector3.ZERO, + 55.0, + SimulationIds.ITEM_CLAW, + CombatantStateRecord.NO_NPC_ID, + true, + &"enemy_boar" + ) + var restored := CombatantStateRecord.from_dictionary(current.to_dictionary()) + assert_not_null(restored) + assert_eq(restored.get_enemy_definition_id(), &"enemy_boar") + + var legacy_data := current.to_dictionary() + legacy_data["schema_version"] = CombatantStateRecord.LEGACY_SCHEMA_VERSION + legacy_data.erase("enemy_definition_id") + var migrated := CombatantStateRecord.from_dictionary(legacy_data) + assert_not_null(migrated) + assert_eq(migrated.get_enemy_definition_id(), &"enemy_wolf") + assert_eq(int(migrated.to_dictionary()["schema_version"]), CombatantStateRecord.SCHEMA_VERSION) + + +func test_conflict_restore_rebuilds_collision_free_definition_allocators() -> void: + var records: Array[CombatantStateRecord] = [ + _enemy_record(&"wolf_0", &"enemy_wolf"), + _enemy_record(&"wolf_4", &"enemy_wolf"), + _enemy_record(&"boar_2", &"enemy_boar"), + ] + var no_factions: Array[FactionStateRecord] = [] + var conflict := ConflictSystem.new() + conflict.restore_state_records(records, no_factions, 12) + + var wolf_id := conflict.spawn_wolf(Vector3.ZERO) + var boar_id := conflict.spawn_enemy(&"enemy_boar", Vector3.ONE) + assert_eq(wolf_id, &"wolf_5") + assert_eq(boar_id, &"boar_3") + assert_eq(conflict.get_combatant(wolf_id).get_enemy_definition_id(), &"enemy_wolf") + assert_eq(conflict.get_combatant(boar_id).get_enemy_definition_id(), &"enemy_boar") + assert_eq(conflict.get_all_combatants().size(), 6) + + +func _enemy_record( + combatant_id: StringName, enemy_definition_id: StringName +) -> CombatantStateRecord: + var definition := SimulationEnemies.get_enemy(enemy_definition_id) + return CombatantStateRecord.create( + combatant_id, + definition.kind, + definition.faction_id, + definition.display_name, + Vector3.ZERO, + definition.health, + definition.weapon_id, + CombatantStateRecord.NO_NPC_ID, + definition.hostile, + definition.enemy_id + )