From 9da2857196db76dca15ef3f9b817f409648c33d3 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 29 Apr 2026 21:42:03 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(game-engine):=20=E2=9C=A8=20Add=20city?= =?UTF-8?q?=20capture=20tracking=20and=20occupation=20penalties=20to=20man?= =?UTF-8?q?age=20strategic=20depth=20in=20city=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/src/entities/turn_processor.gd | 514 ++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 src/game/engine/src/entities/turn_processor.gd diff --git a/src/game/engine/src/entities/turn_processor.gd b/src/game/engine/src/entities/turn_processor.gd new file mode 100644 index 00000000..0bd810a7 --- /dev/null +++ b/src/game/engine/src/entities/turn_processor.gd @@ -0,0 +1,514 @@ +# 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. +## DISABLED stubs: _process_culture, _process_golden_age, _process_loot_decay, +## _process_spell_system, _process_government — blocked on empty module stubs. +## `_process_climate` runs the full marine_harvest→climate→weather→effects chain. +## Grep for `DISABLED:` to find every remaining 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 EconomyScript: GDScript = preload("res://engine/src/modules/empire/economy.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 RustFaunaIntegrationScript: GDScript = preload( + "res://engine/src/modules/management/rust_fauna_integration.gd" +) +const TurnProcessorHelpersScript: GDScript = preload( + "res://engine/src/modules/management/turn_processor_helpers.gd" +) +const TurnProcessorCityHelpersScript: GDScript = preload( + "res://engine/src/modules/management/turn_processor_city_helpers.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() + + +func _process_production(player: RefCounted) -> void: # Player + var game_map: RefCounted = GameState.get_game_map() # GameMap + if game_map == null: + return + + # Effective per-yield mult composes static difficulty handicap + + # linear-per-turn growth (warcouncil p1-29 H4). Per-player overrides + # (batch testing) handled inside the helper. + var prod_modifier: float = GameState.get_effective_yield_mult(player, "production") + # 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") + # production_from_hills — +N prod per worked hills tile (first_mineshaft etc.). + var prod_hills: int = _sum_city_building_effect(c, "production_from_hills") + if prod_hills > 0: + for tile_pos: Vector2i in c.get_worked_tiles(): + var tile: Resource = game_map.get_tile(tile_pos) + if tile != null and tile.biome_id == "hills": + building_prod += prod_hills + var prod_pct: float = _sum_city_building_effect_float(c, "production_percent") + # Occupation penalty: captured cities produce at 50% for 20 turns. + # Slows the attacker's production-snowball: capturing a city doesn't + # immediately double their output — they must garrison and stabilise first. + const OCCUPATION_TURNS: int = 20 + var occupation_mult: float = 1.0 + if c.captured_turn >= 0 and GameState.turn_number - c.captured_turn < OCCUPATION_TURNS: + occupation_mult = 0.5 + var prod: int = int( + (yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier * occupation_mult + ) + # 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 {} + ) + # Strategic resource gate: territory-ownership check (non-consumable model). + # A player can build a resource-gated unit if they own at least one tile + # with that resource — no slot depletion on build (Civ-6 style). + var pre_type: String = current.get("type", "") + var pre_id: String = current.get("id", "") + if pre_type == "unit" and pre_id != "": + var udata_pre: Dictionary = DataLoader.get_unit(pre_id) + var req_pre: String = str(udata_pre.get("requires_resource", "")) + if req_pre != "" and req_pre != "null" and req_pre != "": + if not _player_owns_resource(player, req_pre): + EventBus.strategic_gate_rejected.emit( + player.index, c.city_name, pre_id, req_pre + ) + continue + 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: + var xp_bonus: int = _sum_city_building_effect(c, "unit_xp_start_home_city") + if xp_bonus > 0: + unit.gain_xp(xp_bonus) + EventBus.city_unit_completed.emit(city_ref, unit) + elif item_type == "building": + var tile_pos: Vector2i = current.get("tile_pos", Vector2i(-1, -1)) as Vector2i + c.add_building_at(item_id, tile_pos) + _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 + ## Rail-1: science accumulation + spell/tech completion check delegated + ## to Rust `GdTechWeb::process_research` (warcouncil p1-39 port, + ## 2026-04-27). GDScript only assembles input JSON + applies + ## completion side-effects (school_locked emit, _form_high_archon, + ## tech_researched signal, resource reveals). + if player.researching.is_empty(): + return + + # Per-yield difficulty multiplier (composed by GameState). + var sci_modifier: float = GameState.get_effective_yield_mult(player, "research") + + # Per-city science yields the Rust side will sum. + var game_map: RefCounted = GameState.get_game_map() + var yields_arr: Array = [] + if game_map != null: + for city: Variant in player.cities: + if not city is CityScript: + continue + var c: CityScript = city as CityScript + var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) + var ys: Dictionary = c.get_yields(tile_json) + yields_arr.append({ + "science": int(ys.get("science", 0)), + "building_science": _sum_city_building_effect(c, "science"), + "science_percent": _sum_city_building_effect_float(c, "science_percent"), + }) + + # Player input. spell_cost is set when researching a spell so Rust runs + # the cheap counter branch (no TechWeb lookup). + var spell_data: Dictionary = DataLoader.get_spell(player.researching) + var researching_spell: bool = not spell_data.is_empty() + var researched_arr: Array = Array(player.researched_techs) if player.researched_techs != null else [] + var player_dict: Dictionary = { + "researching": str(player.researching), + "research_progress": int(player.research_progress), + "science_per_turn": int(player.science_per_turn), + "researched_techs": researched_arr, + "instant_complete": EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"), + } + if researching_spell: + player_dict["spell_cost"] = int(spell_data.get("research_cost", 999999)) + + var tw: RefCounted = TurnManager.get_tech_web() + if tw == null: + return + var result: Dictionary = tw.process_research( + JSON.stringify(player_dict), JSON.stringify(yields_arr), sci_modifier + ) + if result.is_empty(): + return + var err: String = str(result.get("error", "")) + if not err.is_empty(): + push_warning("p1-39 _process_research: " + err) + return + player.research_progress = int(result.get("new_progress", 0)) + player.researching = str(result.get("new_researching", "")) + + var completed_spell: String = str(result.get("completed_spell", "")) + if not completed_spell.is_empty(): + var sys: SpellSystemScript = spell_system as SpellSystemScript + sys.research_spell(player.index, completed_spell) + return + + var completed_tech: String = str(result.get("completed_tech", "")) + if not completed_tech.is_empty(): + 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: + TurnProcessorHelpersScript._check_resource_reveals(completed_tech, player_index) + + +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 + + # Apply the Happy / Golden Age +25% growth bonus; preserve the < -10 halt. + # The 0.5× Unhappy tier from mc_happiness::get_growth_modifier is held back + # pending balance-lead sign-off — flipping it would re-tune p1-05 bands. + var growth_modifier: float = 1.25 if player.happiness > 0 else 1.0 + 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) + var prev_pop: int = c.population + # Small cities prioritize food growth; pop 4+ uses balanced Default focus + if c.population < 4 and c.has_method("set_focus"): + c.set_focus("food") + c.process_growth(tile_json, growth_modifier) + if c.population != prev_pop: + # Re-assign citizens to tiles after growth or starvation + c.auto_assign_citizens(tile_json) + EventBus.city_grew.emit(c, c.population) + + +func _sum_city_building_effect(city: CityScript, effect_type: String) -> int: + return TurnProcessorCityHelpersScript.sum_city_building_effect(city, effect_type) + + +func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> float: + return TurnProcessorCityHelpersScript.sum_city_building_effect_float(city, effect_type) + + +func _apply_building_bonuses(city: CityScript, building_id: String) -> void: + var bdata: Dictionary = DataLoader.get_building(building_id) + var effects: Array = bdata.get("effects", []) + var owner_player: RefCounted = GameState.get_player(city.owner) if city.owner >= 0 else null + 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) + elif etype == "city_hp" and value > 0 and owner_player != null: + # Empire-wide max HP bump from mundane wonder (iron_bulwark +100). + for other_ref: Variant in owner_player.cities: + if other_ref is CityScript: + var other: CityScript = other_ref as CityScript + other.set_max_hp(other.max_hp + value) + other.heal(value) + elif etype == "free_tech" and value > 0 and owner_player != null: + _grant_free_tech(owner_player, value) + elif etype == "free_golden_age_on_build" and value > 0 and owner_player != null: + owner_player.golden_age_active = true + owner_player.golden_age_turns = HappinessScript.GOLDEN_AGE_DURATION + EventBus.golden_age_started.emit(owner_player.index) + + +func _grant_free_tech(player: RefCounted, count: int) -> void: + TurnProcessorCityHelpersScript.grant_free_tech(player, count) + + +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(GameState.turn_number) + + +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: + return TurnProcessorHelpersScript._get_healing_rate(unit, player, game_map) + + +func _process_mana(player: RefCounted, game_map: RefCounted = null) -> void: # Player + TurnProcessorHelpersScript.process_mana(player, game_map) + + +func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap + ## Delegate per-turn gold income, upkeep, golden-age bonus, and + ## insolvency-driven unit disbanding to `Economy.process_turn`, which + ## marshals inputs into the Rust `GdEconomy` bridge (mc-economy). Rail-1: + ## no simulation logic in GDScript. + EconomyScript.process_turn(player, game_map) + + +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) + # Culture-port to Rust (`process_culture_with_modifier`) attempted in + # R7/R8 but caused seed-divergence vs R6 baseline (R9 parity test + # reproduced R6 exactly when reverted to this GDScript path; R8 with + # Rust port diverged on every seed). Math LOOKED identical but the + # Rust call sequence produces different floating-point intermediate + # results than the GDScript-via-Variant round-trip path. Culture port + # remains TODO — see p1-39. The other Rail-1 ports (gold, research) + # pass parity and stay. + var pre_culture: float = c.get_culture_stored() + var can_expand: bool = c.process_culture(tile_json) + var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent") + var border_pct: float = _sum_city_building_effect_float(c, "border_growth_percent") + var difficulty_cult_mult: float = GameState.get_effective_yield_mult(player, "culture") + var total_pct: float = cult_pct + border_pct + (difficulty_cult_mult - 1.0) + if total_pct > 0.0: + var post_culture: float = c.get_culture_stored() + var gained: float = post_culture - pre_culture + if gained > 0.0: + c.set_culture_stored(post_culture + gained * total_pct) + can_expand = c.get_can_expand() + 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): + # Re-run citizen assignment so the new tile can be worked immediately + var fresh_tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) + c.auto_assign_citizens(fresh_tile_json) + 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: + return TurnProcessorCityHelpersScript.build_border_candidates_json(city, game_map, player) + + +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: + TurnProcessorHelpersScript.form_high_archon(player) + + +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 → climate → weather → climate_effects. + ## * `marine_harvest` seeds ocean_dead_fraction consumed by climate. + ## * `climate` runs GdEcologyPhysics + GdClimatePhysics + ecological events + ## (restored by p0-31) and leaves the shared GdGridState populated. + ## * `weather` (p0-32) reads that grid to derive this turn's storm / + ## heat_wave / blizzard events via GdWeatherPhysics. + ## * `climate_effects` (p0-32) applies those events back onto tiles + units + ## via GdClimateEffectsPhysics. + (marine_harvest as MarineHarvestScript).process_turn(game_map, GameState.players) + (climate as ClimateScript).ocean_dead_fraction = ( + (marine_harvest as MarineHarvestScript).ocean_dead_fraction + ) + (climate as ClimateScript).process_turn(game_map, GameState.turn_number, GameState.map_seed) + (weather as WeatherScript).process_turn(game_map) + (climate_effects as ClimateEffectsScript).process_turn( + game_map, weather, GameState.players + ) + + +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 + TurnProcessorHelpersScript.process_improvements(player) + + +static func _player_owns_resource(player: RefCounted, resource_id: String) -> bool: + var gm: RefCounted = GameState.get_game_map() + if gm == null: + return false + for city: Variant in player.cities: + for tile_pos: Variant in city.get_owned_tiles(): + var tile: RefCounted = gm.get_tile(tile_pos as Vector2i) + if tile != null and str(tile.get("resource_id")) == resource_id: + return true + return false From a911ce3b002ff90462dd2ed0a53cc99cbab3a5b6 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 29 Apr 2026 21:42:03 -0700 Subject: [PATCH 2/3] =?UTF-8?q?feat(entities):=20=E2=9C=A8=20Implement=20e?= =?UTF-8?q?nhanced=20auto-play=20logic=20with=20new=20decision-making=20st?= =?UTF-8?q?rategies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/src/entities/auto_play.gd | 2728 +++++++++++++++++++++ 1 file changed, 2728 insertions(+) create mode 100644 src/game/engine/src/entities/auto_play.gd diff --git a/src/game/engine/src/entities/auto_play.gd b/src/game/engine/src/entities/auto_play.gd new file mode 100644 index 00000000..64c1701a --- /dev/null +++ b/src/game/engine/src/entities/auto_play.gd @@ -0,0 +1,2728 @@ +extends Node +## Automated player — plays Magic Civilization to domination victory. +## Registered as autoload, only activates when AUTO_PLAY env var is set. +## +## Usage: AUTO_PLAY=true AUTO_PLAY_DIR=/tmp godot --path src/game +## +## Seeded runs (AUTO_PLAY_SEED=N) produce a directory per game: +## ${AUTO_PLAY_DIR}/game__seed/ +## meta.json — one-time run metadata +## turn_stats.jsonl — per-turn analytics (append) +## events.jsonl — append-only event log +## saves/turn_NNNN.save — full GameState.serialize() per turn + +const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") +const PathfinderScript = preload("res://engine/src/map/pathfinder.gd") +const BuildableHelperScript = preload("res://engine/scenes/city/city_buildable_helper.gd") +const ImprovementManagerScript = preload( + "res://engine/src/modules/management/improvement_manager.gd" +) +const HappinessScript = preload("res://engine/src/modules/empire/happiness.gd") +const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd") +const SaveManagerScript = preload("res://engine/src/core/save_manager.gd") + +var _improvement_manager: RefCounted = null + +var _active: bool = false +var _frame: int = 0 +var _state: String = "wait_main_menu" +var _output_dir: String = "/tmp" +var _turn_count: int = 0 +var _max_turns: int = 300 +var _victory: bool = false +var _world_map: Node = null +var _founded_city: bool = false +var _screenshot_interval: int = 10 +var _locked_target: Vector2i = Vector2i(-1, -1) +var _target_stuck_turns: int = 0 +var _last_army_pos: Vector2i = Vector2i(-1, -1) +# Attack commitment: while > 0 we stay in ATTACK. Scoring should flip less +# often than raw thresholds, so 5 turns is enough hysteresis. +var _attack_commitment_turns: int = 0 +# Stack sustain tracking — count of own military within 8 hex of _locked_target. +# Recomputed each turn during _play_turn, read by _next_building + rush-buy. +var _active_attack_mil_count: int = 0 +var _in_attack_phase: bool = false +# Stack-of-doom cap: tracks how many times a city has been attacked this turn +# (keyed by city position string). Reset at the start of each player's turn. +# Limits pile-ons so a 10-warrior stack can't one-shot a city in a single turn. +var _city_attacks_this_turn: Dictionary = {} +const MAX_CITY_ATTACKS_PER_TURN: int = 3 + +# Test harness state (AUTO_PLAY_SEED path) +var _seed: int = 0 +var _seed_set: bool = false +var _start_time: float = 0.0 +var _start_stamp: String = "" +var _game_dir: String = "" +var _victory_winner: int = -1 +var _victory_type: String = "" +var _outcome: String = "in_progress" +# Per-player cumulative/peak tracking — keyed by player_index → Dictionary of ints +var _stats: Dictionary = {} +# Per-player prev-turn snapshot for invariant checks +var _prev_turn_stats: Dictionary = {} +var _starved_this_turn: Dictionary = {} +var _violations: Array[String] = [] +# Game-wide aggregate counters (not per-player) +var _total_combats: int = 0 +var _total_cities_founded: int = 0 +var _total_cities_captured: int = 0 +var _turn_first_combat: int = -1 +var _turn_first_city_captured: int = -1 +# Buffered event log — flushed to events.jsonl once per turn +var _event_buffer: Array[Dictionary] = [] +# Guards against writing terminal outcome line twice (e.g. victory during max_turns path) +var _final_line_written: bool = false +# True once at least one turn_stats line has been appended. Used by the E2E +# gate to distinguish "game ran and wrote stats" from "game crashed silently +# before producing any output" — a silent crash would leave turn_stats.jsonl +# missing or empty, which the batch wrapper now rejects with a nonzero exit. +var _result_written: bool = false +var _strategic_gate_rejected_count: int = 0 +var _lair_cleared_count: int = 0 +# Weather telemetry (p0-36). Per-turn counter reset in _flush_turn_artifacts; +# cumulative counter rolls up for the lifetime of the run. +var _weather_events_this_turn: int = 0 +var _total_weather_events: int = 0 +# Save-resume test vars: AUTO_PLAY_SAVE_AT=N writes autosave at turn N and quits. +# AUTO_PLAY_LOAD_AUTOSAVE= overrides GameState from that save after world loads. +var _save_at_turn: int = -1 +var _load_autosave_path: String = "" + + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE: + _finalize_run() + + +func _ready() -> void: + _active = EnvConfig.get_bool("AUTO_PLAY") + if not _active: + set_process(false) + return + _output_dir = EnvConfig.get_var("AUTO_PLAY_DIR", "/tmp") + DirAccess.make_dir_recursive_absolute(_output_dir) + var limit_str: String = EnvConfig.get_var("AUTO_PLAY_TURN_LIMIT", "") + if not limit_str.is_empty() and limit_str.is_valid_int(): + _max_turns = int(limit_str) + var save_at_str: String = EnvConfig.get_var("AUTO_PLAY_SAVE_AT", "") + if not save_at_str.is_empty() and save_at_str.is_valid_int(): + _save_at_turn = int(save_at_str) + _load_autosave_path = EnvConfig.get_var("AUTO_PLAY_LOAD_AUTOSAVE", "") + print("AutoPlay: active — output to %s, max_turns=%d" % [_output_dir, _max_turns]) + + # Seeded determinism — only when AUTO_PLAY_SEED is set + var seed_str: String = EnvConfig.get_var("AUTO_PLAY_SEED", "") + if not seed_str.is_empty() and seed_str.is_valid_int(): + _seed = int(seed_str) + _seed_set = true + seed(_seed) + GameState.game_settings["seed"] = _seed + # NOTE: we cannot set `turn_limit` here because GameState.initialize_game + # clobbers game_settings back to DEFAULT_SETTINGS when game setup submits. + # turn_limit is re-applied later in the "wait_game_setup" state handler + # after the Start Game button fires. See the `["turn_limit"] = _max_turns` + # assignment ~200 lines below. + var now: Dictionary = Time.get_datetime_dict_from_system(true) + _start_stamp = "%04d%02d%02dT%02d%02d%02dZ" % [ + now["year"], now["month"], now["day"], + now["hour"], now["minute"], now["second"], + ] + # Use AUTO_PLAY_DIR directly as the game output dir. Callers + # (autoplay-batch.sh, run_ap3.sh) are responsible for passing a unique + # per-seed directory — auto_play does not further nest, otherwise the + # batch wrapper's scp would double-nest + # (e.g. `.local/.../game_stamp_seed1/game_stamp_seed1/turn_stats.jsonl`). + _game_dir = _output_dir + DirAccess.make_dir_recursive_absolute(_game_dir.path_join("saves")) + print("AutoPlay: seed=%d stamp=%s dir=%s" % [_seed, _start_stamp, _game_dir]) + _write_meta() + + _start_time = Time.get_unix_time_from_system() + EventBus.victory_achieved.connect(_on_victory) + EventBus.combat_resolved.connect(_on_combat) + EventBus.city_starved.connect(_on_city_starved) + EventBus.city_founded.connect(_on_city_founded) + EventBus.city_captured.connect(_on_city_captured) + EventBus.city_grew.connect(_on_city_grew) + EventBus.tech_researched.connect(_on_tech_researched) + EventBus.unit_created.connect(_on_unit_created) + EventBus.unit_destroyed.connect(_on_unit_destroyed) + EventBus.improvement_started.connect(_on_improvement_started) + EventBus.improvement_completed.connect(_on_improvement_completed) + EventBus.city_building_completed.connect(_on_city_building_completed) + EventBus.loot_dropped.connect(_on_loot_dropped) + EventBus.wild_creature_spawned.connect(_on_wild_creature_spawned) + EventBus.strategic_gate_rejected.connect(_on_strategic_gate_rejected) + EventBus.lair_cleared.connect(_on_lair_cleared_aggregate) + EventBus.weather_event_applied.connect(_on_weather_event_applied) + EventBus.climate_effect_applied.connect(_on_climate_effect_applied) + # p0-34 Freepeople prologue — write chronicle events into events.jsonl so + # the batch grader can verify the tribe-founding sequence fires on every + # seed (events bullet in .project/objectives/p0-34-...). + EventBus.tribe_converged.connect(_on_tribe_converged) + EventBus.capital_founded.connect(_on_capital_founded) + _improvement_manager = ImprovementManagerScript.new() + + +func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> void: + print(" COMBAT: def_dmg=%s def_hp=%s killed=%s" % [ + str(result.get("defender_damage", "N/A")), + str(result.get("defender_hp", "N/A")), + str(result.get("defender_killed", "N/A")), + ]) + _total_combats += 1 + if _turn_first_combat < 0: + _turn_first_combat = _turn_count + var defender_killed: bool = result.get("defender_killed", false) == true + var attacker_killed: bool = result.get("attacker_killed", false) == true + var atk_idx: int = -1 + var def_idx: int = -1 + if attacker != null and attacker.get("owner") != null: + atk_idx = int(attacker.get("owner")) + _ensure_stats(atk_idx) + if defender_killed: + _stats[atk_idx]["kills"] = int(_stats[atk_idx].get("kills", 0)) + 1 + if attacker_killed: + _stats[atk_idx]["units_lost"] = int(_stats[atk_idx].get("units_lost", 0)) + 1 + if defender != null and defender.get("owner") != null: + def_idx = int(defender.get("owner")) + _ensure_stats(def_idx) + if defender_killed: + _stats[def_idx]["units_lost"] = int(_stats[def_idx].get("units_lost", 0)) + 1 + if attacker_killed: + _stats[def_idx]["kills"] = int(_stats[def_idx].get("kills", 0)) + 1 + _append_event({ + "type": "combat_resolved", + "attacker_player": atk_idx, + "defender_player": def_idx, + "atk_damage": int(result.get("attacker_damage", 0)), + "def_damage": int(result.get("defender_damage", 0)), + "atk_killed": attacker_killed, + "def_killed": defender_killed, + }) + + +func _on_city_starved(city: Variant, new_pop: int) -> void: + if city == null or city.get("owner") == null: + return + var idx: int = int(city.get("owner")) + _starved_this_turn[idx] = true + _append_event({ + "type": "city_starved", + "player": idx, + "city": str(city.get("city_name")) if city.get("city_name") != null else "", + "pop": new_pop, + }) + + +func _on_city_founded(city: Variant, player_index: int) -> void: + _total_cities_founded += 1 + _append_event({ + "type": "city_founded", + "player": player_index, + "city": str(city.get("city_name")) if city != null and city.get("city_name") != null else "", + }) + + +func _on_tribe_converged( + player_id: int, centroid: Vector2i, ancestors_merged: int, founding_pop: int +) -> void: + # p0-34: Rust-side chronicle entry serialized into the batch log so the + # grader can assert ≥1 per seed on turn 0 — mirrors the shape emitted by + # mc_turn::chronicle::ChronicleEntry::TribeConverged. + _append_event({ + "type": "tribe_converged", + "player": player_id, + "centroid_q": centroid.x, + "centroid_r": centroid.y, + "ancestors_merged": ancestors_merged, + "founding_pop": founding_pop, + }) + + +func _on_capital_founded(player_id: int, position: Vector2i, pop: int) -> void: + # p0-34: Rust-side chronicle entry serialized into the batch log — mirrors + # mc_turn::chronicle::ChronicleEntry::CapitalFounded. + _append_event({ + "type": "capital_founded", + "player": player_id, + "pos_q": position.x, + "pos_r": position.y, + "pop": pop, + }) + + +func _on_city_captured(city: Variant, old_owner: int, new_owner: int) -> void: + _total_cities_captured += 1 + if _turn_first_city_captured < 0: + _turn_first_city_captured = _turn_count + if new_owner >= 0: + _ensure_stats(new_owner) + _stats[new_owner]["cities_captured"] = ( + int(_stats[new_owner].get("cities_captured", 0)) + 1 + ) + if old_owner >= 0: + _ensure_stats(old_owner) + _stats[old_owner]["cities_lost"] = ( + int(_stats[old_owner].get("cities_lost", 0)) + 1 + ) + _append_event({ + "type": "city_captured", + "old_owner": old_owner, + "new_owner": new_owner, + "city": str(city.get("city_name")) if city != null and city.get("city_name") != null else "", + }) + + +func _on_city_grew(city: Variant, new_pop: int) -> void: + if city == null: + return + var idx: int = int(city.get("owner")) if city.get("owner") != null else -1 + _append_event({ + "type": "city_grew", + "player": idx, + "city": str(city.get("city_name")) if city.get("city_name") != null else "", + "pop": new_pop, + }) + + +func _on_tech_researched(tech_id: String, player_index: int) -> void: + _append_event({ + "type": "tech_researched", + "player": player_index, + "tech": tech_id, + }) + + +func _on_unit_created(unit: Variant, player_index: int) -> void: + if unit == null: + return + _append_event({ + "type": "unit_created", + "player": player_index, + "unit": str(unit.get("type_id")) if unit.get("type_id") != null else "", + }) + + +func _on_unit_destroyed(unit: Variant, _killer: Variant) -> void: + if unit == null: + return + var idx: int = int(unit.get("owner")) if unit.get("owner") != null else -1 + _append_event({ + "type": "unit_destroyed", + "player": idx, + "unit": str(unit.get("type_id")) if unit.get("type_id") != null else "", + }) + var uid: String = str(unit.get("unit_id") if unit.get("unit_id") != null else "") + if uid != "" and idx >= 0: + var udata: Dictionary = DataLoader.get_unit(uid) + var req: String = str(udata.get("requires_resource", "")) + if req != "" and req != "null" and req != "": + var player: RefCounted = GameState.get_player(idx) + if player != null: + player.strategic_ledger[req] = int(player.strategic_ledger.get(req, 0)) + 1 + _maybe_queue_siege_replacement(unit, idx) + + +func _on_strategic_gate_rejected( + player_index: int, city_name: String, unit_id: String, resource_id: String +) -> void: + _strategic_gate_rejected_count += 1 + _append_event({ + "type": "strategic_gate_rejected", + "player": player_index, + "city": city_name, + "unit": unit_id, + "resource": resource_id, + }) + + +func _on_lair_cleared_aggregate(_tile: Vector2i, _reward: Dictionary) -> void: + _lair_cleared_count += 1 + + +func _on_weather_event_applied(kind: String, tile: Vector2i, severity: float) -> void: + _weather_events_this_turn += 1 + _total_weather_events += 1 + _append_event({ + "type": "weather_event", + "kind": kind, + "tile_x": tile.x, + "tile_y": tile.y, + "severity": severity, + }) + + +func _on_climate_effect_applied(unit_id: int, cause: String, hp_loss: int) -> void: + _append_event({ + "type": "climate_effect", + "unit_id": unit_id, + "cause": cause, + "hp_loss": hp_loss, + }) + + +func _maybe_queue_siege_replacement(unit: Variant, idx: int) -> void: + # Siege sustain: if a military unit belonging to the currently-attacking + # player dies mid-siege and the stack is at or below 3, prepend a warrior + # onto the nearest-city production queue so we can replace losses without + # waiting for score-based scheduling next turn. + if not _in_attack_phase or idx < 0: + return + if unit.get("can_found_city") == true or unit.get("can_build_improvements") == true: + return + var current: RefCounted = GameState.get_current_player() + if current == null or current.index != idx or current.cities.is_empty(): + return + if _active_attack_mil_count > 3: + return + var target_city: RefCounted = _nearest_city_to_target(current) + if target_city == null: + return + if target_city.production_queue.size() > 0: + var head: Dictionary = target_city.production_queue[0] + if str(head.get("id", "")) == "warrior" and str(head.get("type", "")) == "unit": + return + var udata: Dictionary = DataLoader.get_unit("warrior") + var wcost: int = int(udata.get("cost", 0)) + target_city.production_queue.insert( + 0, {"type": "unit", "id": "warrior", "cost": wcost} + ) + target_city.production_progress = 0 + print( + ( + " [STACK] turn=%d replacement_queued city=%s (stack=%d)" + % [_turn_count, target_city.city_name, _active_attack_mil_count] + ) + ) + + +func _on_improvement_started(tile: Vector2i, type: String, turns: int) -> void: + _append_event({ + "type": "improvement_started", + "tile_x": tile.x, + "tile_y": tile.y, + "improvement": type, + "turns": turns, + }) + + +func _on_improvement_completed(tile: Vector2i, type: String) -> void: + _append_event({ + "type": "improvement_built", + "tile_x": tile.x, + "tile_y": tile.y, + "improvement": type, + }) + + +func _on_city_building_completed(city: Variant, building_id: String) -> void: + var owner_idx: int = int(city.owner) if city != null and city.get("owner") != null else -1 + var city_name: String = str(city.city_name) if city != null and city.get("city_name") != null else "" + _append_event({ + "type": "city_building_completed", + "player": owner_idx, + "city": city_name, + "building_id": building_id, + }) + + +func _on_loot_dropped(player: Variant, creature_type: String, drops: Array) -> void: + var p_idx: int = int(player.get("index")) if player != null and player.get("index") != null else -1 + _append_event({ + "type": "loot_dropped", + "player": p_idx, + "creature": creature_type, + "drops": drops, + }) + + +func _on_wild_creature_spawned(unit: Variant, pos: Vector2i) -> void: + _append_event({ + "type": "wild_spawned", + "unit_type": str(unit.type_id) if unit != null and unit.get("type_id") != null else "", + "pos": [pos.x, pos.y], + }) + + +func _ensure_stats(player_index: int) -> void: + if not _stats.has(player_index): + _stats[player_index] = { + "kills": 0, + "units_lost": 0, + "cities_captured": 0, + "cities_lost": 0, + "pop_peak": 0, + "gold_peak": 0, + "turn_first_pop_3": -1, + "turn_first_pop_4": -1, + } + + +func _on_victory(player_index: int, victory_type: String) -> void: + _victory = true + _victory_winner = player_index + _victory_type = victory_type + # Also set the stringified outcome — _finalize_run writes the last + # turn_stats line from _outcome, and without this override the line + # persists as "in_progress" even though a winner was declared. + _outcome = "victory" + print("AutoPlay: VICTORY! Player %d wins via %s on turn %d" % [player_index, victory_type, _turn_count]) + _append_event({ + "type": "victory", + "player": player_index, + "victory_type": victory_type, + }) + _screenshot("victory_turn_%03d" % _turn_count) + _finalize_run() + get_tree().quit(0) + + +func _process(_delta: float) -> void: + _frame += 1 + + match _state: + "wait_main_menu": + if _frame % 10 == 0: + var btn: Button = _find_button("New Game") + if btn != null: + _screenshot("01_main_menu") + print("AutoPlay: [menu] New Game") + btn.pressed.emit() + _state = "wait_game_setup" + _frame = 0 + if _frame > 120: + _fail("main menu never appeared") + + "wait_game_setup": + if _frame % 10 == 0: + var btn: Button = _find_button("Start Game") + if btn != null: + _screenshot("02_game_setup") + # Inject AUTO_PLAY_SEED into the setup UI before submitting, + # otherwise game_setup.gd._compose_seed() overrides us with the + # default 42 via GameState.initialize_game() clobbering game_settings. + if _seed_set: + var setup_node: Node = _find_node_by_name( + get_tree().root, "GameSetup" + ) + if setup_node != null and setup_node.has_method("_set_seed"): + setup_node._set_seed(_seed) + print("AutoPlay: injected seed=%d into game_setup UI" % _seed) + var map_size: String = _resolve_map_size_env() + var num_players: int = _resolve_num_players_env() + print("AutoPlay: [setup] Start Game (map_size=%s, num_players=%d)" % [map_size, num_players]) + btn.pressed.emit() + # Override post-press. initialize_game() ran during pressed.emit() + # and populated game_settings from the setup UI; we replace the + # AI-only autoplay slice here so the batch wrappers + # (matchup-grid.sh / huge-map-5clan.sh) can drive the config + # via MAP_SIZE / NUM_PLAYERS env. + # + # Force Pangaea so all players share one landmass (no water + # barriers) — required for AI-vs-AI contact at any player count. + GameState.game_settings["map_type"] = "pangaea" + GameState.game_settings["map_size"] = map_size + GameState.game_settings["num_players"] = num_players + # Override turn_limit AFTER GameState.initialize_game has + # clobbered game_settings back to DEFAULT_SETTINGS (150). + # victory_manager reads this to time the score fallback. + GameState.game_settings["turn_limit"] = _max_turns + var diff_env: String = EnvConfig.get_var("AI_DIFFICULTY", "") + if not diff_env.is_empty(): + GameState.game_settings["difficulty"] = diff_env + print("AutoPlay: AI_DIFFICULTY=%s applied" % diff_env) + # apply_ai_difficulty() + per-player overrides are deferred to + # wait_loading (after DataLoader.load_theme runs in loading_screen). + _state = "wait_loading" + _frame = 0 + if _frame > 120: + _fail("game setup never appeared") + + "wait_loading": + if _frame % 10 == 0: + var btn: Button = _find_button("End Turn") + if btn != null: + print("AutoPlay: [world map] loaded") + _world_map = _find_node_by_name(get_tree().root, "WorldMap") + _count_lairs_on_map() + # Re-emit meta.json now that GameState.initialize_game has + # populated players and loading_screen has run + # PersonalityAssigner on each AI. The initial _write_meta() + # in _ready() fires before any of that, so player_clans is + # always {} there; this second write fills it in with the + # real clan assignments the Python per-clan aggregator + # (tools/autoplay-report.py) reads. + _write_meta() + # Apply difficulty modifiers NOW — DataLoader.load_theme ran + # during loading_screen so get_data("difficulty") is populated. + GameState.apply_ai_difficulty() + _apply_per_player_difficulty_overrides() + _state = "fix_start" + _frame = 0 + if _frame > 600: + _fail("loading timed out") + + "fix_start": + # Wait a few frames for map to fully initialize, then fix start positions + if _frame == 5: + if not _load_autosave_path.is_empty(): + var load_err: Error = SaveManagerScript.load_from_path(_load_autosave_path) + if load_err != OK: + _fail( + "AUTO_PLAY_LOAD_AUTOSAVE: failed to load '%s': %s" + % [_load_autosave_path, error_string(load_err)] + ) + return + print( + "AutoPlay: resumed from save '%s' (turn %d)" + % [_load_autosave_path, GameState.turn_number] + ) + _turn_count = GameState.turn_number + else: + _fix_start_positions_if_needed() + _apply_difficulty_starting_bonuses() + # Test-only scaffold — forces scout near a low-tier lair to exercise + # loot-drop path in a 100-turn smoke. Biases normal batches; keep gated. + if EnvConfig.get_bool("AUTO_PLAY_TEST_LOOT_SCAFFOLD"): + _teleport_scout_near_lair() + _state = "player_turn" + _frame = 0 + + "player_turn": + if _frame == 3: + _dismiss_popups() + if _frame == 10: + _turn_count += 1 + _play_turn() + if _turn_count % _screenshot_interval == 1 or _turn_count <= 3: + _screenshot("turn_%03d" % _turn_count) + if _frame == 20: + _end_turn() + + "wait_next_turn": + if _frame % 3 == 0: + _dismiss_popups() + if _frame % 10 == 0: + var btn: Button = _find_button("End Turn") + if btn != null and btn.is_visible_in_tree(): + _state = "player_turn" + _frame = 0 + if _frame > 600: + _fail("turn %d resolution timed out" % _turn_count) + + "done": + if _frame == 5: + # Score-victory fallback: at the auto_play turn cap, if + # vm.check_all hasn't already declared a winner (race — + # auto_play's "done" state can fire before TurnManager's + # next_player loop calls vm.check_all on the final turn), + # invoke it now so the highest-scoring player is recorded + # as the winner. Without this, all-AI games at turn_limit + # write outcome="max_turns" with winner_index=-1, which + # fails the ultimate_stress decisive-game gate. + if not _victory: + var vm: RefCounted = TurnManager._victory_manager + if vm != null and vm.has_method("check_all"): + # Bump GameState.turn_number to >= turn_limit so + # vm.check_all's score-fallback branch fires. Without + # this, GameState.turn_number can lag _turn_count by 1 + # (depends on whether mid-round next_player has wrapped), + # causing check_all to skip the score victory and + # leave outcome=max_turns with winner_index=-1. + var prev_turn: int = GameState.turn_number + if prev_turn < _max_turns: + GameState.turn_number = _max_turns + print("AutoPlay: invoking score-victory fallback (turn_number %d→%d, limit %d)" % [prev_turn, GameState.turn_number, _max_turns]) + vm.check_all(GameState.get_game_map()) + print("AutoPlay: fallback complete, victory=%s winner=%d" % [_victory, _victory_winner]) + _screenshot("final_turn_%03d" % _turn_count) + print("AutoPlay: finished — %d turns, victory=%s" % [_turn_count, _victory]) + _outcome = "victory" if _victory else "max_turns" + _finalize_run() + get_tree().quit(0 if _victory else 1) + + if _turn_count >= _max_turns and _state != "done": + print("AutoPlay: max turns reached (%d)" % _max_turns) + _state = "done" + _frame = 0 + + +## Resolve MAP_SIZE env to a setup.json map_size id. +## Valid: duel / team_duel / tiny / ffa_shaped / small / standard / large / huge. +## Default "duel" (40×24) preserves the historical 1v1 autoplay footprint — +## verified against .local/iter/* meta.json captures. +func _resolve_map_size_env() -> String: + const VALID_IDS: Array[String] = [ + "duel", "team_duel", "tiny", "ffa_shaped", + "small", "standard", "large", "huge", + ] + const DEFAULT_ID: String = "duel" + var raw: String = EnvConfig.get_var("MAP_SIZE", "").to_lower().strip_edges() + if raw.is_empty(): + return DEFAULT_ID + if raw in VALID_IDS: + return raw + push_warning( + "AutoPlay: MAP_SIZE=%s not in %s; falling back to %s" + % [raw, VALID_IDS, DEFAULT_ID] + ) + return DEFAULT_ID + + +## Resolve NUM_PLAYERS env. Clamped to [2, 5] per p0-22 (MAX_PLAYERS=5 POD cap). +## Default 2 preserves historical 1v1 autoplay behavior when unset. +func _resolve_num_players_env() -> int: + const MIN_PLAYERS: int = 2 + const MAX_PLAYERS: int = 5 + const DEFAULT_PLAYERS: int = 2 + var raw: String = EnvConfig.get_var("NUM_PLAYERS", "").strip_edges() + if raw.is_empty(): + return DEFAULT_PLAYERS + if not raw.is_valid_int(): + push_warning( + "AutoPlay: NUM_PLAYERS=%s not an int; falling back to %d" + % [raw, DEFAULT_PLAYERS] + ) + return DEFAULT_PLAYERS + var n: int = int(raw) + var clamped: int = clampi(n, MIN_PLAYERS, MAX_PLAYERS) + if clamped != n: + push_warning( + "AutoPlay: NUM_PLAYERS=%d clamped to %d (range [%d, %d])" + % [n, clamped, MIN_PLAYERS, MAX_PLAYERS] + ) + return clamped + + +func _teleport_scout_near_lair() -> void: + # Test scaffold: move player 0's scout adjacent to the nearest lair to + # guarantee lair-clearing is exercised. Without this, scouts rarely + # cross the 5+ hex min_distance_from_start gap to reach a lair. + var game_map: RefCounted = GameState.get_game_map() + if game_map == null or GameState.players.is_empty(): + return + var player: RefCounted = GameState.players[0] + var scout: RefCounted = null + for u: RefCounted in player.units: + if u.get("can_found_city") != true and u.is_alive(): + scout = u + break + if scout == null: + return + # Build tier lookup from wilds config so we can prefer low-tier lairs + # (a tier-7 Volcanic Fissure annihilates a fresh scout every attempt). + var wilds_cfg: Dictionary = DataLoader.get_wilds_config() + var tier_by_type: Dictionary = {} + for lt_entry: Dictionary in wilds_cfg.get("lair_types", []): + tier_by_type[lt_entry.get("id", "")] = int(lt_entry.get("base_tier", 4)) + var lair_positions: Array[Vector2i] = [] + for axial: Vector2i in game_map.tiles: + var tile: Resource = game_map.tiles[axial] + if tile != null and tile.lair_type != "": + lair_positions.append(axial) + if lair_positions.is_empty(): + return + # Pick the lowest-tier lair (break ties by distance). + var target_lair: Vector2i = lair_positions[0] + var target_tile: Resource = game_map.get_tile(target_lair) + var target_tier: int = int(tier_by_type.get( + target_tile.lair_type if target_tile != null else "", 99)) + var target_dist: int = HexUtilsScript.hex_distance(scout.position, target_lair) + for lp: Vector2i in lair_positions: + var lt_tile: Resource = game_map.get_tile(lp) + var tier: int = int(tier_by_type.get( + lt_tile.lair_type if lt_tile != null else "", 99)) + var d: int = HexUtilsScript.hex_distance(scout.position, lp) + if tier < target_tier or (tier == target_tier and d < target_dist): + target_tier = tier + target_dist = d + target_lair = lp + var water_biomes: Array = ["ocean", "coast", "deep_ocean", "lake", "inland_sea", "reef"] + for n: Vector2i in HexUtilsScript.get_neighbors(target_lair): + 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 or tile.biome_id in water_biomes: + continue + var from: Vector2i = scout.position + scout.position = norm + # Test scaffold: buff the scout enough to clear a low-tier lair. + # Without this, tier 5-7 wild creatures annihilate a base scout + # and the loot path never fires. + scout.max_hp = 200 + scout.hp = 200 + scout.attack = 40 + scout.defense = 20 + _recalc_vision(player, game_map) + print("AutoPlay: teleported scout from %s to %s (lair %s at %s, buffed)" % [ + from, norm, tile.lair_type if tile != null else "?", target_lair + ]) + return + + +func _count_lairs_on_map() -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + var counts: Dictionary = {} + for axial: Vector2i in game_map.tiles: + var tile: Resource = game_map.tiles[axial] + if tile == null: + continue + var lt: String = tile.lair_type if "lair_type" in tile else "" + if lt != "": + counts[lt] = int(counts.get(lt, 0)) + 1 + var total: int = 0 + for k: String in counts: + total += int(counts[k]) + print("AutoPlay: lairs on map = %d %s" % [total, str(counts)]) + + +# ── Start Position Fix ─────────────────────────────────────────────── + +func _fix_start_positions_if_needed() -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + print(" AutoPlay: no game map") + return + + print(" Map: %dx%d, %d tiles, %d start_positions" % [ + game_map.width, game_map.height, + game_map.tiles.size(), game_map.start_positions.size() + ]) + + if game_map.start_positions.size() > 0: + return + + # Find land tiles + var water_biomes: Array = ["ocean", "coast", "deep_ocean", "lake", "inland_sea", "reef"] + var land_tiles: Array[Vector2i] = [] + for pos: Vector2i in game_map.tiles: + var tile: Resource = game_map.tiles[pos] + if tile != null and tile.biome_id not in water_biomes: + land_tiles.append(pos) + + if land_tiles.is_empty(): + print(" WARNING: no land tiles!") + return + + # Spread players + var num_players: int = GameState.players.size() + var step: int = max(1, land_tiles.size() / max(1, num_players)) + for i: int in range(num_players): + var idx: int = min(i * step, land_tiles.size() - 1) + game_map.start_positions.append(land_tiles[idx]) + + # Relocate units + for p: Variant in GameState.players: + if p.index < game_map.start_positions.size(): + var sp: Vector2i = game_map.start_positions[p.index] + for u: Variant in p.units: + u.position = sp + + print(" FIXED start positions: %s (%d land tiles)" % [ + str(game_map.start_positions), land_tiles.size() + ]) + + +func _apply_per_player_difficulty_overrides() -> void: + # DataLoader.get_data("difficulty") returns {"easy": {full entry}, "normal": {...}, ...} + # already keyed by id — no wrapping "ai_difficulty" array at this level. + var diff_data: Dictionary = DataLoader.get_data("difficulty") + if diff_data == null or diff_data.is_empty(): + return + # Use a fixed upper bound — this runs before loading_screen.gd populates + # GameState.players, so players.size() == 0. Check all possible player slots. + for p_idx: int in range(8): + var key: String = "AI_DIFFICULTY_P%d" % p_idx + var tier: String = EnvConfig.get_var(key, "") + if tier.is_empty(): + continue + var entry: Dictionary = diff_data.get(tier, {}) + var mods: Dictionary = entry.get("ai_modifiers", {}) + if mods.is_empty(): + print("AutoPlay: WARNING: unknown difficulty tier '%s' for %s" % [tier, key]) + continue + GameState.ai_per_player_production_mult[p_idx] = float(mods.get("production_mult", 1.0)) + GameState.ai_per_player_research_mult[p_idx] = float(mods.get("research_mult", 1.0)) + print( + "AutoPlay: %s=%s → player %d prod=%.2f research=%.2f" + % [key, tier, p_idx, + GameState.ai_per_player_production_mult[p_idx], + GameState.ai_per_player_research_mult[p_idx]] + ) + + +func _apply_difficulty_starting_bonuses() -> void: + var gold_bonus: int = GameState.ai_starting_gold_bonus + var extra_units: int = GameState.ai_extra_starting_units + var extra_unit_id: String = GameState.ai_extra_unit_id + if gold_bonus == 0 and extra_units == 0: + return + var unit_manager: Node = get_node_or_null("/root/UnitManager") + for p: Variant in GameState.players: + if p is Player and not p.is_human: + if gold_bonus > 0: + p.gold += gold_bonus + if extra_units > 0 and unit_manager != null: + for city: Variant in p.cities: + for _i: int in range(extra_units): + unit_manager.create_unit(extra_unit_id, p.index, city.position, p) + if gold_bonus > 0 or extra_units > 0: + print( + "AutoPlay: difficulty bonuses applied — gold+%d extra_units=%d (%s)" + % [gold_bonus, extra_units, extra_unit_id] + ) + + +# ── Player Actions ─────────────────────────────────────────────────── + +func _play_turn() -> void: + var player: RefCounted = GameState.get_current_player() + if player == null: + return + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + + var unit_count: int = player.units.size() + var city_count: int = player.cities.size() + + _check_invariants(player) + + if _turn_count <= 5 or _turn_count % 10 == 0: + var happiness: int = player.get("happiness") if player.get("happiness") != null else -99 + var gold: int = player.get("gold") if player.get("gold") != null else 0 + var gpt: int = player.get("gold_per_turn") if player.get("gold_per_turn") != null else 0 + var techs: int = player.researched_techs.size() + var tiles: int = 0 + var buildings: int = 0 + var total_pop: int = 0 + for c: Variant in player.cities: + tiles += c.owned_tiles.size() + buildings += c.buildings.size() + total_pop += c.population + var military_count: int = 0 + for u: Variant in player.units: + if u.is_alive() and u.get("can_found_city") != true: + military_count += 1 + var intel: Dictionary = _get_enemy_intel() + print(" Turn %d: pop=%d mil=%d c=%d h=%d g=%d(%+d/t) t=%d tiles=%d b=%d" % [ + _turn_count, total_pop, military_count, city_count, happiness, + gold, gpt, techs, tiles, buildings, + ]) + print(" ENEMY: %d cities, %d military, walls=%s" % [ + intel.get("cities", 0), intel.get("military", 0), + "Y" if intel.get("has_walls", false) else "N", + ]) + # Per-city detail + for c: Variant in player.cities: + var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) + var cy: Dictionary = c.get_yields(tile_json) + var food_surplus: float = float(cy.get("food", 0)) - float(c.population) * 2.0 + var food_stored: float = c.get_food_stored() if c.has_method("get_food_stored") else -1.0 + print(" [%s] pop=%d food=%+.1f stored=%.1f prod=%.0f tiles=%d bld=%d" % [ + c.city_name, c.population, food_surplus, food_stored, + float(cy.get("production", 0)), c.owned_tiles.size(), c.buildings.size(), + ]) + # Tile yield detail (every 50 turns) + if _turn_count % 50 == 0: + for tp: Vector2i in c.owned_tiles: + var tl: Resource = game_map.get_tile(tp) + if tl == null: + continue + var ty: Dictionary = tl.get_yields(player.index) + var worked: bool = tp in c.get_worked_tiles() + print(" tile(%d,%d) %s f=%d p=%d g=%d %s" % [ + tp.x, tp.y, tl.biome_id, + int(ty.get("food", 0)), int(ty.get("production", 0)), + int(ty.get("trade", 0)), + "[WORKED]" if worked else "", + ]) + + # 0. Pick research if idle + if player.researching.is_empty(): + _pick_research(player) + + # Reset per-turn city attack counter (stack-of-doom cap). + _city_attacks_this_turn.clear() + + # Refresh attack-phase signals and stack-sustain telemetry for this turn. + # _attack_commitment_turns reflects prior-turn commitment; rush-buy and + # building scoring both key off it so they respond mid-siege. + _in_attack_phase = _attack_commitment_turns > 0 and _locked_target != Vector2i(-1, -1) + _active_attack_mil_count = 0 + if _in_attack_phase: + for u_stk: RefCounted in player.units: + if not u_stk.is_alive(): + continue + if u_stk.get("can_found_city") == true: + continue + if u_stk.get("can_build_improvements") == true: + continue + if HexUtilsScript.hex_distance(u_stk.position, _locked_target) <= 8: + _active_attack_mil_count += 1 + if _turn_count % 10 == 0: + print( + ( + " [STACK] turn=%d at_target=%d locked=%s commit=%d" + % [ + _turn_count, + _active_attack_mil_count, + str(_locked_target), + _attack_commitment_turns, + ] + ) + ) + + # 0b. Gold rush-buy warriors — spawn at city nearest to attack target. + # During active siege, lower the threshold so we can replace losses fast. + # Stack critical (<=1 near target) drops the threshold further. + var mil_pre: int = 0 + for u_pre: RefCounted in player.units: + if u_pre.is_alive() and u_pre.get("can_found_city") != true: + mil_pre += 1 + var rush_cost: int = 120 + if _in_attack_phase: + rush_cost = 50 if _active_attack_mil_count <= 1 else 80 + var mil_cap: int = city_count * 2 + if _in_attack_phase and _active_attack_mil_count < 3: + mil_cap = maxi(mil_cap, mil_pre + (3 - _active_attack_mil_count)) + while player.gold >= rush_cost and mil_pre < mil_cap: + if not player.cities.is_empty(): + var spawn_pos: Vector2i = _nearest_city_to_target(player).position + var unit_script: GDScript = load("res://engine/src/entities/unit.gd") + var new_unit: RefCounted = unit_script.new("warrior", player.index, spawn_pos) + new_unit.id = "rush_%d_%d" % [_turn_count, mil_pre] + new_unit.display_name = "Warrior" + player.units.append(new_unit) + var primary_layer: Dictionary = GameState.get_primary_layer() + primary_layer.get("units", []).append(new_unit) + player.gold -= rush_cost + mil_pre += 1 + EventBus.unit_created.emit(new_unit, player.index) + + # 1. Found city if we have a founder + if city_count == 0 or _has_founder(player): + _try_found_city(player, game_map) + + # 2. Always queue warriors + for c: Variant in player.cities: + _manage_production(c) + + # 2b. Command workers to build tile improvements + for u: Variant in player.units: + if u.is_alive() and u.get("can_build_improvements") == true and u.movement_remaining > 0: + _command_worker(u, player, game_map) + + # 3. Strategy: score-based attack/consolidate decision + var military_count: int = 0 + for u: Variant in player.units: + if u.is_alive() and u.get("can_found_city") != true: + military_count += 1 + + var units_snapshot: Array = player.units.duplicate() + + var city_pos: Vector2i = player.cities[0].position if not player.cities.is_empty() else Vector2i.ZERO + + var intel: Dictionary = _get_enemy_intel() + var enemy_mil: int = intel.get("military", 0) + 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) <= 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): + _locked_target = _find_attack_target(player) + # Verify target still exists (city/unit might be destroyed) + var target_alive: bool = false + for p: Variant in GameState.players: + if p.index == player.index: + continue + for c: Variant in p.cities: + if c.position == _locked_target: + target_alive = true + for u: Variant in p.units: + if u.is_alive() and u.position == _locked_target: + target_alive = true + if not target_alive: + _locked_target = _find_attack_target(player) + _target_stuck_turns = 0 + # Target fell (capture/kill) — refresh hysteresis to press the next one. + _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 + for u_chk: Variant in units_snapshot: + if u_chk.is_alive() and u_chk.get("can_found_city") != true: + var d: int = HexUtilsScript.hex_distance(u_chk.position, _locked_target) + if d < min_dist: + min_dist = d + if min_dist <= 1: + _target_stuck_turns = 0 # adjacent — attacking, not stuck + elif min_dist >= _last_army_pos.x: # reusing x as last_distance + _target_stuck_turns += 1 + else: + _target_stuck_turns = 0 + _last_army_pos = Vector2i(min_dist, 0) + if _target_stuck_turns >= 20: + print(" STUCK: army can't reach %s, re-targeting" % _locked_target) + _locked_target = Vector2i(-1, -1) + _target_stuck_turns = 0 + var target: Vector2i = _locked_target + if target != Vector2i(-1, -1): + if _turn_count % 10 == 0: + var unit_positions: Array = [] + for u: Variant in units_snapshot: + if u.is_alive() and u.get("can_found_city") != true: + unit_positions.append(str(u.position)) + print(" ATTACK: %d warriors at %s -> target %s" % [military_count, ", ".join(unit_positions), target]) + for u: Variant in units_snapshot: + if not u.is_alive() or u.movement_remaining <= 0: + continue + if u.get("can_found_city") == true or u.get("can_build_improvements") == true: + continue + # Scouts always redirect to lair-clearing; warriors redirect when far from target + var dist_to_target: int = HexUtilsScript.hex_distance(u.position, target) + var u_max_hp: int = u.get_max_hp() + var w_hp_ok: bool = u_max_hp <= 0 or u.hp >= int(u_max_hp * 0.25) + var is_scout: bool = u.type_id == "dwarf_scout" + var lair_tier_cap: int = 2 if is_scout else 3 + if w_hp_ok and (is_scout or dist_to_target > 12): + var w_lair: Vector2i = _find_nearest_low_lair(u.position, lair_tier_cap) + if w_lair != Vector2i(-1, -1): + u.is_fortified = false + u.fortified_turns = 0 + _move_toward(u, w_lair, game_map) + _try_attack_adjacent_lair(u, game_map) + continue + # Un-fortify before moving + u.is_fortified = false + u.fortified_turns = 0 + _move_toward(u, target, game_map) + else: + # BUILD PHASE: garrison at city, don't engage. Only scouts explore. + for u: Variant in units_snapshot: + if not u.is_alive() or u.movement_remaining <= 0: + continue + if u.get("can_found_city") == true: + continue + if u.get("can_build_improvements") == true: + continue + if u.type_id == "dwarf_scout" and _turn_count <= 20: + _explore(u, player, game_map) + else: + # Scouts and warriors seek low-tier lairs during build phase. + var lair_target: Vector2i = Vector2i(-1, -1) + var u_max_hp: int = u.get_max_hp() + var hp_ok: bool = u_max_hp <= 0 or u.hp >= int(u_max_hp * 0.25) + var lair_max_tier: int = 2 if u.type_id == "dwarf_scout" else 3 + if hp_ok: + lair_target = _find_nearest_low_lair(u.position, lair_max_tier) + if lair_target != Vector2i(-1, -1): + _move_toward(u, lair_target, game_map) + _try_attack_adjacent_lair(u, game_map) + elif u.position != city_pos: + _move_toward(u, city_pos, game_map) + elif not u.is_fortified: + u.is_fortified = true + u.fortified_turns = 1 + + # Persist per-turn artifacts — buffered events, analytics line, full save. + _flush_turn_artifacts() + + +func _pick_research(player: RefCounted) -> void: + ## Score available techs: base = 1000/cost; per-pillar personality multiplier; + ## unlocks tier≥4 unit adds ×3; prerequisite of high-value tech adds ×1.5. + ## Personality axes drive per-pillar multipliers so clan research orders diverge: + ## military → aggression (blackhammer rushes military techs) + ## metallurgy → production (ironhold/deepforge prioritise smithing) + ## agriculture → expansion (blackhammer/runesmith expand aggressively) + ## civics → wealth + trade_willingness (goldvein) + ## scholarship → wealth + production blend (goldvein science income) + ## ecology → expansion × 0.5 + production × 0.5 (deepforge tall-empire) + ## + ## Research scoring belongs in mc-ai::ScoringEvaluator::pick_tech (Rail-1). + ## This test-harness path reads axes inline; wiring through GdAiController + ## requires the tactical bridge to emit research actions (tracked in p0-26). + var all_techs: Array = DataLoader.get_all_techs() + + # Load clan personality axes (1..=10). Defaults to 5 (neutral) if clan + # is unset so vanilla scoring degrades gracefully to neutral multipliers. + var clan_id: String = str(player.get("clan_id") if player.get("clan_id") != null else "") + var axes: Dictionary = {} + if not clan_id.is_empty(): + var personality: Dictionary = DataLoader.get_ai_personality(clan_id) + if personality != null and not personality.is_empty(): + axes = personality.get("strategic_axes", {}) + + # Normalise raw 1..=10 axis values to [0, 1] (neutral 5 → 0.44). + var agg: float = _norm_axis(axes, "aggression") + var prod: float = _norm_axis(axes, "production") + var wlth: float = _norm_axis(axes, "wealth") + var trd: float = _norm_axis(axes, "trade_willingness") + var exp: float = _norm_axis(axes, "expansion") + + # Per-pillar multiplier derived from clan axes (range 1.0..=2.0). + # Base of 1.0 ensures clans with low axes still research every pillar. + var pillar_mult: Dictionary = { + "military": 1.0 + agg, + "metallurgy": 1.0 + prod, + "agriculture": 1.0 + exp * 0.8, + "civics": 1.0 + (wlth + trd) / 2.0 * 0.7, + "scholarship": 1.0 + (wlth + prod) / 2.0 * 0.6, + "ecology": 1.0 + (exp + prod) / 2.0 * 0.5, + } + + # Pass 1: compute raw score for every tech (ignoring availability). + var raw_score: Dictionary = {} + for tech: Dictionary in all_techs: + var tid: String = str(tech.get("id", "")) + if tid.is_empty(): + continue + var cost: int = maxi(int(tech.get("cost", 1)), 1) + var sc: float = 1000.0 / float(cost) + # Apply personality-driven pillar multiplier (replaces hardcoded x2 for military). + var pillar: String = str(tech.get("pillar", "")) + sc *= float(pillar_mult.get(pillar, 1.0)) + # Tier-3+ penalty for mercantile clans (low aggression AND low production). + # Guards goldvein/runesmith from racing to tier_peak=6 identically to + # ironhold/blackhammer. agg < 0.5 catches raw ≤5; prod < 0.5 catches raw ≤5. + if int(tech.get("tier", 0)) >= 3 and agg < 0.5 and prod < 0.5: + var trade_factor: float = (wlth + trd) / 2.0 # mercantile bias, [0, 1] + sc *= maxf(0.4, 1.0 - trade_factor * 0.6) # up to 60% penalty for full mercantile clans + for uid: Variant in tech.get("unlocks", {}).get("units", []): + var udata: Dictionary = DataLoader.get_unit(str(uid)) + if int(udata.get("tier", 1)) >= 4: + sc *= 3.0 + break + raw_score[tid] = sc + + # Pass 2: prerequisites of any tech scoring >= 20 get a 1.5x boost. + var prereq_mult: Dictionary = {} + for tech: Dictionary in all_techs: + var tid: String = str(tech.get("id", "")) + if not raw_score.has(tid) or float(raw_score[tid]) < 20.0: + continue + for req: Variant in tech.get("requires", []): + var rid: String = str(req) + prereq_mult[rid] = maxf(float(prereq_mult.get(rid, 1.0)), 1.5) + + # Pass 3: pick the highest-scoring available tech. + var best_id: String = "" + var best_score: float = -1.0 + for tech: Dictionary in all_techs: + var tid: String = str(tech.get("id", "")) + if tid.is_empty() or player.has_tech(tid): + continue + var reqs: Array = tech.get("requires", []) + var met: bool = true + for req: Variant in reqs: + if not player.has_tech(str(req)): + met = false + break + if not met: + continue + var score: float = float(raw_score.get(tid, 1.0)) * float(prereq_mult.get(tid, 1.0)) + if score > best_score: + best_score = score + best_id = tid + if not best_id.is_empty(): + player.researching = best_id + player.research_progress = 0 + if _turn_count <= 5 or _turn_count % 20 == 0: + print(" Researching: %s (score %.1f)" % [best_id, best_score]) + + +static func _norm_axis(axes: Dictionary, key: String) -> float: + ## Normalise a 1..=10 raw personality axis value to [0, 1]. + ## Returns 0.44 for neutral (5), 0.0 for minimum (1), 1.0 for maximum (10). + ## Missing keys default to 5 (neutral). + var raw: float = float(axes.get(key, 5)) + return (clampf(raw, 1.0, 10.0) - 1.0) / 9.0 + + +func _score_site(pos: Vector2i, game_map: RefCounted) -> float: + ## Score a hex as a city site. Food*2 + production*1.5 + resources. + var tile: Resource = game_map.get_tile(pos) + if tile == null: + return 0.0 + var water: Array = ["ocean", "coast", "deep_ocean", "lake"] + if tile.biome_id in water: + return 0.0 + var score: float = 0.0 + var y: Dictionary = tile.get_yields(-1) + score += float(y.get("food", 0)) * 2.0 + float(y.get("production", 0)) * 1.5 + for n: Vector2i in HexUtilsScript.get_neighbors(pos): + var norm: Vector2i = HexUtilsScript.normalize_position(n, game_map.width, game_map.height, game_map.wrap_mode) + var nt: Resource = game_map.get_tile(norm) + if nt == null: + continue + if nt.biome_id in water: + score += 0.5 + continue + var ny: Dictionary = nt.get_yields(-1) + score += float(ny.get("food", 0)) * 0.5 + float(ny.get("production", 0)) * 0.3 + if nt.resource_id != "": + score += 2.0 + return score + + +func _get_enemy_intel() -> Dictionary: + ## Scan all enemy players and return aggregate intel. + var player: RefCounted = GameState.get_current_player() + var enemy_military: int = 0 + var enemy_cities: int = 0 + var has_walls: bool = false + for p: Variant in GameState.players: + if p.index == player.index: + continue + enemy_cities += p.cities.size() + for c: Variant in p.cities: + if c.has_building("walls") or c.has_building("castle"): + has_walls = true + for u: Variant in p.units: + if u.is_alive() and u.get("can_found_city") != true: + enemy_military += 1 + return { + "military": enemy_military, + "cities": enemy_cities, + "has_walls": has_walls, + } + + +func _has_founder(player: RefCounted) -> bool: + for u: Variant in player.units: + if u.get("can_found_city") == true and u.is_alive(): + return true + return false + + +func _try_found_city(player: RefCounted, game_map: RefCounted) -> void: + for u: Variant in player.units: + if u.get("can_found_city") == true and u.is_alive(): + _decide_founder(u, player, game_map) + if not player.cities.is_empty(): + _founded_city = true + return + + +func _manage_production(city: Variant) -> void: + if _turn_count <= 5 or _turn_count % 20 == 0: + var q_size: int = city.production_queue.size() + var item: String = city.production_queue[0].get("id", "") if q_size > 0 else "none" + print(" [PROD] %s: queue=%d item=%s progress=%d" % [city.city_name, q_size, item, city.production_progress]) + if city.production_queue.is_empty(): + var gs: Node = get_node("/root/GameState") + var player: RefCounted = gs.get_current_player() + var city_count: int = player.cities.size() if player != null else 1 + var has_founder: bool = false + if player != null: + for u: Variant in player.units: + if u.get("can_found_city") == true: + has_founder = true + # Circumstance-based scoring: each turn, score candidate items and pick best + var built: String = _next_building(city, player, city_count, has_founder) + if built.is_empty(): + built = "warrior" + # Once a city has the basics, prefer a world wonder in the capital. + # Override TRUE filler (warrior, monument) with a buildable wonder + # once the city has ≥3 buildings AND has the production multiplier + # (forge). Conservative override — does NOT replace forge / library / + # marketplace / military tiers / walls / units. Earlier wonder-fix v2 + # overrode forge too, which collapsed mid-game production and dropped + # tier_peak/peak_unit_tier across 5-seed validation. Filler-only override + # preserves the production chain while still firing wonders. + var low_pri_filler: Array = ["warrior", "monument"] + if (built in low_pri_filler) and city_count >= 1 and Array(city.buildings).size() >= 3 and city.has_building("forge"): + var existing: Array = Array(city.buildings) + var has_wonder: bool = false + for bld_id: String in existing: + var bd: Dictionary = DataLoader.get_building(bld_id) + if bd.get("wonder_type") != null: + has_wonder = true + break + if not has_wonder: + var best_wonder: String = "" + var best_era: int = 999 + for b: Dictionary in DataLoader.get_all_buildings(): + var wid: String = str(b.get("id", "")) + if wid.is_empty() or wid in existing: + continue + if b.get("wonder_type") == null: + continue + if not city.can_build(wid, player): + continue + var era: int = int(b.get("era", 999)) + if era < best_era: + best_era = era + best_wonder = wid + if not best_wonder.is_empty(): + built = best_wonder + # Use DataLoader.get_unit to classify built item as unit vs building — + # previously a hardcoded list `["warrior", "founder", "worker"]` silently + # mis-queued tier-2+ melee units (p0-39) as buildings → add_to_queue + # rejected them and tier progression never manifested. + var unit_entry: Dictionary = DataLoader.get_unit(built) + if not unit_entry.is_empty(): + city.add_to_queue("unit", built) + else: + city.add_to_queue("building", built) + + +func _next_building(city: Variant, player: Variant, city_count: int, has_founder: bool) -> String: + ## 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 = { + "library": "scholarship", "barracks": "military_doctrine", + "castle": "fortification", "spearmen": "war", + "cavalry": "steelworking", + "ironwarden": "combined_arms", + "forge_titan": "mechanized_warfare", + "mithril_vanguard": "total_war", + } + var candidates: Array[String] = [ + "warrior", "forge", "walls", "marketplace", "temple", + "colosseum", "ale_hall", "bathhouse", "library", "barracks", "monument", + "castle", "founder", "worker", "spearmen", "cavalry", + "ironwarden", "forge_titan", "mithril_vanguard", + ] + var units_set: Array[String] = ["warrior", "founder", "worker", "spearmen", "cavalry", + "ironwarden", "forge_titan", "mithril_vanguard"] + var scores: Dictionary = {} + for cid: String in candidates: + if not (cid in units_set) and city.has_building(cid): + continue + if cid in tech_req and not player.has_tech(tech_req[cid]): + continue + if cid in units_set: + var udata: Dictionary = DataLoader.get_unit(cid) + var req_res: String = str(udata.get("requires_resource", "")) + if req_res != "" and req_res != "null" and req_res != "": + if not BuildableHelperScript.player_owns_resource( + player, req_res + ): + _strategic_gate_rejected_count += 1 + _append_event({ + "type": "strategic_gate_rejected", + "player": player.index, + "city": city.city_name, + "unit": cid, + "resource": req_res, + }) + continue + scores[cid] = 0.0 + + # State gathering + var intel: Dictionary = _get_enemy_intel() + var enemy_mil: int = int(intel.get("military", 0)) + var own_mil: int = 0 + var own_workers: int = 0 + for u: Variant in player.units: + if not u.is_alive() or u.get("can_found_city") == true: + continue + if u.get("can_build_improvements") == true: + own_workers += 1 + else: + own_mil += 1 + var gpt: int = int(player.gold_per_turn) if player.get("gold_per_turn") != null else 0 + var happy: int = int(player.happiness) if player.get("happiness") != null else 0 + var gold_now: int = int(player.gold) if player.get("gold") != null else 0 + var max_pop: int = 0 + for oc: Variant in player.cities: + if int(oc.population) > max_pop: + max_pop = int(oc.population) + var near_enemy: int = 99 + var any_siege: bool = false + for p: Variant in GameState.players: + if p.index == player.index: + continue + for eu: Variant in p.units: + if not eu.is_alive() or eu.get("can_found_city") == true: + continue + var d_self: int = HexUtilsScript.hex_distance(city.position, eu.position) + if d_self < near_enemy: + near_enemy = d_self + for oc2: Variant in player.cities: + if HexUtilsScript.hex_distance(oc2.position, eu.position) <= 3: + any_siege = true + var gm: RefCounted = GameState.get_game_map() + var base_prod: int = 0 + if gm != null: + var cy: Dictionary = city.get_yields(BuildableHelperScript.build_tile_yields_json(city, gm)) + base_prod = int(cy.get("production", 0)) + var unimproved_food: int = 0 + var food_starved: bool = false + for tp: Vector2i in city.owned_tiles: + var tl: Resource = gm.get_tile(tp) if gm != null else null + if tl == null: + continue + if str(tl.get("improvement")) == "" and tl.biome_id in ["grassland", "plains", "forest", "boreal_forest", "jungle", "tundra"]: + unimproved_food += 1 + for tp2: Vector2i in city.get_worked_tiles(): + var tl2: Resource = gm.get_tile(tp2) if gm != null else null + if tl2 != null and int(tl2.get_yields(player.index).get("food", 0)) == 0: + food_starved = true + + # 14 factors (see plan table). Weights tuned against smoke regressions: + # forge gets strong priority early (its prod multiplier gates everything), + # worker is gated by population >=2 so starving-mountain starts don't pick + # worker they can't afford. + if enemy_mil >= own_mil and near_enemy <= 6: + _score_add(scores, "warrior", 8.0); _score_add(scores, "walls", 4.0) + if own_mil < enemy_mil: + _score_add(scores, "warrior", 5.0) + if any_siege: + _score_add(scores, "walls", 10.0); _score_add(scores, "warrior", 3.0) + if own_mil == 0 and _turn_count <= 30: + _score_add(scores, "warrior", 6.0) + if city_count < 3 and not has_founder and max_pop >= 3: + _score_add(scores, "founder", 6.0) + if food_starved and own_workers == 0 and int(city.population) >= 2: + _score_add(scores, "worker", 7.0) + if unimproved_food > 0 and own_workers < city_count and int(city.population) >= 2: + _score_add(scores, "worker", 4.0) + # First worker is a strong priority once pop can spare the food — + # tile improvements are the primary long-term yield multiplier. + if own_workers == 0 and int(city.population) >= 2: + _score_add(scores, "worker", 10.0) + # Keep worker supply replenished: one worker per city after first. + if own_workers < city_count and int(city.population) >= 3: + _score_add(scores, "worker", 3.0) + if gold_now < 20 and gpt < 0: + _score_add(scores, "marketplace", 7.0) + if not city.has_building("forge"): + # Forge is the universal production multiplier; prioritize strongly when absent. + 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, "temple", 5.0); _score_add(scores, "colosseum", 4.0) + _score_add(scores, "ale_hall", 3.5); _score_add(scores, "bathhouse", 4.5) + if happy < -8: + _score_add(scores, "ale_hall", 1.5); _score_add(scores, "bathhouse", 1.5) + # Library: strong priority once scholarship is researched — science/turn + # from a single city starts at 1 so a +2 library doubles research pace. + # Gate only on tech, not on city count, so first city still builds it. + if player.has_tech("scholarship") and not city.has_building("library"): + _score_add(scores, "library", 8.0) + if own_mil >= 4 and not city.has_building("barracks"): + _score_add(scores, "barracks", 3.0) + if city.has_building("walls") and _turn_count > 150 and city_count >= 3: + _score_add(scores, "castle", 3.0) + # Monument: cheap early culture for border expansion. After forge lands, + # push hard to ensure it beats library/walls and actually gets built — + # culture expansion is a major yield multiplier through more worked tiles. + if not city.has_building("monument"): + var monument_w: float = 10.0 if city.has_building("forge") else 2.5 + if _turn_count < 60 and not any_siege: + monument_w += 4.0 + _score_add(scores, "monument", monument_w) + # Siege sustain: while committed to ATTACK, missing stack slots near the + # target dominate everything else — +15 per missing warrior below 3. + if _in_attack_phase and _active_attack_mil_count < 3: + var missing: int = 3 - _active_attack_mil_count + _score_add(scores, "warrior", 15.0 * float(missing)) + _score_add(scores, "warrior", 1.0); _score_add(scores, "forge", 1.0) + # Tier escalation: higher-tier units always beat warrior when available. + # Score is proportional to tier so ironwarden > cavalry > spearmen > warrior. + _score_add(scores, "spearmen", 3.0) + _score_add(scores, "cavalry", 5.0) + _score_add(scores, "ironwarden", 8.0) + _score_add(scores, "forge_titan", 12.0) + _score_add(scores, "mithril_vanguard", 18.0) + + # World wonders: scored alongside units/buildings so they actually compete + # (warcouncil p0-01 wonder gate). Earlier override-after-pick approaches + # rarely fired because the picker preferred tier units. Now wonders enter + # the scoring loop directly with personality-weighted score. + # Score = (era * 1.5 + 4) * personality_axis + # personality_axis ∈ [0.4, 1.6]: tall (deepforge/ironhold high-prod) + + # wealthy (goldvein high-wealth) clans value wonders; aggressive + # (blackhammer high-agg) clans don't. + # Gated on city.buildings >= 3 so very early game still prioritizes the + # fundamental forge / monument / walls chain. + if Array(city.buildings).size() >= 3: + var clan_id_w: String = str(player.get("clan_id") if player.get("clan_id") != null else "") + var w_axis: float = 1.0 + if not clan_id_w.is_empty(): + var pers_w: Dictionary = DataLoader.get_ai_personality(clan_id_w) + if not pers_w.is_empty(): + var axes_w: Dictionary = pers_w.get("strategic_axes", {}) + var prod_w: float = _norm_axis(axes_w, "production") + var wlth_w: float = _norm_axis(axes_w, "wealth") + var agg_w: float = _norm_axis(axes_w, "aggression") + w_axis = clampf(0.4 + (prod_w + wlth_w) * 0.6 - agg_w * 0.3, 0.4, 1.6) + var existing_blds: Array = Array(city.buildings) + for wb: Dictionary in DataLoader.get_all_buildings(): + var wid: String = str(wb.get("id", "")) + if wid.is_empty() or wid in existing_blds: + continue + if wb.get("wonder_type") == null: + continue + if not city.can_build(wid, player): + continue + var era_w: int = int(wb.get("era", 1)) + # Score band: era 1 wonder ~ 5.5, era 5 wonder ~ 11.5, era 10 wonder ~ 19 + # (× 0.4-1.6 personality) — competitive with cavalry (5) → ironwarden (8) → forge_titan (12) → mithril_vanguard (18). + scores[wid] = (4.0 + era_w * 1.5) * w_axis + + # Log top-3 each time production is selected — emergent strategy visibility + if not scores.is_empty(): + var ranked: Array = scores.keys() + ranked.sort_custom(func(a: String, b: String) -> bool: return scores[a] > scores[b]) + var top: Array = [] + for i: int in range(min(3, ranked.size())): + top.append("%s=%.1f" % [ranked[i], float(scores[ranked[i]])]) + print(" [SCORE] t%d city=%s: %s" % [_turn_count, city.city_name, ", ".join(top)]) + + # Pick max; fall back to warrior if all zero + var best_id: String = "warrior" + var best_score: float = 0.0 + for k: String in scores: + var s: float = scores[k] + if s > best_score: + best_score = s + best_id = k + # When nothing else scores above 0, fall back to lowest-era buildable wonder. + if best_score <= 0.0: + var existing: Array = Array(city.buildings) + var best_wonder: String = "" + var best_era: int = 999 + 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 b.get("wonder_type") == null: + continue + if not city.can_build(bid, player): + continue + var era: int = int(b.get("era", 999)) + if era < best_era: + best_era = era + best_wonder = bid + if not best_wonder.is_empty(): + return best_wonder + # p0-41: upgrade any scored melee unit to the highest-tier buildable unit. + # Previously guarded by `best_id == "warrior"`, which blocked the upgrade + # whenever cavalry won the scoring contest — ironwarden/forge_titan/ + # mithril_vanguard were never considered even when combined_arms/ + # mechanized_warfare/total_war were researched (p0-41 root cause). + # Guard is now the full melee-unit set so that cavalry → ironwarden → + # forge_titan → mithril_vanguard progressions fire correctly. + if best_id in units_set and best_id != "founder" and best_id != "worker": + var upgrade: String = _best_melee_for_player(player, city) + if not upgrade.is_empty() and upgrade != best_id: + best_id = upgrade + return best_id + + +func _best_melee_for_player(player: RefCounted, city: Variant) -> String: + # Strongest buildable melee unit per p0-39 rules: + # - unit_type == "military" AND not in the ranged-specialist set + # - tech_required satisfied (player.has_tech) if set + # - requires_resource satisfied (BuildableHelper) if set + # - race_required matches player.race_id if set + # Strength proxy is `cost` (costlier unit = stronger = later game) since + # dwarf unit JSONs (public/resources/units/*.json) don't carry an explicit + # `tier` field — cost ordering matches the tech-chain ordering empirically + # (warrior=40 < spearman=56 < iron_vanguard=90 < berserker=100 < + ## graven=145 < bulwark=165 < ironwarden=190 < mithril_vanguard=280). + # Falls back to `tier` when present; callers with explicit tiers get ranked + # that way for forward-compatibility. + var best_id: String = "warrior" + var best_rank: int = 0 + var ranged_keywords: Array[String] = [ + "archer", "ranger", "arbalest", "crossbow", "flying", "catapult", "ballista", + ] + for u: Dictionary in DataLoader.get_all_units(): + var uid: String = str(u.get("id", "")) + if uid.is_empty(): + continue + var unit_type: String = str(u.get("unit_type", "")) + if unit_type != "military": + continue + var is_ranged: bool = false + for kw: String in ranged_keywords: + if uid.contains(kw): + is_ranged = true + break + if is_ranged: + continue + var tech_req: String = str(u.get("tech_required", "")) + if not tech_req.is_empty() and tech_req != "" and not player.has_tech(tech_req): + continue + var race_req: String = str(u.get("race_required", "")) + if not race_req.is_empty() and race_req != "": + var player_race: String = str(player.race_id) if "race_id" in player else "" + # Empty race_id means unknown race — skip filter rather than excluding all race-specific units. + if not player_race.is_empty() and race_req != player_race: + continue + # Note: requires_resource is intentionally NOT checked here — city.can_build + # and the Rust turn processor are the authoritative gates. Checking it here + # also blocks ironwarden when iron_ore exists on the map but hasn't yet + # expanded into city territory (since player_owns_resource only checks + # city-owned tiles, not the full map). + if not city.can_build(uid, player): + continue + # Rank by explicit tier when present, otherwise by cost (dwarf units + # don't carry tier). `tier * 1000 + cost` keeps explicit tiers from + # colliding with cost-ranked peers in the same tier bucket. + var tier_hint: int = int(u.get("tier", 0)) + var cost: int = int(u.get("cost", 0)) + var rank: int = tier_hint * 1000 + cost + if rank <= best_rank: + continue + best_rank = rank + best_id = uid + return best_id + + +func _score_add(scores: Dictionary, key: String, amount: float) -> void: + if key in scores: + scores[key] = float(scores[key]) + amount + + +func _command_unit(unit: Variant, player: RefCounted, game_map: RefCounted) -> void: + if unit.get("can_build_improvements") == true: + _command_worker(unit, player, game_map) + return + if unit.get("can_found_city") == true: + _decide_founder(unit, player, game_map) + return + + var target_pos: Vector2i = _find_attack_target(player) + if target_pos != Vector2i(-1, -1) and target_pos != unit.position: + _move_toward(unit, target_pos, game_map) + else: + _explore(unit, player, game_map) + + +func _decide_founder(unit: Variant, player: RefCounted, game_map: RefCounted) -> void: + ## State-based founding: score reachable sites, move to the best; only found at a local max. + const MIN_FOUND_SCORE: float = 4.0 + const MAX_WAIT_TURNS: int = 40 + const LOOK_AHEAD_MULT: int = 3 + var water: Array = ["ocean", "coast", "deep_ocean", "lake"] + var reach: int = max(1, int(unit.max_movement) * LOOK_AHEAD_MULT) + var reachable: Dictionary = PathfinderScript.movement_range(game_map, unit.position, reach, "land") + var best_pos: Vector2i = unit.position + var best_score: float = _score_site(unit.position, game_map) + var current_score: float = best_score + for pos: Vector2i in reachable: + var tile: Resource = game_map.get_tile(pos) + if tile == null or tile.biome_id in water: + continue + var too_close: bool = false + for c: Variant in player.cities: + if HexUtilsScript.hex_distance(pos, c.position) < 5: + too_close = true + break + if too_close: + continue + var s: float = _score_site(pos, game_map) + if s > best_score: + best_score = s + best_pos = pos + print("[SITE] turn=%d current=%.1f best=%.1f chosen=%s" % [ + _turn_count, current_score, best_score, str(best_pos) + ]) + var current_valid: bool = true + var cur_tile: Resource = game_map.get_tile(unit.position) + if cur_tile == null or cur_tile.biome_id in water: + current_valid = false + for c: Variant in player.cities: + if HexUtilsScript.hex_distance(unit.position, c.position) < 5: + current_valid = false + break + if best_pos == unit.position and current_valid: + if _world_map != null and _world_map.has_method("_select_unit"): + _world_map._select_unit(unit) + if _world_map != null and _world_map.has_method("_on_found_city_pressed"): + _world_map._on_found_city_pressed() + print(" Founded city at %s (score %.1f)" % [unit.position, current_score]) + return + var waiting: bool = current_score < MIN_FOUND_SCORE and _turn_count < MAX_WAIT_TURNS + var should_wait: bool = (not current_valid) or waiting + if best_pos != unit.position and should_wait: + _move_toward(unit, best_pos, game_map) + return + if current_valid: + if _world_map != null and _world_map.has_method("_select_unit"): + _world_map._select_unit(unit) + if _world_map != null and _world_map.has_method("_on_found_city_pressed"): + _world_map._on_found_city_pressed() + print(" Founded city at %s (good-enough, score %.1f)" % [unit.position, current_score]) + else: + _explore(unit, player, game_map) + + +func _command_worker(unit: Variant, player: RefCounted, game_map: RefCounted) -> void: + ## Build an improvement on the current tile if possible; + ## else walk toward the nearest owned unimproved tile; else toward a city. + if _improvement_manager == null: + return + var buildable: Array[Dictionary] = _improvement_manager.get_buildable_improvements( + unit, game_map, player + ) + if not buildable.is_empty(): + var tile: Resource = game_map.get_tile(unit.position) + var preferred: String = "farm" + if tile != null and tile.biome_id in ["hills", "mountains"]: + preferred = "mine" + elif tile != null and tile.biome_id in ["forest", "boreal_forest", "jungle", "tundra"]: + preferred = "hunting_grounds" + var pick: String = str(buildable[0].get("id", "")) + for entry: Dictionary in buildable: + if entry.get("id", "") == preferred: + pick = preferred + break + if not pick.is_empty(): + _improvement_manager.start_improvement(unit, pick, player) + return + # Seek the nearest buildable tile within 4 hexes: owned or unclaimed, non-water, + # unimproved, no pending build. Unclaimed tiles work — they convert on build. + var best_target: Vector2i = Vector2i(-1, -1) + var best_dist: int = 9999 + for dq: int in range(-4, 5): + for dr: int in range(-4, 5): + var tpos: Vector2i = unit.position + Vector2i(dq, dr) + var d: int = HexUtilsScript.hex_distance(unit.position, tpos) + if d == 0 or d > 4 or d >= best_dist: + continue + var t: Resource = game_map.get_tile(tpos) + if t == null or t.is_water() or str(t.improvement) != "": + continue + if t.owner != -1 and t.owner != player.index: + continue + if _improvement_manager.get_pending_at(tpos, player).size() > 0: + continue + best_dist = d + best_target = tpos + if best_target != Vector2i(-1, -1): + _worker_step_toward(unit, best_target, game_map) + return + if not player.cities.is_empty(): + _worker_step_toward(unit, player.cities[0].position, game_map) + else: + _explore(unit, player, game_map) + + +func _worker_step_toward(unit: Variant, target: Vector2i, game_map: RefCounted) -> void: + # Workers must step ONTO the target tile to build on it — do not use the + # attack-adjacent shortcut in _move_toward which skips movement entirely. + if unit.position == target: + return + var reachable: Dictionary = PathfinderScript.movement_range( + game_map, unit.position, unit.movement_remaining, "land" + ) + if reachable.is_empty(): + return + var best_pos: Vector2i = unit.position + var best_dist: int = HexUtilsScript.hex_distance(unit.position, target) + for pos: Vector2i in reachable: + if pos == unit.position: + continue + var dist: int = HexUtilsScript.hex_distance(pos, target) + if dist < best_dist: + best_dist = dist + best_pos = pos + if best_pos != unit.position: + _do_move(unit, best_pos, game_map) + + +func _nearest_city_to_target(player: RefCounted) -> RefCounted: + ## Return the player's city closest to the locked attack target. + ## Falls back to city[0] if no target is locked. + var best_city: RefCounted = player.cities[0] + if _locked_target == Vector2i(-1, -1): + return best_city + var best_dist: int = HexUtilsScript.hex_distance(best_city.position, _locked_target) + for c: RefCounted in player.cities: + var d: int = HexUtilsScript.hex_distance(c.position, _locked_target) + if d < best_dist: + best_dist = d + best_city = c + return best_city + + +func _find_nearest_low_lair(from: Vector2i, max_tier: int) -> Vector2i: + var best: Vector2i = Vector2i(-1, -1) + var best_dist: int = 999 + var wilds_cfg: Dictionary = DataLoader.get_wilds_config() + var low_ids: Array[String] = [] + for lt: Dictionary in wilds_cfg.get("lair_types", []): + if lt.get("base_tier", 99) <= max_tier: + low_ids.append(lt.get("id", "")) + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return best + for tile: Variant in game_map.tiles.values(): + var lt_id: String = tile.lair_type if tile.lair_type != null else "" + if lt_id == "" or lt_id not in low_ids: + continue + var d: int = HexUtilsScript.hex_distance(from, tile.position) + if d < best_dist: + best_dist = d + best = tile.position + return best + + +func _find_attack_target(player: RefCounted) -> Vector2i: + # Find nearest enemy city reachable by land path + var game_map: RefCounted = GameState.get_game_map() + var my_pos: Vector2i = Vector2i.ZERO + if not player.cities.is_empty(): + my_pos = player.cities[0].position + elif not player.units.is_empty(): + my_pos = player.units[0].position + + var best_pos: Vector2i = Vector2i(-1, -1) + var best_dist: int = 999 + + # Target nearest enemy city — try pathfinding first, fall back to hex distance + for p: Variant in GameState.players: + if p.index == player.index: + continue + for c: Variant in p.cities: + var dist: int = HexUtilsScript.hex_distance(my_pos, c.position) + if dist < best_dist: + # Verify reachable by land (short budget to avoid wraparound issues) + if game_map != null: + var reachable: Dictionary = PathfinderScript.movement_range( + game_map, my_pos, dist + 5, "land" + ) + # Check if any reachable hex is within 2 of the target + var can_reach: bool = false + for rpos: Vector2i in reachable: + if HexUtilsScript.hex_distance(rpos, c.position) <= 2: + can_reach = true + break + if not can_reach: + continue + best_dist = dist + best_pos = c.position + + # Fallback: target enemy units + if best_pos == Vector2i(-1, -1): + for p: Variant in GameState.players: + if p.index == player.index: + continue + for u: Variant in p.units: + if u.is_alive(): + var dist: int = HexUtilsScript.hex_distance(my_pos, u.position) + if dist < best_dist: + best_dist = dist + best_pos = u.position + return best_pos + + +func _move_toward(unit: Variant, target: Vector2i, game_map: RefCounted) -> void: + # If adjacent to target, just attack — don't move + if HexUtilsScript.hex_distance(unit.position, target) <= 1: + _try_attack_adjacent(unit, game_map) + return + + var reachable: Dictionary = PathfinderScript.movement_range( + game_map, unit.position, unit.movement_remaining, "land" + ) + if reachable.is_empty(): + return + + var best_pos: Vector2i = unit.position + var best_dist: int = HexUtilsScript.hex_distance(unit.position, target) + + for pos: Vector2i in reachable: + if pos == unit.position: + continue + var dist: int = HexUtilsScript.hex_distance(pos, target) + if dist < best_dist: + best_dist = dist + best_pos = pos + + if best_pos == unit.position: + # Can't get closer — try any reachable hex we haven't been to + for pos: Vector2i in reachable: + if pos != unit.position: + best_pos = pos + break + + if best_pos != unit.position: + _do_move(unit, best_pos, game_map) + + # After moving, check if adjacent to an enemy and attack (doesn't require movement) + if unit.is_alive(): + _try_attack_adjacent(unit, game_map) + + +func _try_attack_adjacent(unit: Variant, game_map: RefCounted) -> void: + var player: RefCounted = GameState.get_current_player() + if player == null: + return + var primary: Dictionary = GameState.get_primary_layer() + var all_units: Array = primary.get("units", []) + # Debug: count nearby enemies + if _turn_count % 50 == 0: + var nearby_enemies: int = 0 + for e: Variant in all_units: + if e.get("owner") != player.index and e.is_alive(): + var dist: int = HexUtilsScript.hex_distance(unit.position, e.position) + if dist <= 2: + nearby_enemies += 1 + print(" enemy at %s (dist=%d from %s)" % [e.position, dist, unit.position]) + if nearby_enemies == 0: + print(" no enemies within 2 hexes of %s" % unit.position) + + # Find closest enemy unit or city within attack range (distance <= 1) + for enemy: Variant in all_units: + if enemy.get("owner") == player.index: + continue + if not enemy.is_alive(): + continue + var dist: int = HexUtilsScript.hex_distance(unit.position, enemy.position) + if dist <= 1: + print(" ATTACKING: %s at %s -> enemy at %s (dist=%d)" % [unit.type_id, unit.position, enemy.position, dist]) + var resolver_script: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd") + var resolver: RefCounted = resolver_script.new() + resolver.resolve(unit, enemy, game_map, all_units) + unit.movement_remaining = 0 + return + + # Check enemy cities + for p: Variant in GameState.players: + if p.index == player.index: + continue + for c: Variant in p.cities: + var dist: int = HexUtilsScript.hex_distance(unit.position, c.position) + if dist <= 1: + var city_key: String = "%d,%d" % [c.position.x, c.position.y] + var attacks_so_far: int = _city_attacks_this_turn.get(city_key, 0) + if attacks_so_far >= MAX_CITY_ATTACKS_PER_TURN: + # Stack-of-doom cap: don't pile on beyond the limit this turn. + return + print(" ATTACKING CITY: %s at %s -> city at %s (dist=%d)" % [unit.type_id, unit.position, c.position, dist]) + var resolver_script: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd") + var resolver: RefCounted = resolver_script.new() + resolver.resolve(unit, c, game_map, all_units) + _city_attacks_this_turn[city_key] = attacks_so_far + 1 + unit.movement_remaining = 0 + return + + # Check adjacent lairs + _try_attack_adjacent_lair(unit, game_map) + + +func _try_attack_adjacent_lair(unit: Variant, game_map: RefCounted) -> void: + if not unit.is_alive(): + return + if not ClassDB.class_exists("GdCombatResolver"): + return + # Check own tile first (unit moved onto lair), then neighbors + var candidates: Array[Vector2i] = [unit.position] + candidates.append_array(HexUtilsScript.get_neighbors(unit.position)) + for n: Vector2i in candidates: + 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 or tile.lair_type == "": + continue + # Found a lair — look up its data from wilds config + var wilds_cfg: Dictionary = DataLoader.get_wilds_config() + var lair_tier: int = 4 + var lair_size: String = "medium" + var lair_diet: String = "carnivore" + var lair_name: String = tile.lair_type.capitalize() + for lt: Dictionary in wilds_cfg.get("lair_types", []): + if lt.get("id", "") == tile.lair_type: + lair_tier = lt.get("base_tier", 4) + lair_size = lt.get("size", "medium") + lair_diet = lt.get("diet", "carnivore") + lair_name = lt.get("name", lair_name) + break + # Build dicts and resolve via GdCombatResolver + var gd_resolver: RefCounted = ClassDB.instantiate("GdCombatResolver") + var def_dict: Dictionary = gd_resolver.wild_combat_stats(lair_tier, lair_size, lair_diet) + var kws_raw: Array = unit.get_keywords() + var kws: PackedStringArray = PackedStringArray() + for k: String in kws_raw: + kws.append(k) + var atk_dict: Dictionary = { + "hp": unit.hp, + "max_hp": unit.get_max_hp(), + "attack": unit.get_attack(), + "defense": unit.get_defense(), + "ranged_attack": unit.ranged_attack, + "range": 1 if unit.ranged_attack <= 0 else 2, + "movement": unit.get_movement(), + "keywords": kws, + "flanking": 0, + "support": 0, + "terrain_defense": 0, + "fortification": unit.get_fortification_bonus(), + "wall_bonus": 0, + "river_crossing": false, + "is_siege": unit.get_combat_type() == "siege", + } + var combat_type: String = "melee" + if unit.is_ranged(): + combat_type = "ranged" + var ctx_dict: Dictionary = { + "combat_type": combat_type, + "city_hp": -1, + "city_wall_tier": 0, + "city_has_garrison": false, + } + var result: Dictionary = gd_resolver.resolve(atk_dict, def_dict, ctx_dict) + unit.hp = result.get("attacker_hp", unit.hp) + unit.movement_remaining = 0 + var attacker_killed: bool = bool(result.get("attacker_killed", false)) + var defender_killed: bool = bool(result.get("defender_killed", false)) + if defender_killed and not attacker_killed: + # Lair cleared — award gold and bonus XP + var gold_reward: int = lair_tier * 10 + var xp_reward: int = lair_tier * 5 + var player: RefCounted = GameState.get_current_player() + if player != null: + player.gold += gold_reward + unit.gain_xp(xp_reward) + var lair_type_id: String = tile.lair_type + tile.lair_type = "" + var reward: Dictionary = { + "gold": gold_reward, + "xp": xp_reward, + "lair_tier": lair_tier, + "lair_name": lair_name, + } + print(" LAIR CLEARED: %s (tier %d) at %s — +%d gold, +%d XP" % [ + lair_name, lair_tier, norm, gold_reward, xp_reward + ]) + EventBus.lair_cleared.emit(norm, reward) + if player != null: + var turn_seed: int = GameState.game_rng.seed ^ GameState.turn_number + ItemSystemScript.roll_fauna_drops( + lair_type_id, + player, + turn_seed, + hash(unit.id), + hash(norm), + ) + elif attacker_killed: + print(" LAIR ATTACK FAILED: %s killed at %s" % [unit.type_id, norm]) + return + + +func _explore(unit: Variant, player: RefCounted, game_map: RefCounted) -> void: + # Scouts can also clear lairs — try adjacent lair attack first. + _try_attack_adjacent_lair(unit, game_map) + if not unit.is_alive() or unit.movement_remaining <= 0: + return + + var reachable: Dictionary = PathfinderScript.movement_range( + game_map, unit.position, unit.movement_remaining, "land" + ) + if reachable.size() <= 1: + return + + # If a visible lair exists, path toward the closest one. Once adjacent, + # `_try_attack_adjacent_lair` (called next frame) fires the combat. + var target_lair: Vector2i = _find_closest_visible_lair(unit.position, player, game_map) + if target_lair.x >= 0: + var step: Vector2i = _step_toward(unit.position, target_lair, reachable) + if step != unit.position: + _do_move(unit, step, game_map) + return + + # Find founder position for proximity bias + var founder_pos: Vector2i = Vector2i(-1, -1) + for u: Variant in player.units: + if u.get("can_found_city") == true and u.is_alive(): + founder_pos = u.position + break + + var best_pos: Vector2i = unit.position + var best_score: float = -1.0 + var any_valid: Vector2i = unit.position + + for pos: Vector2i in reachable: + if pos == unit.position: + continue + if any_valid == unit.position: + any_valid = pos + var fog_count: float = 0.0 + var food_hint: float = 0.0 + var lair_adjacent: float = 0.0 + 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.get_visibility(player.index) == 0: + fog_count += 1.0 + else: + var ny: Dictionary = tile.get_yields(player.index) + food_hint += float(int(ny.get("food", 0))) + if tile.lair_type != "": + lair_adjacent += 1.0 + var score: float = fog_count * 2.0 + food_hint * 3.0 + lair_adjacent * 20.0 + if founder_pos.x >= 0: + var dist: int = HexUtilsScript.hex_distance(pos, founder_pos) + if dist <= 5: + score += float(6 - dist) * 1.5 + if score > best_score: + best_score = score + best_pos = pos + + if best_pos == unit.position and any_valid != unit.position: + best_pos = any_valid + + if best_pos != unit.position: + _do_move(unit, best_pos, game_map) + + +func _find_closest_visible_lair( + from: Vector2i, player: RefCounted, game_map: RefCounted +) -> Vector2i: + var best: Vector2i = Vector2i(-1, -1) + var best_dist: int = 999999 + for axial: Vector2i in game_map.tiles: + var tile: Resource = game_map.tiles[axial] + if tile == null or tile.lair_type == "": + continue + if tile.get_visibility(player.index) == 0: + continue + var d: int = HexUtilsScript.hex_distance(from, axial) + if d < best_dist: + best_dist = d + best = axial + return best + + +func _step_toward(from: Vector2i, target: Vector2i, reachable: Dictionary) -> Vector2i: + var best: Vector2i = from + var best_dist: int = HexUtilsScript.hex_distance(from, target) + for pos: Vector2i in reachable: + if pos == from: + continue + var d: int = HexUtilsScript.hex_distance(pos, target) + if d < best_dist: + best_dist = d + best = pos + return best + + +func _do_move(unit: Variant, target: Vector2i, game_map: RefCounted) -> void: + var from: Vector2i = unit.position + var path: Array[Vector2i] = PathfinderScript.find_path( + game_map, from, target, unit.movement_remaining, "land" + ) + if path.is_empty(): + return + + for step: Vector2i in path: + var step_tile: Resource = game_map.get_tile(step) + if step_tile == null: + break + var cost: int = step_tile.get_movement_cost() + if cost > unit.movement_remaining: + break + unit.position = step + unit.movement_remaining -= cost + + if unit.position != from: + EventBus.unit_moved.emit(unit, from, unit.position) + # Recalculate vision + var player: RefCounted = GameState.get_current_player() + if player != null: + _recalc_vision(player, game_map) + + +func _recalc_vision(player: RefCounted, game_map: RefCounted) -> void: + for pos: Vector2i in game_map.tiles: + var tile: Resource = game_map.tiles[pos] + if tile != null and tile.get_visibility(player.index) == 2: + tile.set_visibility(player.index, 1) + for u: Variant in player.units: + if not u.is_alive(): + continue + var visible: Array[Vector2i] = PathfinderScript.visible_hexes( + game_map, u.position, u.vision + ) + for vpos: Vector2i in visible: + var vtile: Resource = game_map.get_tile(vpos) + if vtile != null: + vtile.set_visibility(player.index, 2) + + +# ── Turn Management ────────────────────────────────────────────────── + +func _end_turn() -> void: + var btn: Button = _find_button("End Turn") + if btn != null: + btn.pressed.emit() + _state = "wait_next_turn" + _frame = 0 + + +func _dismiss_popups() -> void: + # Dismiss turn notification + var tn: Node = _find_node_by_name(get_tree().root, "TurnNotification") + if tn != null and tn.has_method("_dismiss"): + tn._dismiss() + + # Dismiss combat result (CanvasLayer uses .visible, not is_visible_in_tree) + var cr: Node = _find_node_by_name(get_tree().root, "CombatResult") + if cr != null and cr.get("visible") == true: + var dismiss_btn: Button = _find_button_in(cr, "OK") + if dismiss_btn == null: + dismiss_btn = _find_button_in(cr, "Continue") + if dismiss_btn == null: + dismiss_btn = _find_button_in(cr, "Dismiss") + if dismiss_btn != null: + dismiss_btn.pressed.emit() + else: + cr.visible = false + + # Auto-click Attack on combat preview + var atk: Button = _find_button("Attack") + if atk != null: + atk.pressed.emit() + + +# ── Utilities ──────────────────────────────────────────────────────── + +func _screenshot(name: String) -> void: + # In --headless mode the rendering server uses the "dummy" driver, whose + # viewport texture RID points at no backing texture. Calling get_image() on + # it trips "texture_2d_get: Parameter 't' is null" in C++ and dirties stderr + # (batch runners misclassify the noise as a crash). Skip cleanly. + if DisplayServer.get_name() == "headless": + return + RenderingServer.force_draw() + var tex: ViewportTexture = get_viewport().get_texture() + if tex == null: + return + var img: Image = tex.get_image() + if img != null and img.get_width() > 1: + var path: String = _output_dir.path_join("autoplay_%s.png" % name) + img.save_png(path) + print(" -> %s (%dx%d)" % [path, img.get_width(), img.get_height()]) + + +func _find_button(text: String) -> Button: + return _find_button_in(get_tree().root, text) + + +func _find_button_in(node: Node, text: String) -> Button: + if node is Button: + var btn: Button = node as Button + if btn.text.strip_edges() == text and btn.is_visible_in_tree(): + return btn + for child in node.get_children(): + var result: Button = _find_button_in(child, text) + if result != null: + return result + return null + + +func _find_node_by_name(node: Node, target_name: String) -> Node: + if node.name == target_name: + return node + for child in node.get_children(): + var found: Node = _find_node_by_name(child, target_name) + if found != null: + return found + return null + + +func _fail(msg: String) -> void: + push_error("AutoPlay: FAIL — %s" % msg) + _screenshot("error") + _outcome = "defeat" + _finalize_run() + get_tree().quit(1) + + +# ── Invariants, Event Log, Turn Stats, Saves ───────────────────────── + +func _check_invariants(player: RefCounted) -> void: + ## Per-turn invariant checks and peak/milestone tracking for the current + ## player. Violations are captured into `_violations` without aborting — + ## failures are reported in turn_stats.jsonl so the batch runner can grade runs. + var idx: int = player.index + var pop: int = 0 + for c: Variant in player.cities: + pop += int(c.population) + var gold: int = int(player.get("gold")) if player.get("gold") != null else 0 + var mil: int = 0 + for u: Variant in player.units: + if u.is_alive() and u.get("can_found_city") != true: + mil += 1 + + # Invariant checks (require prior-turn baseline) + if _prev_turn_stats.has(idx): + var prev: Dictionary = _prev_turn_stats[idx] + var prev_pop: int = int(prev.get("pop", pop)) + if pop < prev_pop and not _starved_this_turn.get(idx, false): + _violations.append( + "turn_%d: player %d pop dropped %d→%d without starvation event" + % [_turn_count, idx, prev_pop, pop] + ) + var techs_prev: int = player.researched_techs.size() + var floor_val: int = -max(5, techs_prev * 3) + if gold < floor_val: + _violations.append( + "turn_%d: player %d gold=%d below deficit floor %d" + % [_turn_count, idx, gold, floor_val] + ) + + # Peak/milestone tracking + _ensure_stats(idx) + var pstat: Dictionary = _stats[idx] + if pop > int(pstat.get("pop_peak", 0)): + pstat["pop_peak"] = pop + if gold > int(pstat.get("gold_peak", 0)): + pstat["gold_peak"] = gold + if int(pstat.get("turn_first_pop_3", -1)) < 0 and pop >= 3: + pstat["turn_first_pop_3"] = _turn_count + if int(pstat.get("turn_first_pop_4", -1)) < 0 and pop >= 4: + pstat["turn_first_pop_4"] = _turn_count + + _prev_turn_stats[idx] = {"pop": pop, "gold": gold, "mil": mil} + _starved_this_turn[idx] = false + + +func _build_player_stats() -> Dictionary: + var game_map: RefCounted = GameState.get_game_map() + var out: Dictionary = {} + for p: Variant in GameState.players: + var idx: int = int(p.index) + var pop: int = 0 + var tiles: int = 0 + var buildings: int = 0 + var food_total: float = 0.0 + var production_total: float = 0.0 + for c: Variant in p.cities: + pop += int(c.population) + tiles += int(c.owned_tiles.size()) + buildings += int(c.buildings.size()) + if game_map != null: + var tile_json: String = BuildableHelperScript.build_tile_yields_json( + c, game_map + ) + var cy: Dictionary = c.get_yields(tile_json) + food_total += float(cy.get("food", 0)) + production_total += float(cy.get("production", 0)) + var mil: int = 0 + for u: Variant in p.units: + if u.is_alive() and u.get("can_found_city") != true: + mil += 1 + _ensure_stats(idx) + var pstat: Dictionary = _stats[idx] + var luxuries: int = HappinessScript._collect_luxury_happiness_map(p, game_map).size() + var happiness: int = int(p.get("happiness")) if p.get("happiness") != null else 0 + var gpt: int = int(p.get("gold_per_turn")) if p.get("gold_per_turn") != null else 0 + var gold: int = int(p.get("gold")) if p.get("gold") != null else 0 + # Keep peak in sync with current (final-stats is also called at exit) + if pop > int(pstat.get("pop_peak", 0)): + pstat["pop_peak"] = pop + if gold > int(pstat.get("gold_peak", 0)): + pstat["gold_peak"] = gold + + # Quality metrics (p0-25): tier_peak, peak_unit_tier, wonder_count. + var tier_peak: int = 0 + for tech_id: String in p.researched_techs: + var tech_data: Dictionary = DataLoader.get_tech(tech_id) + var era: int = int(tech_data.get("era", 0)) + if era > tier_peak: + tier_peak = era + + var unit_tier_now: int = 0 + for u: Variant in p.units: + if u.is_alive(): + var uid: String = str(u.get("unit_id") if u.get("unit_id") != null else "") + if not uid.is_empty(): + var udata: Dictionary = DataLoader.get_unit(uid) + var ut: int = int(udata.get("tier", 0)) + if ut > unit_tier_now: + unit_tier_now = ut + var prev_peak_unit: int = int(pstat.get("peak_unit_tier", 0)) + if unit_tier_now > prev_peak_unit: + pstat["peak_unit_tier"] = unit_tier_now + var peak_unit_tier: int = int(pstat.get("peak_unit_tier", unit_tier_now)) + + var wonder_count: int = 0 + for c: Variant in p.cities: + for bid: String in c.buildings: + var bdata: Dictionary = DataLoader.get_building(bid) + var bflags: Array = bdata.get("flags", []) as Array + if bflags.has("wonder"): + wonder_count += 1 + + var mcts: Dictionary = AiTurnBridge.get_last_mcts_stats(_turn_count, idx) + out[str(idx)] = { + "pop": pop, + "pop_peak": int(pstat.get("pop_peak", pop)), + "mil": mil, + "cities": int(p.cities.size()), + "cities_captured": int(pstat.get("cities_captured", 0)), + "cities_lost": int(pstat.get("cities_lost", 0)), + "gold": gold, + "gold_peak": int(pstat.get("gold_peak", gold)), + "gold_per_turn": gpt, + "techs": int(p.researched_techs.size()), + "tiles": tiles, + "buildings": buildings, + "luxuries": luxuries, + "happiness": happiness, + "food_total": food_total, + "production_total": production_total, + "kills": int(pstat.get("kills", 0)), + "units_lost": int(pstat.get("units_lost", 0)), + "turn_first_pop_3": int(pstat.get("turn_first_pop_3", -1)), + "turn_first_pop_4": int(pstat.get("turn_first_pop_4", -1)), + "tier_peak": tier_peak, + "peak_unit_tier": peak_unit_tier, + "wonder_count": wonder_count, + "mcts_action": str(mcts.get("action", "")), + "mcts_root_idle": int(mcts.get("root_idle", 0)), + "mcts_root_found": int(mcts.get("root_found", 0)), + "mcts_root_spawn": int(mcts.get("root_spawn", 0)), + } + return out + + +func _write_meta() -> void: + ## Write meta.json once at start-of-run. Captures seed + settings snapshot. + if not _seed_set: + return + var player_clans: Dictionary = {} + for p: Variant in GameState.players: + var clan: String = str(p.get("clan_id") if p.get("clan_id") != null else "") + player_clans[str(int(p.index))] = clan + var meta: Dictionary = { + "seed": _seed, + "start_stamp": _start_stamp, + "game_settings": GameState.game_settings.duplicate(true), + "schema_version": 1, + "player_clans": player_clans, + } + var path: String = _game_dir.path_join("meta.json") + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("AutoPlay: cannot open %s for writing" % path) + return + file.store_string(JSON.stringify(meta, " ")) + file.close() + + +func _append_event(event: Dictionary) -> void: + ## Buffer an event; flushed once per turn in _flush_turn_artifacts(). + if not _seed_set: + return + event["turn"] = _turn_count + _event_buffer.append(event) + + +func _flush_events() -> void: + ## Append all buffered events to events.jsonl as newline-delimited JSON. + ## Opens in READ_WRITE + seek_end to preserve prior turns' lines. + if not _seed_set or _event_buffer.is_empty(): + return + var path: String = _game_dir.path_join("events.jsonl") + var file: FileAccess = FileAccess.open(path, FileAccess.READ_WRITE) + if file == null: + # First write — file doesn't exist yet + file = FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("AutoPlay: cannot open %s for writing" % path) + return + file.seek_end() + for event: Dictionary in _event_buffer: + file.store_line(JSON.stringify(event)) + file.close() + _event_buffer.clear() + + +func _append_turn_stats(outcome: String) -> void: + ## Append one JSON line describing the current turn's state. + if not _seed_set: + return + _result_written = true + var wall_clock: float = Time.get_unix_time_from_system() - _start_time + var aggregate: Dictionary = { + "total_combats": _total_combats, + "total_cities_founded": _total_cities_founded, + "total_cities_captured": _total_cities_captured, + "turn_first_combat": _turn_first_combat, + "turn_first_city_captured": _turn_first_city_captured, + "strategic_gate_rejected": _strategic_gate_rejected_count, + "lair_cleared": _lair_cleared_count, + "weather_events_count": _weather_events_this_turn, + "total_weather_events": _total_weather_events, + } + var ecology_block: Dictionary = _snapshot_ecology() + var winner_personality: String = "" + if _victory_winner >= 0: + for p: Variant in GameState.players: + if int(p.index) == _victory_winner: + winner_personality = str(p.get("clan_id") if p.get("clan_id") != null else "") + # Defensive fallback: if clan_id was never assigned (e.g. human + # slot in a legacy matchup-grid run without per-slot pinning), + # fall back to the AI_PIN_PERSONALITY_P{index} env var so the + # downstream verdict can still attribute the win. + if winner_personality.is_empty(): + winner_personality = OS.get_environment( + "AI_PIN_PERSONALITY_P%d" % _victory_winner + ) + break + # p0-34: prefer prologue.display_turn() during the -1 → 0 → 1 cold-open so + # the first lines of turn_stats.jsonl carry the prologue turn sequence + # (−1, 0, 1) rather than the legacy _turn_count which starts at 1. Once + # prologue has resolved, fall back to the normal counter. + var effective_turn: int = _turn_count + if TurnManager.get("prologue") != null: + var drv: RefCounted = TurnManager.prologue + if drv != null and drv.has_method("is_prologue") and bool(drv.call("is_prologue")): + effective_turn = int(drv.call("display_turn")) + elif drv != null and drv.has_method("display_turn"): + # Just-crossed-into-Normal case: keep showing the prologue's last + # labelled turn (1) on the transition frame so nothing regresses. + var dt: int = int(drv.call("display_turn")) + if _turn_count < dt: + effective_turn = dt + var line: Dictionary = { + "turn": effective_turn, + "outcome": outcome, + "winner_index": _victory_winner, + "winner_personality": winner_personality, + "victory_type": _victory_type, + "wall_clock_sec": wall_clock, + "aggregate": aggregate, + "ecology": ecology_block, + "player_stats": _build_player_stats(), + "invariant_violations": _violations, + } + var path: String = _game_dir.path_join("turn_stats.jsonl") + var file: FileAccess = FileAccess.open(path, FileAccess.READ_WRITE) + if file == null: + file = FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("AutoPlay: cannot open %s for writing" % path) + return + file.seek_end() + file.store_line(JSON.stringify(line)) + file.close() + + +func _save_turn_snapshot() -> void: + ## Write full GameState serialization for this turn. + if not _seed_set: + return + var save_path: String = _game_dir.path_join("saves/turn_%04d.save" % _turn_count) + var err: Error = SaveManagerScript.save_to_path(save_path) + if err != OK: + push_error("AutoPlay: save failed (%s): %s" % [error_string(err), save_path]) + + +func _snapshot_ecology() -> Dictionary: + ## Ecology block for the current turn_stats line (p0-35). Sources canopy + ## mean + delta from Climate.get_canopy_summary, which wraps the Rust + ## GdEcologyPhysics bridge. Returns zeros before TurnManager has produced + ## a climate tick so the first seeded line still schema-validates. + var climate: RefCounted = TurnManager.climate as RefCounted + if climate == null or not climate.has_method("get_canopy_summary"): + return {"flora_canopy_mean": 0.0, "flora_canopy_delta": 0.0} + var summary: Dictionary = climate.get_canopy_summary() as Dictionary + return { + "flora_canopy_mean": float(summary.get("mean", 0.0)), + "flora_canopy_delta": float(summary.get("delta_since_last_turn", 0.0)), + } + + +func _flush_turn_artifacts() -> void: + ## End-of-turn persistence: events, turn_stats line, save snapshot. + ## Cheap to skip for unseeded runs (each callee short-circuits on _seed_set). + _flush_events() + _append_turn_stats(_outcome) + _weather_events_this_turn = 0 + _save_turn_snapshot() + if _save_at_turn > 0 and _turn_count == _save_at_turn: + var save_path: String = _output_dir.path_join("mid_run.save") + var err: Error = SaveManagerScript.save_to_path(save_path) + if err != OK: + push_error("AutoPlay: mid-run save failed (%s): %s" % [error_string(err), save_path]) + else: + print("AutoPlay: mid-run save written to %s at turn %d" % [save_path, _turn_count]) + _finalize_run() + get_tree().quit(0) + + +func _finalize_run() -> void: + ## Terminal persistence: flush any trailing events, write one final + ## turn_stats line with the terminal outcome. Idempotent — guarded by + ## `_final_line_written` so max_turns→victory overlap doesn't double-write. + if not _seed_set or _final_line_written: + return + _final_line_written = true + _flush_events() + _append_turn_stats(_outcome) From 423b2f96c05ef471a01c1847695b2b6d91fa1f0e Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 29 Apr 2026 21:42:03 -0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(entities):=20=E2=9C=A8=20Add=20modular?= =?UTF-8?q?=20combat=20utility=20functions=20for=20damage=20calculation,?= =?UTF-8?q?=20hit=20resolution,=20and=20reusable=20combat=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/src/entities/combat_utils.gd | 164 +++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/game/engine/src/entities/combat_utils.gd diff --git a/src/game/engine/src/entities/combat_utils.gd b/src/game/engine/src/entities/combat_utils.gd new file mode 100644 index 00000000..89019a93 --- /dev/null +++ b/src/game/engine/src/entities/combat_utils.gd @@ -0,0 +1,164 @@ +class_name CombatUtils +extends RefCounted +## Static utility functions for combat: unit/city lookup, death handling, siege capture. +## No damage computation lives here — all combat math is in Rust (GdCombatResolver). + +const UnitScript = preload("res://engine/src/entities/unit.gd") +const CityScript = preload("res://engine/src/entities/city.gd") +const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") +const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd") + + +## Get garrison combat unit at a city tile. +static func get_garrison(pos: Vector2i, all_units: Array) -> RefCounted: + for unit: RefCounted in all_units: + if unit is UnitScript and unit.position == pos and unit.is_military(): + return unit + return null + + +## Get all units at a position. +static func get_units_at(pos: Vector2i, all_units: Array) -> Array: + var result: Array = [] + for unit: RefCounted in all_units: + if unit is UnitScript and unit.position == pos: + result.append(unit) + return result + + +## Get all specialist units at a position owned by non-attacker. +static func get_specialists_at(pos: Vector2i, all_units: Array, attacker_owner: int) -> Array: + var result: Array = [] + for unit: RefCounted in all_units: + if unit is UnitScript and unit.position == pos and unit.owner != attacker_owner: + if unit.is_specialist(): + result.append(unit) + return result + + +## Get all units adjacent to a position. +static func get_adjacent_units(pos: Vector2i, all_units: Array) -> Array: + var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(pos) + var result: Array = [] + for neighbor_pos: Vector2i in neighbors: + for unit: RefCounted in all_units: + if unit is UnitScript and unit.position == neighbor_pos: + result.append(unit) + return result + + +## Get the city at a position (if any) from GameState. +static func get_city_at(pos: Vector2i) -> RefCounted: + for player: RefCounted in GameState.players: + for city: RefCounted in player.cities: + if city is CityScript and city.position == pos: + return city + return null + + +## Whether this unit is a siege unit (gets bonus vs cities). +static func is_siege_unit(unit: RefCounted) -> bool: + if unit is UnitScript: + return unit.get_combat_type() == "siege" + return false + + +## Handle a unit's death: check soul gem, drop loot, remove from game state, emit signal. +static func handle_unit_death(unit: RefCounted, killer: RefCounted, all_units: Array) -> void: + if not unit is UnitScript: + return + + # Soul Gem check: resurrect at 30% HP instead of dying. + if ItemSystemScript.has_active_item(unit, "soul_gem"): + ItemSystemScript.consume_charge(unit, "soul_gem") + unit.hp = maxi(int(float(unit.get_max_hp()) * 0.3), 1) + return + + # Drop equipped items as ground loot before removing the unit. + # ItemSystem.drop_all_loot wants the dying unit's tile, not the whole + # map — passing the map used to Array-type-error the FFI and abort the + # death handler before it could emit unit_destroyed. + var primary_layer: Dictionary = GameState.get_primary_layer() + if not primary_layer.is_empty(): + var game_map: RefCounted = primary_layer.get("map") + if game_map != null: + var tile: Resource = game_map.get_tile(unit.position) as Resource + if tile != null: + ItemSystemScript.drop_all_loot(unit, tile) + + if unit.owner == -1 and killer != null and killer is UnitScript and killer.owner >= 0: + _roll_wild_creature_loot(unit, killer) + + all_units.erase(unit) + + if unit.owner >= 0 and unit.owner < GameState.players.size(): + var player: RefCounted = GameState.players[unit.owner] + player.units.erase(unit) + + if not primary_layer.is_empty(): + primary_layer.get("units", []).erase(unit) + + EventBus.unit_destroyed.emit(unit, killer) + + +## Handle city capture: transfer ownership, emit signals, destroy High Archon if capital. +static func capture_city( + city: RefCounted, + attacker: RefCounted, + old_owner: int, + all_units: Array, +) -> void: + var old_player: RefCounted = GameState.get_player(old_owner) + var new_player: RefCounted = GameState.get_player(attacker.owner) + if old_player != null: + old_player.cities.erase(city) + if new_player != null and city not in new_player.cities: + new_player.cities.append(city) + + city.owner = attacker.owner + city.is_capital = false + city.captured_turn = GameState.turn_number + + for tile_pos: Vector2i in city.owned_tiles: + var layer: Dictionary = GameState.get_primary_layer() + if layer.is_empty(): + continue + var map_ref: RefCounted = layer.get("map") + if map_ref == null: + continue + var t: RefCounted = map_ref.get_tile(tile_pos) + if t != null: + t.owner = attacker.owner + + if old_player != null: + _destroy_high_archon(old_player, all_units) + + EventBus.city_captured.emit(city, old_owner, attacker.owner) + + if old_player != null and old_player.cities.is_empty(): + EventBus.player_eliminated.emit(old_owner) + + +static func _roll_wild_creature_loot(victim: RefCounted, killer: RefCounted) -> void: + if killer.owner < 0 or killer.owner >= GameState.players.size(): + return + var killer_player: RefCounted = GameState.players[killer.owner] + var creature_type: String = victim.type_id if victim.type_id != "" else victim.unit_id + var turn_seed: int = GameState.game_rng.seed ^ GameState.turn_number + var killer_id: int = hash(killer.id) + var victim_id: int = hash(victim.id) + ItemSystemScript.roll_fauna_drops(creature_type, killer_player, turn_seed, killer_id, victim_id) + + +## Destroy the High Archon of a player (capital capture penalty). +static func _destroy_high_archon(player: RefCounted, all_units: Array) -> void: + for unit: RefCounted in player.units: + if unit is UnitScript and unit.type_id == "high_archon": + unit.hp = 0 + all_units.erase(unit) + player.units.erase(unit) + var primary_layer: Dictionary = GameState.get_primary_layer() + if not primary_layer.is_empty(): + primary_layer.get("units", []).erase(unit) + EventBus.unit_destroyed.emit(unit, null) + break