From 5980926b84508f8cb885a793bcd102c7bf0fff0b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 10 Apr 2026 19:20:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E2=9C=A8=20Add=20AI=20turn=20bridg?= =?UTF-8?q?e=20class=20to=20coordinate=20AI=20components=20and=20game=20st?= =?UTF-8?q?ate=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/src/modules/ai/ai_turn_bridge.gd | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index 298556a9..056714ad 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -13,6 +13,12 @@ extends RefCounted const CityScript: GDScript = preload("res://engine/src/entities/city.gd") const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const CombatResolverScript: GDScript = preload( + "res://engine/src/modules/combat/combat_resolver.gd" +) +const CombatUtilsScript: GDScript = preload( + "res://engine/src/modules/combat/combat_utils.gd" +) ## Run all AI decisions for one player, apply them, then return the count @@ -198,8 +204,7 @@ static func _apply_action(action: Dictionary, player: RefCounted) -> bool: "set_production": return _apply_set_production(action, player) "attack": - push_warning("AiTurnBridge: 'attack' action not yet wired — skipping") - return false + return _apply_attack(action, player) push_warning("AiTurnBridge: unknown action type '%s'" % action_type) return false @@ -267,6 +272,60 @@ static func _apply_set_production(action: Dictionary, player: RefCounted) -> boo return true +static func _apply_attack(action: Dictionary, player: RefCounted) -> bool: + ## Resolve an AI-initiated attack via the same combat path the human UI uses. + ## CombatResolver.resolve() handles HP, death, XP, signals, and capture; we + ## only spend the attacker's movement and do basic input validation here. + var idx: int = int(action.get("unit_index", -1)) + if idx < 0 or idx >= player.units.size(): + return false + var attacker: Unit = player.units[idx] as Unit + if attacker == null or not attacker.is_alive(): + return false + if attacker.movement_remaining <= 0: + return false + + var target: Vector2i = Vector2i( + int(action.get("target_col", 0)), + int(action.get("target_row", 0)), + ) + + var primary: Dictionary = GameState.get_primary_layer() + var all_units: Array = primary.get("units", []) + var defender: RefCounted = _find_enemy_at(target, player.index, all_units) + if defender == null: + defender = CombatUtilsScript.get_city_at(target) + if defender == null or defender.owner == player.index: + return false + + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return false + + var resolver: RefCounted = CombatResolverScript.new() + resolver.resolve(attacker, defender, game_map, all_units) + attacker.movement_remaining = 0 + return true + + +static func _find_enemy_at( + pos: Vector2i, attacker_owner: int, all_units: Array +) -> RefCounted: + ## Return the first enemy unit standing on `pos`, or null. Mirrors + ## world_map_combat.get_enemy_at so AI and human paths agree on targeting. + for unit_ref: Variant in all_units: + if not unit_ref is UnitScript: + continue + if unit_ref.position != pos: + continue + if unit_ref.owner == attacker_owner: + continue + if not unit_ref.is_alive(): + continue + return unit_ref + return null + + static func _generate_city_name(player: RefCounted) -> String: var race_data: Dictionary = DataLoader.get_race(player.race_id) var city_names: Array = race_data.get("city_names", [])