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