feat(ai): ✨ add ai action dispatch bridge logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5f04b0bb08
commit
e80f744fd9
6 changed files with 667 additions and 86 deletions
185
src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd
Normal file
185
src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
extends RefCounted
|
||||
## Action dispatch helpers for AiTurnBridge.
|
||||
## All methods are static. Receives index_maps built by AiTurnBridgeState.
|
||||
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
const CombatResolverScript: GDScript = preload(
|
||||
"res://engine/src/modules/combat/combat_resolver.gd"
|
||||
)
|
||||
const CombatUtilsScript: GDScript = preload(
|
||||
"res://engine/src/modules/combat/combat_utils.gd"
|
||||
)
|
||||
|
||||
|
||||
static func dispatch_action(
|
||||
action_str: String, player: RefCounted, index_maps: Dictionary, city_name: String
|
||||
) -> bool:
|
||||
var action: Dictionary = JSON.parse_string(action_str) as Dictionary
|
||||
if action == null or action.is_empty():
|
||||
push_warning("AiTurnBridge: malformed action JSON: %s" % action_str)
|
||||
return false
|
||||
var variant: String = String(action.keys()[0])
|
||||
var fields: Dictionary = action[variant] as Dictionary
|
||||
if fields == null:
|
||||
return false
|
||||
match variant:
|
||||
"MoveUnit", "Scout":
|
||||
return dispatch_move(fields, index_maps)
|
||||
"AttackTarget":
|
||||
return dispatch_attack(fields, index_maps)
|
||||
"Fortify":
|
||||
return dispatch_fortify(fields, index_maps)
|
||||
"Heal":
|
||||
return dispatch_heal(fields, index_maps)
|
||||
"FoundCity":
|
||||
return dispatch_found_city(fields, player, index_maps, city_name)
|
||||
"SetProduction":
|
||||
return dispatch_set_production(fields, index_maps)
|
||||
"AssignCitizen":
|
||||
return false
|
||||
push_warning("AiTurnBridge: unknown action variant '%s'" % variant)
|
||||
return false
|
||||
|
||||
|
||||
static func resolve_unit(uid: int, index_maps: Dictionary) -> RefCounted:
|
||||
return (index_maps.get("units", {}) as Dictionary).get(uid)
|
||||
|
||||
|
||||
static func resolve_city(cid: int, index_maps: Dictionary) -> RefCounted:
|
||||
return (index_maps.get("cities", {}) as Dictionary).get(cid)
|
||||
|
||||
|
||||
static func dispatch_move(fields: Dictionary, index_maps: Dictionary) -> bool:
|
||||
var unit: RefCounted = resolve_unit(int(fields.get("unit_id", -1)), index_maps)
|
||||
if unit == null or not unit.is_alive():
|
||||
return false
|
||||
var to_hex: Array = fields.get("to_hex", [])
|
||||
if to_hex.size() != 2:
|
||||
return false
|
||||
var to: Vector2i = Vector2i(int(to_hex[0]), int(to_hex[1]))
|
||||
var enemy_defender: RefCounted = find_enemy_at(to, int(unit.owner))
|
||||
if enemy_defender != null:
|
||||
return resolve_move_as_attack(unit, enemy_defender)
|
||||
var enemy_city: RefCounted = CombatUtilsScript.get_city_at(to)
|
||||
if enemy_city != null and int(enemy_city.owner) != int(unit.owner):
|
||||
return resolve_move_as_attack(unit, enemy_city)
|
||||
var from: Vector2i = unit.position
|
||||
unit.position = to
|
||||
unit.movement_remaining = maxi(0, unit.movement_remaining - 1)
|
||||
EventBus.unit_moved.emit(unit, from, to)
|
||||
return true
|
||||
|
||||
|
||||
static func find_enemy_at(pos: Vector2i, attacker_owner: int) -> RefCounted:
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var all_units: Array = primary.get("units", [])
|
||||
for u: RefCounted in all_units:
|
||||
if u == null or not u.is_alive():
|
||||
continue
|
||||
if u.position != pos:
|
||||
continue
|
||||
if int(u.owner) == attacker_owner:
|
||||
continue
|
||||
return u
|
||||
return null
|
||||
|
||||
|
||||
static func resolve_move_as_attack(attacker: RefCounted, defender: RefCounted) -> bool:
|
||||
if attacker.movement_remaining <= 0:
|
||||
return false
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return false
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var all_units: Array = primary.get("units", [])
|
||||
var resolver: RefCounted = CombatResolverScript.new()
|
||||
resolver.resolve(attacker, defender, game_map, all_units)
|
||||
attacker.movement_remaining = 0
|
||||
return true
|
||||
|
||||
|
||||
static func dispatch_attack(fields: Dictionary, index_maps: Dictionary) -> bool:
|
||||
var attacker: RefCounted = resolve_unit(int(fields.get("attacker_id", -1)), index_maps)
|
||||
if attacker == null or not attacker.is_alive() or attacker.movement_remaining <= 0:
|
||||
return false
|
||||
var defender: RefCounted = resolve_unit(int(fields.get("target_id", -1)), index_maps)
|
||||
if defender == null or not defender.is_alive():
|
||||
return false
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return false
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
var all_units: Array = primary.get("units", [])
|
||||
var resolver: RefCounted = CombatResolverScript.new()
|
||||
resolver.resolve(attacker, defender, game_map, all_units)
|
||||
attacker.movement_remaining = 0
|
||||
return true
|
||||
|
||||
|
||||
static func dispatch_fortify(fields: Dictionary, index_maps: Dictionary) -> bool:
|
||||
var unit: RefCounted = resolve_unit(int(fields.get("unit_id", -1)), index_maps)
|
||||
if unit == null or not unit.is_alive():
|
||||
return false
|
||||
unit.is_fortified = true
|
||||
unit.fortified_turns = int(unit.fortified_turns) + 1
|
||||
unit.movement_remaining = 0
|
||||
return true
|
||||
|
||||
|
||||
static func dispatch_heal(fields: Dictionary, index_maps: Dictionary) -> bool:
|
||||
var unit: RefCounted = resolve_unit(int(fields.get("unit_id", -1)), index_maps)
|
||||
if unit == null or not unit.is_alive():
|
||||
return false
|
||||
unit.movement_remaining = 0
|
||||
return true
|
||||
|
||||
|
||||
static func dispatch_found_city(
|
||||
fields: Dictionary, player: RefCounted, index_maps: Dictionary, city_name: String
|
||||
) -> bool:
|
||||
var settler: RefCounted = resolve_unit(int(fields.get("settler_id", -1)), index_maps)
|
||||
if settler == null or not settler.is_alive() or not settler.can_found_city:
|
||||
return false
|
||||
var at_hex: Array = fields.get("at_hex", [])
|
||||
if at_hex.size() != 2:
|
||||
return false
|
||||
if settler.position != Vector2i(int(at_hex[0]), int(at_hex[1])):
|
||||
return false
|
||||
var city: RefCounted = CityScript.new()
|
||||
city.player = player
|
||||
city.owner = player.index
|
||||
city.found(
|
||||
city_name,
|
||||
settler.position.x, settler.position.y,
|
||||
player.cities.is_empty(),
|
||||
GameState.turn_number,
|
||||
)
|
||||
player.cities.append(city)
|
||||
player.units.erase(settler)
|
||||
var primary: Dictionary = GameState.get_primary_layer()
|
||||
primary.get("units", []).erase(settler)
|
||||
EventBus.unit_destroyed.emit(settler, null)
|
||||
EventBus.city_founded.emit(city, player.index)
|
||||
return true
|
||||
|
||||
|
||||
static func dispatch_set_production(fields: Dictionary, index_maps: Dictionary) -> bool:
|
||||
var city: RefCounted = resolve_city(int(fields.get("city_id", -1)), index_maps)
|
||||
if city == null:
|
||||
return false
|
||||
var item_id: String = String(fields.get("item_id", ""))
|
||||
if item_id.is_empty():
|
||||
return false
|
||||
var udata: Dictionary = DataLoader.get_unit(item_id)
|
||||
if not udata.is_empty():
|
||||
city.production_queue = [{"type": "unit", "id": item_id, "cost": int(udata.get("cost", 0))}]
|
||||
city.production_progress = 0
|
||||
return true
|
||||
var bdata: Dictionary = DataLoader.get_building(item_id)
|
||||
if not bdata.is_empty():
|
||||
city.production_queue = [
|
||||
{"type": "building", "id": item_id, "cost": int(bdata.get("cost", 0))}
|
||||
]
|
||||
city.production_progress = 0
|
||||
return true
|
||||
return false
|
||||
368
src/game/engine/src/modules/ai/ai_turn_bridge_state.gd
Normal file
368
src/game/engine/src/modules/ai/ai_turn_bridge_state.gd
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
extends RefCounted
|
||||
## State-serialization helpers for AiTurnBridge.
|
||||
## Builds the JSON dicts consumed by GdMcTreeController and GdAiController.
|
||||
|
||||
const ID_STRIDE: int = 10000
|
||||
|
||||
|
||||
static func build_mc_tree_state(ctrl: RefCounted, personalities_json: String) -> Dictionary:
|
||||
var player_list: Array = []
|
||||
for p: RefCounted in GameState.players:
|
||||
if p == null:
|
||||
continue
|
||||
var city_list: Array = []
|
||||
var city_positions: Array = []
|
||||
for c: RefCounted in p.cities:
|
||||
if c == null:
|
||||
continue
|
||||
city_list.append({
|
||||
"population": maxi(1, int(c.population)),
|
||||
"food_stored": 0, "production_stored": 0,
|
||||
"food_yield": 2, "prod_yield": 2,
|
||||
})
|
||||
city_positions.append([int(c.position.x), int(c.position.y)])
|
||||
var unit_list: Array = []
|
||||
for u: RefCounted in p.units:
|
||||
if u == null or not u.is_alive():
|
||||
continue
|
||||
unit_list.append({
|
||||
"col": int(u.position.x), "row": int(u.position.y),
|
||||
"hp": int(u.hp), "max_hp": int(u.max_hp),
|
||||
"attack": int(u.attack), "defense": int(u.defense),
|
||||
"is_fortified": false, "unit_id": str(u.unit_id),
|
||||
})
|
||||
var axes: Dictionary = (p.strategic_axes
|
||||
if not p.strategic_axes.is_empty()
|
||||
else {"expansion": 2, "production": 2, "wealth": 2})
|
||||
var clan: String = str(p.clan_id) if "clan_id" in p else ""
|
||||
var weights_json: String = (
|
||||
ctrl.scoring_weights_for_clan_json(clan, personalities_json)
|
||||
if ctrl != null and not clan.is_empty() and not personalities_json.is_empty()
|
||||
else "{}"
|
||||
)
|
||||
var weights: Dictionary = JSON.parse_string(weights_json) as Dictionary
|
||||
if weights == null:
|
||||
weights = {}
|
||||
var formation_list: Array = build_formations_for_player(p)
|
||||
player_list.append({
|
||||
"player_index": int(p.index), "gold": int(p.gold),
|
||||
"cities": city_list, "unit_upkeep": [],
|
||||
"strategic_axes": axes, "scoring_weights": weights,
|
||||
"expansion_points": 0, "city_buildings": [], "city_ecology": [],
|
||||
"science_yield": 0, "units": unit_list,
|
||||
"city_positions": city_positions,
|
||||
"capital_position": (city_positions[0] if not city_positions.is_empty() else null),
|
||||
"culture_total": int(p.culture_total),
|
||||
"arcane_lore_pop_deducted": false,
|
||||
"formations": formation_list,
|
||||
})
|
||||
return {"turn": GameState.turn_number, "players": player_list, "grid": null}
|
||||
|
||||
|
||||
static func build_index_maps() -> Dictionary:
|
||||
var units: Dictionary = {}
|
||||
var cities: Dictionary = {}
|
||||
for p: RefCounted in GameState.players:
|
||||
if p == null:
|
||||
continue
|
||||
var base: int = int(p.index) * ID_STRIDE
|
||||
var ui: int = 0
|
||||
for u: RefCounted in p.units:
|
||||
if u == null or not u.is_alive():
|
||||
continue
|
||||
units[base + ui] = u
|
||||
ui += 1
|
||||
var ci: int = 0
|
||||
for c: RefCounted in p.cities:
|
||||
if c == null:
|
||||
continue
|
||||
cities[base + ci] = c
|
||||
ci += 1
|
||||
return {"units": units, "cities": cities}
|
||||
|
||||
|
||||
static func build_tactical_state(focal: RefCounted) -> Dictionary:
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
var tiles: Array = []
|
||||
if game_map != null:
|
||||
width = int(game_map.width)
|
||||
height = int(game_map.height)
|
||||
for row: int in range(height):
|
||||
for col: int in range(width):
|
||||
tiles.append(tile_to_dict(game_map, col, row))
|
||||
var players: Array = []
|
||||
for p: RefCounted in GameState.players:
|
||||
if p == null:
|
||||
continue
|
||||
players.append(player_to_dict(p))
|
||||
return {
|
||||
"current_player": int(focal.index),
|
||||
"turn": int(GameState.turn_number),
|
||||
"map": {"width": width, "height": height, "tiles": tiles},
|
||||
"players": players,
|
||||
"unit_catalog": build_unit_catalog(),
|
||||
"difficulty_threshold_mult": load_difficulty_threshold_mult(),
|
||||
}
|
||||
|
||||
|
||||
static func build_unit_catalog() -> Array:
|
||||
var out: Array = []
|
||||
var data: Dictionary = DataLoader.get_data("units")
|
||||
if data == null:
|
||||
return out
|
||||
for uid: String in data.keys():
|
||||
if not (data[uid] is Dictionary):
|
||||
continue
|
||||
var entry: Dictionary = data[uid]
|
||||
if entry.is_empty():
|
||||
continue
|
||||
if not entry.has("id") and not entry.has("tier"):
|
||||
continue
|
||||
var tech_raw: String = dict_string_field(entry, "tech_required")
|
||||
var resource_raw: String = dict_string_field(entry, "requires_resource")
|
||||
var race_raw: String = dict_string_field(entry, "race_required")
|
||||
var gate_fields: Dictionary = {
|
||||
"tech_required": null,
|
||||
"requires_resource": null,
|
||||
"race_required": null,
|
||||
}
|
||||
if not tech_raw.is_empty():
|
||||
gate_fields["tech_required"] = tech_raw
|
||||
if not resource_raw.is_empty():
|
||||
gate_fields["requires_resource"] = resource_raw
|
||||
if not race_raw.is_empty():
|
||||
gate_fields["race_required"] = race_raw
|
||||
var tier_val: int = 1
|
||||
if entry.has("tier") and (entry["tier"] is int or entry["tier"] is float):
|
||||
tier_val = int(entry["tier"])
|
||||
var id_val: String = dict_string_field(entry, "id")
|
||||
if id_val.is_empty():
|
||||
id_val = uid
|
||||
var type_val: String = dict_string_field(entry, "unit_type")
|
||||
if type_val.is_empty():
|
||||
type_val = "military"
|
||||
var item: Dictionary = {"id": id_val, "tier": tier_val, "unit_type": type_val}
|
||||
item.merge(gate_fields)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
static func dict_string_field(entry: Dictionary, key: String) -> String:
|
||||
if not entry.has(key):
|
||||
return ""
|
||||
if entry[key] is String:
|
||||
return entry[key]
|
||||
if entry[key] is StringName:
|
||||
return String(entry[key])
|
||||
return ""
|
||||
|
||||
|
||||
static func tile_to_dict(game_map: RefCounted, col: int, row: int) -> Dictionary:
|
||||
var tile: Resource = game_map.get_tile(Vector2i(col, row))
|
||||
if tile == null:
|
||||
return {
|
||||
"hex": [col, row], "biome": "", "yields": [0, 0, 0],
|
||||
"resource": null, "is_coast": false, "owner": null,
|
||||
}
|
||||
var yields: Dictionary = tile.get_yields(-1)
|
||||
var resource: String = String(tile.resource_id)
|
||||
return {
|
||||
"hex": [col, row],
|
||||
"biome": String(tile.biome_id),
|
||||
"yields": [
|
||||
maxi(0, int(yields.get("food", 0))),
|
||||
maxi(0, int(yields.get("production", 0))),
|
||||
maxi(0, int(yields.get("trade", 0))),
|
||||
],
|
||||
"resource": (resource if not resource.is_empty() else null),
|
||||
"is_coast": bool(tile.is_coastal),
|
||||
"owner": (int(tile.owner) if int(tile.owner) >= 0 else null),
|
||||
}
|
||||
|
||||
|
||||
static func player_to_dict(p: RefCounted) -> Dictionary:
|
||||
var slot: int = int(p.index)
|
||||
var base: int = slot * ID_STRIDE
|
||||
var units: Array = []
|
||||
var ui: int = 0
|
||||
for u: RefCounted in p.units:
|
||||
if u == null or not u.is_alive():
|
||||
continue
|
||||
units.append({
|
||||
"id": base + ui,
|
||||
"kind": String(u.unit_id),
|
||||
"hex": [int(u.position.x), int(u.position.y)],
|
||||
"hp": maxi(0, int(u.hp)),
|
||||
"hp_max": maxi(1, int(u.max_hp)),
|
||||
"moves_left": maxi(0, int(u.movement_remaining)),
|
||||
"fortified": bool(u.is_fortified),
|
||||
"can_found_city": bool(u.get("can_found_city") == true),
|
||||
})
|
||||
ui += 1
|
||||
var cities: Array = []
|
||||
var ci: int = 0
|
||||
for c: RefCounted in p.cities:
|
||||
if c == null:
|
||||
continue
|
||||
var queue_ids: Array = []
|
||||
for entry: Dictionary in c.production_queue:
|
||||
var item_id: String = String(entry.get("id", ""))
|
||||
if not item_id.is_empty():
|
||||
queue_ids.append(item_id)
|
||||
var health: int = 25
|
||||
if "hp" in c:
|
||||
health = maxi(0, int(c.hp))
|
||||
cities.append({
|
||||
"id": base + ci,
|
||||
"hex": [int(c.position.x), int(c.position.y)],
|
||||
"population": maxi(0, int(c.population)),
|
||||
"tiles_worked": [],
|
||||
"production_queue": queue_ids,
|
||||
"buildings": Array(c.buildings),
|
||||
"health": health,
|
||||
"is_capital": bool(c.is_capital),
|
||||
})
|
||||
ci += 1
|
||||
var techs: Array = Array(p.researched_techs)
|
||||
var relations: Array = []
|
||||
for other: RefCounted in GameState.players:
|
||||
if other == null:
|
||||
continue
|
||||
relations.append(0 if int(other.index) == slot else -1)
|
||||
var axes: Dictionary = (
|
||||
p.strategic_axes
|
||||
if "strategic_axes" in p and not p.strategic_axes.is_empty()
|
||||
else load_clan_axes(String(p.clan_id) if "clan_id" in p else "")
|
||||
)
|
||||
var race_id: String = (String(p.race_id) if "race_id" in p else "")
|
||||
var strategic_resources: Array = collect_strategic_resources(p)
|
||||
return {
|
||||
"index": slot,
|
||||
"clan_id": (String(p.clan_id) if "clan_id" in p else ""),
|
||||
"gold": int(p.gold),
|
||||
"happiness_pool": (int(p.happiness_pool) if "happiness_pool" in p else 0),
|
||||
"units": units, "cities": cities,
|
||||
"researched_techs": techs, "relations": relations,
|
||||
"strategic_axes": axes,
|
||||
"race_id": (race_id if not race_id.is_empty() else null),
|
||||
"strategic_resources": strategic_resources,
|
||||
}
|
||||
|
||||
|
||||
static func collect_strategic_resources(p: RefCounted) -> Array:
|
||||
var seen: Dictionary = {}
|
||||
var game_map: RefCounted = GameState.get_game_map()
|
||||
if game_map == null:
|
||||
return []
|
||||
for city: RefCounted in p.cities:
|
||||
if city == null:
|
||||
continue
|
||||
var owned: Array = []
|
||||
if "owned_tiles" in city and city.owned_tiles != null:
|
||||
owned = Array(city.owned_tiles)
|
||||
for coord: Vector2i in owned:
|
||||
var tile: Resource = game_map.get_tile(coord)
|
||||
if tile == null:
|
||||
continue
|
||||
var rid: String = String(tile.resource_id)
|
||||
if rid.is_empty():
|
||||
continue
|
||||
seen[rid] = true
|
||||
var out: Array = []
|
||||
for rid: String in seen.keys():
|
||||
out.append(rid)
|
||||
return out
|
||||
|
||||
|
||||
static func load_difficulty_threshold_mult() -> float:
|
||||
var diff_id: String = str(GameState.game_settings.get("difficulty", "normal"))
|
||||
var diff_data: Dictionary = DataLoader.get_data("difficulty")
|
||||
if diff_data == null:
|
||||
return 1.0
|
||||
for entry: Dictionary in diff_data.get("ai_difficulty", []):
|
||||
if entry.get("id", "") == diff_id:
|
||||
return float(entry.get("ai_modifiers", {}).get("difficulty_threshold_mult", 1.0))
|
||||
return 1.0
|
||||
|
||||
|
||||
static func load_clan_axes(clan_id: String) -> Dictionary:
|
||||
if clan_id.is_empty():
|
||||
return {}
|
||||
var data: Dictionary = DataLoader.get_data("ai_personalities")
|
||||
if data == null:
|
||||
return {}
|
||||
var entry: Dictionary = data.get(clan_id, {})
|
||||
if entry == null:
|
||||
return {}
|
||||
return entry.get("strategic_axes", {})
|
||||
|
||||
|
||||
static func build_formations_for_player(p: RefCounted) -> Array:
|
||||
var alive_units: Array = []
|
||||
for u: RefCounted in p.units:
|
||||
if u == null or not u.is_alive():
|
||||
continue
|
||||
var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "")
|
||||
var udata: Dictionary = DataLoader.get_unit(uid_str)
|
||||
if udata.get("unit_type", "") != "military":
|
||||
continue
|
||||
alive_units.append(u)
|
||||
var visited: Array[bool] = []
|
||||
visited.resize(alive_units.size())
|
||||
var next_fid: int = 0
|
||||
var formations: Array = []
|
||||
for i: int in alive_units.size():
|
||||
if visited[i]:
|
||||
continue
|
||||
var component: Array[int] = [i]
|
||||
visited[i] = true
|
||||
var queue: Array[int] = [i]
|
||||
while not queue.is_empty():
|
||||
var qi: int = queue.pop_back()
|
||||
var ua: RefCounted = alive_units[qi]
|
||||
var ax: float = float(ua.position.x)
|
||||
var ay: float = float(ua.position.y)
|
||||
for j: int in alive_units.size():
|
||||
if visited[j]:
|
||||
continue
|
||||
var ub: RefCounted = alive_units[j]
|
||||
var bx: float = float(ub.position.x)
|
||||
var by: float = float(ub.position.y)
|
||||
var dq: float = bx - ax
|
||||
var dr: float = by - ay
|
||||
var ds: float = -dq - dr
|
||||
if maxf(maxf(absf(dq), absf(dr)), absf(ds)) <= 1.0:
|
||||
visited[j] = true
|
||||
component.append(j)
|
||||
queue.append(j)
|
||||
if component.size() < 2:
|
||||
continue
|
||||
var tier_max: int = 0
|
||||
var leader_hex: Array = [0, 0]
|
||||
for idx: int in component:
|
||||
var u: RefCounted = alive_units[idx]
|
||||
var uid_str: String = str(u.get("unit_id") if "unit_id" in u else "")
|
||||
var tier: int = int(DataLoader.get_unit(uid_str).get("tier", 1))
|
||||
if tier > tier_max:
|
||||
tier_max = tier
|
||||
leader_hex = [int(u.position.x), int(u.position.y)]
|
||||
formations.append({
|
||||
"formation_id": next_fid,
|
||||
"size": component.size(),
|
||||
"tier_max": tier_max,
|
||||
"command": "Defend",
|
||||
"hex": leader_hex,
|
||||
})
|
||||
next_fid += 1
|
||||
if not formations.is_empty():
|
||||
var sizes: Array = []
|
||||
var tiers: Array = []
|
||||
for f: Dictionary in formations:
|
||||
sizes.append(int(f.get("size", 0)))
|
||||
tiers.append(int(f.get("tier_max", 0)))
|
||||
print("AiTurnBridge: formations turn=%d player=%d count=%d sizes=%s tiers=%s" % [
|
||||
GameState.turn_number, int(p.index), formations.size(), str(sizes), str(tiers)
|
||||
])
|
||||
return formations
|
||||
|
|
@ -1,18 +1,10 @@
|
|||
# gdlint: disable=no-elif-return,no-else-return,max-returns,class-definitions-order
|
||||
extends RefCounted
|
||||
## End-of-turn processing. Per-player and global _process_* logic.
|
||||
## Arena task #2 restored the four visible methods (production, growth,
|
||||
## healing, economy). Still known-broken and out of scope: _process_culture,
|
||||
## _process_golden_age, _process_loot_decay, _process_spell_system,
|
||||
## _process_government — all blocked on empty module stubs.
|
||||
## Calls disabled in turn_manager.gd::next_player (Diplomacy.process_turn,
|
||||
## EconomyScript.apply_protection_effects) and turn_processor.gd::_process_*
|
||||
## until these modules are rebuilt. `_process_climate` now runs the full
|
||||
## marine_harvest → climate → weather → climate_effects chain (ClimateScript
|
||||
## bugs fixed by p0-31, WeatherScript + ClimateEffectsScript implemented by
|
||||
## p0-32 as thin marshalers over `GdWeatherPhysics` and
|
||||
## `GdClimateEffectsPhysics`). Grep for `DISABLED:` to find every remaining
|
||||
## guarded call site.
|
||||
## DISABLED stubs: _process_culture, _process_golden_age, _process_loot_decay,
|
||||
## _process_spell_system, _process_government — blocked on empty module stubs.
|
||||
## `_process_climate` runs the full marine_harvest→climate→weather→effects chain.
|
||||
## Grep for `DISABLED:` to find every remaining guarded call site.
|
||||
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
|
|
@ -40,6 +32,9 @@ const RustFaunaIntegrationScript: GDScript = preload(
|
|||
const TurnProcessorHelpersScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/turn_processor_helpers.gd"
|
||||
)
|
||||
const TurnProcessorCityHelpersScript: GDScript = preload(
|
||||
"res://engine/src/modules/management/turn_processor_city_helpers.gd"
|
||||
)
|
||||
|
||||
var unit_manager: RefCounted # UnitManager — set by TurnManager._ready()
|
||||
var spell_system: RefCounted # SpellSystem — set by TurnManager._ready()
|
||||
|
|
@ -270,28 +265,11 @@ func _process_growth(player: RefCounted) -> void: # Player
|
|||
|
||||
|
||||
func _sum_city_building_effect(city: CityScript, effect_type: String) -> int:
|
||||
## Sum a building effect type for a single city (e.g., "production", "gold", "science").
|
||||
var total: int = 0
|
||||
for building_id: Variant in city.buildings:
|
||||
var bdata: Dictionary = DataLoader.get_building(str(building_id))
|
||||
if bdata.is_empty():
|
||||
continue
|
||||
for effect: Dictionary in bdata.get("effects", []):
|
||||
if effect.get("type", "") == effect_type:
|
||||
total += int(effect.get("value", 0))
|
||||
return total
|
||||
return TurnProcessorCityHelpersScript.sum_city_building_effect(city, effect_type)
|
||||
|
||||
|
||||
func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> float:
|
||||
var total: float = 0.0
|
||||
for building_id: Variant in city.buildings:
|
||||
var bdata: Dictionary = DataLoader.get_building(str(building_id))
|
||||
if bdata.is_empty():
|
||||
continue
|
||||
for effect: Dictionary in bdata.get("effects", []):
|
||||
if effect.get("type", "") == effect_type:
|
||||
total += float(effect.get("value", 0))
|
||||
return total
|
||||
return TurnProcessorCityHelpersScript.sum_city_building_effect_float(city, effect_type)
|
||||
|
||||
|
||||
func _apply_building_bonuses(city: CityScript, building_id: String) -> void:
|
||||
|
|
@ -320,7 +298,7 @@ func _apply_building_bonuses(city: CityScript, building_id: String) -> void:
|
|||
|
||||
|
||||
func _grant_free_tech(player: RefCounted, count: int) -> void:
|
||||
TurnProcessorHelpersScript.grant_free_tech(player, count)
|
||||
TurnProcessorCityHelpersScript.grant_free_tech(player, count)
|
||||
|
||||
|
||||
func _process_city_healing(player: RefCounted) -> void:
|
||||
|
|
@ -411,7 +389,7 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void:
|
|||
func _build_border_candidates_json(
|
||||
city: CityScript, game_map: RefCounted, player: RefCounted
|
||||
) -> String:
|
||||
return TurnProcessorHelpersScript.build_border_candidates_json(city, game_map, player)
|
||||
return TurnProcessorCityHelpersScript.build_border_candidates_json(city, game_map, player)
|
||||
|
||||
|
||||
func _process_golden_age(player: RefCounted, game_map: RefCounted) -> void: # Player, GameMap
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
extends RefCounted
|
||||
## City-scoped turn processing helpers: building effect summation, tech grants,
|
||||
## and border candidate JSON builder. Extracted from turn_processor_helpers.gd
|
||||
## to stay within the 500-line file limit.
|
||||
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
|
||||
|
||||
static func sum_city_building_effect(city: CityScript, effect_type: String) -> int:
|
||||
var total: int = 0
|
||||
for building_id: String in city.buildings:
|
||||
var bdata: Dictionary = DataLoader.get_building(building_id)
|
||||
if bdata.is_empty():
|
||||
continue
|
||||
for effect: Dictionary in bdata.get("effects", []):
|
||||
if effect.get("type", "") == effect_type:
|
||||
total += int(effect.get("value", 0))
|
||||
return total
|
||||
|
||||
|
||||
static func sum_city_building_effect_float(city: CityScript, effect_type: String) -> float:
|
||||
var total: float = 0.0
|
||||
for building_id: String in city.buildings:
|
||||
var bdata: Dictionary = DataLoader.get_building(building_id)
|
||||
if bdata.is_empty():
|
||||
continue
|
||||
for effect: Dictionary in bdata.get("effects", []):
|
||||
if effect.get("type", "") == effect_type:
|
||||
total += float(effect.get("value", 0))
|
||||
return total
|
||||
|
||||
|
||||
static func grant_free_tech(player: RefCounted, count: int) -> void:
|
||||
var researched: Array = player.researched_techs if player.researched_techs != null else []
|
||||
var candidates: Array[Dictionary] = []
|
||||
for t: Dictionary in DataLoader.get_all_techs():
|
||||
var tid: String = str(t.get("id", ""))
|
||||
if tid == "" or tid in researched:
|
||||
continue
|
||||
var prereqs: Array = t.get("prerequisites", []) as Array
|
||||
var all_met: bool = true
|
||||
for pr: String in prereqs:
|
||||
if not (pr in researched):
|
||||
all_met = false
|
||||
break
|
||||
if all_met:
|
||||
candidates.append(t)
|
||||
candidates.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return int(a.get("cost", 999999)) < int(b.get("cost", 999999))
|
||||
)
|
||||
for i: int in range(mini(count, candidates.size())):
|
||||
player.add_tech(str(candidates[i].get("id", "")))
|
||||
EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index)
|
||||
|
||||
|
||||
static func build_border_candidates_json(
|
||||
city: CityScript, game_map: RefCounted, player: RefCounted
|
||||
) -> String:
|
||||
var candidates: Array[Dictionary] = []
|
||||
for owned_pos: Vector2i in city.owned_tiles:
|
||||
var neighbors: Array[Vector2i] = HexUtilsScript.get_neighbors(owned_pos)
|
||||
for n: Vector2i in neighbors:
|
||||
var norm: Vector2i = HexUtilsScript.normalize_position(
|
||||
n, game_map.width, game_map.height, game_map.wrap_mode
|
||||
)
|
||||
if norm in city.owned_tiles:
|
||||
continue
|
||||
var tile: Resource = game_map.get_tile(norm)
|
||||
if tile == null:
|
||||
continue
|
||||
if tile.owner != -1 and tile.owner != player.index:
|
||||
continue
|
||||
var tile_yields: Dictionary = tile.get_yields(player.index)
|
||||
var score: float = float(tile_yields.get("food", 0)) * 2.0
|
||||
score += float(tile_yields.get("production", 0)) * 1.5
|
||||
score += float(tile_yields.get("trade", 0))
|
||||
score += float(tile_yields.get("culture", 0))
|
||||
if tile.resource_id != "":
|
||||
score += 5.0
|
||||
candidates.append({"col": norm.x, "row": norm.y, "value": score})
|
||||
return JSON.stringify(candidates)
|
||||
|
|
@ -437,56 +437,3 @@ static func _find_unit_by_render_id(unit_id: String) -> RefCounted:
|
|||
if u is UnitScript and (u as UnitScript).get_render_id() == unit_id:
|
||||
return u
|
||||
return null
|
||||
|
||||
|
||||
static func grant_free_tech(player: RefCounted, count: int) -> void:
|
||||
var researched: Array = player.researched_techs if player.researched_techs != null else []
|
||||
var candidates: Array[Dictionary] = []
|
||||
for t: Dictionary in DataLoader.get_all_techs():
|
||||
var tid: String = str(t.get("id", ""))
|
||||
if tid == "" or tid in researched:
|
||||
continue
|
||||
var prereqs: Array = t.get("prerequisites", []) as Array
|
||||
var all_met: bool = true
|
||||
for pr: String in prereqs:
|
||||
if not (pr in researched):
|
||||
all_met = false
|
||||
break
|
||||
if all_met:
|
||||
candidates.append(t)
|
||||
candidates.sort_custom(
|
||||
func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return int(a.get("cost", 999999)) < int(b.get("cost", 999999))
|
||||
)
|
||||
for i: int in range(mini(count, candidates.size())):
|
||||
player.add_tech(str(candidates[i].get("id", "")))
|
||||
EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index)
|
||||
|
||||
|
||||
static func build_border_candidates_json(
|
||||
city: CityScript, game_map: RefCounted, player: RefCounted
|
||||
) -> String:
|
||||
var HexUtils: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
var candidates: Array[Dictionary] = []
|
||||
for owned_pos: Vector2i in city.owned_tiles:
|
||||
var neighbors: Array[Vector2i] = HexUtils.get_neighbors(owned_pos)
|
||||
for n: Vector2i in neighbors:
|
||||
var norm: Vector2i = HexUtils.normalize_position(
|
||||
n, game_map.width, game_map.height, game_map.wrap_mode
|
||||
)
|
||||
if norm in city.owned_tiles:
|
||||
continue
|
||||
var tile: Resource = game_map.get_tile(norm)
|
||||
if tile == null:
|
||||
continue
|
||||
if tile.owner != -1 and tile.owner != player.index:
|
||||
continue
|
||||
var tile_yields: Dictionary = tile.get_yields(player.index)
|
||||
var score: float = float(tile_yields.get("food", 0)) * 2.0
|
||||
score += float(tile_yields.get("production", 0)) * 1.5
|
||||
score += float(tile_yields.get("trade", 0))
|
||||
score += float(tile_yields.get("culture", 0))
|
||||
if tile.resource_id != "":
|
||||
score += 5.0
|
||||
candidates.append({"col": norm.x, "row": norm.y, "value": score})
|
||||
return JSON.stringify(candidates)
|
||||
|
|
|
|||
|
|
@ -400,6 +400,13 @@ pub struct GdAiController {
|
|||
/// Deterministic RNG seed, advanced per `decide_actions` call so
|
||||
/// successive turns draw distinct xorshift streams.
|
||||
rng_seed: u64,
|
||||
/// Per-decision wall-clock budget in milliseconds. `0` means unbounded
|
||||
/// (default). When > 0, `decide_actions` computes `Instant::now() + budget`
|
||||
/// and threads it through the tactical submodules so each per-unit /
|
||||
/// per-city loop exits early once elapsed time exceeds the budget. Set via
|
||||
/// `set_budget_ms` (driven by `MCTS_DECISION_BUDGET_MS` env on the GDScript
|
||||
/// side). See p1-22.
|
||||
budget_ms: u64,
|
||||
base: Base<RefCounted>,
|
||||
}
|
||||
|
||||
|
|
@ -409,6 +416,7 @@ impl IRefCounted for GdAiController {
|
|||
Self {
|
||||
weights: Default::default(),
|
||||
rng_seed: 0x9E37_79B9_7F4A_7C15,
|
||||
budget_ms: 0,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
|
@ -426,6 +434,18 @@ impl GdAiController {
|
|||
self.rng_seed = seed as u64;
|
||||
}
|
||||
|
||||
/// Set the per-decision wall-clock budget in milliseconds for the tactical
|
||||
/// AI path. Pass `0` (default) for unbounded behavior. When > 0,
|
||||
/// `decide_actions` threads `Some(Instant::now() + budget)` through the
|
||||
/// tactical submodules; their per-unit / per-city / per-citizen loops
|
||||
/// check the deadline and break early once elapsed time exceeds it.
|
||||
/// Mirrors `GdMcTreeController::set_budget_ms` for the strategic path.
|
||||
/// Called from `ai_turn_bridge.gd` based on `MCTS_DECISION_BUDGET_MS` env (p1-22).
|
||||
#[func]
|
||||
fn set_budget_ms(&mut self, ms: i64) {
|
||||
self.budget_ms = ms.max(0) as u64;
|
||||
}
|
||||
|
||||
/// Install a player's scoring weights from a serialized JSON blob
|
||||
/// produced by [`mc_ai::evaluator::ScoringWeights`]'s serde impl.
|
||||
///
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue