feat(ai): Implement heuristic logic to prioritize attacking adjacent enemy cities for AI units

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 14:32:31 -07:00
parent 35dc508f70
commit ca2e70240f

View file

@ -371,8 +371,27 @@ static func _decide_military_action(
var hp_frac: float = float(unit.hp) / maxf(1.0, float(unit.max_hp))
var nearest_enemy: Variant = _nearest_enemy_unit(unit.position, enemy_units)
# Retreat if wounded and a threat is within reach.
if hp_frac <= RETREAT_HP_FRACTION and nearest_enemy != null:
# Adjacent-to-enemy-city attack ALWAYS fires (captures undefended cities and
# grinds garrisons). Commit flag below also blocks retreat so we don't bleed
# turns healing next to the target while p0 rebuilds walls.
var city_adj_col: int = -1
var city_adj_row: int = -1
for cp: Vector2i in enemy_city_positions:
if HexUtilsScript.hex_distance(unit.position, cp) == 1:
city_adj_col = cp.x
city_adj_row = cp.y
break
if city_adj_col >= 0:
return {"type": "attack", "unit_index": idx,
"target_col": city_adj_col, "target_row": city_adj_row}
# Retreat if wounded — but not while committed to a capture push (enemy
# city within 4). Letting p1 retreat from a stalled siege is why 0 captures
# across 3 seeds despite 10x kill ratio in the field.
var city_dist: int = INF_DISTANCE
if not enemy_city_positions.is_empty():
city_dist = _min_distance(unit.position, enemy_city_positions)
if hp_frac <= RETREAT_HP_FRACTION and nearest_enemy != null and city_dist > 4:
return _move_action(
idx,
unit.position,
@ -408,8 +427,27 @@ static func _decide_military_action(
}
var aggression: int = int(personality.get("aggression", 0))
# Dominance redirect: when we outnumber the enemy field 2x and have a
# city target closer than the nearest stray enemy, march on the city
# instead of chasing. Prevents p1's 6-7 military from bleeding turns
# on a lone wandering p0 unit while the capital sits undefended.
var enemy_mil_count: int = 0
for eu: Variant in enemy_units:
if int(eu.get("attack")) > 0 or int(eu.get("ranged_attack")) > 0:
enemy_mil_count += 1
var own_mil_count: int = 0
for ou: Variant in player.units:
if ou == null or not ou.is_alive() or ou.get("can_found_city") == true:
continue
if int(ou.get("attack")) > 0 or int(ou.get("ranged_attack")) > 0:
own_mil_count += 1
var dominant: bool = (
own_mil_count >= 2 * maxi(1, enemy_mil_count)
and not enemy_city_positions.is_empty()
and city_dist <= enemy_dist
)
var should_chase: bool = (
aggression > 0 or enemy_dist <= DEFENSIVE_CHASE_RANGE
not dominant and (aggression > 0 or enemy_dist <= DEFENSIVE_CHASE_RANGE)
)
if should_chase:
return _move_action(