From 6afef17150c047a107f791ae454afefe8d4dd463 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 15 Apr 2026 20:15:06 -0700 Subject: [PATCH] =?UTF-8?q?test(engine):=20=E2=9C=85=20Add=20and=20update?= =?UTF-8?q?=20test=20cases=20for=20simple=5Fheuristic=5Fai=20behavior=20in?= =?UTF-8?q?=20turn=5Fprocessor.gd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/scenes/tests/turn_processor.gd | 576 ----------------- src/game/engine/src/simple_heuristic_ai.gd | 610 ------------------ 2 files changed, 1186 deletions(-) delete mode 100644 src/game/engine/scenes/tests/turn_processor.gd delete mode 100644 src/game/engine/src/simple_heuristic_ai.gd diff --git a/src/game/engine/scenes/tests/turn_processor.gd b/src/game/engine/scenes/tests/turn_processor.gd deleted file mode 100644 index b97e2593..00000000 --- a/src/game/engine/scenes/tests/turn_processor.gd +++ /dev/null @@ -1,576 +0,0 @@ -# gdlint: disable=no-elif-return,no-else-return,max-returns,class-definitions-order -extends RefCounted -## End-of-turn processing. Per-player and global _process_* logic. -## Arena task #2 restored the four visible methods (production, growth, -## healing, economy). Still known-broken and out of scope: _process_culture, -## _process_golden_age, _process_loot_decay, _process_spell_system, -## _process_government — all blocked on empty module stubs. -## Calls disabled in turn_manager.gd::next_player (Diplomacy.process_turn, -## EconomyScript.apply_protection_effects) and turn_processor.gd::_process_* -## until these modules are rebuilt. The `_process_climate` sub-calls into -## WeatherScript, ClimateEffectsScript, and ClimateScript.process_turn are -## also disabled — marine_harvest and ecosystem still run. ClimateScript has -## real-code bugs (int-cast failure + ecological_events arg-count mismatch) -## that need a proper fix in a follow-up task. Grep for `DISABLED:` to find -## every guarded call site. - -const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") -const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") -const CityScript: GDScript = preload("res://engine/src/entities/city.gd") -const CultureScript: GDScript = preload("res://engine/src/modules/empire/culture.gd") -const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") -const HappinessScript: GDScript = preload("res://engine/src/modules/empire/happiness.gd") -const AscensionRitualScript: GDScript = preload( - "res://engine/src/modules/victory/ascension_ritual.gd" -) -const GovernmentScript: GDScript = preload("res://engine/src/modules/empire/government.gd") -const ItemSystemScript: GDScript = preload( - "res://engine/src/modules/management/item_system.gd" -) -const SpellSystemScript: GDScript = preload("res://engine/src/modules/magic/spell_system.gd") -const BuildableHelperScript: GDScript = preload("res://engine/scenes/city/city_buildable_helper.gd") -const MarineHarvestScript: GDScript = preload( - "res://engine/src/modules/events/marine_harvest.gd" -) -const ClimateScript: GDScript = preload("res://engine/src/modules/climate/climate.gd") -const ClimateEffectsScript: GDScript = preload( - "res://engine/src/modules/climate/climate_effects.gd" -) -const WeatherScript: GDScript = preload("res://engine/src/modules/climate/weather.gd") -const EcosystemScript: GDScript = preload( - "res://engine/src/modules/ecology/ecosystem.gd" -) -const RustFaunaIntegrationScript: GDScript = preload( - "res://engine/src/modules/management/rust_fauna_integration.gd" -) - -var unit_manager: RefCounted # UnitManager — set by TurnManager._ready() -var spell_system: RefCounted # SpellSystem — set by TurnManager._ready() -var wild_ai: RefCounted # WildCreatureAI — set via TurnManager.set_wild_creature_ai() -var weather: RefCounted # Weather — set by TurnManager._ready() -var climate: RefCounted # Climate — set by TurnManager._ready() -var climate_effects: RefCounted # ClimateEffects — set by TurnManager._ready() -var marine_harvest: RefCounted # MarineHarvest — set by TurnManager._ready() -var ecosystem: RefCounted # EcosystemOrchestrator — set by TurnManager._ready() -var ecology_db: RefCounted # EcologyDB — set by TurnManager._ready() - - -func _process_production(player: RefCounted) -> void: # Player - var game_map: RefCounted = GameState.get_game_map() # GameMap - if game_map == null: - return - - # Apply difficulty modifier for AI players - var prod_modifier: float = 1.0 - if player is PlayerScript and not player.is_human: - prod_modifier = GameState.ai_difficulty_modifier - # Unhappy penalty: -25% production when happiness < 0 - if player.happiness < 0: - prod_modifier *= 0.75 - # Golden Age: +20% production - if player.golden_age_active: - prod_modifier *= 1.2 - - for city_ref: RefCounted in player.cities: - if not city_ref is CityScript: - continue - var c: CityScript = city_ref as CityScript - var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) - var yields: Dictionary = c.get_yields(tile_json) - # Add building production bonuses (forge +2, barracks +1, etc.) - var building_prod: int = _sum_city_building_effect(c, "production") - var prod: int = int((yields.get("production", 1) + building_prod) * prod_modifier) - # Capture current item before apply_production pops it on completion. - var current: Dictionary = ( - c.production_queue.front() as Dictionary - if not c.production_queue.is_empty() - else {} - ) - if not c.apply_production(prod): - continue - var item_type: String = current.get("type", "") - var item_id: String = current.get("id", "") - - if item_type == "unit": - var unit: UnitScript = _spawn_unit(item_id, player, c.position) - if unit != null: - EventBus.city_unit_completed.emit(city_ref, unit) - elif item_type == "building": - c.add_building(item_id) - _apply_building_bonuses(c, item_id) - EventBus.city_building_completed.emit(city_ref, item_id) - elif item_type == "item": - var i_data: Dictionary = DataLoader.get_item(item_id) - if not i_data.is_empty(): - var charges: int = i_data.get("charges", -1) - if c.get("item_stockpile") is Array: - c.item_stockpile.append({ - "item_id": item_id, - "charges_remaining": charges, - }) - # Deduct mana cost on completion. - var mana_cost: Dictionary = i_data.get("cost_mana", {}) as Dictionary - if not mana_cost.is_empty(): - var color: String = mana_cost.get("color", "") - var amount: int = mana_cost.get("amount", 0) - if color != "" and amount > 0: - player.mana_pool[color] = ( - player.mana_pool.get(color, 0.0) - float(amount) - ) - EventBus.item_produced.emit(city_ref, item_id) - - -func _process_research(player: RefCounted) -> void: # Player - if player.researching.is_empty(): - return - - # Debug: instantly complete any queued research/spell. - if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"): - player.research_progress = 999999 - - # Apply difficulty modifier for AI players - var sci_modifier: float = 1.0 - if player is PlayerScript and not player.is_human: - sci_modifier = GameState.ai_difficulty_modifier - - player.research_progress += int(player.science_per_turn * sci_modifier) - - # Add science from cities - var game_map: RefCounted = GameState.get_game_map() # GameMap - if game_map != null: - for city: Variant in player.cities: - if city is CityScript: - var tile_json: String = BuildableHelperScript.build_tile_yields_json( - city as CityScript, game_map - ) - var yields: Dictionary = city.get_yields(tile_json) - var building_sci: int = _sum_city_building_effect(city as CityScript, "science") - player.research_progress += int((yields.get("science", 0) + building_sci) * sci_modifier) - - # Check if researching a spell (not a tech) - var spell_data: Dictionary = DataLoader.get_spell(player.researching) - if not spell_data.is_empty(): - var spell_cost: int = spell_data.get("research_cost", 999999) - if player.research_progress >= spell_cost: - var completed_spell: String = player.researching - player.research_progress = 0 - player.researching = "" - var sys: SpellSystemScript = spell_system as SpellSystemScript - sys.research_spell(player.index, completed_spell) - return - - var tech_data: Dictionary = DataLoader.get_tech(player.researching) - var tech_cost: int = tech_data.get("cost", 999999) - - if player.research_progress >= tech_cost: - var completed_tech: String = player.researching - player.research_progress = 0 - player.researching = "" - var old_school_count: int = player.schools.size() - player.add_tech(completed_tech) - # Arcane Lore completion: transform leader into High Archon - if completed_tech == "arcane_lore": - _form_high_archon(player) - # Emit school_locked when the 2nd school is entered for the first time. - if player.schools.size() == 2 and old_school_count < 2: - EventBus.school_locked.emit(player.index, player.schools.duplicate()) - EventBus.tech_researched.emit(completed_tech, player.index) - _check_resource_reveals(completed_tech, player.index) - - -func _check_resource_reveals(completed_tech: String, player_index: int) -> void: - var all_resources: Array = DataLoader.get_all_resources() - for res: Dictionary in all_resources: - if res.get("revealed_by_tech", "") == completed_tech: - EventBus.resources_revealed.emit(completed_tech, player_index) - return - - -func _spawn_unit(type_id: String, player: RefCounted, pos: Vector2i) -> UnitScript: - ## Minimal unit spawn used when a city finishes building a unit. - ## `UnitManager` does not own a `create_unit` method in the current engine; - ## the world-map spawner uses the same pattern (new Unit + register into - ## player.units and the primary layer). Kept inline here so arena matches - ## do not depend on an out-of-scope unit-manager rewrite. - var unit: UnitScript = UnitScript.new(type_id, player.index, pos) - if unit == null: - return null - unit.id = "unit_p%d_%d_%d_%d" % [ - player.index, pos.x, pos.y, GameState.turn_number - ] - var data: Dictionary = DataLoader.get_unit(type_id) - unit.display_name = data.get("name", type_id) - player.units.append(unit) - var primary: Dictionary = GameState.get_primary_layer() - var layer_units: Array = primary.get("units", []) - layer_units.append(unit) - primary["units"] = layer_units - EventBus.unit_created.emit(unit, player.index) - return unit - - -func _process_growth(player: RefCounted) -> void: # Player - var game_map: RefCounted = GameState.get_game_map() # GameMap - if game_map == null: - return - - # Very unhappy (< -10): no growth at all. Unhappy (< 0): -50% food surplus. - var skip_growth: bool = player.happiness < -10 - for city: Variant in player.cities: - if not city is CityScript: - continue - var c: CityScript = city as CityScript - if skip_growth: - continue - var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) - c.process_growth(tile_json) - - -func _sum_city_building_effect(city: CityScript, effect_type: String) -> int: - ## Sum a building effect type for a single city (e.g., "production", "gold", "science"). - var total: int = 0 - for building_id: Variant in city.buildings: - var bdata: Dictionary = DataLoader.get_building(str(building_id)) - if bdata.is_empty(): - continue - for effect: Dictionary in bdata.get("effects", []): - if effect.get("type", "") == effect_type: - total += int(effect.get("value", 0)) - return total - - -func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> float: - var total: float = 0.0 - for building_id: Variant in city.buildings: - var bdata: Dictionary = DataLoader.get_building(str(building_id)) - if bdata.is_empty(): - continue - for effect: Dictionary in bdata.get("effects", []): - if effect.get("type", "") == effect_type: - total += float(effect.get("value", 0)) - return total - - -func _apply_building_bonuses(city: CityScript, building_id: String) -> void: - var bdata: Dictionary = DataLoader.get_building(building_id) - var effects: Array = bdata.get("effects", []) - for effect: Dictionary in effects: - var etype: String = effect.get("type", "") - var value: int = int(effect.get("value", 0)) - if etype == "hp_bonus" and value > 0: - city.set_max_hp(city.max_hp + value) - city.heal(value) - - -func _process_city_healing(player: RefCounted) -> void: - for city_ref: Variant in player.cities: - if city_ref is CityScript: - (city_ref as CityScript).heal_per_turn() - - -func _process_healing(player: RefCounted) -> void: # Player - var game_map: RefCounted = GameState.get_game_map() # GameMap - if game_map == null: - return - - for unit: Variant in player.units: - if not unit is UnitScript: - continue - - # Note: terrain power effects (TerrainAffinityScript) and dead-zone - # damage for summoned units are gated on subsystem rewrites that are - # out of scope for arena task #2. Arena healing is purely: - # "if the unit did not move or attack this turn, heal a little". - if unit.hp >= unit.max_hp: - continue - if unit.movement_remaining < unit.get_movement() or unit.has_attacked: - continue - - var heal_amount: int = _get_healing_rate(unit, player, game_map) - if heal_amount > 0: - unit.heal(heal_amount) - EventBus.unit_healed.emit(unit, heal_amount) - - -func _get_healing_rate(unit: RefCounted, player: RefCounted, game_map: RefCounted) -> int: - var tile: Resource = game_map.get_tile(unit.position) as Resource - if tile == null: - return 10 - - # In-city healing: per-building healing modifiers live in the - # BuildingScript stub rewrite (out of scope). Use a flat base heal for - # arena play. - for city_ref: RefCounted in player.cities: - if city_ref is CityScript and (city_ref as CityScript).position == unit.position: - return 20 - - # Territory-based healing - if tile.owner == player.index: - return 15 - elif tile.owner == -1: - return 10 - else: - return 5 - - -func _process_mana(player: RefCounted, game_map: RefCounted = null) -> void: # Player - ## Recalculate mana_income from city yields (terrain + buildings), then add to pool. - var new_income: Dictionary = {} - - if game_map != null: - for city_ref: RefCounted in player.cities: - if not city_ref is CityScript: - continue - # Task #4: get_yields() takes a tile_yields_json string built via - # BuildableHelperScript, same pattern as _process_production / - # _process_economy. Passing the GameMap directly raised - # "Cannot convert argument 1 from Object to String". - var tile_yields_json: String = BuildableHelperScript.build_tile_yields_json( - city_ref, game_map - ) - var city_yields: Dictionary = city_ref.get_yields(tile_yields_json) - var mana_entry: Dictionary = city_yields.get("mana", {}) as Dictionary - for school: String in mana_entry: - new_income[school] = (new_income.get(school, 0.0) + float(mana_entry[school])) - - player.mana_income = new_income - - if player.mana_income.is_empty(): - return - - for school: String in player.mana_income: - var income: int = roundi(player.mana_income[school]) - var current: int = player.mana_pool.get(school, 0) - player.mana_pool[school] = mini(current + income, player.mana_cap) - - EventBus.mana_changed.emit(player.index, player.mana_pool) - - -func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap - ## Minimal screensaver economy: sum gold yields across a player's cities - ## and credit the treasury. The full EconomyScript rewrite (upkeep, - ## deficit disbanding, trade routes) is out of scope for arena task #2. - if game_map == null: - return - var income: int = 0 - for city_ref: RefCounted in player.cities: - if not city_ref is CityScript: - continue - var c: CityScript = city_ref as CityScript - var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) - var yields: Dictionary = c.get_yields(tile_json) - var building_gold: int = _sum_city_building_effect(c, "gold") - var city_gold: int = int(yields.get("gold", 0)) + building_gold - # Apply percentage bonuses (marketplace +25% = 0.25) - var gold_pct: float = _sum_city_building_effect_float(c, "gold_percent") - if gold_pct > 0.0: - city_gold = int(float(city_gold) * (1.0 + gold_pct)) - income += city_gold - # Golden Age: +20% gold income - if player.golden_age_active: - income = int(income * 1.2) - # Unit upkeep: 1 gold per military unit - var upkeep: int = 0 - for u: Variant in player.units: - if u != null and u.is_alive(): - if u.get("can_found_city") != true: - upkeep += 1 - var net: int = income - upkeep - player.gold_per_turn = net - player.gold += net - # Disband units if bankrupt - if player.gold < 0 and not player.units.is_empty(): - var disbanded: RefCounted = null - for u: Variant in player.units: - if u != null and u.is_alive() and u.get("can_found_city") != true: - disbanded = u - break - if disbanded != null: - player.units.erase(disbanded) - var primary: Dictionary = GameState.get_primary_layer() - primary.get("units", []).erase(disbanded) - EventBus.unit_destroyed.emit(disbanded, null) - player.gold = 0 - - -func _process_culture(player: RefCounted, game_map: RefCounted) -> void: - ## Expand city borders using Rust's expand_borders() method. Culture - ## accumulation happens inside Rust's process_growth() each turn. - if game_map == null: - return - for city_ref: Variant in player.cities: - if not city_ref is CityScript: - continue - var c: CityScript = city_ref as CityScript - var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) - var can_expand: bool = c.process_culture(tile_json) - if not can_expand: - continue - # Build candidates JSON for Rust border expansion - var candidates_json: String = _build_border_candidates_json(c, game_map, player) - var claimed: Vector2i = c.expand_borders(candidates_json) - if claimed != Vector2i(-1, -1): - var tile: Resource = game_map.get_tile(claimed) - if tile != null: - tile.owner = player.index - EventBus.city_border_expanded.emit(c, claimed) - - -func _build_border_candidates_json( - city: CityScript, game_map: RefCounted, player: RefCounted -) -> String: - ## Build JSON array of [{col, row, value}, ...] for unclaimed adjacent tiles. - var candidates: Array[Dictionary] = [] - for owned_pos: Vector2i in city.owned_tiles: - var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(owned_pos) - for n: Vector2i in neighbors: - var norm: Vector2i = HexUtilsScript.normalize_position( - n, game_map.width, game_map.height, game_map.wrap_mode - ) - if norm in city.owned_tiles: - continue - var tile: Resource = game_map.get_tile(norm) - if tile == null: - continue - if tile.owner != -1 and tile.owner != player.index: - continue - # Score: food*2 + production*1.5 + trade + culture + resource bonus - var tile_yields: Dictionary = tile.get_yields(player.index) - var score: float = float(tile_yields.get("food", 0)) * 2.0 - score += float(tile_yields.get("production", 0)) * 1.5 - score += float(tile_yields.get("trade", 0)) - score += float(tile_yields.get("culture", 0)) - if tile.resource_id != "": - score += 5.0 - candidates.append({"col": norm.x, "row": norm.y, "value": score}) - return JSON.stringify(candidates) - - -func _process_golden_age(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap - ## Delegates to HappinessScript.process_turn, which wraps the mc-happiness - ## Rust crate through GdHappiness (GDExtension). Method name kept so - ## turn_manager.gd's existing call site does not need to change. - HappinessScript.process_turn(player, game_map) - - -func _process_wild_creatures() -> void: - ## Run wild creature AI for all owner==-1 units once per game turn. - if wild_ai == null: - return - var game_map: RefCounted = GameState.get_game_map() - if game_map == null: - return - wild_ai.process_wild_turn(game_map) - - -func _process_rust_fauna_encounters() -> void: - ## Iter 7k: gated parallel Rust fauna encounter pass. - ## Delegates to `RustFaunaIntegration.run_all_players()`, which handles - ## the env-flag check, lair enumeration, and per-player bridge calls. - ## Kept as a method on `TurnProcessor` so `turn_manager.gd` can call it - ## through the same `proc.()` pattern as the other turn phases. - RustFaunaIntegrationScript.run_all_players() - - -func _process_spell_system(_player: RefCounted) -> void: # Player - ## DISABLED: SpellSystem has no `overworld_queue` property on its current - ## stub; the first access aborts the method. Pending-summon spawning also - ## depends on a `unit_manager.create_unit` that does not exist. See the - ## top-of-file out-of-scope list. Revive once SpellSystem is rebuilt and - ## the unit-manager spawn helper ships. - pass - - -func _process_ascension(player: RefCounted) -> void: # Player - if not player.ascension_active: - return - var ritual: AscensionRitualScript = ( - GameState.ascension_rituals.get(player.index, null) as AscensionRitualScript - ) - if ritual == null: - return - var sys: SpellSystemScript = spell_system as SpellSystemScript - ritual.tick(player, sys) - - -func _form_high_archon(player: RefCounted) -> void: - ## Called when Arcane Lore completes. Transforms the leader into an Archon. - var ArchonScript: GDScript = preload("res://engine/src/entities/archon.gd") - var capital: CityScript = null - for city: RefCounted in player.cities: - if city is CityScript and (city as CityScript).is_capital: - capital = city as CityScript - break - if capital == null: - push_warning("TurnProcessor: no capital for player %d" % player.index) - return - var is_female: bool = player.gender_preset == "female" - var archon: RefCounted = ArchonScript.make_high_archon( - player.index, capital.position, player.player_name, is_female - ) - capital.set("archon", archon) - EventBus.archon_created.emit(archon, capital) - - -func _process_government(_player: RefCounted) -> void: # Player - ## DISABLED: GovernmentScript is an empty stub with no `process_anarchy`. - ## See top-of-file out-of-scope list. Revive once government is rebuilt. - ## Original body: GovernmentScript.process_anarchy(player) - pass - - -func _process_climate(game_map: RefCounted) -> void: # GameMap - ## Order: marine_harvest → weather → climate → climate_effects → ecosystem. - ## DISABLED: WeatherScript and ClimateEffectsScript are empty stubs; their - ## process_turn calls abort next_player and kill the arena turn loop. - ## DISABLED: ClimateScript.process_turn currently raises "Invalid cast to - ## int" inside _sync_tiles_to_grid/_sync_grid_to_tiles and an arg-count - ## mismatch inside ecological_events.process_events (process_drought/ - ## process_wildfire/process_marine all expect 8-9 args, fewer are passed). - ## These aborts propagate up to next_player and kill the arena turn loop. - ## See top-of-file out-of-scope list. Revive once climate.gd cast handling - ## and ecological_events signatures are fixed. - (marine_harvest as MarineHarvestScript).process_turn(game_map, GameState.players) - # (climate as ClimateScript).ocean_dead_fraction = ( - # (marine_harvest as MarineHarvestScript).ocean_dead_fraction - # ) - # (weather as WeatherScript).process_turn(game_map) - # (climate as ClimateScript).process_turn( - # game_map, GameState.turn_number, GameState.map_seed - # ) - # (climate_effects as ClimateEffectsScript).process_turn( - # game_map, weather, GameState.players - # ) - # Step 5: Ecosystem — flora dynamics, fauna dynamics, quality recomputation - if ecosystem != null and ecology_db != null: - (ecosystem as EcosystemScript).process_turn( - game_map, ecology_db, GameState.map_seed + GameState.turn_number - ) - - -func _process_loot_decay() -> void: - ## DISABLED: ItemSystemScript.process_loot_decay expects a different - ## GameMap type than the live RefCounted GameMap wrapper, so the call - ## raises a type-mismatch error. See top-of-file out-of-scope list. - ## Revive once ItemSystem is updated to the current GameMap signature. - pass - - -func _process_improvements(player: RefCounted) -> void: # Player - ## Tick pending tile improvements (Engineer build progress). - if player.pending_improvements.is_empty(): - return - - var completed_indices: Array[int] = [] - for i: int in range(player.pending_improvements.size()): - var imp: Variant = player.pending_improvements[i] - if imp is Dictionary: - imp["turns_remaining"] = imp.get("turns_remaining", 1) - 1 - if imp["turns_remaining"] <= 0: - completed_indices.append(i) - var tile_pos: Vector2i = Vector2i(imp.get("x", 0), imp.get("y", 0)) - EventBus.improvement_completed.emit(tile_pos, imp.get("type", "")) - - # Remove completed in reverse order to preserve indices - for i: int in range(completed_indices.size() - 1, -1, -1): - player.pending_improvements.remove_at(completed_indices[i]) diff --git a/src/game/engine/src/simple_heuristic_ai.gd b/src/game/engine/src/simple_heuristic_ai.gd deleted file mode 100644 index aaa59985..00000000 --- a/src/game/engine/src/simple_heuristic_ai.gd +++ /dev/null @@ -1,610 +0,0 @@ -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