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:
parent
ab49c99bb9
commit
7c8af2be96
1 changed files with 62 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue