diff --git a/src/game/engine/src/generation/auto_play.gd b/src/game/engine/src/generation/auto_play.gd deleted file mode 100644 index 779e177b..00000000 --- a/src/game/engine/src/generation/auto_play.gd +++ /dev/null @@ -1,2268 +0,0 @@ -extends Node -## ⚠ STALE DUPLICATE — NOT AUTOLOADED. -## -## The live autoload is `src/game/engine/scenes/tests/auto_play.gd`, registered -## at `project.godot:30`. This file receives no runtime dispatch and is -## retained only because it contains a `_maybe_prioritize_worker` + four -## `WORKER_OVERRIDE_*` named-constant gates that never got ported to the live -## autoload. Acceptance for `.project/objectives/p0-16-worker-improvement-loop.md` -## cites those symbols; flagged 2026-04-17 (task #12) as a shipwright decision -## — either port the override pattern into the live file and re-run the p0-16 -## evidence batch, or re-validate p0-16 against the score-based worker boost -## that scenes/tests/ actually uses (_manage_production lines ~1285-1295). -## -## DO NOT EDIT THIS FILE to add features. All autoplay changes land in -## `scenes/tests/auto_play.gd`. This file will be deleted once the shipwright -## decision lands. -## -## Historical header follows: -## -## 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") -const CombatResolverScript = preload("res://engine/src/modules/combat/combat_resolver.gd") - -## Minimum city population at which a worker becomes a viable production target. -## Pop 2 is the earliest the city can spare a citizen without starving the -## founding pair; below that the food cost of building a worker exceeds its -## near-term yield contribution. -const WORKER_OVERRIDE_MIN_POP: int = 2 -## Turn ceiling at which the worker-first override stops firing. Past T60 the -## scoring logic has typically had ~3 empty-queue windows and if it still -## hasn't picked a worker then a city is genuinely pinned by military need. -const WORKER_OVERRIDE_MAX_TURN: int = 60 -## Radius in hexes below which we consider the city "under threat" and decline -## to override production to worker. Matches the threat-band used by -## `_next_building` siege scoring (near_enemy <= 6 triggers warrior+wall -## emergency) so the two code paths stay consistent. -const WORKER_OVERRIDE_PEACE_RADIUS: int = 6 -## Minimum defenders required before we slot a worker. One warrior is the bare -## minimum garrison — without it the worker has nothing to rebuild behind. -const WORKER_OVERRIDE_MIN_DEFENDERS: int = 1 - -## Sentinel value for quality-metric fields (tier_peak, peak_unit_tier, -## wonder_count) when no data has been recorded yet. Matches schema default -## so historical jsonl without these fields round-trips through the reporter -## as this sentinel rather than raising. -const QUALITY_METRIC_UNKNOWN: int = 0 -## Floor tier applied to any tech / unit that lacks a populated `era`/`tier` -## field in its JSON definition. Keeps `tier_peak` monotonic: having any -## researched tech at all means the player has reached era 1. -const QUALITY_METRIC_FLOOR_TIER: int = 1 - -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 = 500 -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 - -# 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 -var _strategic_gate_rejected_count: int = 0 -# 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 - - -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) - 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 - 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"], - ] - # If AUTO_PLAY_DIR is already a game__seed/ path (caller named it), - # write directly into it. Otherwise create a subdirectory. - var dir_name: String = _output_dir.get_file() - if dir_name.begins_with("game_") and dir_name.ends_with("_seed%d" % _seed): - _game_dir = _output_dir - else: - _game_dir = _output_dir.path_join("game_%s_seed%d" % [_start_stamp, _seed]) - 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) - _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_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 "", - }) - if player_index >= 0: - _ensure_stats(player_index) - var uid: String = str(unit.get("unit_id", "")) - var udata: Dictionary = DataLoader.get_unit(uid) if uid != "" else {} - var tier: int = int(udata.get("tier", QUALITY_METRIC_UNKNOWN)) - if tier > int(_stats[player_index].get("peak_unit_tier", QUALITY_METRIC_UNKNOWN)): - _stats[player_index]["peak_unit_tier"] = tier - - -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 "", - }) - # Return strategic resource to ledger when a resource-gated unit dies. - var uid: String = str(unit.get("unit_id", "")) - 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 _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 _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 _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, - "tier_peak": QUALITY_METRIC_UNKNOWN, - "peak_unit_tier": QUALITY_METRIC_UNKNOWN, - } - - -func _on_victory(player_index: int, victory_type: String) -> void: - _victory = true - _victory_winner = player_index - _victory_type = victory_type - _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) - print("AutoPlay: [setup] Start Game (2 players)") - btn.pressed.emit() - # Force Pangaea so all players share one landmass (no water barriers) - GameState.game_settings["map_type"] = "pangaea" - GameState.game_settings["num_players"] = 2 - 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) - GameState.apply_ai_difficulty() - _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() - _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: - _fix_start_positions_if_needed() - # 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: - _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 - - -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() - ]) - - -# ── 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) - - # 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)) - var attack_msg: String = ( - " ATTACK: %d warriors at %s -> target %s" - % [military_count, ", ".join(unit_positions), target] - ) - print(attack_msg) - 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 - # 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: - # Fortify at city — do NOT attack or chase enemies - if 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; military pillar ×2; - ## unlocks tier≥4 unit adds ×3; prerequisite of high-value tech adds ×1.5. - ## The prereq-chain boost ensures steelworking (→combined_arms→ironwarden) - ## gets researched ~50 turns earlier than cheapest-first ordering. - var all_techs: Array = DataLoader.get_all_techs() - - 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) - if str(tech.get("pillar", "")) == "military": - sc *= 2.0 - 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 - - 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) - - 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]) - - -func _score_site(pos: Vector2i, game_map: RefCounted) -> float: - ## Score a hex as a city site. Food*2 + production*1.5 + resources + mixed-yield bonus. - 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) - var center_food: int = int(y.get("food", 0)) - var center_prod: int = int(y.get("production", 0)) - score += float(center_food) * 2.0 + float(center_prod) * 1.5 - # Heavy penalty for food-starved center biomes (mountain/tundra/desert). - if tile.biome_id in ["mountains", "tundra", "desert"]: - score -= 5.0 - var neighbor_food: int = 0 - var neighbor_prod: int = 0 - 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 - if nt.biome_id == "river": - score += 2.0 - var ny: Dictionary = nt.get_yields(-1) - var nf: int = int(ny.get("food", 0)) - var np: int = int(ny.get("production", 0)) - neighbor_food += nf - neighbor_prod += np - score += float(nf) * 0.5 + float(np) * 0.3 - if nt.resource_id != "": - score += 3.0 - # Mixed-yield reward: at least one food + one prod in neighbors. - if neighbor_food >= 1 and neighbor_prod >= 1: - 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" - var prod_msg: String = ( - " [PROD] %s: queue=%d item=%s progress=%d" - % [city.city_name, q_size, item, city.production_progress] - ) - print(prod_msg) - # Worker-first override: the scoring path gates worker on pop>=2, which - # misses turn-1 scoring windows where pop=1. After forge and warrior- - # replacement cycles take over the queue, the city never re-scores and - # worker never gets built — the seed-4 class of 0-improvement runs. Force - # a worker slot once when queue is empty or stuck on a long building and - # conditions are safe. See WORKER_OVERRIDE_* constants above for gates. - if _maybe_prioritize_worker(city): - return - 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" - var unit_ids: Array[String] = ["warrior", "founder", "worker"] - if built in unit_ids: - city.add_to_queue("unit", built) - else: - city.add_to_queue("building", built) - - -func _maybe_prioritize_worker(city: Variant) -> bool: - ## Slot a worker at the head of the queue when own_workers == 0 and the - ## early-game window is still open. Returns true when it mutated the queue - ## so the caller can skip the score-based path. Named gates above decide - ## what counts as "early", "safe", "viable pop" — no literals inline. - if _turn_count > WORKER_OVERRIDE_MAX_TURN: - return false - if city == null or int(city.population) < WORKER_OVERRIDE_MIN_POP: - return false - var gs: Node = get_node("/root/GameState") - var player: RefCounted = gs.get_current_player() if gs != null else null - if player == null or player.cities.is_empty(): - return false - if city.owner != player.index: - return false - if not city.can_build("worker", player): - return false - var own_mil: int = 0 - var own_workers: int = 0 - for u: Variant in player.units: - if u == null or not u.is_alive() or u.get("can_found_city") == true: - continue - if u.get("can_build_improvements") == true: - own_workers += 1 - else: - own_mil += 1 - if own_workers > 0: - return false - if own_mil < WORKER_OVERRIDE_MIN_DEFENDERS: - return false - # Check safety: no enemy within peace radius of this city. We read from - # GameState directly rather than calling _get_enemy_intel so this helper - # stays cheap and does not trigger the intel cache side-effects. - for p: Variant in GameState.players: - if not p is Object or p.index == player.index: - continue - for eu: Variant in p.units: - if eu == null or not eu.is_alive(): - continue - if eu.get("can_found_city") == true or eu.get("can_build_improvements") == true: - continue - var d: int = HexUtilsScript.hex_distance(city.position, eu.position) - if d <= WORKER_OVERRIDE_PEACE_RADIUS: - return false - # If queue head is already a worker, nothing to do. - if not city.production_queue.is_empty(): - var head: Dictionary = city.production_queue[0] as Dictionary - if str(head.get("id", "")) == "worker" and str(head.get("type", "")) == "unit": - return false - # Swap to worker. Prepending (vs replacing) preserves any queued items - # behind it — caller's existing queue is not thrown away; the worker just - # jumps to the front once. - var udata: Dictionary = DataLoader.get_unit("worker") - var wcost: int = int(udata.get("cost", 0)) - city.production_queue.insert( - 0, {"type": "unit", "id": "worker", "cost": wcost} - ) - city.production_progress = 0 - print(" [WORKER_FIRST] turn=%d city=%s prepended worker" % [_turn_count, city.city_name]) - return true - - -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": "animal_husbandry", - } - var candidates: Array[String] = [ - "warrior", "forge", "walls", "marketplace", "temple", - "colosseum", "ale_hall", "bathhouse", "library", "barracks", "monument", - "castle", "founder", "worker", "spearmen", "cavalry", - ] - var units_set: Array[String] = ["warrior", "founder", "worker", "spearmen", "cavalry"] - 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) - - # 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 - 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_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: - var atk_msg: String = ( - " ATTACKING: %s at %s -> enemy at %s (dist=%d)" - % [unit.type_id, unit.position, enemy.position, dist] - ) - print(atk_msg) - var resolver: RefCounted = CombatResolverScript.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 atk_city_msg: String = ( - " ATTACKING CITY: %s at %s -> city at %s (dist=%d)" - % [unit.type_id, unit.position, c.position, dist] - ) - print(atk_city_msg) - var resolver: RefCounted = CombatResolverScript.new() - resolver.resolve(unit, c, game_map, all_units) - 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() or unit.movement_remaining <= 0: - return - if not ClassDB.class_exists("GdCombatResolver"): - return - var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(unit.position) - 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 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": 0, - "city_wall_tier": 0, - "city_has_garrison": false, - } - print(" ATTACKING LAIR: %s at %s -> %s (tier %d) at %s" % [ - unit.type_id, unit.position, lair_name, lair_tier, norm - ]) - 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 - # Quality metric: tier_peak is the highest era of any researched tech. - # Monotonic — never decreases. Floor at 1 once any tech is researched so - # players who own a capital but haven't published a tech still show era 1. - var current_tier: int = QUALITY_METRIC_UNKNOWN - for tech_id: String in player.researched_techs: - var tdata: Dictionary = DataLoader.get_tech(tech_id) - var tech_era: int = int(tdata.get("era", QUALITY_METRIC_FLOOR_TIER)) - if tech_era > current_tier: - current_tier = tech_era - if current_tier > int(pstat.get("tier_peak", QUALITY_METRIC_UNKNOWN)): - pstat["tier_peak"] = current_tier - - _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._count_unique_luxuries(p, game_map) - 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 - # Wonder count per player — GameState.wonders_built is keyed by - # wonder_id → owner_index, so count entries owned by idx. - var wonder_count: int = 0 - for wid: String in GameState.wonders_built: - if int(GameState.wonders_built[wid]) == idx: - wonder_count += 1 - 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": int(pstat.get("tier_peak", QUALITY_METRIC_UNKNOWN)), - "peak_unit_tier": int(pstat.get("peak_unit_tier", QUALITY_METRIC_UNKNOWN)), - "wonder_count": wonder_count, - } - return out - - -func _write_meta() -> void: - ## Write meta.json once at start-of-run. Captures seed + settings snapshot. - if not _seed_set: - return - var meta: Dictionary = { - "seed": _seed, - "start_stamp": _start_stamp, - "game_settings": GameState.game_settings.duplicate(true), - "schema_version": 1, - } - 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 - 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, - } - var line: Dictionary = { - "turn": _turn_count, - "outcome": outcome, - "winner_index": _victory_winner, - "victory_type": _victory_type, - "wall_clock_sec": wall_clock, - "aggregate": aggregate, - "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 _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) - _save_turn_snapshot() - - -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) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd index d4dc1fc1..826a3bb7 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -3,14 +3,11 @@ extends RefCounted ## Thin dispatch layer between the engine turn loop and the Rust AI. ## ## One AI turn is two Rust round-trips: -## 1. `GdMcTreeController.choose_action_with_stats` — strategic directive -## (FoundCity / SpawnUnit / Idle). Primes the first city's production. -## 2. `GdAiController.decide_actions` — tactical actions as JSON-encoded -## `mc_ai::tactical::Action` records. Each is parsed and dispatched -## to engine entities via EventBus + direct mutation. +## 1. `GdMcTreeController.choose_action_with_stats` — strategic directive. +## 2. `GdAiController.decide_actions` — tactical actions as JSON-encoded records. ## -## Both Rust classes must be loaded — no silent GDScript fallback. The port -## per p0-26 deleted simple_heuristic_ai.gd / ai_tactical.gd / ai_military.gd. +## State serialization: ai_turn_bridge_state.gd +## Action dispatch: ai_turn_bridge_dispatch.gd const CityScript: GDScript = preload("res://engine/src/entities/city.gd") const CombatResolverScript: GDScript = preload( @@ -19,31 +16,23 @@ const CombatResolverScript: GDScript = preload( const CombatUtilsScript: GDScript = preload( "res://engine/src/modules/combat/combat_utils.gd" ) +const StateScript: GDScript = preload( + "res://engine/src/modules/ai/ai_turn_bridge_state.gd" +) +const DispatchScript: GDScript = preload( + "res://engine/src/modules/ai/ai_turn_bridge_dispatch.gd" +) -## MCTS rollout budgets. 300 keeps per-turn wall time under ~50 ms on the -## 8-core apricot batch host during the early game; 100 protects main-thread -## responsiveness once rollout trees expand late-game. const MCTS_ROLLOUT_COUNT_EARLY: int = 300 const MCTS_ROLLOUT_COUNT_LATE: int = 100 const MCTS_LATE_GAME_TURN_THRESHOLD: int = 100 const MCTS_ROLLOUT_DEPTH: int = 20 - -## u32-id encoding: (player_slot * ID_STRIDE) + per-player entity index. -## Stride wide enough that per-player indices never collide up to the -## MAX_PLAYERS POD cap. const ID_STRIDE: int = 10000 -## Per-(turn, player_index) MCTS telemetry consumed by the AI sanity proof. -## ":" key shape so same-frame calls don't collide. static var _mcts_stats_log: Dictionary = {} - - -## Cached `ai_personalities.json` contents — read once per process via -## FileAccess (works in both editor / development and packed builds). -## Empty string until first read; empty also indicates "file missing". -## p1-24. static var _ai_personalities_json_cache: String = "" + static func _load_ai_personalities_json() -> String: if not _ai_personalities_json_cache.is_empty(): return _ai_personalities_json_cache @@ -53,9 +42,10 @@ static func _load_ai_personalities_json() -> String: return "" var contents: String = FileAccess.get_file_as_string(path) if contents.is_empty(): - push_warning("AiTurnBridge: FileAccess returned empty contents for %s (err=%d)" % [ - path, FileAccess.get_open_error() - ]) + push_warning( + "AiTurnBridge: FileAccess returned empty contents for %s (err=%d)" + % [path, FileAccess.get_open_error()] + ) return "" _ai_personalities_json_cache = contents return contents @@ -68,8 +58,6 @@ static func get_last_mcts_stats(turn: int, player_index: int) -> Dictionary: return {"path": "heuristic", "rollouts": 0, "win_rate": null, "action": "Heuristic"} -## Run the strategic MCTS override then the tactical action pass for `player`. -## Returns the count of tactical actions that dispatched successfully. static func run(player: RefCounted) -> int: _apply_mcts_strategic_override(player) return _apply_tactical_actions(player) @@ -92,18 +80,17 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void: ctrl.set_rollout_budget(budget) ctrl.set_rollout_depth(MCTS_ROLLOUT_DEPTH) ctrl.set_gpu_enabled(OS.get_environment("AI_GPU_ROLLOUT") in ["1", "true", "TRUE", "True"]) - ctrl.set_priors_enabled(OS.get_environment("AI_MCTS_PRIORS") in ["1", "true", "TRUE", "True"]) + ctrl.set_priors_enabled( + OS.get_environment("AI_MCTS_PRIORS") in ["1", "true", "TRUE", "True"] + ) var budget_ms_env: String = OS.get_environment("MCTS_DECISION_BUDGET_MS") if not budget_ms_env.is_empty() and budget_ms_env.is_valid_int(): var budget_ms_val: int = int(budget_ms_env) if budget_ms_val > 0: ctrl.set_budget_ms(budget_ms_val) print("AiTurnBridge: MCTS_DECISION_BUDGET_MS=%d ms active (p1-22)" % budget_ms_val) - # p1-24: read ai_personalities.json via FileAccess so it works in packed - # builds where res:// content lives inside .pck and std::fs (data_dir-style - # OS paths) cannot reach it. Pass JSON contents straight to Rust. var personalities_json: String = _load_ai_personalities_json() - var json: String = JSON.stringify(_build_mc_tree_state(ctrl, personalities_json)) + var json: String = JSON.stringify(StateScript.build_mc_tree_state(ctrl, personalities_json)) var seed: int = GameState.turn_number * 1000 + player.index var stats: Dictionary = (JSON.parse_string( ctrl.choose_action_with_stats(json, player.index, seed) @@ -130,66 +117,6 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void: _queue_military(player) -## Build the GdMcTreeController strategic-layer dict. mc-turn's snapshot -## format differs from TacticalState and is kept as a plain dict so -## JSON.stringify produces the exact shape Rust expects. -## -## `personalities_json`: the full contents of `res://public/games/age-of-dwarves/data/ai_personalities.json` -## as a string (read by `_load_ai_personalities_json`). p1-24. -static func _build_mc_tree_state(ctrl: RefCounted, personalities_json: String) -> Dictionary: - var player_list: Array = [] - for p: RefCounted in GameState.players: - if p == null: - continue - var city_list: Array = [] - var city_positions: Array = [] - for c: RefCounted in p.cities: - if c == null: - continue - city_list.append({ - "population": maxi(1, int(c.population)), - "food_stored": 0, "production_stored": 0, - "food_yield": 2, "prod_yield": 2, - }) - city_positions.append([int(c.position.x), int(c.position.y)]) - var unit_list: Array = [] - for u: RefCounted in p.units: - if u == null or not u.is_alive(): - continue - unit_list.append({ - "col": int(u.position.x), "row": int(u.position.y), - "hp": int(u.hp), "max_hp": int(u.max_hp), - "attack": int(u.attack), "defense": int(u.defense), - "is_fortified": false, "unit_id": str(u.unit_id), - }) - var axes: Dictionary = (p.strategic_axes - if not p.strategic_axes.is_empty() - else {"expansion": 2, "production": 2, "wealth": 2}) - var clan: String = str(p.clan_id) if "clan_id" in p else "" - var weights_json: String = ( - ctrl.scoring_weights_for_clan_json(clan, personalities_json) - if ctrl != null and not clan.is_empty() and not personalities_json.is_empty() - else "{}" - ) - var weights: Dictionary = JSON.parse_string(weights_json) as Dictionary - if weights == null: - weights = {} - var formation_list: Array = _build_formations_for_player(p) - player_list.append({ - "player_index": int(p.index), "gold": int(p.gold), - "cities": city_list, "unit_upkeep": [], - "strategic_axes": axes, "scoring_weights": weights, - "expansion_points": 0, "city_buildings": [], "city_ecology": [], - "science_yield": 0, "units": unit_list, - "city_positions": city_positions, - "capital_position": (city_positions[0] if not city_positions.is_empty() else null), - "culture_total": int(p.culture_total), - "arcane_lore_pop_deducted": false, - "formations": formation_list, - }) - return {"turn": GameState.turn_number, "players": player_list, "grid": null} - - # ── Tactical actions ───────────────────────────────────────────────────────── @@ -200,581 +127,21 @@ static func _apply_tactical_actions(player: RefCounted) -> int: return 0 var ctrl: RefCounted = ClassDB.instantiate("GdAiController") ctrl.set_rng_seed(GameState.turn_number * 1000 + player.index) - # p1-22: bound the tactical decision path with the same env var used for - # the strategic MCTS budget so both controllers respect MCTS_DECISION_BUDGET_MS. var budget_ms_env: String = OS.get_environment("MCTS_DECISION_BUDGET_MS") if not budget_ms_env.is_empty() and budget_ms_env.is_valid_int(): var budget_ms_val: int = int(budget_ms_env) if budget_ms_val > 0: ctrl.set_budget_ms(budget_ms_val) - var index_maps: Dictionary = _build_index_maps() - var state_json: String = JSON.stringify(_build_tactical_state(player)) + var index_maps: Dictionary = StateScript.build_index_maps() + var state_json: String = JSON.stringify(StateScript.build_tactical_state(player)) var action_strs: PackedStringArray = ctrl.decide_actions(state_json, player.index) var applied: int = 0 for s: String in action_strs: - if _dispatch_action(s, player, index_maps): + if DispatchScript.dispatch_action(s, player, index_maps, _generate_city_name(player)): applied += 1 return applied -## Build {"units": {u32: Unit}, "cities": {u32: City}} reverse lookups. -## u32 id contract matches `_build_tactical_state`. -static func _build_index_maps() -> Dictionary: - var units: Dictionary = {} - var cities: Dictionary = {} - for p: RefCounted in GameState.players: - if p == null: - continue - var base: int = int(p.index) * ID_STRIDE - var ui: int = 0 - for u: RefCounted in p.units: - if u == null or not u.is_alive(): - continue - units[base + ui] = u - ui += 1 - var ci: int = 0 - for c: RefCounted in p.cities: - if c == null: - continue - cities[base + ci] = c - ci += 1 - return {"units": units, "cities": cities} - - -## Serialize the live game state to a `mc_ai::tactical::TacticalState` dict. -## JSON.stringify converts to the serde shape — nested arrays/dicts only. -static func _build_tactical_state(focal: RefCounted) -> Dictionary: - var game_map: RefCounted = GameState.get_game_map() - var width: int = 0 - var height: int = 0 - var tiles: Array = [] - if game_map != null: - width = int(game_map.width) - height = int(game_map.height) - # Row-major iteration matches the TacticalMap::tiles documented layout. - for row: int in range(height): - for col: int in range(width): - tiles.append(_tile_to_dict(game_map, col, row)) - var players: Array = [] - for p: RefCounted in GameState.players: - if p == null: - continue - players.append(_player_to_dict(p)) - return { - "current_player": int(focal.index), - "turn": int(GameState.turn_number), - "map": {"width": width, "height": height, "tiles": tiles}, - "players": players, - "unit_catalog": _build_unit_catalog(), - "difficulty_threshold_mult": _load_difficulty_threshold_mult(), - } - - -static func _build_unit_catalog() -> Array: - # Emit the unit catalog for `tactical::production::pick_best_melee` (p0-39). - # Populated from DataLoader's unit pack; tier-2+ units carry `tech_required`, - # `requires_resource`, and `race_required` gates the Rust helper filters - # against each player's `researched_techs`, `strategic_resources`, and - # `race_id`. All unit kinds included — Rust filters by - # `unit_type == "military"` at selection time. - var out: Array = [] - var data: Dictionary = DataLoader.get_data("units") - if data == null: - return out - for uid: String in data.keys(): - if not (data[uid] is Dictionary): - continue - var entry: Dictionary = data[uid] - if entry.is_empty(): - continue - # Skip entries that don't look like real units (manifest, schemas). - if not entry.has("id") and not entry.has("tier"): - continue - var tech_raw: String = _dict_string_field(entry, "tech_required") - var resource_raw: String = _dict_string_field(entry, "requires_resource") - var race_raw: String = _dict_string_field(entry, "race_required") - var gate_fields: Dictionary = { - "tech_required": null, - "requires_resource": null, - "race_required": null, - } - if not tech_raw.is_empty(): - gate_fields["tech_required"] = tech_raw - if not resource_raw.is_empty(): - gate_fields["requires_resource"] = resource_raw - if not race_raw.is_empty(): - gate_fields["race_required"] = race_raw - var tier_val: int = 1 - if entry.has("tier") and (entry["tier"] is int or entry["tier"] is float): - tier_val = int(entry["tier"]) - var id_val: String = _dict_string_field(entry, "id") - if id_val.is_empty(): - id_val = uid - var type_val: String = _dict_string_field(entry, "unit_type") - if type_val.is_empty(): - type_val = "military" - var item: Dictionary = { - "id": id_val, - "tier": tier_val, - "unit_type": type_val, - } - item.merge(gate_fields) - out.append(item) - return out - - -static func _dict_string_field(entry: Dictionary, key: String) -> String: - # Safe String coercion — returns empty string when the value is missing, - # null, or non-string-coercible (prevents `Invalid String constructor` - # on Resource/Object values that appear in DataLoader output when a - # loader stored something other than a plain dict). - if not entry.has(key): - return "" - if entry[key] is String: - return entry[key] - if entry[key] is StringName: - return String(entry[key]) - return "" - - -static func _tile_to_dict(game_map: RefCounted, col: int, row: int) -> Dictionary: - var tile: Resource = game_map.get_tile(Vector2i(col, row)) - if tile == null: - return { - "hex": [col, row], "biome": "", "yields": [0, 0, 0], - "resource": null, "is_coast": false, "owner": null, - } - var yields: Dictionary = tile.get_yields(-1) - var resource: String = String(tile.resource_id) - return { - "hex": [col, row], - "biome": String(tile.biome_id), - "yields": [ - maxi(0, int(yields.get("food", 0))), - maxi(0, int(yields.get("production", 0))), - maxi(0, int(yields.get("trade", 0))), - ], - "resource": (resource if not resource.is_empty() else null), - "is_coast": bool(tile.is_coastal), - "owner": (int(tile.owner) if int(tile.owner) >= 0 else null), - } - - -static func _player_to_dict(p: RefCounted) -> Dictionary: - var slot: int = int(p.index) - var base: int = slot * ID_STRIDE - var units: Array = [] - var ui: int = 0 - for u: RefCounted in p.units: - if u == null or not u.is_alive(): - continue - units.append({ - "id": base + ui, - "kind": String(u.unit_id), - "hex": [int(u.position.x), int(u.position.y)], - "hp": maxi(0, int(u.hp)), - "hp_max": maxi(1, int(u.max_hp)), - "moves_left": maxi(0, int(u.movement_remaining)), - "fortified": bool(u.is_fortified), - # Data-driven founder flag — clan-themed units like - # "dwarf_tribe" aren't recognized by string match alone, - # so we pass the engine's already-computed boolean - # through to the Rust port. Matches the settle.rs - # `is_settler()` fallback. - "can_found_city": bool(u.get("can_found_city") == true), - }) - ui += 1 - var cities: Array = [] - var ci: int = 0 - for c: RefCounted in p.cities: - if c == null: - continue - var queue_ids: Array = [] - for entry: Dictionary in c.production_queue: - var item_id: String = String(entry.get("id", "")) - if not item_id.is_empty(): - queue_ids.append(item_id) - var health: int = 25 - if "hp" in c: - health = maxi(0, int(c.hp)) - cities.append({ - "id": base + ci, - "hex": [int(c.position.x), int(c.position.y)], - "population": maxi(0, int(c.population)), - "tiles_worked": [], - "production_queue": queue_ids, - "buildings": Array(c.buildings), - "health": health, - "is_capital": bool(c.is_capital), - }) - ci += 1 - var techs: Array = Array(p.researched_techs) - # Diplomacy ledger: 0 for self, -1 for everyone else — matches the - # "AI-vs-AI warring by default" assumption the deleted tactical code - # relied on. Tighten once p0-??-diplomacy lands. - var relations: Array = [] - for other: RefCounted in GameState.players: - if other == null: - continue - relations.append(0 if int(other.index) == slot else -1) - # Personality axes for tactical::thresholds (p0-37). Emerges posture-flip, - # retreat, chase, siege, and final-push thresholds from clan personality - # instead of a flat global constant. - var axes: Dictionary = ( - p.strategic_axes - if "strategic_axes" in p and not p.strategic_axes.is_empty() - else _load_clan_axes(String(p.clan_id) if "clan_id" in p else "") - ) - # Race id (for race-gated unit selection, p0-39). - var race_id: String = (String(p.race_id) if "race_id" in p else "") - # Strategic resources the player currently controls — collected by - # scanning owned tiles' `resource_id` for entries tagged as strategic in - # `resources.json`. Consumed by `tactical::production::pick_best_melee` - # to filter units like cavalry (requires iron_ore). - var strategic_resources: Array = _collect_strategic_resources(p) - return { - "index": slot, - "clan_id": (String(p.clan_id) if "clan_id" in p else ""), - "gold": int(p.gold), - "happiness_pool": (int(p.happiness_pool) if "happiness_pool" in p else 0), - "units": units, "cities": cities, - "researched_techs": techs, "relations": relations, - "strategic_axes": axes, - "race_id": (race_id if not race_id.is_empty() else null), - "strategic_resources": strategic_resources, - } - - -static func _collect_strategic_resources(p: RefCounted) -> Array: - # Scan the player's owned tiles (via cities' worked + fat-cross tiles) and - # collect unique strategic resource ids. Lightweight; runs once per AI - # player per turn at JSON build time. - var seen: Dictionary = {} - var game_map: RefCounted = GameState.get_game_map() - if game_map == null: - return [] - for city: RefCounted in p.cities: - if city == null: - continue - # Iterate the city's owned-tile set. Cities expose `owned_tiles` as - # Array[Vector2i] in Game 1 scope. - var owned: Array = [] - if "owned_tiles" in city and city.owned_tiles != null: - owned = Array(city.owned_tiles) - for coord: Vector2i in owned: - var tile: Resource = game_map.get_tile(coord) - if tile == null: - continue - var rid: String = String(tile.resource_id) - if rid.is_empty(): - continue - # Treat every resource on an owned tile as available. The - # engine-side strategic-gate check still enforces the real rule - # at unit-production time; this list is an AI-hint, not the gate. - seen[rid] = true - var out: Array = [] - for rid: String in seen.keys(): - out.append(rid) - return out - - -static func _load_difficulty_threshold_mult() -> float: - var diff_id: String = str(GameState.game_settings.get("difficulty", "normal")) - var diff_data: Dictionary = DataLoader.get_data("difficulty") - if diff_data == null: - return 1.0 - for entry: Dictionary in diff_data.get("ai_difficulty", []): - if entry.get("id", "") == diff_id: - return float(entry.get("ai_modifiers", {}).get("difficulty_threshold_mult", 1.0)) - return 1.0 - - -static func _load_clan_axes(clan_id: String) -> Dictionary: - if clan_id.is_empty(): - return {} - var data: Dictionary = DataLoader.get_data("ai_personalities") - if data == null: - return {} - var entry: Dictionary = data.get(clan_id, {}) - if entry == null: - return {} - return entry.get("strategic_axes", {}) - - -static func _build_formations_for_player(p: RefCounted) -> Array: - # Cluster alive military units into adjacency-connected components (hex distance ≤ 1). - # Groups of 2+ become AiFormationState dicts for the MCTS tree. - var alive_units: Array = [] - for u: RefCounted in p.units: - if u == null or not u.is_alive(): - continue - var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "") - var udata: Dictionary = DataLoader.get_unit(uid_str) - if udata.get("unit_type", "") != "military": - continue - alive_units.append(u) - - var visited: Array[bool] = [] - visited.resize(alive_units.size()) - var next_fid: int = 0 - var formations: Array = [] - - for i: int in alive_units.size(): - if visited[i]: - continue - var component: Array[int] = [i] - visited[i] = true - var queue: Array[int] = [i] - while not queue.is_empty(): - var qi: int = queue.pop_back() - var ua: RefCounted = alive_units[qi] - var ax: float = float(ua.position.x) - var ay: float = float(ua.position.y) - for j: int in alive_units.size(): - if visited[j]: - continue - var ub: RefCounted = alive_units[j] - var bx: float = float(ub.position.x) - var by: float = float(ub.position.y) - # Hex cube-distance ≤ 1 (axial coords) - var dq: float = bx - ax - var dr: float = by - ay - var ds: float = -dq - dr - if maxf(maxf(absf(dq), absf(dr)), absf(ds)) <= 1.0: - visited[j] = true - component.append(j) - queue.append(j) - if component.size() < 2: - continue - # Compute tier_max for the formation - var tier_max: int = 0 - var leader_hex: Array = [0, 0] - for idx: int in component: - var u: RefCounted = alive_units[idx] - var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "") - var tier: int = int(DataLoader.get_unit(uid_str).get("tier", 1)) - if tier > tier_max: - tier_max = tier - leader_hex = [int(u.position.x), int(u.position.y)] - formations.append({ - "formation_id": next_fid, - "size": component.size(), - "tier_max": tier_max, - "command": "Defend", - "hex": leader_hex, - }) - next_fid += 1 - if not formations.is_empty(): - var sizes: Array = [] - var tiers: Array = [] - for f: Dictionary in formations: - sizes.append(int(f.get("size", 0))) - tiers.append(int(f.get("tier_max", 0))) - print("AiTurnBridge: formations turn=%d player=%d count=%d sizes=%s tiers=%s" % [ - GameState.turn_number, int(p.index), formations.size(), str(sizes), str(tiers) - ]) - return formations - - -# ── Action dispatch ────────────────────────────────────────────────────────── - - -static func _dispatch_action( - action_str: String, player: RefCounted, index_maps: Dictionary -) -> bool: - var action: Dictionary = JSON.parse_string(action_str) as Dictionary - if action == null or action.is_empty(): - push_warning("AiTurnBridge: malformed action JSON: %s" % action_str) - return false - # Externally-tagged serde: {"": {fields...}}. - var variant: String = String(action.keys()[0]) - var fields: Dictionary = action[variant] as Dictionary - if fields == null: - return false - match variant: - "MoveUnit", "Scout": - return _dispatch_move(fields, index_maps) - "AttackTarget": - return _dispatch_attack(fields, index_maps) - "Fortify": - return _dispatch_fortify(fields, index_maps) - "Heal": - return _dispatch_heal(fields, index_maps) - "FoundCity": - return _dispatch_found_city(fields, player, index_maps) - "SetProduction": - return _dispatch_set_production(fields, index_maps) - "AssignCitizen": - # Engine has no per-citizen worked-tile model yet; ack and drop. - return false - push_warning("AiTurnBridge: unknown action variant '%s'" % variant) - return false - - -static func _resolve_unit(uid: int, index_maps: Dictionary) -> RefCounted: - return (index_maps.get("units", {}) as Dictionary).get(uid) - - -static func _resolve_city(cid: int, index_maps: Dictionary) -> RefCounted: - return (index_maps.get("cities", {}) as Dictionary).get(cid) - - -static func _dispatch_move(fields: Dictionary, index_maps: Dictionary) -> bool: - var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps) - if unit == null or not unit.is_alive(): - return false - var to_hex: Array = fields.get("to_hex", []) - if to_hex.size() != 2: - return false - var to: Vector2i = Vector2i(int(to_hex[0]), int(to_hex[1])) - # mc-ai::tactical::movement encodes attacks as MoveUnit onto an enemy - # hex — the Action enum has no AttackHex variant. Mirror the pre-port - # GDScript "move into enemy hex = attack" convention so we don't - # teleport through them. - var enemy_defender: RefCounted = _find_enemy_at(to, int(unit.owner)) - if enemy_defender != null: - return _resolve_move_as_attack(unit, enemy_defender) - var enemy_city: RefCounted = CombatUtilsScript.get_city_at(to) - if enemy_city != null and int(enemy_city.owner) != int(unit.owner): - return _resolve_move_as_attack(unit, enemy_city) - var from: Vector2i = unit.position - unit.position = to - unit.movement_remaining = maxi(0, unit.movement_remaining - 1) - EventBus.unit_moved.emit(unit, from, to) - return true - - -static func _find_enemy_at(pos: Vector2i, attacker_owner: int) -> RefCounted: - var primary: Dictionary = GameState.get_primary_layer() - var all_units: Array = primary.get("units", []) - for u: RefCounted in all_units: - if u == null or not u.is_alive(): - continue - if u.position != pos: - continue - if int(u.owner) == attacker_owner: - continue - return u - return null - - -## Resolve a combat round initiated by MoveUnit onto an enemy-held hex. -## Mirrors `_dispatch_attack` but takes the already-resolved attacker + defender -## RefCounteds directly, skipping the attacker_id / target_id re-lookup. -static func _resolve_move_as_attack(attacker: RefCounted, defender: RefCounted) -> bool: - if attacker.movement_remaining <= 0: - return false - var game_map: RefCounted = GameState.get_game_map() - if game_map == null: - return false - var primary: Dictionary = GameState.get_primary_layer() - var all_units: Array = primary.get("units", []) - var resolver: RefCounted = CombatResolverScript.new() - resolver.resolve(attacker, defender, game_map, all_units) - attacker.movement_remaining = 0 - return true - - -static func _dispatch_attack(fields: Dictionary, index_maps: Dictionary) -> bool: - var attacker: RefCounted = _resolve_unit( - int(fields.get("attacker_id", -1)), index_maps - ) - if attacker == null or not attacker.is_alive() or attacker.movement_remaining <= 0: - return false - var defender: RefCounted = _resolve_unit( - int(fields.get("target_id", -1)), index_maps - ) - if defender == null or not defender.is_alive(): - return false - var game_map: RefCounted = GameState.get_game_map() - if game_map == null: - return false - var primary: Dictionary = GameState.get_primary_layer() - var all_units: Array = primary.get("units", []) - var resolver: RefCounted = CombatResolverScript.new() - resolver.resolve(attacker, defender, game_map, all_units) - attacker.movement_remaining = 0 - return true - - -static func _dispatch_fortify(fields: Dictionary, index_maps: Dictionary) -> bool: - var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps) - if unit == null or not unit.is_alive(): - return false - unit.is_fortified = true - unit.fortified_turns = int(unit.fortified_turns) + 1 - unit.movement_remaining = 0 - return true - - -static func _dispatch_heal(fields: Dictionary, index_maps: Dictionary) -> bool: - var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps) - if unit == null or not unit.is_alive(): - return false - # Heal = skip turn so the engine's turn-advance heal step can fire. - unit.movement_remaining = 0 - return true - - -static func _dispatch_found_city( - fields: Dictionary, player: RefCounted, index_maps: Dictionary -) -> bool: - var settler: RefCounted = _resolve_unit( - int(fields.get("settler_id", -1)), index_maps - ) - if settler == null or not settler.is_alive() or not settler.can_found_city: - return false - var at_hex: Array = fields.get("at_hex", []) - if at_hex.size() != 2: - return false - if settler.position != Vector2i(int(at_hex[0]), int(at_hex[1])): - # Engine founds at the settler's current hex. Stepping onto the - # target is the movement submodule's job; reject mismatches rather - # than teleporting the settler. - return false - var city: RefCounted = CityScript.new() - city.player = player - city.owner = player.index - city.found( - _generate_city_name(player), - settler.position.x, settler.position.y, - player.cities.is_empty(), - GameState.turn_number, - ) - player.cities.append(city) - player.units.erase(settler) - var primary: Dictionary = GameState.get_primary_layer() - primary.get("units", []).erase(settler) - EventBus.unit_destroyed.emit(settler, null) - EventBus.city_founded.emit(city, player.index) - return true - - -static func _dispatch_set_production( - fields: Dictionary, index_maps: Dictionary -) -> bool: - var city: RefCounted = _resolve_city(int(fields.get("city_id", -1)), index_maps) - if city == null: - return false - var item_id: String = String(fields.get("item_id", "")) - if item_id.is_empty(): - return false - # Rust Action doesn't carry a unit-vs-building tag. DataLoader - # disambiguates — unit wins on tie because authored data reserves unit - # ids colliding with building ids for the upgrade-in-place flow. - var udata: Dictionary = DataLoader.get_unit(item_id) - if not udata.is_empty(): - city.production_queue = [{"type": "unit", "id": item_id, "cost": int(udata.get("cost", 0))}] - city.production_progress = 0 - return true - var bdata: Dictionary = DataLoader.get_building(item_id) - if not bdata.is_empty(): - city.production_queue = [{"type": "building", "id": item_id, "cost": int(bdata.get("cost", 0))}] - city.production_progress = 0 - return true - return false - - # ── MCTS directive helpers ─────────────────────────────────────────────────── @@ -786,7 +153,9 @@ static func _queue_settler(player: RefCounted) -> void: if founder_id.is_empty(): return var udata: Dictionary = DataLoader.get_unit(founder_id) - city.production_queue = [{"type": "unit", "id": founder_id, "cost": int(udata.get("cost", 0))}] + city.production_queue = [ + {"type": "unit", "id": founder_id, "cost": int(udata.get("cost", 0))} + ] city.production_progress = 0 @@ -798,7 +167,9 @@ static func _queue_military(player: RefCounted) -> void: if mil_id.is_empty(): return var udata: Dictionary = DataLoader.get_unit(mil_id) - city.production_queue = [{"type": "unit", "id": mil_id, "cost": int(udata.get("cost", 0))}] + city.production_queue = [ + {"type": "unit", "id": mil_id, "cost": int(udata.get("cost", 0))} + ] city.production_progress = 0 @@ -812,9 +183,6 @@ static func _find_unit_type_by_flag(player: RefCounted, flag: String) -> String: return "" -## Pick the cheapest buildable non-settler unit from the race's start_units. -## Inlined from the deleted simple_heuristic_ai.gd so the MCTS `SpawnUnit` -## directive has a concrete unit id to enqueue. static func _pick_buildable_military_unit_id(city: RefCounted, player: RefCounted) -> String: var race_data: Dictionary = DataLoader.get_race(player.race_id) var start_units: Array = race_data.get("start_units", [])