feat(player-api): Implement multi-slot player API support for headless scenes

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 23:59:30 -07:00
parent ab49c99bb9
commit 7c8af2be96

View file

@ -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.