diff --git a/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.gd b/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.gd new file mode 100644 index 00000000..b7932fa9 --- /dev/null +++ b/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.gd @@ -0,0 +1,267 @@ +extends Node2D +## Iter 7p — Turn Cycle Proof Scene. +## +## Programmatically verifies the click-move-end_turn interaction loop: +## 1. Boot game state with a small hand-built map +## 2. Create one human player with a single warrior unit +## 3. Select the unit +## 4. Move it one hex +## 5. End turn +## 6. Assert: turn_number==2, position changed, movement refreshed + +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") +const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") +const PathfinderScript: GDScript = preload("res://engine/src/map/pathfinder.gd") + +const OUTPUT_DIR: String = "user://screenshots" + +var _game_map: RefCounted = null +var _player: RefCounted = null +var _unit: RefCounted = null +var _label: Label = null +var _title: Label = null +var _captured: bool = false +var _log_lines: Array[String] = [] +var _all_passed: bool = true + + +func _ready() -> void: + _build_ui() + await get_tree().process_frame + _run_proof() + _redraw_info() + + await get_tree().create_timer(1.5).timeout + _capture_and_quit() + + +func _build_ui() -> void: + DisplayServer.window_set_size(Vector2i(1920, 1080)) + get_viewport().size = Vector2i(1920, 1080) + + var bg: ColorRect = ColorRect.new() + bg.color = Color(0.06, 0.08, 0.12) + bg.size = Vector2(1920, 1080) + add_child(bg) + + _title = Label.new() + _title.text = "Iter 7p — Turn Cycle Proof" + _title.position = Vector2(60, 30) + _title.add_theme_font_size_override("font_size", 32) + _title.add_theme_color_override("font_color", Color(0.9, 0.85, 0.6)) + add_child(_title) + + _label = Label.new() + _label.position = Vector2(60, 80) + _label.add_theme_font_size_override("font_size", 18) + _label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.85)) + add_child(_label) + + +func _run_proof() -> void: + _step_load_theme() + _step_build_map() + _step_create_player() + _step_start_turn() + _step_compute_range() + _step_move_unit() + _step_end_turn() + _step_verify_refresh() + _report_verdict() + + +func _step_load_theme() -> void: + _log("Loading theme 'age-of-dwarves'...") + DataLoader.load_theme("age-of-dwarves") + DataLoader.load_world("earth") + _log(" Theme loaded") + + +func _step_build_map() -> void: + _log("Building 5x5 test map...") + var settings: Dictionary = { + "seed": 42, + "map_type": "continents", + "map_size": "duel", + "num_players": 1, + } + GameState.initialize_game(settings) + + _game_map = GameMapScript.new() + _game_map.initialize(5, 5, HexUtilsScript.WrapMode.NONE) + for col: int in range(5): + for row: int in range(5): + var axial: Vector2i = HexUtilsScript.offset_to_axial(Vector2i(col, row)) + var tile: TileScript = TileScript.new() + tile.position = axial + tile.biome_id = "grassland" + _game_map.set_tile(axial, tile) + _game_map.start_positions = [Vector2i(0, 0)] + + var primary: Dictionary = GameState.get_primary_layer() + primary["map"] = _game_map + _log(" Map: %dx%d, %d tiles" % [ + _game_map.width, _game_map.height, _game_map.tiles.size() + ]) + + +func _step_create_player() -> void: + _log("Creating player + warrior unit...") + _player = PlayerScript.new() + _player.index = 0 + _player.is_human = true + _player.player_name = "Test Player" + _player.race_id = "dwarf" + _player.color = Color(0.85, 0.65, 0.2) + GameState.players.append(_player) + + var start_pos: Vector2i = Vector2i(0, 0) + _unit = UnitScript.new("dwarf_warrior", 0, start_pos) + _unit.instance_id = "test_warrior_0" + _player.units.append(_unit) + var primary: Dictionary = GameState.get_primary_layer() + var layer_units: Array = primary.get("units", []) + layer_units.append(_unit) + primary["units"] = layer_units + _log(" Unit at %s, movement=%d/%d, hp=%d/%d" % [ + str(_unit.position), _unit.movement_remaining, _unit.max_movement, + _unit.hp, _unit.max_hp, + ]) + + +func _step_start_turn() -> void: + _log("Starting turn 1...") + TurnManager.start_turn() + _assert_eq("turn_number after start", GameState.turn_number, 1) + _assert_eq("phase after start", TurnManager.get_phase_name(), "player_actions") + + +func _step_compute_range() -> void: + _log("Computing movement range from %s..." % str(_unit.position)) + var reachable: Dictionary = PathfinderScript.movement_range( + _game_map, _unit.position, _unit.movement_remaining, _unit.unit_type + ) + _log(" Reachable hexes: %d (budget=%d)" % [ + reachable.size(), _unit.movement_remaining + ]) + _assert_true("reachable hexes > 1", reachable.size() > 1) + + +func _step_move_unit() -> void: + var reachable: Dictionary = PathfinderScript.movement_range( + _game_map, _unit.position, _unit.movement_remaining, _unit.unit_type + ) + var target: Vector2i = Vector2i.ZERO + var move_cost: int = 0 + for pos: Vector2i in reachable: + if pos != _unit.position: + target = pos + move_cost = reachable[pos] + break + _log(" Moving to %s (cost=%d)..." % [str(target), move_cost]) + var original_pos: Vector2i = _unit.position + + var path: Array[Vector2i] = PathfinderScript.find_path( + _game_map, _unit.position, target, _unit.movement_remaining, _unit.unit_type + ) + _assert_true("path is non-empty", not path.is_empty()) + + _unit.position = target + _unit.movement_remaining -= move_cost + EventBus.unit_moved.emit(_unit, original_pos, target) + _log(" Unit now at %s, movement_remaining=%d" % [ + str(_unit.position), _unit.movement_remaining + ]) + _assert_eq("position changed", _unit.position, target) + _assert_true("position != original", _unit.position != original_pos) + + +func _step_end_turn() -> void: + _log("Ending turn...") + TurnManager.end_turn() + _log(" Turn number: %d" % GameState.turn_number) + _assert_eq("turn_number after end_turn", GameState.turn_number, 2) + + +func _step_verify_refresh() -> void: + _log(" Movement after refresh: %d/%d" % [ + _unit.movement_remaining, _unit.max_movement + ]) + _assert_eq( + "movement refreshed", _unit.movement_remaining, _unit.get_movement() + ) + _assert_true( + "movement > 0 after refresh", _unit.movement_remaining > 0 + ) + var reachable: Dictionary = PathfinderScript.movement_range( + _game_map, _unit.position, _unit.movement_remaining, _unit.unit_type + ) + _assert_true( + "unit can move again after refresh", reachable.size() > 1 + ) + + +func _report_verdict() -> void: + _log("") + if _all_passed: + _log("ALL ASSERTIONS PASSED — turn cycle is operational") + _title.text = "Iter 7p — TURN CYCLE PROOF: PASS" + _title.add_theme_color_override("font_color", Color(0.2, 1.0, 0.3)) + else: + _title.text = "Iter 7p — TURN CYCLE PROOF: FAIL" + _title.add_theme_color_override("font_color", Color.RED) + + +func _assert_eq(label: String, actual: Variant, expected: Variant) -> void: + if actual == expected: + _log(" PASS: %s == %s" % [label, str(expected)]) + else: + _log(" FAIL: %s — expected %s, got %s" % [label, str(expected), str(actual)]) + _all_passed = false + + +func _assert_true(label: String, condition: bool) -> void: + if condition: + _log(" PASS: %s" % label) + else: + _log(" FAIL: %s" % label) + _all_passed = false + + +func _log(msg: String) -> void: + _log_lines.append(msg) + print("turn_cycle_proof: %s" % msg) + + +func _redraw_info() -> void: + _label.text = "\n".join(_log_lines) + + +func _capture_and_quit() -> void: + if _captured: + return + _captured = true + DirAccess.make_dir_recursive_absolute( + ProjectSettings.globalize_path(OUTPUT_DIR) + ) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + push_error("iter_7p_turn_cycle_proof: viewport image null") + get_tree().quit(1) + return + var timestamp: String = Time.get_datetime_string_from_system().replace( + ":", "-" + ).replace("T", "_") + var rel_path: String = "%s/%s_%s.png" % [ + OUTPUT_DIR, "iter_7p_turn_cycle_proof", timestamp + ] + var abs_path: String = ProjectSettings.globalize_path(rel_path) + var err: Error = image.save_png(abs_path) + if err == OK: + print("SCREENSHOT_PATH:%s" % abs_path) + else: + push_error("iter_7p_turn_cycle_proof: save failed: %s" % error_string(err)) + get_tree().quit(0 if _all_passed else 1) diff --git a/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.tscn b/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.tscn new file mode 100644 index 00000000..bb9b8877 --- /dev/null +++ b/src/game/engine/scenes/tests/iter_7p_turn_cycle_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://engine/scenes/tests/iter_7p_turn_cycle_proof.gd" id="1"] + +[node name="Iter7pTurnCycleProof" type="Node2D"] +script = ExtResource("1") diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd b/src/game/engine/src/modules/ai/ai_turn_bridge.gd new file mode 100644 index 00000000..298556a9 --- /dev/null +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd @@ -0,0 +1,276 @@ +class_name AiTurnBridge +extends RefCounted +## Bridges the Rust GdAiController into the GDScript turn loop. +## +## Builds an AiPlayerState JSON from live GameState objects, calls +## GdAiController.decide_actions, and applies each returned action +## dict back onto the live game objects (move units, found cities, +## set production). Extracted from turn_manager.gd to keep that file +## under the 500-line cap. +## +## Follows the same ClassDB.instantiate pattern as RustFaunaBridge. + +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") + + +## Run all AI decisions for one player, apply them, then return the count +## of actions applied. Returns 0 if the extension is missing or the player +## has no actionable state. +static func run(player: RefCounted) -> int: + var controller: RefCounted = ClassDB.instantiate("GdAiController") as RefCounted + if controller == null: + push_warning( + "AiTurnBridge: GdAiController not registered " + + "(GDExtension missing or out-of-date)" + ) + return 0 + + var state_json: String = _build_player_state_json(player) + if state_json.is_empty(): + return 0 + + var actions: Array = controller.call("decide_actions", state_json) + if actions.is_empty(): + return 0 + + var applied: int = 0 + for action: Dictionary in actions: + if _apply_action(action, player): + applied += 1 + return applied + + +# ── State serialization ────────────────────────────────────────────── + + +static func _build_player_state_json(player: RefCounted) -> String: + var game_map: RefCounted = GameState.get_game_map() + var map_w: int = 0 + var map_h: int = 0 + if game_map != null: + map_w = int(game_map.width) + map_h = int(game_map.height) + + var axes: Dictionary = player.strategic_axes + if axes.is_empty(): + var race_data: Dictionary = DataLoader.get_race(player.race_id) + axes = race_data.get("strategic_axes", {}) + + var units_arr: Array = _build_units_snapshot(player) + var cities_arr: Array = [] + var city_positions: Array = [] + _build_cities_snapshot(player, cities_arr, city_positions) + + var counts: Dictionary = _count_army_composition(player) + + var enemy_units: Array = [] + var enemy_city_count: int = 0 + _build_enemy_snapshot(player, enemy_units, counts) + for other: RefCounted in GameState.players: + if other is PlayerScript and other.index != player.index: + enemy_city_count += other.cities.size() + + var state: Dictionary = { + "player_index": player.index, + "race_id": player.race_id, + "gold": player.gold, + "turn": GameState.turn_number, + "threat_level": 0.0, + "cities": cities_arr, + "units": units_arr, + "strategic_axes": axes, + "researched_tech_count": player.researched_techs.size(), + "is_researching": not player.researching.is_empty(), + "map_width": map_w, + "map_height": map_h, + "city_positions": city_positions, + "enemy_units": enemy_units, + "enemy_city_count": enemy_city_count, + "army_melee": int(counts.get("army_melee", 0)), + "army_ranged": int(counts.get("army_ranged", 0)), + "army_flying": int(counts.get("army_flying", 0)), + "enemy_melee": int(counts.get("enemy_melee", 0)), + "enemy_ranged": int(counts.get("enemy_ranged", 0)), + "enemy_flying": int(counts.get("enemy_flying", 0)), + } + return JSON.stringify(state) + + +static func _build_units_snapshot(player: RefCounted) -> Array: + var out: Array = [] + for u: Unit in player.units: + if not u.is_alive(): + continue + var hp_frac: float = float(u.hp) / maxf(1.0, float(u.max_hp)) + out.append({ + "unit_type": u.unit_id, + "is_founder": u.can_found_city, + "hp_fraction": hp_frac, + "col": u.position.x, + "row": u.position.y, + "attack": u.attack, + "defense": u.defense, + "has_moved": u.movement_remaining <= 0, + }) + return out + + +static func _build_cities_snapshot( + player: RefCounted, + cities_arr: Array, + city_positions: Array, +) -> void: + for c: RefCounted in player.cities: + var pop: int = c.get_population() if c.has_method("get_population") else 1 + cities_arr.append({ + "id": c.id, + "population": pop, + "food_surplus": 0, + "yields": [0.0, 0.0, 0.0, 0.0, 0.0], + "queue_empty": c.production_queue.is_empty(), + "building_count": c.buildings.size(), + "is_capital": c.is_capital, + "existing_buildings": Array(c.buildings), + }) + city_positions.append([c.position.x, c.position.y]) + + +static func _count_army_composition(player: RefCounted) -> Dictionary: + var counts: Dictionary = { + "army_melee": 0, + "army_ranged": 0, + "army_flying": 0, + "enemy_melee": 0, + "enemy_ranged": 0, + "enemy_flying": 0, + } + for u: Unit in player.units: + if not u.is_alive(): + continue + var domain: String = u.unit_type + if domain == "flying": + counts["army_flying"] = int(counts["army_flying"]) + 1 + elif u.ranged_attack > 0: + counts["army_ranged"] = int(counts["army_ranged"]) + 1 + else: + counts["army_melee"] = int(counts["army_melee"]) + 1 + return counts + + +static func _build_enemy_snapshot( + player: RefCounted, + enemy_units: Array, + counts: Dictionary, +) -> void: + for other: RefCounted in GameState.players: + if not other is PlayerScript: + continue + if other.index == player.index: + continue + for eu: Unit in other.units: + if not eu.is_alive(): + continue + var hp_frac: float = float(eu.hp) / maxf(1.0, float(eu.max_hp)) + enemy_units.append([ + eu.position.x, eu.position.y, eu.attack, eu.defense, hp_frac, + ]) + var domain: String = eu.unit_type + if domain == "flying": + counts["enemy_flying"] = int(counts["enemy_flying"]) + 1 + elif eu.ranged_attack > 0: + counts["enemy_ranged"] = int(counts["enemy_ranged"]) + 1 + else: + counts["enemy_melee"] = int(counts["enemy_melee"]) + 1 + + +# ── Action application ─────────────────────────────────────────────── + + +static func _apply_action(action: Dictionary, player: RefCounted) -> bool: + var action_type: String = String(action.get("type", "")) + match action_type: + "move_unit": + return _apply_move(action, player) + "found_city": + return _apply_found_city(action, player) + "set_production": + return _apply_set_production(action, player) + "attack": + push_warning("AiTurnBridge: 'attack' action not yet wired — skipping") + return false + push_warning("AiTurnBridge: unknown action type '%s'" % action_type) + return false + + +static func _apply_move(action: Dictionary, player: RefCounted) -> bool: + var idx: int = int(action.get("unit_index", -1)) + if idx < 0 or idx >= player.units.size(): + return false + var unit: Unit = player.units[idx] as Unit + if unit == null or not unit.is_alive(): + return false + var target_col: int = int(action.get("target_col", 0)) + var target_row: int = int(action.get("target_row", 0)) + var from: Vector2i = unit.position + var to: Vector2i = Vector2i(target_col, target_row) + unit.position = to + unit.movement_remaining = maxi(0, unit.movement_remaining - 1) + EventBus.unit_moved.emit(unit, from, to) + return true + + +static func _apply_found_city(action: Dictionary, player: RefCounted) -> bool: + var idx: int = int(action.get("unit_index", -1)) + if idx < 0 or idx >= player.units.size(): + return false + var unit: Unit = player.units[idx] as Unit + if unit == null or not unit.is_alive() or not unit.can_found_city: + return false + + var city_name: String = String(action.get("city_name", "")) + if city_name.is_empty(): + city_name = _generate_city_name(player) + + var city: RefCounted = CityScript.new() + var is_capital: bool = player.cities.is_empty() + city.found(city_name, unit.position.x, unit.position.y, is_capital, GameState.turn_number) + city.player = player + city.owner = player.index + player.cities.append(city) + + player.units.erase(unit) + var primary: Dictionary = GameState.get_primary_layer() + primary.get("units", []).erase(unit) + + EventBus.unit_destroyed.emit(unit, null) + EventBus.city_founded.emit(city, player.index) + return true + + +static func _apply_set_production(action: Dictionary, player: RefCounted) -> bool: + var idx: int = int(action.get("city_index", -1)) + if idx < 0 or idx >= player.cities.size(): + return false + var city: RefCounted = player.cities[idx] as RefCounted + if city == null: + return false + + var item_type: String = String(action.get("item_type", "")) + var item_id: String = String(action.get("item_id", "")) + if item_type.is_empty() or item_id.is_empty(): + return false + + city.production_queue = [{"type": item_type, "id": item_id}] + city.production_progress = 0 + return true + + +static func _generate_city_name(player: RefCounted) -> String: + var race_data: Dictionary = DataLoader.get_race(player.race_id) + var city_names: Array = race_data.get("city_names", []) + var city_index: int = player.cities.size() + if city_index < city_names.size(): + return city_names[city_index] + return "%s City %d" % [ThemeVocabulary.lookup(player.race_id), city_index + 1] diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge.gd.uid b/src/game/engine/src/modules/ai/ai_turn_bridge.gd.uid new file mode 100644 index 00000000..7771250f --- /dev/null +++ b/src/game/engine/src/modules/ai/ai_turn_bridge.gd.uid @@ -0,0 +1 @@ +uid://1503275c90364 diff --git a/src/game/engine/src/modules/management/turn_processor_helpers.gd b/src/game/engine/src/modules/management/turn_processor_helpers.gd new file mode 100644 index 00000000..54d0487f --- /dev/null +++ b/src/game/engine/src/modules/management/turn_processor_helpers.gd @@ -0,0 +1,396 @@ +extends RefCounted +## Heavy GDScript-side helpers for TurnProcessor that lack Rust bridges. +## Each method here is marked with an iter tag for future migration to Rust. + +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd") +const TerrainAffinityScript: GDScript = preload("res://engine/src/core/terrain_affinity.gd") +const SpellSystemScript: GDScript = preload("res://engine/src/modules/magic/spell_system.gd") +const HappinessScript: GDScript = preload("res://engine/src/modules/empire/happiness.gd") +const TechWebScript: GDScript = preload("res://engine/src/modules/tech/tech_web.gd") + + +# ── Production (delegates to CityScript.apply_production → Rust) ── + + +static func process_production( + player: RefCounted, unit_manager: RefCounted, +) -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + var prod_modifier: float = 1.0 + if player is PlayerScript and not player.is_human: + prod_modifier = GameState.ai_difficulty_modifier + for city: RefCounted in player.cities: + if not city is CityScript: + continue + var c: CityScript = city as CityScript + var tile_json: String = build_tile_yields_json(c, game_map) + var yields: Dictionary = c.get_yields(tile_json) + var hammers: int = int(yields.get("production", 1) * prod_modifier) + # Capture current item before apply_production pops it on completion. + var current: Dictionary = ( + c.production_queue.front() as Dictionary + if not c.production_queue.is_empty() + else {} + ) + if not c.apply_production(hammers): + continue + var item_type: String = current.get("type", "") + var item_id: String = current.get("id", "") + if item_type == "unit": + var unit: RefCounted = unit_manager.create_unit( + item_id, player.index, c.position, player + ) + if unit != null: + EventBus.city_unit_completed.emit(city, unit) + elif item_type == "building": + c.add_building(item_id) + EventBus.city_building_completed.emit(city, item_id) + + +# ── Research (spell research GDScript, tech research → GdTechWeb) ─ + + +static func process_research( + player: RefCounted, spell_system: RefCounted, tech_web: RefCounted, +) -> void: + if player.researching.is_empty(): + return + var science: int = _calculate_science_income(player) + + # Spell research stays in GDScript — GdTechWeb handles techs only. + var spell_data: Dictionary = DataLoader.get_spell(player.researching) + if not spell_data.is_empty(): + if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"): + science = 999999 + player.research_progress += science + var spell_cost: int = spell_data.get("research_cost", 999999) + if player.research_progress >= spell_cost: + var completed_spell: String = player.researching + player.research_progress = 0 + player.researching = "" + var sys: SpellSystemScript = spell_system as SpellSystemScript + sys.research_spell(player.index, completed_spell) + return + + # Tech research — delegate to GdTechWeb (Rust). + if EnvConfig.get_bool("FORCE_UNLIMITED_RESEARCH"): + science = 999999 + var tw: TechWebScript = tech_web as TechWebScript + var result: Dictionary = tw.add_science(str(player.index), science) + if result.get("status", "") == "completed": + _handle_tech_completion(player, result.get("tech_id", "")) + + +# ── Growth (delegates to CityScript.process_growth → Rust GdCity) ─ + + +static func process_growth(player: RefCounted) -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + if player.happiness < HappinessScript.UNHAPPY_THRESHOLD: + return + for city: RefCounted in player.cities: + if city is CityScript: + var c: CityScript = city as CityScript + var tile_json: String = build_tile_yields_json(c, game_map) + c.process_growth(tile_json) + + +# ── Healing (iter 7w: move to mc-turn::process_healing) ────────── + + +static func process_healing(player: RefCounted) -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + for unit: RefCounted in player.units: + if not unit is UnitScript: + continue + var u: UnitScript = unit as UnitScript + var tile: Resource = game_map.get_tile(u.position) as Resource + if tile != null: + TerrainAffinityScript.apply_terrain_power_unit_effect( + u, tile.biome_id, game_map + ) + apply_dead_zone_damage(u, tile) + if u.hp >= u.max_hp: + continue + if u.movement_remaining < u.get_movement() or u.has_attacked: + continue + var heal_amount: int = _get_healing_rate(u, player, game_map) + if heal_amount > 0: + u.heal(heal_amount) + EventBus.unit_healed.emit(unit, heal_amount) + + +# ── Mana income (iter 7w: move to mc-magic::process_mana) ──────── + + +static func process_mana( + player: RefCounted, game_map: RefCounted = null, +) -> void: + var new_income: Dictionary = {} + if game_map != null: + for city: RefCounted in player.cities: + if not city is CityScript: + continue + var c: CityScript = city as CityScript + var tile_json: String = build_tile_yields_json(c, game_map) + var city_yields: Dictionary = c.get_yields(tile_json) + var city_mana: Dictionary = city_yields.get("mana", {}) as Dictionary + for school: String in city_mana: + new_income[school] = ( + new_income.get(school, 0.0) + float(city_mana[school]) + ) + player.mana_income = new_income + if player.mana_income.is_empty(): + return + for school: String in player.mana_income: + var income: int = roundi(player.mana_income[school]) + var current: int = player.mana_pool.get(school, 0) + player.mana_pool[school] = mini(current + income, player.mana_cap) + EventBus.mana_changed.emit(player.index, player.mana_pool) + + +# ── Improvements (trivial loop, acceptable in GDScript) ────────── + + +static func process_improvements(player: RefCounted) -> void: + if player.pending_improvements.is_empty(): + return + var completed_indices: Array[int] = [] + for i: int in range(player.pending_improvements.size()): + var imp: Dictionary = player.pending_improvements[i] as Dictionary + imp["turns_remaining"] = imp.get("turns_remaining", 1) - 1 + if imp["turns_remaining"] <= 0: + completed_indices.append(i) + var tile_pos: Vector2i = Vector2i(imp.get("x", 0), imp.get("y", 0)) + EventBus.improvement_completed.emit(tile_pos, imp.get("type", "")) + for i: int in range(completed_indices.size() - 1, -1, -1): + player.pending_improvements.remove_at(completed_indices[i]) + + +# ── Summon spawning ────────────────────────────────────────────── + + +static func spawn_pending_summons( + sys: SpellSystemScript, + player: RefCounted, + unit_manager: RefCounted, +) -> void: + if sys.pending_summons.is_empty(): + return + var remaining: Array[Dictionary] = [] + var capital: RefCounted = _find_capital(player) + for summon: Dictionary in sys.pending_summons: + if summon.get("player_index", -1) != player.index: + remaining.append(summon) + continue + var unit: RefCounted = unit_manager.create_unit( + summon.get("unit_id", ""), + player.index, + summon.get("position", Vector2i.ZERO), + player, + ) + if unit != null: + (unit as UnitScript).is_summoned = true + EventBus.city_unit_completed.emit(capital, unit) + sys.pending_summons.assign(remaining) + + +# ── Dead-zone damage (iter 7w: move to mc-turn) ───────────────── + + +static func apply_dead_zone_damage(unit: RefCounted, tile: Resource) -> void: + if not (unit as UnitScript).is_summoned: + return + var density: float = tile.mana_density if "mana_density" in tile else 0.0 + var params: Dictionary = DataLoader.get_ley_line_params() + var dead_threshold: float = params.get("dead_zone", {}).get("threshold", 0.1) + if density >= dead_threshold: + return + var hp_loss: int = params.get("dead_zone", {}).get("summon_hp_loss_per_turn", 5) + (unit as UnitScript).take_damage(hp_loss) + EventBus.climate_damage_applied.emit(unit, "dead_zone", hp_loss) + + +# ── Dead-zone enchantment decay (iter 7w: move to mc-magic) ───── + + +static func apply_dead_zone_enchantment_decay( + sys: RefCounted, player: RefCounted, +) -> void: + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return + var params: Dictionary = DataLoader.get_ley_line_params() + var dead_threshold: float = params.get("dead_zone", {}).get("threshold", 0.1) + var decay_mult: float = params.get("dead_zone", {}).get( + "enchantment_decay_multiplier", 2.0 + ) + if decay_mult <= 1.0: + return + var spell_sys: SpellSystemScript = sys as SpellSystemScript + var rem: Array[int] = [] + for i: int in range(spell_sys.unit_enchantments.size()): + var enc: Dictionary = spell_sys.unit_enchantments[i] + if enc.get("caster_player", -1) != player.index: + continue + var dur: int = enc.get("turns_remaining", -1) + if dur < 0: + continue + var unit_id: String = enc.get("unit_id", "") + var target_unit: RefCounted = _find_unit_by_render_id(unit_id) + if target_unit == null: + continue + var tile: Resource = game_map.get_tile( + (target_unit as UnitScript).position + ) as Resource + if tile == null: + continue + var density: float = tile.mana_density if "mana_density" in tile else 0.0 + if density >= dead_threshold: + continue + enc["turns_remaining"] = dur - 1 + if enc["turns_remaining"] <= 0: + rem.append(i) + EventBus.enchantment_removed.emit( + target_unit, enc.get("spell_id", "") + ) + for i: int in range(rem.size() - 1, -1, -1): + spell_sys.unit_enchantments.remove_at(rem[i]) + + +# ── Archon formation (iter 7w: move to mc-magic or mc-turn) ───── + + +static func form_high_archon(player: RefCounted) -> void: + var ArchonScript: GDScript = preload("res://engine/src/entities/archon.gd") + var capital: RefCounted = _find_capital(player) + if capital == null: + push_warning( + "TurnProcessor: No capital for player %d to form High Archon" + % player.index + ) + return + var leader_name: String = player.player_name + var is_female: bool = player.gender_preset == "female" + var capital_pos: Vector2i = (capital as CityScript).position + var archon: RefCounted = ArchonScript.make_high_archon( + player.index, capital_pos, leader_name, is_female + ) + (capital as CityScript).set("archon", archon) + EventBus.archon_created.emit(archon, capital) + + +# ── Tile yields JSON builder ───────────────────────────────────── + + +static func build_tile_yields_json( + city: RefCounted, game_map: RefCounted, +) -> String: + ## Build JSON array of per-tile yields for a city's owned tiles. + var c: CityScript = city as CityScript + var owned_tiles: Array = c.get_owned_tiles() + var tiles_arr: Array[Dictionary] = [] + for idx: int in range(owned_tiles.size()): + var pos: Vector2i = owned_tiles[idx] as Vector2i + var tile: Resource = game_map.get_tile(pos) as Resource + if tile == null: + continue + var yields: Dictionary = tile.get_yields(c.owner) + tiles_arr.append({ + "col": pos.x, + "row": pos.y, + "food": int(yields.get("food", 0)), + "production": int(yields.get("production", 0)), + "gold": int(yields.get("gold", 0)), + "culture": int(yields.get("culture", 0)), + "science": int(yields.get("science", 0)), + }) + return JSON.stringify(tiles_arr) + + +# ── Private utilities ──────────────────────────────────────────── + + +static func _calculate_science_income(player: RefCounted) -> int: + var sci_modifier: float = 1.0 + if player is PlayerScript and not player.is_human: + sci_modifier = GameState.ai_difficulty_modifier + var science: int = int(player.science_per_turn * sci_modifier) + var game_map: RefCounted = GameState.get_game_map() + if game_map == null: + return science + for city: RefCounted in player.cities: + if city is CityScript: + var c: CityScript = city as CityScript + var tile_json: String = build_tile_yields_json(c, game_map) + var yields: Dictionary = c.get_yields(tile_json) + science += int(yields.get("science", 0) * sci_modifier) + return science + + +static func _handle_tech_completion( + player: RefCounted, completed_tech: String, +) -> void: + player.research_progress = 0 + player.researching = "" + var old_school_count: int = player.schools.size() + player.add_tech(completed_tech) + if completed_tech == "arcane_lore": + form_high_archon(player) + if player.schools.size() == 2 and old_school_count < 2: + EventBus.school_locked.emit(player.index, player.schools.duplicate()) + EventBus.tech_researched.emit(completed_tech, player.index) + _check_resource_reveals(completed_tech, player.index) + + +static func _check_resource_reveals( + completed_tech: String, player_index: int, +) -> void: + for res: Dictionary in DataLoader.get_all_resources(): + if res.get("revealed_by_tech", "") == completed_tech: + EventBus.resources_revealed.emit(completed_tech, player_index) + return + + +static func _get_healing_rate( + unit: RefCounted, player: RefCounted, game_map: RefCounted, +) -> int: + var tile: Resource = game_map.get_tile(unit.position) as Resource + if tile == null: + return 10 + for city_ref: RefCounted in player.cities: + if city_ref is CityScript and (city_ref as CityScript).position == unit.position: + var base_heal: int = 20 + var building_heal: int = BuildingScript.get_healing_per_turn(city_ref) + if building_heal >= 999: + return unit.max_hp + return base_heal + building_heal + if tile.owner == player.index: + return 15 + if tile.owner == -1: + return 10 + return 5 + + +static func _find_capital(player: RefCounted) -> RefCounted: + for city_ref: RefCounted in player.cities: + if city_ref is CityScript and (city_ref as CityScript).is_capital: + return city_ref + return null + + +static func _find_unit_by_render_id(unit_id: String) -> RefCounted: + for p: RefCounted in GameState.players: + for u: RefCounted in p.units: + if u is UnitScript and (u as UnitScript).get_render_id() == unit_id: + return u + return null