From e80f744fd92edee5a2c329450b02f42b2be4f5ad Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 25 Apr 2026 23:09:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E2=9C=A8=20add=20ai=20action=20dis?= =?UTF-8?q?patch=20bridge=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/modules/ai/ai_turn_bridge_dispatch.gd | 185 +++++++++ .../src/modules/ai/ai_turn_bridge_state.gd | 368 ++++++++++++++++++ .../src/modules/management/turn_processor.gd | 44 +-- .../management/turn_processor_city_helpers.gd | 83 ++++ .../management/turn_processor_helpers.gd | 53 --- src/simulator/api-gdext/src/ai.rs | 20 + 6 files changed, 667 insertions(+), 86 deletions(-) create mode 100644 src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd create mode 100644 src/game/engine/src/modules/ai/ai_turn_bridge_state.gd create mode 100644 src/game/engine/src/modules/management/turn_processor_city_helpers.gd diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd new file mode 100644 index 00000000..ea4b555b --- /dev/null +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd @@ -0,0 +1,185 @@ +extends RefCounted +## Action dispatch helpers for AiTurnBridge. +## All methods are static. Receives index_maps built by AiTurnBridgeState. + +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const CombatResolverScript: GDScript = preload( + "res://engine/src/modules/combat/combat_resolver.gd" +) +const CombatUtilsScript: GDScript = preload( + "res://engine/src/modules/combat/combat_utils.gd" +) + + +static func dispatch_action( + action_str: String, player: RefCounted, index_maps: Dictionary, city_name: String +) -> 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 + 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, city_name) + "SetProduction": + return dispatch_set_production(fields, index_maps) + "AssignCitizen": + 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])) + 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 + + +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 + unit.movement_remaining = 0 + return true + + +static func dispatch_found_city( + fields: Dictionary, player: RefCounted, index_maps: Dictionary, city_name: String +) -> 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])): + return false + var city: RefCounted = CityScript.new() + city.player = player + city.owner = player.index + city.found( + city_name, + 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 + 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 diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd new file mode 100644 index 00000000..973a63dc --- /dev/null +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_state.gd @@ -0,0 +1,368 @@ +extends RefCounted +## State-serialization helpers for AiTurnBridge. +## Builds the JSON dicts consumed by GdMcTreeController and GdAiController. + +const ID_STRIDE: int = 10000 + + +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} + + +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} + + +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) + 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: + 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 + 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: + 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), + "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) + var relations: Array = [] + for other: RefCounted in GameState.players: + if other == null: + continue + relations.append(0 if int(other.index) == slot else -1) + 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 "") + ) + var race_id: String = (String(p.race_id) if "race_id" in p else "") + 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: + 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 + 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 + 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: + 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) + 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 + 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 diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index 056f7451..f03ccbcb 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -1,18 +1,10 @@ # gdlint: disable=no-elif-return,no-else-return,max-returns,class-definitions-order extends RefCounted ## End-of-turn processing. Per-player and global _process_* logic. -## Arena task #2 restored the four visible methods (production, growth, -## healing, economy). Still known-broken and out of scope: _process_culture, -## _process_golden_age, _process_loot_decay, _process_spell_system, -## _process_government — all blocked on empty module stubs. -## Calls disabled in turn_manager.gd::next_player (Diplomacy.process_turn, -## EconomyScript.apply_protection_effects) and turn_processor.gd::_process_* -## until these modules are rebuilt. `_process_climate` now runs the full -## marine_harvest → climate → weather → climate_effects chain (ClimateScript -## bugs fixed by p0-31, WeatherScript + ClimateEffectsScript implemented by -## p0-32 as thin marshalers over `GdWeatherPhysics` and -## `GdClimateEffectsPhysics`). Grep for `DISABLED:` to find every remaining -## guarded call site. +## DISABLED stubs: _process_culture, _process_golden_age, _process_loot_decay, +## _process_spell_system, _process_government — blocked on empty module stubs. +## `_process_climate` runs the full marine_harvest→climate→weather→effects chain. +## Grep for `DISABLED:` to find every remaining guarded call site. const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") @@ -40,6 +32,9 @@ const RustFaunaIntegrationScript: GDScript = preload( const TurnProcessorHelpersScript: GDScript = preload( "res://engine/src/modules/management/turn_processor_helpers.gd" ) +const TurnProcessorCityHelpersScript: GDScript = preload( + "res://engine/src/modules/management/turn_processor_city_helpers.gd" +) var unit_manager: RefCounted # UnitManager — set by TurnManager._ready() var spell_system: RefCounted # SpellSystem — set by TurnManager._ready() @@ -270,28 +265,11 @@ func _process_growth(player: RefCounted) -> void: # Player func _sum_city_building_effect(city: CityScript, effect_type: String) -> int: - ## Sum a building effect type for a single city (e.g., "production", "gold", "science"). - var total: int = 0 - for building_id: Variant in city.buildings: - var bdata: Dictionary = DataLoader.get_building(str(building_id)) - if bdata.is_empty(): - continue - for effect: Dictionary in bdata.get("effects", []): - if effect.get("type", "") == effect_type: - total += int(effect.get("value", 0)) - return total + return TurnProcessorCityHelpersScript.sum_city_building_effect(city, effect_type) func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> float: - var total: float = 0.0 - for building_id: Variant in city.buildings: - var bdata: Dictionary = DataLoader.get_building(str(building_id)) - if bdata.is_empty(): - continue - for effect: Dictionary in bdata.get("effects", []): - if effect.get("type", "") == effect_type: - total += float(effect.get("value", 0)) - return total + return TurnProcessorCityHelpersScript.sum_city_building_effect_float(city, effect_type) func _apply_building_bonuses(city: CityScript, building_id: String) -> void: @@ -320,7 +298,7 @@ func _apply_building_bonuses(city: CityScript, building_id: String) -> void: func _grant_free_tech(player: RefCounted, count: int) -> void: - TurnProcessorHelpersScript.grant_free_tech(player, count) + TurnProcessorCityHelpersScript.grant_free_tech(player, count) func _process_city_healing(player: RefCounted) -> void: @@ -411,7 +389,7 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void: func _build_border_candidates_json( city: CityScript, game_map: RefCounted, player: RefCounted ) -> String: - return TurnProcessorHelpersScript.build_border_candidates_json(city, game_map, player) + return TurnProcessorCityHelpersScript.build_border_candidates_json(city, game_map, player) func _process_golden_age(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap diff --git a/src/game/engine/src/modules/management/turn_processor_city_helpers.gd b/src/game/engine/src/modules/management/turn_processor_city_helpers.gd new file mode 100644 index 00000000..f9bbb39d --- /dev/null +++ b/src/game/engine/src/modules/management/turn_processor_city_helpers.gd @@ -0,0 +1,83 @@ +extends RefCounted +## City-scoped turn processing helpers: building effect summation, tech grants, +## and border candidate JSON builder. Extracted from turn_processor_helpers.gd +## to stay within the 500-line file limit. + +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") + + +static func sum_city_building_effect(city: CityScript, effect_type: String) -> int: + var total: int = 0 + for building_id: String in city.buildings: + var bdata: Dictionary = DataLoader.get_building(building_id) + if bdata.is_empty(): + continue + for effect: Dictionary in bdata.get("effects", []): + if effect.get("type", "") == effect_type: + total += int(effect.get("value", 0)) + return total + + +static func sum_city_building_effect_float(city: CityScript, effect_type: String) -> float: + var total: float = 0.0 + for building_id: String in city.buildings: + var bdata: Dictionary = DataLoader.get_building(building_id) + if bdata.is_empty(): + continue + for effect: Dictionary in bdata.get("effects", []): + if effect.get("type", "") == effect_type: + total += float(effect.get("value", 0)) + return total + + +static func grant_free_tech(player: RefCounted, count: int) -> void: + var researched: Array = player.researched_techs if player.researched_techs != null else [] + var candidates: Array[Dictionary] = [] + for t: Dictionary in DataLoader.get_all_techs(): + var tid: String = str(t.get("id", "")) + if tid == "" or tid in researched: + continue + var prereqs: Array = t.get("prerequisites", []) as Array + var all_met: bool = true + for pr: String in prereqs: + if not (pr in researched): + all_met = false + break + if all_met: + candidates.append(t) + candidates.sort_custom( + func(a: Dictionary, b: Dictionary) -> bool: + return int(a.get("cost", 999999)) < int(b.get("cost", 999999)) + ) + for i: int in range(mini(count, candidates.size())): + player.add_tech(str(candidates[i].get("id", ""))) + EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index) + + +static func build_border_candidates_json( + city: CityScript, game_map: RefCounted, player: RefCounted +) -> String: + var candidates: Array[Dictionary] = [] + for owned_pos: Vector2i in city.owned_tiles: + var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(owned_pos) + for n: Vector2i in neighbors: + var norm: Vector2i = HexUtilsScript.normalize_position( + n, game_map.width, game_map.height, game_map.wrap_mode + ) + if norm in city.owned_tiles: + continue + var tile: Resource = game_map.get_tile(norm) + if tile == null: + continue + if tile.owner != -1 and tile.owner != player.index: + continue + var tile_yields: Dictionary = tile.get_yields(player.index) + var score: float = float(tile_yields.get("food", 0)) * 2.0 + score += float(tile_yields.get("production", 0)) * 1.5 + score += float(tile_yields.get("trade", 0)) + score += float(tile_yields.get("culture", 0)) + if tile.resource_id != "": + score += 5.0 + candidates.append({"col": norm.x, "row": norm.y, "value": score}) + return JSON.stringify(candidates) diff --git a/src/game/engine/src/modules/management/turn_processor_helpers.gd b/src/game/engine/src/modules/management/turn_processor_helpers.gd index 320b818f..e95359f3 100644 --- a/src/game/engine/src/modules/management/turn_processor_helpers.gd +++ b/src/game/engine/src/modules/management/turn_processor_helpers.gd @@ -437,56 +437,3 @@ static func _find_unit_by_render_id(unit_id: String) -> RefCounted: if u is UnitScript and (u as UnitScript).get_render_id() == unit_id: return u return null - - -static func grant_free_tech(player: RefCounted, count: int) -> void: - var researched: Array = player.researched_techs if player.researched_techs != null else [] - var candidates: Array[Dictionary] = [] - for t: Dictionary in DataLoader.get_all_techs(): - var tid: String = str(t.get("id", "")) - if tid == "" or tid in researched: - continue - var prereqs: Array = t.get("prerequisites", []) as Array - var all_met: bool = true - for pr: String in prereqs: - if not (pr in researched): - all_met = false - break - if all_met: - candidates.append(t) - candidates.sort_custom( - func(a: Dictionary, b: Dictionary) -> bool: - return int(a.get("cost", 999999)) < int(b.get("cost", 999999)) - ) - for i: int in range(mini(count, candidates.size())): - player.add_tech(str(candidates[i].get("id", ""))) - EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index) - - -static func build_border_candidates_json( - city: CityScript, game_map: RefCounted, player: RefCounted -) -> String: - var HexUtils: GDScript = preload("res://engine/src/map/hex_utils.gd") - var candidates: Array[Dictionary] = [] - for owned_pos: Vector2i in city.owned_tiles: - var neighbors: Array[Vector2i] = HexUtils.get_neighbors(owned_pos) - for n: Vector2i in neighbors: - var norm: Vector2i = HexUtils.normalize_position( - n, game_map.width, game_map.height, game_map.wrap_mode - ) - if norm in city.owned_tiles: - continue - var tile: Resource = game_map.get_tile(norm) - if tile == null: - continue - if tile.owner != -1 and tile.owner != player.index: - continue - var tile_yields: Dictionary = tile.get_yields(player.index) - var score: float = float(tile_yields.get("food", 0)) * 2.0 - score += float(tile_yields.get("production", 0)) * 1.5 - score += float(tile_yields.get("trade", 0)) - score += float(tile_yields.get("culture", 0)) - if tile.resource_id != "": - score += 5.0 - candidates.append({"col": norm.x, "row": norm.y, "value": score}) - return JSON.stringify(candidates) diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index f5dce59e..4c6ccead 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -400,6 +400,13 @@ pub struct GdAiController { /// Deterministic RNG seed, advanced per `decide_actions` call so /// successive turns draw distinct xorshift streams. rng_seed: u64, + /// Per-decision wall-clock budget in milliseconds. `0` means unbounded + /// (default). When > 0, `decide_actions` computes `Instant::now() + budget` + /// and threads it through the tactical submodules so each per-unit / + /// per-city loop exits early once elapsed time exceeds the budget. Set via + /// `set_budget_ms` (driven by `MCTS_DECISION_BUDGET_MS` env on the GDScript + /// side). See p1-22. + budget_ms: u64, base: Base, } @@ -409,6 +416,7 @@ impl IRefCounted for GdAiController { Self { weights: Default::default(), rng_seed: 0x9E37_79B9_7F4A_7C15, + budget_ms: 0, base, } } @@ -426,6 +434,18 @@ impl GdAiController { self.rng_seed = seed as u64; } + /// Set the per-decision wall-clock budget in milliseconds for the tactical + /// AI path. Pass `0` (default) for unbounded behavior. When > 0, + /// `decide_actions` threads `Some(Instant::now() + budget)` through the + /// tactical submodules; their per-unit / per-city / per-citizen loops + /// check the deadline and break early once elapsed time exceeds it. + /// Mirrors `GdMcTreeController::set_budget_ms` for the strategic path. + /// Called from `ai_turn_bridge.gd` based on `MCTS_DECISION_BUDGET_MS` env (p1-22). + #[func] + fn set_budget_ms(&mut self, ms: i64) { + self.budget_ms = ms.max(0) as u64; + } + /// Install a player's scoring weights from a serialized JSON blob /// produced by [`mc_ai::evaluator::ScoringWeights`]'s serde impl. ///