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 39b42b27..c0f268e0 100644 --- a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd +++ b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd @@ -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():