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 c9839a66..9b5913f7 100644 --- a/src/game/engine/src/modules/ai/simple_heuristic_ai.gd +++ b/src/game/engine/src/modules/ai/simple_heuristic_ai.gd @@ -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(