magicciv/src/game/engine/scenes/headless/claude_player_main.gd
Natalie 7d111acb1a fix(@projects/@magic-civilization): 🐛 resolve ai personality loading and turn processing
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-11 12:27:23 -07:00

405 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"