feat(ai): Add military unit health-based retreat logic to trigger strategic withdrawals when units fall below configurable health thresholds

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 00:30:54 -07:00
parent 926ee5777e
commit ced9601387

View file

@ -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
)