🔥 remove(@projects/@magic-civilization): delete stale auto-play engine script
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
e80f744fd9
commit
b0eeeb819c
2 changed files with 28 additions and 2928 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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", [])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue