405 lines
15 KiB
GDScript
405 lines
15 KiB
GDScript
extends Node
|
||
## p2-67 Phase 3 — Headless harness for the Claude Player API.
|
||
##
|
||
## Boots a seeded GameState, instantiates a `GdPlayerApi`, then enters
|
||
## a JSON-Lines pump on stdin/stdout. Each line in is one `Request`
|
||
## (`view` / `act` / `shutdown`); each line out is one `Response` or
|
||
## one `Notification`. The protocol contract lives in
|
||
## `src/game/engine/docs/CLAUDE_PLAYER_API.md`.
|
||
##
|
||
## Env vars consumed:
|
||
## - `CP_SEED` — RNG seed (default 42)
|
||
## - `CP_PLAYERS` — total player slots (default 2)
|
||
## - `CP_CLAUDE_SLOT` — which slot stdin controls (default 0)
|
||
## - `CP_MAP_SIZE` — MapGenerator size key (default "duel")
|
||
## - `CP_MAP_TYPE` — MapGenerator map type (default "continents")
|
||
## - `CP_OMNISCIENT` — `1` disables fog redaction (default 0)
|
||
## - `CP_TIMEOUT_SEC` — per-action timeout in seconds (default 60)
|
||
## - `CP_LOG_FILE` — if set, mirror all wire I/O to this path
|
||
|
||
var _api: RefCounted = null
|
||
var _claude_slot: int = 0
|
||
var _omniscient: bool = false
|
||
var _log_path: String = ""
|
||
var _shutdown: bool = false
|
||
|
||
|
||
func _ready() -> void:
|
||
_claude_slot = _env_int("CP_CLAUDE_SLOT", 0)
|
||
_omniscient = _env_bool("CP_OMNISCIENT", false)
|
||
_log_path = OS.get_environment("CP_LOG_FILE")
|
||
|
||
if not ClassDB.class_exists("GdPlayerApi"):
|
||
_emit_protocol_error(
|
||
"GdPlayerApi GDExtension class not registered — rebuild gdext"
|
||
)
|
||
get_tree().quit(1)
|
||
return
|
||
|
||
DataLoader.load_theme("age-of-dwarves")
|
||
DataLoader.load_world("earth")
|
||
|
||
var seed_v: int = _env_int("CP_SEED", 42)
|
||
var num_players: int = _env_int("CP_PLAYERS", 2)
|
||
var map_size: String = _env_or("CP_MAP_SIZE", "duel")
|
||
var map_type: String = _env_or("CP_MAP_TYPE", "continents")
|
||
_bootstrap_game(seed_v, num_players, map_size, map_type)
|
||
|
||
_api = ClassDB.instantiate("GdPlayerApi") as RefCounted
|
||
if _api == null:
|
||
_emit_protocol_error("ClassDB.instantiate('GdPlayerApi') returned null")
|
||
get_tree().quit(1)
|
||
return
|
||
_api.set_omniscient(_omniscient)
|
||
_hydrate_player_api(num_players)
|
||
|
||
# Announce initial state to the adapter. Notification lines carry
|
||
# no `id` field; adapters can use them to drive streaming UIs or
|
||
# ignore them entirely (the synchronous response after the next
|
||
# `act` carries the same data via `events`).
|
||
_emit_event("turn_started", {"turn": 0, "player": _claude_slot})
|
||
_emit_event("phase_changed", {"phase": "player_actions"})
|
||
|
||
# Enter the pump on the next frame so any pending engine init flushes.
|
||
_pump.call_deferred()
|
||
|
||
|
||
func _bootstrap_game(
|
||
seed_v: int, num_players: int, map_size: String, map_type: String
|
||
) -> void:
|
||
## Initialise the GDScript-side GameState autoload. The headless API
|
||
## state hydration happens separately in `_hydrate_player_api` via
|
||
## `GdGameState.to_json` so the Claude Player API gets a populated
|
||
## starting world (cities, units, grid) instead of an empty default.
|
||
GameState.initialize_game({
|
||
"seed": seed_v,
|
||
"map_type": map_type,
|
||
"map_size": map_size,
|
||
"num_players": num_players,
|
||
})
|
||
|
||
|
||
func _hydrate_player_api(num_players: int) -> void:
|
||
## Build a deterministic seeded `GdGameState` with a real generated
|
||
## map, then place `num_players` militarist capitals on land tiles
|
||
## found via `_find_land_tile_near`. Serialise + load into
|
||
## `GdPlayerApi`. Mirrors the production game's boot path:
|
||
## `GdMapGenerator.generate` → `GdGameState.set_grid_from_gridstate`
|
||
## → per-player land-aware capital placement.
|
||
var gs: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted
|
||
if gs == null:
|
||
_emit_protocol_error("ClassDB.instantiate('GdGameState') returned null")
|
||
return
|
||
# Generate a real seeded map via GdMapGenerator.
|
||
var gen: RefCounted = ClassDB.instantiate("GdMapGenerator") as RefCounted
|
||
if gen == null:
|
||
_emit_protocol_error("ClassDB.instantiate('GdMapGenerator') returned null")
|
||
return
|
||
gen.initialize("{}")
|
||
var seed_v: int = _env_int("CP_SEED", 42)
|
||
var map_size: String = _env_or("CP_MAP_SIZE", "duel")
|
||
var grid: RefCounted = gen.generate(seed_v, map_size) as RefCounted
|
||
if grid == null:
|
||
_emit_protocol_error("GdMapGenerator.generate returned null")
|
||
return
|
||
gs.set_grid_from_gridstate(grid)
|
||
var grid_w: int = int(grid.get_width())
|
||
var grid_h: int = int(grid.get_height())
|
||
# Pre-compute land-tile set so we can pick capitals.
|
||
var land_tiles: Array[Vector2i] = _scan_land_tiles(grid, grid_w, grid_h)
|
||
if land_tiles.is_empty():
|
||
_emit_protocol_error("no land tiles in generated map — bad seed/params")
|
||
return
|
||
# Pick `num_players` capitals well-spaced across the land set.
|
||
var capitals: Array[Vector2i] = _pick_spaced_capitals(land_tiles, num_players)
|
||
for cap: Vector2i in capitals:
|
||
gs.add_player_militarist(cap.x, cap.y)
|
||
|
||
# p2-68 final wave — load `ai_personalities.json` once and stamp real
|
||
# personality-shaped `ScoringWeights` onto every non-Claude slot's
|
||
# `PlayerState`. Without this, `mc_player_api::dispatch::drive_ai_slot`
|
||
# pulls `ScoringWeights::default()` and produces a flat AI action chain
|
||
# regardless of slot. Single source of truth: the file is parsed inside
|
||
# Rust via `mc_core::scoring_weights::ScoringWeights::from_personality_json`
|
||
# (called from `GdGameState::set_player_personality_json`). GDScript
|
||
# 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)
|
||
|
||
var json: String = String(gs.to_json())
|
||
if json.is_empty() or json == "{}":
|
||
_emit_protocol_error("GdGameState.to_json returned empty payload")
|
||
return
|
||
if not _api.load_state_json(json):
|
||
_emit_protocol_error("GdPlayerApi.load_state_json rejected bootstrap state")
|
||
return
|
||
|
||
# p2-71 — push the tactical AI catalogs into GdPlayerApi. These live on
|
||
# `GameState` but are `#[serde(skip)]`, so `load_state_json` doesn't
|
||
# carry them — we stamp them via dedicated setters AFTER load. The
|
||
# catalogs come from the same DataLoader entries the live-game bridge
|
||
# (`ai_turn_bridge_state.gd::build_*_catalog`) consumes — single source
|
||
# of truth for both AI paths.
|
||
_apply_ai_catalogs()
|
||
|
||
|
||
## p2-71 — push the unit + building catalogs (built from DataLoader) into
|
||
## the held GdPlayerApi state so `project_tactical` sees real buildable
|
||
## choices for each AI slot.
|
||
##
|
||
## The catalog builders live on `AiTurnBridgeState` and walk the same
|
||
## DataLoader dictionaries the live-game tactical AI consumes. We
|
||
## JSON-serialise the Array and hand it to the new Rust setters
|
||
## (`set_units_catalog_json` / `set_buildings_catalog_json`).
|
||
func _apply_ai_catalogs() -> void:
|
||
# Project root is mounted at `src/game/` (see claude-player-server.sh
|
||
# `--path` arg), so the bridge module's `res://` form drops the
|
||
# `src/game/` prefix.
|
||
var AiTurnBridgeState: Script = load("res://engine/src/modules/ai/ai_turn_bridge_state.gd")
|
||
if AiTurnBridgeState == null:
|
||
_emit_protocol_error("could not load AiTurnBridgeState — AI catalogs empty")
|
||
return
|
||
var unit_catalog: Array = AiTurnBridgeState.build_unit_catalog()
|
||
var building_catalog: Array = AiTurnBridgeState.build_building_catalog()
|
||
var unit_json: String = JSON.stringify(unit_catalog)
|
||
var building_json: String = JSON.stringify(building_catalog)
|
||
var n_units: int = int(_api.set_units_catalog_json(unit_json))
|
||
var n_buildings: int = int(_api.set_buildings_catalog_json(building_json))
|
||
# Difficulty mult — same source as `ai_turn_bridge_state.gd::load_difficulty_threshold_mult`.
|
||
var diff_mult: float = AiTurnBridgeState.load_difficulty_threshold_mult()
|
||
_api.set_difficulty_threshold_mult(diff_mult)
|
||
_emit_event("ai_catalogs_loaded", {
|
||
"units": n_units,
|
||
"buildings": n_buildings,
|
||
"difficulty_threshold_mult": diff_mult,
|
||
})
|
||
|
||
|
||
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
|
||
## clan id list so a 2-player game always pairs Claude vs `blackhammer`
|
||
## (alphabetically first), a 6-player game uses every clan exactly once.
|
||
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.
|
||
var json_text: String = ""
|
||
if FileAccess.file_exists(PERSONALITIES_PATH):
|
||
var f: FileAccess = FileAccess.open(PERSONALITIES_PATH, FileAccess.READ)
|
||
if f != null:
|
||
json_text = f.get_as_text()
|
||
f.close()
|
||
if json_text.is_empty():
|
||
_emit_protocol_error("could not read ai_personalities.json — AI slots will use default weights")
|
||
return
|
||
# Parse just for the key list (so slot→clan mapping is deterministic).
|
||
# We do NOT hand the parsed dict to Rust — the raw JSON bytes go down
|
||
# so the Rust parser stays the single source of truth for the data shape.
|
||
var personalities: Dictionary = JSON.parse_string(json_text) as Dictionary
|
||
if personalities.is_empty():
|
||
_emit_protocol_error("ai_personalities.json parsed empty")
|
||
return
|
||
var clan_ids: Array[String] = []
|
||
for k: String in personalities.keys():
|
||
clan_ids.append(k)
|
||
clan_ids.sort()
|
||
if clan_ids.is_empty():
|
||
_emit_protocol_error("ai_personalities.json has no clans")
|
||
return
|
||
# 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
|
||
for slot: int in range(num_players):
|
||
if slot == _claude_slot:
|
||
continue
|
||
var clan_id: String = clan_ids[ai_index % clan_ids.size()]
|
||
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})
|
||
|
||
|
||
func _scan_land_tiles(grid: RefCounted, w: int, h: int) -> Array[Vector2i]:
|
||
## Walk the grid and collect every land hex. Mirrors
|
||
## `mc_mapgen::spawn_box::FORBIDDEN_BIOMES` ∪ pathfinder LAND_IMPASSABLE_FLAGS.
|
||
const FORBIDDEN: Array[String] = [
|
||
"ocean", "deep_ocean", "coast", "inland_sea", "lake",
|
||
"mountains", "volcano", "ice",
|
||
]
|
||
var out: Array[Vector2i] = []
|
||
for col: int in range(w):
|
||
for row: int in range(h):
|
||
var tile: Dictionary = grid.get_tile_dict(col, row) as Dictionary
|
||
if tile.is_empty():
|
||
continue
|
||
var biome: String = String(tile.get("biome_id", ""))
|
||
if biome.is_empty() or biome in FORBIDDEN:
|
||
continue
|
||
out.append(Vector2i(col, row))
|
||
return out
|
||
|
||
|
||
func _pick_spaced_capitals(land: Array[Vector2i], count: int) -> Array[Vector2i]:
|
||
## Greedy max-distance picker: first capital = land[0]; each subsequent
|
||
## capital = the land tile maximising minimum distance to all picks.
|
||
## Deterministic given a sorted input (we don't sort — caller order
|
||
## is the grid scan order, which is itself deterministic).
|
||
var picks: Array[Vector2i] = []
|
||
if land.is_empty() or count <= 0:
|
||
return picks
|
||
picks.append(land[0])
|
||
for _i: int in range(1, count):
|
||
var best: Vector2i = land[0]
|
||
var best_min_d: int = -1
|
||
for candidate: Vector2i in land:
|
||
var min_d: int = 1_000_000
|
||
for p: Vector2i in picks:
|
||
var dx: int = candidate.x - p.x
|
||
var dy: int = candidate.y - p.y
|
||
var d: int = absi(dx) + absi(dy)
|
||
if d < min_d:
|
||
min_d = d
|
||
if min_d > best_min_d:
|
||
best_min_d = min_d
|
||
best = candidate
|
||
picks.append(best)
|
||
return picks
|
||
|
||
|
||
func _pump() -> void:
|
||
while not _shutdown:
|
||
var line: String = OS.read_string_from_stdin(4096)
|
||
if line == "":
|
||
# EOF or no line yet — yield to the engine and try again.
|
||
await get_tree().process_frame
|
||
continue
|
||
line = line.strip_edges()
|
||
if line.is_empty():
|
||
continue
|
||
_log_wire("<-", line)
|
||
var parsed_dict: Dictionary = JSON.parse_string(line) as Dictionary
|
||
if parsed_dict.is_empty() and line != "{}":
|
||
_emit_protocol_error("could not parse line: %s" % line)
|
||
continue
|
||
_handle_request(parsed_dict)
|
||
get_tree().quit(0)
|
||
|
||
|
||
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
|
||
match rtype:
|
||
"view":
|
||
var view_json: String = String(_api.view_json(_claude_slot))
|
||
_emit_response_with_view(rid_int, has_id, view_json)
|
||
"act":
|
||
var action_payload: Dictionary = req.get("action", {}) as Dictionary
|
||
if action_payload.is_empty():
|
||
_emit_response_error(rid_int, has_id, "parse_error", "missing action field")
|
||
return
|
||
var action_json: String = JSON.stringify(action_payload)
|
||
var envelope_str: String = String(
|
||
_api.apply_action_json(_claude_slot, action_json)
|
||
)
|
||
# api wrapper already emits a full ok/err envelope — splice
|
||
# in the request id and forward as the response body.
|
||
var envelope: Dictionary = JSON.parse_string(envelope_str) as Dictionary
|
||
if envelope.is_empty():
|
||
_emit_response_error(
|
||
rid_int, has_id, "internal", "gdext returned non-JSON envelope"
|
||
)
|
||
return
|
||
if has_id:
|
||
envelope["id"] = rid_int
|
||
_write_line(JSON.stringify(envelope))
|
||
"shutdown":
|
||
_emit_response_ack(rid_int, has_id)
|
||
_shutdown = true
|
||
_:
|
||
_emit_response_error(
|
||
rid_int, has_id, "parse_error", "unknown request type: %s" % rtype
|
||
)
|
||
|
||
|
||
func _emit_response_with_view(rid_int: int, has_id: bool, view_json: String) -> void:
|
||
var view_dict: Dictionary = JSON.parse_string(view_json) as Dictionary
|
||
var body: Dictionary = {"ok": true, "view": view_dict}
|
||
if has_id:
|
||
body["id"] = rid_int
|
||
_write_line(JSON.stringify(body))
|
||
|
||
|
||
func _emit_response_ack(rid_int: int, has_id: bool) -> void:
|
||
var body: Dictionary = {"ok": true}
|
||
if has_id:
|
||
body["id"] = rid_int
|
||
_write_line(JSON.stringify(body))
|
||
|
||
|
||
func _emit_response_error(
|
||
rid_int: int, has_id: bool, code: String, message: String
|
||
) -> void:
|
||
var body: Dictionary = {
|
||
"ok": false,
|
||
"error": {"code": code, "message": message},
|
||
}
|
||
if has_id:
|
||
body["id"] = rid_int
|
||
_write_line(JSON.stringify(body))
|
||
|
||
|
||
func _emit_event(event_type: String, payload: Dictionary) -> void:
|
||
## Notification line (no id) — adapter ignores or streams.
|
||
var body: Dictionary = {"type": event_type}
|
||
for k: String in payload.keys():
|
||
body[k] = payload[k]
|
||
_write_line(JSON.stringify(body))
|
||
|
||
|
||
func _emit_protocol_error(message: String) -> void:
|
||
_emit_event("protocol_error", {"message": message})
|
||
|
||
|
||
func _write_line(line: String) -> void:
|
||
_log_wire("->", line)
|
||
# `print` already appends a newline → exactly one JSON value per line.
|
||
print(line)
|
||
|
||
|
||
func _log_wire(dir_arrow: String, line: String) -> void:
|
||
if _log_path.is_empty():
|
||
return
|
||
var f: FileAccess = FileAccess.open(_log_path, FileAccess.READ_WRITE)
|
||
if f == null:
|
||
f = FileAccess.open(_log_path, FileAccess.WRITE)
|
||
if f == null:
|
||
return
|
||
f.seek_end()
|
||
f.store_line("%s %s" % [dir_arrow, line])
|
||
f.close()
|
||
|
||
|
||
func _env_or(name: String, fallback: String) -> String:
|
||
var v: String = OS.get_environment(name)
|
||
if v.is_empty():
|
||
return fallback
|
||
return v
|
||
|
||
|
||
func _env_int(name: String, fallback: int) -> int:
|
||
var v: String = OS.get_environment(name)
|
||
if v.is_empty():
|
||
return fallback
|
||
return int(v)
|
||
|
||
|
||
func _env_bool(name: String, fallback: bool) -> bool:
|
||
var v: String = OS.get_environment(name)
|
||
if v.is_empty():
|
||
return fallback
|
||
return v == "1" or v.to_lower() == "true"
|