diff --git a/src/game/engine/scenes/headless/player_api_main.gd b/src/game/engine/scenes/headless/player_api_main.gd index 7f1cc8a1..0470be20 100644 --- a/src/game/engine/scenes/headless/player_api_main.gd +++ b/src/game/engine/scenes/headless/player_api_main.gd @@ -23,6 +23,7 @@ extends Node var _api: RefCounted = null var _player_slot: int = 0 +var _player_slots: Array[int] = [] # Stage 4 — slots driven externally. var _omniscient: bool = false var _log_path: String = "" var _shutdown: bool = false @@ -30,6 +31,27 @@ var _shutdown: bool = false func _ready() -> void: _player_slot = _env_int("CP_PLAYER_SLOT", 0) + # Stage 4 (multi-slot adapter) — `CP_PLAYER_SLOTS="0,1,2"` lets one + # harness process drive several slots (e.g. all 5 learned slots in a + # 5v5 FFA). Per-request `slot` field on view/act picks which slot + # this call targets. Empty / unset → fall through to single-slot + # mode using `CP_PLAYER_SLOT`. + _player_slots = [] + var slots_env: String = OS.get_environment("CP_PLAYER_SLOTS") + if not slots_env.is_empty(): + for piece: String in slots_env.split(","): + var trimmed: String = piece.strip_edges() + if trimmed.is_empty(): + continue + var n: int = int(trimmed) + if not _player_slots.has(n): + _player_slots.append(n) + if _player_slots.is_empty(): + _player_slots = [_player_slot] + else: + # First entry in the list is the back-compat default for + # requests that omit the `slot` field. + _player_slot = _player_slots[0] _omniscient = _env_bool("CP_OMNISCIENT", false) _log_path = OS.get_environment("CP_LOG_FILE") @@ -149,7 +171,7 @@ func _hydrate_player_api(num_players: int) -> void: # only reads the file bytes and picks the clan id per slot — no JSON # parsing here. Deterministic slot→clan mapping: slot index modulo # the clan list (sorted by id for stable ordering across runs). - _apply_ai_personalities(gs, num_players) + _apply_ai_assignments(gs, num_players) var json: String = String(gs.to_json()) if json.is_empty() or json == "{}": @@ -299,12 +321,20 @@ func _apply_ai_catalogs() -> void: }) -func _apply_ai_personalities(gs: RefCounted, num_players: int) -> void: - ## p2-68 final wave. Read `ai_personalities.json` once and hand the - ## raw bytes plus a per-slot clan id to `GdGameState::set_player_personality_json`. - ## Slot 0 = Claude — skipped. Slots 1..N-1 cycle through the sorted +func _apply_ai_assignments(gs: RefCounted, num_players: int) -> void: + ## Stage 3 (mod system) — per-AI-slot assignment of BOTH personality + ## (`scoring_weights` / clan id) AND `controller_id` (which + ## `AiController` impl decides this slot's turn). + ## + ## Personality: read `ai_personalities.json` once and hand the raw + ## bytes plus a per-slot clan id to `GdGameState::set_player_personality_json`. + ## Slot 0 = Claude — skipped. Other slots cycle through the sorted ## clan id list so a 2-player game always pairs Claude vs `blackhammer` ## (alphabetically first), a 6-player game uses every clan exactly once. + ## + ## Controller: per-slot id from `CP_PLAYER_CONTROLLERS` (comma list, + ## indexed by AI position) or fall through to `scripted:default`. + ## Stamped via `GdGameState::set_player_controller`. const PERSONALITIES_PATH: String = "res://public/games/age-of-dwarves/data/ai_personalities.json" # Canonical project-resource path — same constant `how_to_play.gd` and # `loading_screen.gd` use. Works under editor + flatpak headless. @@ -331,6 +361,15 @@ func _apply_ai_personalities(gs: RefCounted, num_players: int) -> void: if clan_ids.is_empty(): _emit_protocol_error("ai_personalities.json has no clans") return + # Per-slot controller override list. Format: comma-separated ids, + # one entry per AI slot in slot order (excluding `_player_slot`). + # Empty / missing entries default to `scripted:default`. + # CP_PLAYER_CONTROLLERS="learned:duel-v1b,scripted:default,..." + var controllers_env: String = _env_or("CP_PLAYER_CONTROLLERS", "") + var controller_overrides: Array[String] = [] + if not controllers_env.is_empty(): + for piece: String in controllers_env.split(","): + controller_overrides.append(piece.strip_edges()) # Deterministic slot→clan mapping: count AI slots in slot order and # assign clan_ids[ai_index % clan_count]. Stable across runs. var ai_index: int = 0 @@ -338,12 +377,22 @@ func _apply_ai_personalities(gs: RefCounted, num_players: int) -> void: if slot == _player_slot: continue var clan_id: String = clan_ids[ai_index % clan_ids.size()] + var controller_id: String = "scripted:default" + if ai_index < controller_overrides.size(): + var override: String = controller_overrides[ai_index] + if not override.is_empty(): + controller_id = override ai_index += 1 var ok: bool = bool(gs.set_player_personality_json(slot, clan_id, json_text)) if not ok: _emit_protocol_error("set_player_personality_json failed slot=%d clan=%s" % [slot, clan_id]) else: _emit_event("ai_personality_assigned", {"slot": slot, "clan_id": clan_id}) + var ok_ctrl: bool = bool(gs.set_player_controller(slot, controller_id)) + if not ok_ctrl: + _emit_protocol_error("set_player_controller failed slot=%d controller=%s" % [slot, controller_id]) + else: + _emit_event("ai_controller_assigned", {"slot": slot, "controller_id": controller_id}) func _scan_land_tiles(grid: RefCounted, w: int, h: int) -> Array[Vector2i]: @@ -416,9 +465,15 @@ func _handle_request(req: Dictionary) -> void: var rtype: String = String(req.get("type", "")) var rid_int: int = int(req.get("id", -1)) var has_id: bool = req.has("id") and req.get("id") != null + # Stage 4 — per-request `slot` field picks which player this view / + # act targets. Missing → fall through to `_player_slot` (the first + # entry of `_player_slots` or the legacy single-slot value). + var target_slot: int = _player_slot + if req.has("slot") and req.get("slot") != null: + target_slot = int(req.get("slot")) match rtype: "view": - var view_json: String = String(_api.view_json(_player_slot)) + var view_json: String = String(_api.view_json(target_slot)) _emit_response_with_view(rid_int, has_id, view_json) "act": var action_payload: Dictionary = req.get("action", {}) as Dictionary @@ -427,7 +482,7 @@ func _handle_request(req: Dictionary) -> void: return var action_json: String = JSON.stringify(action_payload) var envelope_str: String = String( - _api.apply_action_json(_player_slot, action_json) + _api.apply_action_json(target_slot, action_json) ) # api wrapper already emits a full ok/err envelope — splice # in the request id and forward as the response body.