feat(ai): Optimize AI turn handling logic and add test validation for 7-player turn cycles

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-10 08:13:01 -07:00
parent a3de8efdd5
commit b89e5c1d03
5 changed files with 946 additions and 0 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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]

View file

@ -0,0 +1 @@
uid://1503275c90364

View file

@ -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