From ced9601387182959a631cb6b1c37709dc0be65b9 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 00:30:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E2=9C=A8=20Add=20military=20unit?= =?UTF-8?q?=20health-based=20retreat=20logic=20to=20trigger=20strategic=20?= =?UTF-8?q?withdrawals=20when=20units=20fall=20below=20configurable=20heal?= =?UTF-8?q?th=20thresholds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/modules/ai/simple_heuristic_ai.gd | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd index 61528fb8..8af2fa38 100644 --- a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd +++ b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd @@ -25,7 +25,7 @@ const FOUND_MIN_DIST_OWN: int = 4 ## deadlock founders that spawned near each other (observed in arena ## smoke tests where start placement put both players on tile 0,0). const FOUND_MIN_DIST_ENEMY: int = 1 -const RETREAT_HP_FRACTION: float = 0.0 +const RETREAT_HP_FRACTION: float = 0.4 const DEFENSIVE_CHASE_RANGE: int = 12 const MILITARY_COMBAT_TYPES: Array[String] = [ "melee", "ranged", "cavalry", "siege", @@ -208,6 +208,22 @@ static func _tile_has_enemy_unit( return false +static func _count_own_military_at( + player: RefCounted, pos: Vector2i +) -> int: + var total: int = 0 + for u: Variant in player.units: + if u == null or not u.is_alive(): + continue + if u.get("can_found_city") == true: + continue + if int(u.get("attack")) <= 0 and int(u.get("ranged_attack")) <= 0: + continue + if u.position == pos: + total += 1 + return total + + static func _enemy_military_threat(player: RefCounted) -> Dictionary: ## count=in-range(<=8), total_count=all enemy combat. threatens_city @<=5. var count: int = 0 @@ -337,11 +353,25 @@ static func _decide_military_action( _score_away_from_pos(nearest_enemy.position), ) - # Adjacent attack if healthy enough. + # Garrison — hold home city when we're the only defender and no enemy + # is adjacent. Prevents the lone warrior from marching out to attack + # and leaving the city open, which cascaded into attrition spirals + # (warrior dies → rebuild → dies → ...). + var enemy_dist: int = INF_DISTANCE if nearest_enemy != null: - var enemy_dist: int = HexUtilsScript.hex_distance( + enemy_dist = HexUtilsScript.hex_distance( unit.position, nearest_enemy.position ) + if not player.cities.is_empty() and enemy_dist > 1: + var home_pos: Vector2i = (player.cities[0] as RefCounted).position + if ( + unit.position == home_pos + and _count_own_military_at(player, home_pos) <= 1 + ): + return {} + + # Adjacent attack if healthy enough. + if nearest_enemy != null: if enemy_dist == 1: return { "type": "attack", @@ -455,11 +485,13 @@ static func _decide_production( if not rush_unit.is_empty(): return _prod_unit(city_index, rush_unit) - # Priority 0: Early military floor — reach 2 warriors before committing - # to walls (70 prod, ~35 turns) or founder (70 prod). Two warriors at - # 20 prod each get us to a baseline garrison by ~T20 at 2 prod/turn, - # so walls land around T50 with a usable army rather than T35 with none. - if military_count < 2: + # Priority 0: Early military floor — maintain 4 warriors during the + # first 80 turns before committing to walls/happiness/founder. Early + # combat attrition (p0 harasses) was dropping mil to 1-2 by T75; this + # keeps the replacement pipeline ahead of losses so we hit mil≥4 by + # T100. After T80 the standard Priority 4 target takes over. + var early_mil_floor: int = 4 if GameState.turn_number <= 80 else 0 + if military_count < maxi(1, early_mil_floor): var emergency_unit: String = _pick_buildable_military_unit_id( city, player )