🔥 remove(@projects/@magic-civilization): delete stale auto-play engine script

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-25 23:14:38 -07:00
parent e80f744fd9
commit b0eeeb819c
2 changed files with 28 additions and 2928 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,11 @@ extends RefCounted
## Thin dispatch layer between the engine turn loop and the Rust AI.
##
## One AI turn is two Rust round-trips:
## 1. `GdMcTreeController.choose_action_with_stats` — strategic directive
## (FoundCity / SpawnUnit / Idle). Primes the first city's production.
## 2. `GdAiController.decide_actions` — tactical actions as JSON-encoded
## `mc_ai::tactical::Action` records. Each is parsed and dispatched
## to engine entities via EventBus + direct mutation.
## 1. `GdMcTreeController.choose_action_with_stats` — strategic directive.
## 2. `GdAiController.decide_actions` — tactical actions as JSON-encoded records.
##
## Both Rust classes must be loaded — no silent GDScript fallback. The port
## per p0-26 deleted simple_heuristic_ai.gd / ai_tactical.gd / ai_military.gd.
## State serialization: ai_turn_bridge_state.gd
## Action dispatch: ai_turn_bridge_dispatch.gd
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
const CombatResolverScript: GDScript = preload(
@ -19,31 +16,23 @@ const CombatResolverScript: GDScript = preload(
const CombatUtilsScript: GDScript = preload(
"res://engine/src/modules/combat/combat_utils.gd"
)
const StateScript: GDScript = preload(
"res://engine/src/modules/ai/ai_turn_bridge_state.gd"
)
const DispatchScript: GDScript = preload(
"res://engine/src/modules/ai/ai_turn_bridge_dispatch.gd"
)
## MCTS rollout budgets. 300 keeps per-turn wall time under ~50 ms on the
## 8-core apricot batch host during the early game; 100 protects main-thread
## responsiveness once rollout trees expand late-game.
const MCTS_ROLLOUT_COUNT_EARLY: int = 300
const MCTS_ROLLOUT_COUNT_LATE: int = 100
const MCTS_LATE_GAME_TURN_THRESHOLD: int = 100
const MCTS_ROLLOUT_DEPTH: int = 20
## u32-id encoding: (player_slot * ID_STRIDE) + per-player entity index.
## Stride wide enough that per-player indices never collide up to the
## MAX_PLAYERS POD cap.
const ID_STRIDE: int = 10000
## Per-(turn, player_index) MCTS telemetry consumed by the AI sanity proof.
## "<turn>:<player_index>" key shape so same-frame calls don't collide.
static var _mcts_stats_log: Dictionary = {}
## Cached `ai_personalities.json` contents — read once per process via
## FileAccess (works in both editor / development and packed builds).
## Empty string until first read; empty also indicates "file missing".
## p1-24.
static var _ai_personalities_json_cache: String = ""
static func _load_ai_personalities_json() -> String:
if not _ai_personalities_json_cache.is_empty():
return _ai_personalities_json_cache
@ -53,9 +42,10 @@ static func _load_ai_personalities_json() -> String:
return ""
var contents: String = FileAccess.get_file_as_string(path)
if contents.is_empty():
push_warning("AiTurnBridge: FileAccess returned empty contents for %s (err=%d)" % [
path, FileAccess.get_open_error()
])
push_warning(
"AiTurnBridge: FileAccess returned empty contents for %s (err=%d)"
% [path, FileAccess.get_open_error()]
)
return ""
_ai_personalities_json_cache = contents
return contents
@ -68,8 +58,6 @@ static func get_last_mcts_stats(turn: int, player_index: int) -> Dictionary:
return {"path": "heuristic", "rollouts": 0, "win_rate": null, "action": "Heuristic"}
## Run the strategic MCTS override then the tactical action pass for `player`.
## Returns the count of tactical actions that dispatched successfully.
static func run(player: RefCounted) -> int:
_apply_mcts_strategic_override(player)
return _apply_tactical_actions(player)
@ -92,18 +80,17 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
ctrl.set_rollout_budget(budget)
ctrl.set_rollout_depth(MCTS_ROLLOUT_DEPTH)
ctrl.set_gpu_enabled(OS.get_environment("AI_GPU_ROLLOUT") in ["1", "true", "TRUE", "True"])
ctrl.set_priors_enabled(OS.get_environment("AI_MCTS_PRIORS") in ["1", "true", "TRUE", "True"])
ctrl.set_priors_enabled(
OS.get_environment("AI_MCTS_PRIORS") in ["1", "true", "TRUE", "True"]
)
var budget_ms_env: String = OS.get_environment("MCTS_DECISION_BUDGET_MS")
if not budget_ms_env.is_empty() and budget_ms_env.is_valid_int():
var budget_ms_val: int = int(budget_ms_env)
if budget_ms_val > 0:
ctrl.set_budget_ms(budget_ms_val)
print("AiTurnBridge: MCTS_DECISION_BUDGET_MS=%d ms active (p1-22)" % budget_ms_val)
# p1-24: read ai_personalities.json via FileAccess so it works in packed
# builds where res:// content lives inside .pck and std::fs (data_dir-style
# OS paths) cannot reach it. Pass JSON contents straight to Rust.
var personalities_json: String = _load_ai_personalities_json()
var json: String = JSON.stringify(_build_mc_tree_state(ctrl, personalities_json))
var json: String = JSON.stringify(StateScript.build_mc_tree_state(ctrl, personalities_json))
var seed: int = GameState.turn_number * 1000 + player.index
var stats: Dictionary = (JSON.parse_string(
ctrl.choose_action_with_stats(json, player.index, seed)
@ -130,66 +117,6 @@ static func _apply_mcts_strategic_override(player: RefCounted) -> void:
_queue_military(player)
## Build the GdMcTreeController strategic-layer dict. mc-turn's snapshot
## format differs from TacticalState and is kept as a plain dict so
## JSON.stringify produces the exact shape Rust expects.
##
## `personalities_json`: the full contents of `res://public/games/age-of-dwarves/data/ai_personalities.json`
## as a string (read by `_load_ai_personalities_json`). p1-24.
static func _build_mc_tree_state(ctrl: RefCounted, personalities_json: String) -> Dictionary:
var player_list: Array = []
for p: RefCounted in GameState.players:
if p == null:
continue
var city_list: Array = []
var city_positions: Array = []
for c: RefCounted in p.cities:
if c == null:
continue
city_list.append({
"population": maxi(1, int(c.population)),
"food_stored": 0, "production_stored": 0,
"food_yield": 2, "prod_yield": 2,
})
city_positions.append([int(c.position.x), int(c.position.y)])
var unit_list: Array = []
for u: RefCounted in p.units:
if u == null or not u.is_alive():
continue
unit_list.append({
"col": int(u.position.x), "row": int(u.position.y),
"hp": int(u.hp), "max_hp": int(u.max_hp),
"attack": int(u.attack), "defense": int(u.defense),
"is_fortified": false, "unit_id": str(u.unit_id),
})
var axes: Dictionary = (p.strategic_axes
if not p.strategic_axes.is_empty()
else {"expansion": 2, "production": 2, "wealth": 2})
var clan: String = str(p.clan_id) if "clan_id" in p else ""
var weights_json: String = (
ctrl.scoring_weights_for_clan_json(clan, personalities_json)
if ctrl != null and not clan.is_empty() and not personalities_json.is_empty()
else "{}"
)
var weights: Dictionary = JSON.parse_string(weights_json) as Dictionary
if weights == null:
weights = {}
var formation_list: Array = _build_formations_for_player(p)
player_list.append({
"player_index": int(p.index), "gold": int(p.gold),
"cities": city_list, "unit_upkeep": [],
"strategic_axes": axes, "scoring_weights": weights,
"expansion_points": 0, "city_buildings": [], "city_ecology": [],
"science_yield": 0, "units": unit_list,
"city_positions": city_positions,
"capital_position": (city_positions[0] if not city_positions.is_empty() else null),
"culture_total": int(p.culture_total),
"arcane_lore_pop_deducted": false,
"formations": formation_list,
})
return {"turn": GameState.turn_number, "players": player_list, "grid": null}
# ── Tactical actions ─────────────────────────────────────────────────────────
@ -200,581 +127,21 @@ static func _apply_tactical_actions(player: RefCounted) -> int:
return 0
var ctrl: RefCounted = ClassDB.instantiate("GdAiController")
ctrl.set_rng_seed(GameState.turn_number * 1000 + player.index)
# p1-22: bound the tactical decision path with the same env var used for
# the strategic MCTS budget so both controllers respect MCTS_DECISION_BUDGET_MS.
var budget_ms_env: String = OS.get_environment("MCTS_DECISION_BUDGET_MS")
if not budget_ms_env.is_empty() and budget_ms_env.is_valid_int():
var budget_ms_val: int = int(budget_ms_env)
if budget_ms_val > 0:
ctrl.set_budget_ms(budget_ms_val)
var index_maps: Dictionary = _build_index_maps()
var state_json: String = JSON.stringify(_build_tactical_state(player))
var index_maps: Dictionary = StateScript.build_index_maps()
var state_json: String = JSON.stringify(StateScript.build_tactical_state(player))
var action_strs: PackedStringArray = ctrl.decide_actions(state_json, player.index)
var applied: int = 0
for s: String in action_strs:
if _dispatch_action(s, player, index_maps):
if DispatchScript.dispatch_action(s, player, index_maps, _generate_city_name(player)):
applied += 1
return applied
## Build {"units": {u32: Unit}, "cities": {u32: City}} reverse lookups.
## u32 id contract matches `_build_tactical_state`.
static func _build_index_maps() -> Dictionary:
var units: Dictionary = {}
var cities: Dictionary = {}
for p: RefCounted in GameState.players:
if p == null:
continue
var base: int = int(p.index) * ID_STRIDE
var ui: int = 0
for u: RefCounted in p.units:
if u == null or not u.is_alive():
continue
units[base + ui] = u
ui += 1
var ci: int = 0
for c: RefCounted in p.cities:
if c == null:
continue
cities[base + ci] = c
ci += 1
return {"units": units, "cities": cities}
## Serialize the live game state to a `mc_ai::tactical::TacticalState` dict.
## JSON.stringify converts to the serde shape — nested arrays/dicts only.
static func _build_tactical_state(focal: RefCounted) -> Dictionary:
var game_map: RefCounted = GameState.get_game_map()
var width: int = 0
var height: int = 0
var tiles: Array = []
if game_map != null:
width = int(game_map.width)
height = int(game_map.height)
# Row-major iteration matches the TacticalMap::tiles documented layout.
for row: int in range(height):
for col: int in range(width):
tiles.append(_tile_to_dict(game_map, col, row))
var players: Array = []
for p: RefCounted in GameState.players:
if p == null:
continue
players.append(_player_to_dict(p))
return {
"current_player": int(focal.index),
"turn": int(GameState.turn_number),
"map": {"width": width, "height": height, "tiles": tiles},
"players": players,
"unit_catalog": _build_unit_catalog(),
"difficulty_threshold_mult": _load_difficulty_threshold_mult(),
}
static func _build_unit_catalog() -> Array:
# Emit the unit catalog for `tactical::production::pick_best_melee` (p0-39).
# Populated from DataLoader's unit pack; tier-2+ units carry `tech_required`,
# `requires_resource`, and `race_required` gates the Rust helper filters
# against each player's `researched_techs`, `strategic_resources`, and
# `race_id`. All unit kinds included — Rust filters by
# `unit_type == "military"` at selection time.
var out: Array = []
var data: Dictionary = DataLoader.get_data("units")
if data == null:
return out
for uid: String in data.keys():
if not (data[uid] is Dictionary):
continue
var entry: Dictionary = data[uid]
if entry.is_empty():
continue
# Skip entries that don't look like real units (manifest, schemas).
if not entry.has("id") and not entry.has("tier"):
continue
var tech_raw: String = _dict_string_field(entry, "tech_required")
var resource_raw: String = _dict_string_field(entry, "requires_resource")
var race_raw: String = _dict_string_field(entry, "race_required")
var gate_fields: Dictionary = {
"tech_required": null,
"requires_resource": null,
"race_required": null,
}
if not tech_raw.is_empty():
gate_fields["tech_required"] = tech_raw
if not resource_raw.is_empty():
gate_fields["requires_resource"] = resource_raw
if not race_raw.is_empty():
gate_fields["race_required"] = race_raw
var tier_val: int = 1
if entry.has("tier") and (entry["tier"] is int or entry["tier"] is float):
tier_val = int(entry["tier"])
var id_val: String = _dict_string_field(entry, "id")
if id_val.is_empty():
id_val = uid
var type_val: String = _dict_string_field(entry, "unit_type")
if type_val.is_empty():
type_val = "military"
var item: Dictionary = {
"id": id_val,
"tier": tier_val,
"unit_type": type_val,
}
item.merge(gate_fields)
out.append(item)
return out
static func _dict_string_field(entry: Dictionary, key: String) -> String:
# Safe String coercion — returns empty string when the value is missing,
# null, or non-string-coercible (prevents `Invalid String constructor`
# on Resource/Object values that appear in DataLoader output when a
# loader stored something other than a plain dict).
if not entry.has(key):
return ""
if entry[key] is String:
return entry[key]
if entry[key] is StringName:
return String(entry[key])
return ""
static func _tile_to_dict(game_map: RefCounted, col: int, row: int) -> Dictionary:
var tile: Resource = game_map.get_tile(Vector2i(col, row))
if tile == null:
return {
"hex": [col, row], "biome": "", "yields": [0, 0, 0],
"resource": null, "is_coast": false, "owner": null,
}
var yields: Dictionary = tile.get_yields(-1)
var resource: String = String(tile.resource_id)
return {
"hex": [col, row],
"biome": String(tile.biome_id),
"yields": [
maxi(0, int(yields.get("food", 0))),
maxi(0, int(yields.get("production", 0))),
maxi(0, int(yields.get("trade", 0))),
],
"resource": (resource if not resource.is_empty() else null),
"is_coast": bool(tile.is_coastal),
"owner": (int(tile.owner) if int(tile.owner) >= 0 else null),
}
static func _player_to_dict(p: RefCounted) -> Dictionary:
var slot: int = int(p.index)
var base: int = slot * ID_STRIDE
var units: Array = []
var ui: int = 0
for u: RefCounted in p.units:
if u == null or not u.is_alive():
continue
units.append({
"id": base + ui,
"kind": String(u.unit_id),
"hex": [int(u.position.x), int(u.position.y)],
"hp": maxi(0, int(u.hp)),
"hp_max": maxi(1, int(u.max_hp)),
"moves_left": maxi(0, int(u.movement_remaining)),
"fortified": bool(u.is_fortified),
# Data-driven founder flag — clan-themed units like
# "dwarf_tribe" aren't recognized by string match alone,
# so we pass the engine's already-computed boolean
# through to the Rust port. Matches the settle.rs
# `is_settler()` fallback.
"can_found_city": bool(u.get("can_found_city") == true),
})
ui += 1
var cities: Array = []
var ci: int = 0
for c: RefCounted in p.cities:
if c == null:
continue
var queue_ids: Array = []
for entry: Dictionary in c.production_queue:
var item_id: String = String(entry.get("id", ""))
if not item_id.is_empty():
queue_ids.append(item_id)
var health: int = 25
if "hp" in c:
health = maxi(0, int(c.hp))
cities.append({
"id": base + ci,
"hex": [int(c.position.x), int(c.position.y)],
"population": maxi(0, int(c.population)),
"tiles_worked": [],
"production_queue": queue_ids,
"buildings": Array(c.buildings),
"health": health,
"is_capital": bool(c.is_capital),
})
ci += 1
var techs: Array = Array(p.researched_techs)
# Diplomacy ledger: 0 for self, -1 for everyone else — matches the
# "AI-vs-AI warring by default" assumption the deleted tactical code
# relied on. Tighten once p0-??-diplomacy lands.
var relations: Array = []
for other: RefCounted in GameState.players:
if other == null:
continue
relations.append(0 if int(other.index) == slot else -1)
# Personality axes for tactical::thresholds (p0-37). Emerges posture-flip,
# retreat, chase, siege, and final-push thresholds from clan personality
# instead of a flat global constant.
var axes: Dictionary = (
p.strategic_axes
if "strategic_axes" in p and not p.strategic_axes.is_empty()
else _load_clan_axes(String(p.clan_id) if "clan_id" in p else "")
)
# Race id (for race-gated unit selection, p0-39).
var race_id: String = (String(p.race_id) if "race_id" in p else "")
# Strategic resources the player currently controls — collected by
# scanning owned tiles' `resource_id` for entries tagged as strategic in
# `resources.json`. Consumed by `tactical::production::pick_best_melee`
# to filter units like cavalry (requires iron_ore).
var strategic_resources: Array = _collect_strategic_resources(p)
return {
"index": slot,
"clan_id": (String(p.clan_id) if "clan_id" in p else ""),
"gold": int(p.gold),
"happiness_pool": (int(p.happiness_pool) if "happiness_pool" in p else 0),
"units": units, "cities": cities,
"researched_techs": techs, "relations": relations,
"strategic_axes": axes,
"race_id": (race_id if not race_id.is_empty() else null),
"strategic_resources": strategic_resources,
}
static func _collect_strategic_resources(p: RefCounted) -> Array:
# Scan the player's owned tiles (via cities' worked + fat-cross tiles) and
# collect unique strategic resource ids. Lightweight; runs once per AI
# player per turn at JSON build time.
var seen: Dictionary = {}
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return []
for city: RefCounted in p.cities:
if city == null:
continue
# Iterate the city's owned-tile set. Cities expose `owned_tiles` as
# Array[Vector2i] in Game 1 scope.
var owned: Array = []
if "owned_tiles" in city and city.owned_tiles != null:
owned = Array(city.owned_tiles)
for coord: Vector2i in owned:
var tile: Resource = game_map.get_tile(coord)
if tile == null:
continue
var rid: String = String(tile.resource_id)
if rid.is_empty():
continue
# Treat every resource on an owned tile as available. The
# engine-side strategic-gate check still enforces the real rule
# at unit-production time; this list is an AI-hint, not the gate.
seen[rid] = true
var out: Array = []
for rid: String in seen.keys():
out.append(rid)
return out
static func _load_difficulty_threshold_mult() -> float:
var diff_id: String = str(GameState.game_settings.get("difficulty", "normal"))
var diff_data: Dictionary = DataLoader.get_data("difficulty")
if diff_data == null:
return 1.0
for entry: Dictionary in diff_data.get("ai_difficulty", []):
if entry.get("id", "") == diff_id:
return float(entry.get("ai_modifiers", {}).get("difficulty_threshold_mult", 1.0))
return 1.0
static func _load_clan_axes(clan_id: String) -> Dictionary:
if clan_id.is_empty():
return {}
var data: Dictionary = DataLoader.get_data("ai_personalities")
if data == null:
return {}
var entry: Dictionary = data.get(clan_id, {})
if entry == null:
return {}
return entry.get("strategic_axes", {})
static func _build_formations_for_player(p: RefCounted) -> Array:
# Cluster alive military units into adjacency-connected components (hex distance ≤ 1).
# Groups of 2+ become AiFormationState dicts for the MCTS tree.
var alive_units: Array = []
for u: RefCounted in p.units:
if u == null or not u.is_alive():
continue
var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "")
var udata: Dictionary = DataLoader.get_unit(uid_str)
if udata.get("unit_type", "") != "military":
continue
alive_units.append(u)
var visited: Array[bool] = []
visited.resize(alive_units.size())
var next_fid: int = 0
var formations: Array = []
for i: int in alive_units.size():
if visited[i]:
continue
var component: Array[int] = [i]
visited[i] = true
var queue: Array[int] = [i]
while not queue.is_empty():
var qi: int = queue.pop_back()
var ua: RefCounted = alive_units[qi]
var ax: float = float(ua.position.x)
var ay: float = float(ua.position.y)
for j: int in alive_units.size():
if visited[j]:
continue
var ub: RefCounted = alive_units[j]
var bx: float = float(ub.position.x)
var by: float = float(ub.position.y)
# Hex cube-distance ≤ 1 (axial coords)
var dq: float = bx - ax
var dr: float = by - ay
var ds: float = -dq - dr
if maxf(maxf(absf(dq), absf(dr)), absf(ds)) <= 1.0:
visited[j] = true
component.append(j)
queue.append(j)
if component.size() < 2:
continue
# Compute tier_max for the formation
var tier_max: int = 0
var leader_hex: Array = [0, 0]
for idx: int in component:
var u: RefCounted = alive_units[idx]
var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "")
var tier: int = int(DataLoader.get_unit(uid_str).get("tier", 1))
if tier > tier_max:
tier_max = tier
leader_hex = [int(u.position.x), int(u.position.y)]
formations.append({
"formation_id": next_fid,
"size": component.size(),
"tier_max": tier_max,
"command": "Defend",
"hex": leader_hex,
})
next_fid += 1
if not formations.is_empty():
var sizes: Array = []
var tiers: Array = []
for f: Dictionary in formations:
sizes.append(int(f.get("size", 0)))
tiers.append(int(f.get("tier_max", 0)))
print("AiTurnBridge: formations turn=%d player=%d count=%d sizes=%s tiers=%s" % [
GameState.turn_number, int(p.index), formations.size(), str(sizes), str(tiers)
])
return formations
# ── Action dispatch ──────────────────────────────────────────────────────────
static func _dispatch_action(
action_str: String, player: RefCounted, index_maps: Dictionary
) -> bool:
var action: Dictionary = JSON.parse_string(action_str) as Dictionary
if action == null or action.is_empty():
push_warning("AiTurnBridge: malformed action JSON: %s" % action_str)
return false
# Externally-tagged serde: {"<Variant>": {fields...}}.
var variant: String = String(action.keys()[0])
var fields: Dictionary = action[variant] as Dictionary
if fields == null:
return false
match variant:
"MoveUnit", "Scout":
return _dispatch_move(fields, index_maps)
"AttackTarget":
return _dispatch_attack(fields, index_maps)
"Fortify":
return _dispatch_fortify(fields, index_maps)
"Heal":
return _dispatch_heal(fields, index_maps)
"FoundCity":
return _dispatch_found_city(fields, player, index_maps)
"SetProduction":
return _dispatch_set_production(fields, index_maps)
"AssignCitizen":
# Engine has no per-citizen worked-tile model yet; ack and drop.
return false
push_warning("AiTurnBridge: unknown action variant '%s'" % variant)
return false
static func _resolve_unit(uid: int, index_maps: Dictionary) -> RefCounted:
return (index_maps.get("units", {}) as Dictionary).get(uid)
static func _resolve_city(cid: int, index_maps: Dictionary) -> RefCounted:
return (index_maps.get("cities", {}) as Dictionary).get(cid)
static func _dispatch_move(fields: Dictionary, index_maps: Dictionary) -> bool:
var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps)
if unit == null or not unit.is_alive():
return false
var to_hex: Array = fields.get("to_hex", [])
if to_hex.size() != 2:
return false
var to: Vector2i = Vector2i(int(to_hex[0]), int(to_hex[1]))
# mc-ai::tactical::movement encodes attacks as MoveUnit onto an enemy
# hex — the Action enum has no AttackHex variant. Mirror the pre-port
# GDScript "move into enemy hex = attack" convention so we don't
# teleport through them.
var enemy_defender: RefCounted = _find_enemy_at(to, int(unit.owner))
if enemy_defender != null:
return _resolve_move_as_attack(unit, enemy_defender)
var enemy_city: RefCounted = CombatUtilsScript.get_city_at(to)
if enemy_city != null and int(enemy_city.owner) != int(unit.owner):
return _resolve_move_as_attack(unit, enemy_city)
var from: Vector2i = unit.position
unit.position = to
unit.movement_remaining = maxi(0, unit.movement_remaining - 1)
EventBus.unit_moved.emit(unit, from, to)
return true
static func _find_enemy_at(pos: Vector2i, attacker_owner: int) -> RefCounted:
var primary: Dictionary = GameState.get_primary_layer()
var all_units: Array = primary.get("units", [])
for u: RefCounted in all_units:
if u == null or not u.is_alive():
continue
if u.position != pos:
continue
if int(u.owner) == attacker_owner:
continue
return u
return null
## Resolve a combat round initiated by MoveUnit onto an enemy-held hex.
## Mirrors `_dispatch_attack` but takes the already-resolved attacker + defender
## RefCounteds directly, skipping the attacker_id / target_id re-lookup.
static func _resolve_move_as_attack(attacker: RefCounted, defender: RefCounted) -> bool:
if attacker.movement_remaining <= 0:
return false
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return false
var primary: Dictionary = GameState.get_primary_layer()
var all_units: Array = primary.get("units", [])
var resolver: RefCounted = CombatResolverScript.new()
resolver.resolve(attacker, defender, game_map, all_units)
attacker.movement_remaining = 0
return true
static func _dispatch_attack(fields: Dictionary, index_maps: Dictionary) -> bool:
var attacker: RefCounted = _resolve_unit(
int(fields.get("attacker_id", -1)), index_maps
)
if attacker == null or not attacker.is_alive() or attacker.movement_remaining <= 0:
return false
var defender: RefCounted = _resolve_unit(
int(fields.get("target_id", -1)), index_maps
)
if defender == null or not defender.is_alive():
return false
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return false
var primary: Dictionary = GameState.get_primary_layer()
var all_units: Array = primary.get("units", [])
var resolver: RefCounted = CombatResolverScript.new()
resolver.resolve(attacker, defender, game_map, all_units)
attacker.movement_remaining = 0
return true
static func _dispatch_fortify(fields: Dictionary, index_maps: Dictionary) -> bool:
var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps)
if unit == null or not unit.is_alive():
return false
unit.is_fortified = true
unit.fortified_turns = int(unit.fortified_turns) + 1
unit.movement_remaining = 0
return true
static func _dispatch_heal(fields: Dictionary, index_maps: Dictionary) -> bool:
var unit: RefCounted = _resolve_unit(int(fields.get("unit_id", -1)), index_maps)
if unit == null or not unit.is_alive():
return false
# Heal = skip turn so the engine's turn-advance heal step can fire.
unit.movement_remaining = 0
return true
static func _dispatch_found_city(
fields: Dictionary, player: RefCounted, index_maps: Dictionary
) -> bool:
var settler: RefCounted = _resolve_unit(
int(fields.get("settler_id", -1)), index_maps
)
if settler == null or not settler.is_alive() or not settler.can_found_city:
return false
var at_hex: Array = fields.get("at_hex", [])
if at_hex.size() != 2:
return false
if settler.position != Vector2i(int(at_hex[0]), int(at_hex[1])):
# Engine founds at the settler's current hex. Stepping onto the
# target is the movement submodule's job; reject mismatches rather
# than teleporting the settler.
return false
var city: RefCounted = CityScript.new()
city.player = player
city.owner = player.index
city.found(
_generate_city_name(player),
settler.position.x, settler.position.y,
player.cities.is_empty(),
GameState.turn_number,
)
player.cities.append(city)
player.units.erase(settler)
var primary: Dictionary = GameState.get_primary_layer()
primary.get("units", []).erase(settler)
EventBus.unit_destroyed.emit(settler, null)
EventBus.city_founded.emit(city, player.index)
return true
static func _dispatch_set_production(
fields: Dictionary, index_maps: Dictionary
) -> bool:
var city: RefCounted = _resolve_city(int(fields.get("city_id", -1)), index_maps)
if city == null:
return false
var item_id: String = String(fields.get("item_id", ""))
if item_id.is_empty():
return false
# Rust Action doesn't carry a unit-vs-building tag. DataLoader
# disambiguates — unit wins on tie because authored data reserves unit
# ids colliding with building ids for the upgrade-in-place flow.
var udata: Dictionary = DataLoader.get_unit(item_id)
if not udata.is_empty():
city.production_queue = [{"type": "unit", "id": item_id, "cost": int(udata.get("cost", 0))}]
city.production_progress = 0
return true
var bdata: Dictionary = DataLoader.get_building(item_id)
if not bdata.is_empty():
city.production_queue = [{"type": "building", "id": item_id, "cost": int(bdata.get("cost", 0))}]
city.production_progress = 0
return true
return false
# ── MCTS directive helpers ───────────────────────────────────────────────────
@ -786,7 +153,9 @@ static func _queue_settler(player: RefCounted) -> void:
if founder_id.is_empty():
return
var udata: Dictionary = DataLoader.get_unit(founder_id)
city.production_queue = [{"type": "unit", "id": founder_id, "cost": int(udata.get("cost", 0))}]
city.production_queue = [
{"type": "unit", "id": founder_id, "cost": int(udata.get("cost", 0))}
]
city.production_progress = 0
@ -798,7 +167,9 @@ static func _queue_military(player: RefCounted) -> void:
if mil_id.is_empty():
return
var udata: Dictionary = DataLoader.get_unit(mil_id)
city.production_queue = [{"type": "unit", "id": mil_id, "cost": int(udata.get("cost", 0))}]
city.production_queue = [
{"type": "unit", "id": mil_id, "cost": int(udata.get("cost", 0))}
]
city.production_progress = 0
@ -812,9 +183,6 @@ static func _find_unit_type_by_flag(player: RefCounted, flag: String) -> String:
return ""
## Pick the cheapest buildable non-settler unit from the race's start_units.
## Inlined from the deleted simple_heuristic_ai.gd so the MCTS `SpawnUnit`
## directive has a concrete unit id to enqueue.
static func _pick_buildable_military_unit_id(city: RefCounted, player: RefCounted) -> String:
var race_data: Dictionary = DataLoader.get_race(player.race_id)
var start_units: Array = race_data.get("start_units", [])