From 09ea4f7bec5b4c4cf95bddaa64f7d298b6c07cc6 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 15 Apr 2026 19:35:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(game-engine):=20=E2=9C=A8=20Refine=20scori?= =?UTF-8?q?ng=20logic=20in=20SimpleHeuristicAI=20for=20attack/consolidate?= =?UTF-8?q?=20decision-making?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/scenes/tests/auto_play.gd | 79 ++- src/game/engine/src/simple_heuristic_ai.gd | 610 +++++++++++++++++++++ 2 files changed, 661 insertions(+), 28 deletions(-) create mode 100644 src/game/engine/src/simple_heuristic_ai.gd diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index f5e8655c..7f7c0072 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -542,38 +542,61 @@ func _play_turn() -> void: var intel: Dictionary = _get_enemy_intel() var enemy_mil: int = intel.get("military", 0) - var advantage: float = float(military_count) / maxf(1.0, float(enemy_mil)) - # Option B: loosen attack trigger — we're killing more per engagement so - # near-parity is enough to press. Commit via hysteresis (10 turns) to prevent - # BUILD/ATTACK ping-pong when ratios oscillate at the threshold. - var enemy_city_in_range: bool = false + var attack_score: float = 0.0 + var consolidate_score: float = 0.0 + + var p_idx: int = player.index + var my_kills: int = int(_stats[p_idx].get("kills", 0)) if _stats.has(p_idx) else 0 + var my_losses: int = int(_stats[p_idx].get("units_lost", 0)) if _stats.has(p_idx) else 0 + var kill_ratio: float = float(my_kills) / maxf(1.0, float(my_losses)) + if kill_ratio > 1.0: + attack_score += 5.0 * kill_ratio + if military_count > enemy_mil: + attack_score += 3.0 + var enemy_city_nearby: bool = false + var enemy_city_wounded: bool = false for p_scan: Variant in GameState.players: if p_scan.index == player.index: continue for c_scan: Variant in p_scan.cities: + if c_scan.hp < c_scan.max_hp * 0.5: + enemy_city_wounded = true for u_scan: Variant in units_snapshot: if not u_scan.is_alive() or u_scan.get("can_found_city") == true: continue if u_scan.get("can_build_improvements") == true: continue - if HexUtilsScript.hex_distance(u_scan.position, c_scan.position) <= 6: - enemy_city_in_range = true - break - if enemy_city_in_range: - break - if enemy_city_in_range: - break - var trigger_attack: bool = ( - advantage >= 1.1 - or (military_count >= 3 and enemy_city_in_range) - or (military_count >= 3 and enemy_mil == 0) - ) - # Enter ATTACK (or refresh counter) on fresh trigger; otherwise decrement. - if trigger_attack and _attack_phase_turns_remaining <= 0: - _attack_phase_turns_remaining = 10 - var should_attack: bool = _attack_phase_turns_remaining > 0 - if _attack_phase_turns_remaining > 0: - _attack_phase_turns_remaining -= 1 + if HexUtilsScript.hex_distance(u_scan.position, c_scan.position) <= 8: + enemy_city_nearby = true + if enemy_city_nearby: + attack_score += 4.0 + if enemy_city_wounded: + attack_score += 6.0 + var my_captures: int = int(_stats[p_idx].get("cities_captured", 0)) if _stats.has(p_idx) else 0 + if _turn_count > 100 and my_captures == 0: + attack_score += 3.0 + + var own_threatened: bool = false + for c_own: Variant in player.cities: + for p_en: Variant in GameState.players: + if p_en.index == player.index: + continue + for u_en: Variant in p_en.units: + if u_en.is_alive() and HexUtilsScript.hex_distance(u_en.position, c_own.position) <= 4: + own_threatened = true + if own_threatened: + consolidate_score += 5.0 + var gpt_now: int = int(player.get("gold_per_turn")) if player.get("gold_per_turn") != null else 0 + if gpt_now < -3: + consolidate_score += 3.0 + if military_count < 2: + consolidate_score += 4.0 + + if _attack_commitment_turns <= 0 and attack_score > consolidate_score: + _attack_commitment_turns = 5 + var should_attack: bool = _attack_commitment_turns > 0 + if _attack_commitment_turns > 0: + _attack_commitment_turns -= 1 if should_attack: # ATTACK PHASE: lock onto one target and march until it's destroyed if _locked_target == Vector2i(-1, -1): @@ -593,7 +616,7 @@ func _play_turn() -> void: _locked_target = _find_attack_target(player) _target_stuck_turns = 0 # Target fell (capture/kill) — refresh hysteresis to press the next one. - _attack_phase_turns_remaining = 10 + _attack_commitment_turns = 5 # Detect stuck warriors — if army hasn't moved in 20 turns, pick new target # Track closest warrior distance to target var min_dist: int = 999 @@ -790,12 +813,12 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder ## Score candidates from current state; return highest. See plan: cosmic-questing-allen.md. ## 14 factors — priorities emerge from circumstances, not prescriptive order. var tech_req: Dictionary = { - "brewery": "brewing", "library": "scholarship", "barracks": "military_doctrine", + "library": "scholarship", "barracks": "military_doctrine", "monument": "ancestor_rites", "castle": "fortification", } var candidates: Array[String] = [ - "warrior", "forge", "walls", "marketplace", "brewery", - "library", "barracks", "monument", "castle", "founder", "worker", + "warrior", "forge", "walls", "marketplace", "temple", + "colosseum", "library", "barracks", "monument", "castle", "founder", "worker", ] var units_set: Array[String] = ["warrior", "founder", "worker"] var scores: Dictionary = {} @@ -882,7 +905,7 @@ func _next_building(city: Variant, player: Variant, city_count: int, has_founder var forge_bonus: float = 9.0 if base_prod < 3 else 6.0 _score_add(scores, "forge", forge_bonus) if happy < -4: - _score_add(scores, "brewery", 4.0); _score_add(scores, "monument", 3.0) + _score_add(scores, "temple", 5.0); _score_add(scores, "colosseum", 4.0) if city_count >= 2 and not city.has_building("library"): _score_add(scores, "library", 3.0) if own_mil >= 4 and not city.has_building("barracks"): diff --git a/src/game/engine/src/simple_heuristic_ai.gd b/src/game/engine/src/simple_heuristic_ai.gd new file mode 100644 index 00000000..aaa59985 --- /dev/null +++ b/src/game/engine/src/simple_heuristic_ai.gd @@ -0,0 +1,610 @@ +class_name SimpleHeuristicAi +extends RefCounted +## Personality-driven heuristic AI for arena-quality 1v1 matches. +## +## This module is the current source of action generation for AI players. +## There is no Rust GdAiController — `mc-ai` exposes scoring weights only. +## The heuristics here are intentionally cheap and deterministic per turn: +## the goal is a screensaver-watchable match, not tournament play. +## +## Personality is derived from race `strategic_axes` (expansion/production/ +## wealth) and can be overridden per-arena-window via env vars +## `AI_ARENA_PERSONALITY_AGGRESSION` and `AI_ARENA_PERSONALITY_EXPANSION`. +## +## Entry point: `process_player(player) -> Array[Dictionary]` — returns +## actions in the shape consumed by `ai_turn_bridge.gd::_apply_action`. + +const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") + +const FOUND_MIN_DIST_OWN: int = 4 +## Minimum distance to nearest enemy unit before a founder will settle. +## We only block on "adjacent or same tile" — founding 2 hexes from an +## enemy is fine for screensaver play, and the old value of 3 would +## 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.3 +const DEFENSIVE_CHASE_RANGE: int = 4 +const MILITARY_COMBAT_TYPES: Array[String] = [ + "melee", "ranged", "cavalry", "siege", +] +const INF_DISTANCE: int = 1 << 30 + + +## Generate this turn's actions for `player`. Returns an Array of action +## dictionaries; an empty array means "no usable actions this turn". +static func process_player(player: RefCounted) -> Array: + var actions: Array = [] + if player == null: + return actions + + var personality: Dictionary = _resolve_personality(player) + var enemy_units: Array = _collect_enemy_units(player) + var enemy_city_positions: Array[Vector2i] = _collect_enemy_city_positions( + player + ) + + # Units: founders first (expansion), then military. + for idx: int in player.units.size(): + var unit: Variant = player.units[idx] + if unit == null or not unit.is_alive(): + continue + if unit.movement_remaining <= 0: + continue + var action: Dictionary = {} + if unit.can_found_city: + action = _decide_founder_action(idx, unit, player, enemy_units) + elif unit.attack > 0 or unit.ranged_attack > 0: + # Stat-based dispatch — `unit.unit_type` is read from a JSON field + # (`combat_type`) that the current data files don't populate, so it + # would always be empty here. Anything with combat stats and no + # founder flag is treated as a military unit. + action = _decide_military_action( + idx, + unit, + player, + enemy_units, + enemy_city_positions, + personality, + ) + if not action.is_empty(): + actions.append(action) + + # Cities: set production for any empty queues + bombard nearby enemies. + for ci: int in player.cities.size(): + var city: RefCounted = player.cities[ci] + if city == null: + continue + # Bombard: attack nearest enemy within range + if not city.has_bombarded: + var bombard: Dictionary = _decide_city_bombard(ci, city, player) + if not bombard.is_empty(): + actions.append(bombard) + if not city.production_queue.is_empty(): + continue + var prod: Dictionary = _decide_production(ci, player) + if not prod.is_empty(): + actions.append(prod) + + # Research: pick a tech if idle + if player.researching.is_empty(): + var tech_id: String = _pick_next_tech(player) + if not tech_id.is_empty(): + player.researching = tech_id + player.research_progress = 0 + + return actions + + +# ── Personality ────────────────────────────────────────────────────────── + + +static func _resolve_personality(player: RefCounted) -> Dictionary: + ## Pull strategic axes from the player's assigned axes or race JSON, + ## then let env var overrides (AI_ARENA_PERSONALITY_*) take precedence. + var axes: Dictionary = player.strategic_axes + if axes.is_empty(): + var race_data: Dictionary = DataLoader.get_race(player.race_id) + axes = race_data.get("strategic_axes", {}) + + var aggression: int = int(axes.get("expansion", 0)) + var expansion: int = int(axes.get("expansion", 0)) + var production_pref: int = int(axes.get("production", 0)) + var wealth_pref: int = int(axes.get("wealth", 0)) + + var env_agg: String = OS.get_environment("AI_ARENA_PERSONALITY_AGGRESSION") + if not env_agg.is_empty(): + aggression = int(env_agg) + var env_exp: String = OS.get_environment("AI_ARENA_PERSONALITY_EXPANSION") + if not env_exp.is_empty(): + expansion = int(env_exp) + + return { + "aggression": aggression, + "expansion": expansion, + "production": production_pref, + "wealth": wealth_pref, + } + + +# ── Enemy enumeration ──────────────────────────────────────────────────── + + +static func _collect_enemy_units(player: RefCounted) -> Array: + var out: Array = [] + for other: RefCounted in GameState.players: + if not other is PlayerScript: + continue + if other.index == player.index: + continue + for eu: Variant in other.units: + if eu == null or not eu.is_alive(): + continue + out.append(eu) + return out + + +static func _collect_enemy_city_positions( + player: RefCounted +) -> Array[Vector2i]: + var out: Array[Vector2i] = [] + for other: RefCounted in GameState.players: + if not other is PlayerScript: + continue + if other.index == player.index: + continue + for c: RefCounted in other.cities: + if c != null: + out.append(c.position) + return out + + +static func _nearest_enemy_unit(pos: Vector2i, enemies: Array) -> Variant: + var best: Variant = null + var best_dist: int = INF_DISTANCE + for eu: Variant in enemies: + var d: int = HexUtilsScript.hex_distance(pos, eu.position) + if d < best_dist: + best_dist = d + best = eu + return best + + +static func _nearest_position( + pos: Vector2i, candidates: Array[Vector2i] +) -> Vector2i: + var best: Vector2i = pos + var best_dist: int = INF_DISTANCE + for c: Vector2i in candidates: + var d: int = HexUtilsScript.hex_distance(pos, c) + if d < best_dist: + best_dist = d + best = c + return best + + +static func _tile_has_enemy_unit( + pos: Vector2i, enemy_units: Array +) -> bool: + for eu: Variant in enemy_units: + if eu.position == pos: + return true + return false + + +# ── Founder logic ──────────────────────────────────────────────────────── + + +static func _decide_founder_action( + idx: int, unit: Variant, player: RefCounted, enemy_units: Array +) -> Dictionary: + var own_city_positions: Array[Vector2i] = [] + for c: RefCounted in player.cities: + own_city_positions.append(c.position) + + var dist_own: int = _min_distance(unit.position, own_city_positions) + var dist_enemy: int = _min_distance_to_units(unit.position, enemy_units) + + var clear_of_enemies: bool = ( + dist_enemy > FOUND_MIN_DIST_ENEMY or enemy_units.is_empty() + ) + var far_enough_from_own: bool = ( + dist_own >= FOUND_MIN_DIST_OWN or own_city_positions.is_empty() + ) + + if far_enough_from_own and clear_of_enemies: + # Check tile quality — only found if the current tile is decent + var quality: float = _score_city_site(unit.position) + if quality >= 1.0 or dist_own >= FOUND_MIN_DIST_OWN + 3: + # Good site, or we've wandered far enough — settle here + return { + "type": "found_city", + "unit_index": idx, + "city_name": "", + } + + # Otherwise walk toward open space. When enemies are the blocker, + # flee from the nearest one; when own cities crowd us, walk away + # from them. Falling back to a score-by-position prevents the + # vacuously-zero score case that stalls founders with no cities. + var score_fn: Callable + if not clear_of_enemies: + var nearest: Variant = _nearest_enemy_unit(unit.position, enemy_units) + if nearest != null: + score_fn = _score_away_from_pos(nearest.position) + else: + score_fn = _score_away_from_own(own_city_positions) + else: + score_fn = _score_away_from_own(own_city_positions) + return _move_action(idx, unit.position, enemy_units, score_fn) + + +static func _score_away_from_own(own: Array[Vector2i]) -> Callable: + return func(pos: Vector2i) -> float: + return float(_min_distance(pos, own)) + + +# ── Military logic ─────────────────────────────────────────────────────── + + +static func _decide_military_action( + idx: int, + unit: Variant, + player: RefCounted, + enemy_units: Array, + enemy_city_positions: Array[Vector2i], + personality: Dictionary, +) -> Dictionary: + 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: + return _move_action( + idx, + unit.position, + enemy_units, + _score_away_from_pos(nearest_enemy.position), + ) + + # Adjacent attack if healthy enough. + if nearest_enemy != null: + var enemy_dist: int = HexUtilsScript.hex_distance( + unit.position, nearest_enemy.position + ) + if enemy_dist == 1: + return { + "type": "attack", + "unit_index": idx, + "target_col": nearest_enemy.position.x, + "target_row": nearest_enemy.position.y, + } + + var aggression: int = int(personality.get("aggression", 0)) + var should_chase: bool = ( + aggression > 0 or enemy_dist <= DEFENSIVE_CHASE_RANGE + ) + if should_chase: + return _move_action( + idx, + unit.position, + enemy_units, + _score_toward_pos(nearest_enemy.position), + ) + + # No visible enemy units — march on the nearest enemy city. + if not enemy_city_positions.is_empty(): + var target_city: Vector2i = _nearest_position( + unit.position, enemy_city_positions + ) + return _move_action( + idx, + unit.position, + enemy_units, + _score_toward_pos(target_city), + ) + + # Defensive fallback: drift back toward our own cities. + if not player.cities.is_empty(): + var home: Vector2i = (player.cities[0] as RefCounted).position + return _move_action( + idx, unit.position, enemy_units, _score_toward_pos(home) + ) + + return {} + + +static func _score_toward_pos(target: Vector2i) -> Callable: + return func(pos: Vector2i) -> float: + return -float(HexUtilsScript.hex_distance(pos, target)) + + +static func _score_away_from_pos(threat: Vector2i) -> Callable: + return func(pos: Vector2i) -> float: + return float(HexUtilsScript.hex_distance(pos, threat)) + + +# ── Production logic ───────────────────────────────────────────────────── + + +static func _decide_city_bombard( + city_index: int, city: Variant, player: Variant +) -> Dictionary: + var primary: Dictionary = GameState.get_primary_layer() + var all_units: Array = primary.get("units", []) + var bombard_range: int = city.get("bombard_range") if city.get("bombard_range") else 2 + for u: Variant in all_units: + if u.get("owner") == player.index: + continue + if not u.is_alive(): + continue + var dist: int = HexUtilsScript.hex_distance(city.position, u.position) + if dist <= bombard_range: + return { + "type": "city_bombard", + "city_index": city_index, + "target_col": u.position.x, + "target_row": u.position.y, + } + return {} + + +static func _decide_production( + city_index: int, player: RefCounted +) -> Dictionary: + var military_count: int = 0 + var founder_count: int = 0 + for u: Variant in player.units: + if u == null or not u.is_alive(): + continue + if u.get("can_found_city") == true: + founder_count += 1 + elif u.unit_type in MILITARY_COMBAT_TYPES: + military_count += 1 + + var city: RefCounted = player.cities[city_index] + var city_count: int = player.cities.size() + + # Priority 1: Build walls if city has none (defense first) + if not city.has_building("walls"): + var wdata: Dictionary = DataLoader.get_building("walls") + if not wdata.is_empty(): + return _prod_building(city_index, "walls") + + # Priority 2: Happiness building when unhappy + if player.happiness < 0: + var hb_id: String = _pick_happiness_building_id(city, player) + if not hb_id.is_empty(): + return _prod_building(city_index, hb_id) + + # Priority 3: Expand — build founder if fewer than 3 cities and none in progress + if city_count < 3 and founder_count == 0 and city_index == 0: + 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) + if want_military: + var unit_id: String = _pick_military_unit_id() + if not unit_id.is_empty(): + return _prod_unit(city_index, unit_id) + + # Priority 5: Production building (forge boosts future output) + if not city.has_building("forge"): + var fdata: Dictionary = DataLoader.get_building("forge") + if not fdata.is_empty(): + return _prod_building(city_index, "forge") + + # Priority 6: Castle (upgrades walls, enables bombard) + if city.has_building("walls") and not city.has_building("castle"): + var cdata: Dictionary = DataLoader.get_building("castle") + if not cdata.is_empty(): + return _prod_building(city_index, "castle") + + # Priority 7: Any other available building + var building_id: String = _pick_building_id(city) + if not building_id.is_empty(): + return _prod_building(city_index, building_id) + + # Fallback: more military + var fallback_unit: String = _pick_military_unit_id() + if not fallback_unit.is_empty(): + return _prod_unit(city_index, fallback_unit) + return {} + + +static func _prod_unit(city_index: int, unit_id: String) -> Dictionary: + return {"type": "set_production", "city_index": city_index, + "item_type": "unit", "item_id": unit_id} + + +static func _prod_building(city_index: int, building_id: String) -> Dictionary: + return {"type": "set_production", "city_index": city_index, + "item_type": "building", "item_id": building_id} + + +static func _pick_next_tech(player: Variant) -> String: + ## Pick the cheapest available tech the player hasn't researched yet. + ## Respects prerequisites — only considers techs whose requires are all met. + var best_id: String = "" + var best_cost: int = 999999 + for tech: Dictionary in DataLoader.get_all_techs(): + var tid: String = String(tech.get("id", "")) + if tid.is_empty() or player.has_tech(tid): + continue + # Check prerequisites + var reqs: Array = tech.get("requires", []) + var reqs_met: bool = true + for req: Variant in reqs: + if not player.has_tech(String(req)): + reqs_met = false + break + if not reqs_met: + continue + var cost: int = int(tech.get("cost", 999999)) + if cost < best_cost: + best_cost = cost + best_id = tid + return best_id + + +static func _pick_military_unit_id() -> String: + var preferred: String = "warrior" + var data: Dictionary = DataLoader.get_unit(preferred) + if not data.is_empty(): + return preferred + for u: Dictionary in DataLoader.get_all_units(): + if String(u.get("combat_type", "")) == "melee": + return String(u.get("id", "")) + return "" + + +static func _pick_happiness_building_id( + city: RefCounted, player: RefCounted +) -> String: + var existing: Array = Array(city.buildings) + var best_id: String = "" + var best_happiness: int = 0 + for b: Dictionary in DataLoader.get_all_buildings(): + var bid: String = str(b.get("id", "")) + if bid.is_empty() or bid in existing: + continue + if not _can_build(b, player): + continue + var happiness_value: int = _sum_effect(b, "happiness") + if happiness_value > best_happiness: + best_happiness = happiness_value + best_id = bid + return best_id + + +static func _can_build(building_data: Dictionary, player: RefCounted) -> bool: + if building_data.get("wonder_type") != null: + return false + var tech_req: String = str(building_data.get("tech_required", "")) + if not tech_req.is_empty() and not player.has_tech(tech_req): + return false + var culture_req: String = str(building_data.get("culture_required", "")) + if not culture_req.is_empty(): + return false + return true + + +static func _sum_effect(building_data: Dictionary, effect_type: String) -> int: + var total: int = 0 + var effects: Array = building_data.get("effects", []) as Array + for effect: Variant in effects: + if typeof(effect) != TYPE_DICTIONARY: + continue + var ed: Dictionary = effect as Dictionary + if str(ed.get("type", "")) == effect_type: + total += int(ed.get("value", 0)) + return total + + +static func _pick_building_id(city: RefCounted) -> String: + var existing: Array = Array(city.buildings) + # Skip buildings handled by priority logic + var skip: Array = ["walls", "forge", "castle"] + for b: Dictionary in DataLoader.get_all_buildings(): + var bid: String = str(b.get("id", "")) + if bid.is_empty() or bid in existing or bid in skip: + continue + if not str(b.get("tech_required", "")).is_empty(): + continue + if b.get("wonder_type") != null: + continue + return bid + return "" + + +# ── Movement helpers ───────────────────────────────────────────────────── + + +static func _move_action( + idx: int, + origin: Vector2i, + enemy_units: Array, + score_fn: Callable, +) -> Dictionary: + ## Emit a move_unit action toward the best neighbor of `origin`. + ## Neighbors occupied by enemy units are skipped. Returns {} if there + ## is no valid neighbor (caller treats that as "no action"). + var best: Vector2i = origin + var best_score: float = -INF + var found: bool = false + for n: Vector2i in HexUtilsScript.get_neighbors(origin): + if _tile_has_enemy_unit(n, enemy_units): + continue + var s: float = score_fn.call(n) + if not found or s > best_score: + best_score = s + best = n + found = true + if not found or best == origin: + return {} + return { + "type": "move_unit", + "unit_index": idx, + "target_col": best.x, + "target_row": best.y, + } + + +static func _score_city_site(pos: Vector2i) -> float: + ## Score a tile as a potential city site. Higher = better. + ## Considers: tile yields of center + neighbors, resources nearby. + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return 0.0 + var score: float = 0.0 + # Center tile yields + var center_tile: Resource = game_map.get_tile(pos) + if center_tile == null: + return 0.0 + # Don't settle on water + var water_biomes: Array = ["ocean", "coast", "deep_ocean", "lake"] + if center_tile.biome_id in water_biomes: + return 0.0 + var center_yields: Dictionary = center_tile.get_yields(-1) + score += float(center_yields.get("food", 0)) * 2.0 + score += float(center_yields.get("production", 0)) * 1.5 + score += float(center_yields.get("trade", 0)) + # Neighbor tiles (ring 1 — first workable tiles) + var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(pos) + for n: Vector2i in neighbors: + var norm: Vector2i = HexUtilsScript.normalize_position( + n, game_map.width, game_map.height, game_map.wrap_mode + ) + var tile: Resource = game_map.get_tile(norm) + if tile == null: + continue + if tile.biome_id in water_biomes: + score += 0.5 # Coastal bonus (food from coast) + continue + var t_yields: Dictionary = tile.get_yields(-1) + score += float(t_yields.get("food", 0)) * 0.5 + score += float(t_yields.get("production", 0)) * 0.3 + # Resource bonus + if tile.resource_id != "": + score += 2.0 + return score + + +static func _min_distance(pos: Vector2i, others: Array[Vector2i]) -> int: + var best: int = INF_DISTANCE + for o: Vector2i in others: + var d: int = HexUtilsScript.hex_distance(pos, o) + if d < best: + best = d + return best + + +static func _min_distance_to_units(pos: Vector2i, units: Array) -> int: + var best: int = INF_DISTANCE + for u: Variant in units: + var d: int = HexUtilsScript.hex_distance(pos, u.position) + if d < best: + best = d + return best