feat(ai): Implement military threat detection and defensive unit purchasing heuristics

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-15 22:39:54 -07:00
parent dad8673a8d
commit 8ae2193bf3

View file

@ -46,6 +46,10 @@ static func process_player(player: RefCounted) -> Array:
player
)
var threat: Dictionary = _enemy_military_threat(player)
if bool(threat.get("threatens_city", false)):
_rush_buy_defenders(player, threat)
# Units: founders first (expansion), then military.
for idx: int in player.units.size():
var unit: Variant = player.units[idx]
@ -194,6 +198,54 @@ static func _tile_has_enemy_unit(
return false
static func _enemy_military_threat(player: RefCounted) -> Dictionary:
## count=in-range(<=8), total_count=all enemy combat. threatens_city @<=5.
var count: int = 0
var total: int = 0
var nearest: int = INF_DISTANCE
var spawn: RefCounted = null
for eu: Variant in _collect_enemy_units(player):
if int(eu.get("attack")) <= 0 and int(eu.get("ranged_attack")) <= 0:
continue
total += 1
var min_d: int = INF_DISTANCE
var best_c: RefCounted = null
for c: RefCounted in player.cities:
var d: int = HexUtilsScript.hex_distance(eu.position, c.position)
if d < min_d: min_d = d; best_c = c
if min_d <= 8: count += 1
if min_d < nearest: nearest = min_d; spawn = best_c
return {"count": count, "total_count": total, "nearest_dist": nearest,
"threatens_city": nearest <= 5, "spawn_city": spawn}
static func _rush_buy_defenders(player: RefCounted, threat: Dictionary) -> void:
## Spawn warriors at threat.spawn_city while gold >= 50 and under
## max(2, threat+1) defenders (hard cap 3x cities). Direct-spawn pattern
## from auto_play.gd so the bridge needs no new action type.
var spawn_city: RefCounted = threat.get("spawn_city")
if spawn_city == null: return
var mil: int = 0
for u: Variant in player.units:
if u == null or not u.is_alive() or u.get("can_found_city") == true:
continue
if int(u.get("attack")) > 0 or int(u.get("ranged_attack")) > 0:
mil += 1
var target: int = maxi(2, int(threat.get("count", 0)) + 1)
var cap: int = player.cities.size() * 3
while player.gold >= 50 and mil < target and mil < cap:
var nu: RefCounted = UnitScript.new(
"warrior", player.index, spawn_city.position
)
nu.id = "rush_%d_%d" % [GameState.turn_number, mil]
nu.display_name = "Warrior"
player.units.append(nu)
GameState.get_primary_layer().get("units", []).append(nu)
player.gold -= 50
mil += 1
EventBus.unit_created.emit(nu, player.index)
# ── Founder logic ────────────────────────────────────────────────────────
@ -374,6 +426,19 @@ static func _decide_production(
var city: RefCounted = player.cities[city_index]
var city_count: int = player.cities.size()
var threat: Dictionary = _enemy_military_threat(player)
var threatened: bool = bool(threat.get("threatens_city", false))
var enemy_total: int = int(threat.get("total_count", 0))
# Threat preemption: when an enemy stack is closing on a city, force
# military production over walls/happiness/founders until we can field
# at least enemy_total + 1 defenders (matches opponent's full army, not
# just the in-range slice — in-range saturates while reserves escalate).
if threatened and military_count < maxi(3, enemy_total + 1):
var rush_unit: String = _pick_buildable_military_unit_id(city, player)
if not rush_unit.is_empty():
return _prod_unit(city_index, rush_unit)
# Priority 0: Emergency garrison — no military at all means the next
# enemy stack wins uncontested before any wall is finished. A single
# warrior buys ~10 turns of breathing room at a fraction of walls'
@ -408,8 +473,14 @@ static func _decide_production(
):
return _prod_unit(city_index, "founder")
# Priority 4: Military — maintain 2 warriors per city
var want_military: bool = military_count < maxi(2, city_count * 2)
# Priority 4: Military — maintain 2 warriors per city, scaling up to
# match enemy's FULL army when they're closing on us so we don't lose
# on parity once reserves arrive.
var enemy_mil: int = enemy_total if threatened else 0
var mil_target: int = maxi(2, city_count * 2)
if enemy_mil > 0:
mil_target = maxi(mil_target, enemy_mil + 1)
var want_military: bool = military_count < mil_target
if want_military:
var unit_id: String = _pick_buildable_military_unit_id(city, player)
if not unit_id.is_empty():