feat(@projects): add ai vs claudio render test suite

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 11:40:40 -07:00
parent 77c6c4219d
commit cc927240fb
4 changed files with 898 additions and 0 deletions

View file

@ -182,6 +182,19 @@ This is the proof that `GdPlayerApi` mutations propagate to renderers.
- Same wall as `PlayerScript` (already documented blocked above). The BuildingScript precedent does **not** transfer: `npc_buildings: Vec<BuildingEntity>` was already a complete mirror on `GdGameState`; no equivalent exists for cities.
- **Unblock prerequisite:** a new objective analogous to `p2-72a-save-format-migration`*"Promote `PlayerState.cities` from `CityState` → `City`"* (or its inverse: widen `CityState` to absorb the missing fields). Either direction is a multi-day port touching `mc-turn`, `mc-economy`, `mc-score`, `mc-ai`, and every existing `PlayerState.cities` consumer. Decision required from user; not spawned unilaterally.
- Evidence: `src/simulator/crates/mc-city/src/lib.rs:110-150` (CityState fields), `src/simulator/crates/mc-city/src/city.rs:234` (City fields), `src/simulator/api-gdext/src/lib.rs:3515,3640-3644,3721-3737,3836-3858` (PlayerState construction + only accessor is `city_count` + only mutator is `set_player_cities_from_array` from a `Vector2i` array — confirms CityState-only shape), `src/game/engine/src/entities/city.gd:96-105` (per-CityScript independent GdCity instantiation).
- **2026-05-12 update:** `p2-72b-promote-playerstate-cities-to-city`
was spawned to unblock this checkbox with a locked Option C
(parallel `presentation_cities` slot at the Godot bridge). The
Section 1 audit on p2-72b re-fired Hard Stop #1: the per-instance
`GdCity` architecture means CityScript-readable fields outside
CityState are ~30 (incl. containers + behavioural methods like
`process_growth`/`tick_building`/`take_damage`), not the 8-field
minimum Option C assumed. p2-72b status flipped `open`
`blocked` pending user pick between three resolution paths
(balloon CityPresentation / Vec<Vec<mc_city::City>> on
GdGameState / partial unblock). See p2-72b "STATUS — 2026-05-12
(Hard Stop #1, Section 1 audit)" for full enumeration. This
Stage 4 Wave 2b checkbox stays ☐ until p2-72b unblocks.
- ☐ Every `GameState.players[*]` / `GameState.layers[*]` raw access in `src/game/engine/` is replaced with a typed `#[func]` accessor call.
- ☐ `GdGameState` exposes accessors for every field the renderers consume.
- ☐ `world_map.tscn`, `gameplay_arc_proof.tscn`, city screen, combat preview, HUD, tech tree, culture panel, diplomacy panel all still render correctly (visual regression check via existing proof scenes).

View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# p2-72 Option B — Drive the Claude-vs-AI render proof scene on apricot
# and fetch the resulting demo-run directory back to plum.
#
# Spawns flatpak Godot headless with the claude_vs_ai_render_proof scene.
# The scene runs CP_TURNS turns of Claude-vs-AI gameplay, writes per-turn
# screenshots + a transcript + a recap markdown into the CP_OUTPUT_DIR
# (default `user://demo-runs/p2-72-option-b`), then quits.
#
# Run on apricot (via apricot-run.sh or direct ssh) AFTER `build-gdext.sh`
# has produced a fresh .so on the apricot worktree.
#
# Env vars (forwarded into the sandbox):
# CP_SEED (default 42)
# CP_PLAYERS (default 3)
# CP_CLAUDE_SLOT (default 0)
# CP_MAP_SIZE (default duel)
# CP_TURNS (default 25)
# CP_SCREENSHOT_EVERY (default 1)
# CP_OUTPUT_DIR (default "user://demo-runs/p2-72-option-b")
# CP_HOST_OUTPUT (default ".local/demo-runs/2026-05-12-phase13-screenshots")
# relative to PROJECT_DIR — final landing spot for
# copied screenshots after the run.
#
# Exit 0 on PROOF_DONE marker present; non-zero otherwise.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
: "${CP_SEED:=42}"
: "${CP_PLAYERS:=3}"
: "${CP_CLAUDE_SLOT:=0}"
: "${CP_MAP_SIZE:=duel}"
: "${CP_TURNS:=25}"
: "${CP_SCREENSHOT_EVERY:=1}"
: "${CP_OUTPUT_DIR:=user://demo-runs/p2-72-option-b}"
: "${CP_HOST_OUTPUT:=.local/demo-runs/2026-05-12-phase13-screenshots}"
: "${CP_TIMEOUT_SEC:=600}"
LOG_FILE="${LOG_FILE:-/tmp/p2-72-option-b-render.log}"
# Flatpak sandbox layout — `user://` resolves here.
GODOT_USERDATA="$HOME/.var/app/org.godotengine.Godot/data/godot/app_userdata/Magic Civilization"
echo "=== p2-72 Option B render proof ==="
echo "Seed=$CP_SEED Players=$CP_PLAYERS Map=$CP_MAP_SIZE Turns=$CP_TURNS"
echo "Output (in sandbox): $CP_OUTPUT_DIR"
echo "Log: $LOG_FILE"
# Pure-headless — no Wayland (renderer draws into the headless framebuffer,
# Image.save_png reads from get_viewport().get_texture()).
timeout "$CP_TIMEOUT_SEC" flatpak run --user \
--env=CP_SEED="$CP_SEED" \
--env=CP_PLAYERS="$CP_PLAYERS" \
--env=CP_CLAUDE_SLOT="$CP_CLAUDE_SLOT" \
--env=CP_MAP_SIZE="$CP_MAP_SIZE" \
--env=CP_TURNS="$CP_TURNS" \
--env=CP_SCREENSHOT_EVERY="$CP_SCREENSHOT_EVERY" \
--env=CP_OUTPUT_DIR="$CP_OUTPUT_DIR" \
--env=FORCE_DISABLE_FOGOFWAR="true" \
--env=MC_USE_PROCEDURAL_SPRITES="1" \
org.godotengine.Godot \
--path "$PROJECT_DIR/src/game" \
--headless \
--rendering-driver opengl3 \
--rendering-method gl_compatibility \
res://engine/scenes/tests/claude_vs_ai_render_proof.tscn \
> "$LOG_FILE" 2>&1
RC=$?
echo "--- Tail of log ---"
tail -40 "$LOG_FILE"
echo "-------------------"
if ! grep -q "PROOF_DONE" "$LOG_FILE"; then
echo "ERROR: PROOF_DONE marker missing — run aborted (rc=$RC)"
exit 1
fi
OUTPUT_DIR_ABS=$(grep -m1 "OUTPUT_DIR_ABS:" "$LOG_FILE" | sed 's/.*OUTPUT_DIR_ABS://')
if [ -z "$OUTPUT_DIR_ABS" ] || [ ! -d "$OUTPUT_DIR_ABS" ]; then
# Fall back to deriving from CP_OUTPUT_DIR.
OUTPUT_DIR_ABS="$GODOT_USERDATA/${CP_OUTPUT_DIR#user://}"
fi
echo "Run output dir: $OUTPUT_DIR_ABS"
ls -la "$OUTPUT_DIR_ABS" | head -40 || true
HOST_OUTPUT_ABS="$PROJECT_DIR/$CP_HOST_OUTPUT"
mkdir -p "$HOST_OUTPUT_ABS"
cp -a "$OUTPUT_DIR_ABS/." "$HOST_OUTPUT_ABS/"
echo "Copied to: $HOST_OUTPUT_ABS"
ls "$HOST_OUTPUT_ABS"
echo "=== Done ==="
exit 0

View file

@ -0,0 +1,782 @@
extends Node2D
## p2-72 Option B — Claude-vs-AI render proof scene.
##
## DUAL-STATE ACKNOWLEDGEMENT (read this before editing):
##
## - `GdPlayerApi` (held in `_api`) is the source of truth for state.
## - The GDScript `PlayerScript` / `CityScript` / `UnitScript` / `GameMap`
## instances built here are a per-turn rehydrated VIEW, never
## authoritative. We pull `view_json(0, omniscient=true)` after every
## action and rebuild the GDScript arrays from that projection.
## - DO NOT mutate the GDScript-side state directly in this proof. All
## mutations go through `_api.apply_action_json(slot, json)`.
## - This proof's render path is NOT the production game's render path;
## it is a Phase-13 deliverable workaround that bypasses the full
## canonical render source extraction (`p2-72a`, deferred).
##
## Mirrors `claude_player_main.gd` for the boot sequence (DataLoader,
## GdMapGenerator, capital placement, AI personalities, runtime + tactical
## catalogs) and `full_game_demo_proof.gd` for the renderer scaffold
## (HexRenderer / UnitRenderer / CityRenderer wired directly under a
## Camera2D — no SubViewport tiering).
##
## Drives `CP_TURNS` turns (default 25) of Claude-vs-AI gameplay. Claude
## policy is a faithful GDScript port of
## `mc-player-api/tests/full_game_transcript.rs::pick_claude_action`:
## 1. FoundCity from any unit's legal_actions.
## 2. QueueProduction(dwarf_warrior) from any city.
## 3. Any other QueueProduction.
## 4. Move any unit.
## 5. Fortify any unit.
## 6. EndTurn.
## After up to MAX_ACTIONS_PER_TURN priority picks, an EndTurn is forced
## so the AI actually runs.
##
## Outputs (under `user://demo-runs/p2-72-option-b/`):
## - `turn-NN.png` screenshots every CP_SCREENSHOT_EVERY turns
## (default 1; minimum coverage is 6 captures).
## - `transcript.jsonl` one JSON line per request + response, same
## wire shape as `claude-demo-25turn.sh`.
## - `recap.md` per-turn action log + score deltas.
##
## Env vars:
## - CP_SEED (default 42)
## - CP_PLAYERS (default 3)
## - CP_CLAUDE_SLOT (default 0)
## - CP_MAP_SIZE (default "duel")
## - CP_TURNS (default 25)
## - CP_SCREENSHOT_EVERY (default 1) — capture every Nth turn
## - CP_OUTPUT_DIR (default "user://demo-runs/p2-72-option-b")
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const HexRendererScript: GDScript = preload("res://engine/src/rendering/hex_renderer.gd")
const UnitRendererScript: GDScript = preload("res://engine/src/rendering/unit_renderer.gd")
const CityRendererScript: GDScript = preload("res://engine/src/rendering/city_renderer.gd")
const VIEWPORT_SIZE: Vector2i = Vector2i(1920, 1080)
const MAX_ACTIONS_PER_TURN: int = 8
# Stable hard-coded slot colours so screenshots stay visually consistent
# across runs (clan banner colours aren't yet projected into PlayerView).
const SLOT_COLORS: Array[Color] = [
Color(0.85, 0.65, 0.20), # slot 0 — Claude — gold
Color(0.20, 0.55, 0.85), # slot 1 — AI — blue
Color(0.80, 0.30, 0.25), # slot 2 — AI — red
Color(0.35, 0.75, 0.40), # slot 3 — AI — green
Color(0.70, 0.45, 0.85), # slot 4 — AI — purple
Color(0.95, 0.50, 0.65), # slot 5 — AI — pink
]
var _api: RefCounted = null
var _gd_state: RefCounted = null # GdGameState used at boot for map+placement
var _game_map: RefCounted = null # GameMap instance (presentation-side)
var _hex_renderer: Node2D = null
var _unit_renderer: Node2D = null
var _city_renderer: Node2D = null
var _camera: Camera2D = null
var _claude_slot: int = 0
var _num_players: int = 3
var _seed: int = 42
var _map_size: String = "duel"
var _max_turns: int = 25
var _screenshot_every: int = 1
var _output_dir_setting: String = ""
var _output_dir_abs: String = ""
var _transcript_path: String = ""
var _recap_path: String = ""
var _wire_request_id: int = 0
var _wire_lines: int = 0
var _turn_records: Array = [] # Array of Dictionaries: {turn, claude_actions[], scores[]}
var _captured_turns: Array[int] = []
func _ready() -> void:
# Force-disable fog for visual clarity — we drive renderers from the
# omniscient projection, so fog should never blank the map anyway, but
# this guards against any DataLoader-side default flipping.
OS.set_environment("FORCE_DISABLE_FOGOFWAR", "true")
OS.set_environment("MC_USE_PROCEDURAL_SPRITES", "1")
DisplayServer.window_set_size(VIEWPORT_SIZE)
get_viewport().size = VIEWPORT_SIZE
RenderingServer.set_default_clear_color(Color(0.05, 0.07, 0.10))
_seed = _env_int("CP_SEED", 42)
_num_players = _env_int("CP_PLAYERS", 3)
_claude_slot = _env_int("CP_CLAUDE_SLOT", 0)
_map_size = _env_or("CP_MAP_SIZE", "duel")
_max_turns = _env_int("CP_TURNS", 25)
_screenshot_every = max(1, _env_int("CP_SCREENSHOT_EVERY", 1))
_output_dir_setting = _env_or("CP_OUTPUT_DIR", "user://demo-runs/p2-72-option-b")
_output_dir_abs = ProjectSettings.globalize_path(_output_dir_setting)
DirAccess.make_dir_recursive_absolute(_output_dir_abs)
_transcript_path = _output_dir_abs + "/transcript.jsonl"
_recap_path = _output_dir_abs + "/recap.md"
# Truncate any prior transcript so determinism checks are byte-exact.
var f: FileAccess = FileAccess.open(_transcript_path, FileAccess.WRITE)
if f != null:
f.close()
if not ClassDB.class_exists("GdPlayerApi"):
push_error("GdPlayerApi GDExtension class not registered — rebuild gdext")
get_tree().quit(1)
return
DataLoader.load_theme("age-of-dwarves")
DataLoader.load_world("earth")
ThemeAssets.set_theme("age-of-dwarves")
# Initialise GameState autoload — keeps DataLoader-bound systems happy
# even though we never read from it directly during the loop.
GameState.initialize_game({
"seed": _seed,
"map_type": "continents",
"map_size": _map_size,
"num_players": _num_players,
})
_bootstrap_world()
_build_renderers()
# One frame for renderers to flush their first _draw passes.
for _i: int in range(8):
await get_tree().process_frame
await get_tree().create_timer(0.25).timeout
_drive_game()
# ── Boot ──────────────────────────────────────────────────────────────────
func _bootstrap_world() -> void:
_gd_state = ClassDB.instantiate("GdGameState") as RefCounted
if _gd_state == null:
push_error("ClassDB.instantiate('GdGameState') returned null")
get_tree().quit(1)
return
var gen: RefCounted = ClassDB.instantiate("GdMapGenerator") as RefCounted
if gen == null:
push_error("ClassDB.instantiate('GdMapGenerator') returned null")
get_tree().quit(1)
return
gen.initialize("{}")
var grid: RefCounted = gen.generate(_seed, _map_size) as RefCounted
if grid == null:
push_error("GdMapGenerator.generate returned null")
get_tree().quit(1)
return
_gd_state.set_grid_from_gridstate(grid)
# Build the GDScript-side GameMap from the same seed + settings so the
# renderer has tile + axial data. MapGenerator is deterministic given
# the settings dict.
var settings: Dictionary = {
"seed": _seed,
"map_type": "continents",
"map_size": _map_size,
"num_players": _num_players,
}
var presentation_gen: MapGeneratorScript = MapGeneratorScript.new()
_game_map = presentation_gen.generate(settings)
if _game_map == null:
push_error("MapGeneratorScript.generate returned null")
get_tree().quit(1)
return
# Scan land tiles + pick spaced capitals, mirroring claude_player_main.gd.
var grid_w: int = int(grid.get_width())
var grid_h: int = int(grid.get_height())
var land_tiles: Array[Vector2i] = _scan_land_tiles(grid, grid_w, grid_h)
if land_tiles.is_empty():
push_error("no land tiles in generated map")
get_tree().quit(1)
return
_apply_runtime_units_catalog(_gd_state)
var capitals: Array[Vector2i] = _pick_spaced_capitals(land_tiles, _num_players)
for cap: Vector2i in capitals:
var assigned: int = int(_gd_state.add_player_militarist(cap.x, cap.y))
if assigned < 0:
push_error("add_player_militarist returned -1 — runtime units catalog missing")
get_tree().quit(1)
return
_apply_ai_personalities(_gd_state, _num_players)
# Hand the state to GdPlayerApi.
_api = ClassDB.instantiate("GdPlayerApi") as RefCounted
if _api == null:
push_error("ClassDB.instantiate('GdPlayerApi') returned null")
get_tree().quit(1)
return
# Omniscient — we drive renderers from the unredacted projection.
_api.set_omniscient(true)
var bootstrap_json: String = String(_gd_state.to_json())
if bootstrap_json.is_empty() or bootstrap_json == "{}":
push_error("GdGameState.to_json returned empty")
get_tree().quit(1)
return
if not _api.load_state_json(bootstrap_json):
push_error("GdPlayerApi.load_state_json rejected bootstrap state")
get_tree().quit(1)
return
_apply_ai_catalogs()
func _build_renderers() -> void:
_hex_renderer = HexRendererScript.new()
_hex_renderer.name = "HexRenderer"
add_child(_hex_renderer)
_hex_renderer.render_map(_game_map)
# Force-mark every tile visible — we're in omniscient mode.
var all_positions: Array[Vector2i] = []
for pos_key: Vector2i in _game_map.tiles:
all_positions.append(pos_key)
var empty_fog: Array[Vector2i] = []
_hex_renderer.update_fog(all_positions, empty_fog)
_unit_renderer = UnitRendererScript.new()
_unit_renderer.name = "UnitRenderer"
add_child(_unit_renderer)
_unit_renderer.call("setup_visibility", _claude_slot, _game_map)
_city_renderer = CityRendererScript.new()
_city_renderer.name = "CityRenderer"
add_child(_city_renderer)
# Camera centred on the full map bbox.
var min_p: Vector2 = Vector2(INF, INF)
var max_p: Vector2 = Vector2(-INF, -INF)
for pos_key: Vector2i in _game_map.tiles:
var pp: Vector2 = HexUtilsScript.axial_to_pixel(pos_key)
min_p = min_p.min(pp)
max_p = max_p.max(pp)
var bbox: Vector2 = max_p - min_p
var center: Vector2 = (min_p + max_p) * 0.5
var pad: float = 1.10
var fit_x: float = (bbox.x * pad) / float(VIEWPORT_SIZE.x)
var fit_y: float = (bbox.y * pad) / float(VIEWPORT_SIZE.y)
var world_per_screen: float = max(fit_x, fit_y)
var zoom_factor: float = 1.0 / max(world_per_screen, 0.001)
_camera = Camera2D.new()
_camera.name = "DemoCamera"
_camera.position = center
_camera.zoom = Vector2(zoom_factor, zoom_factor)
_camera.make_current()
add_child(_camera)
# ── Main loop ─────────────────────────────────────────────────────────────
func _drive_game() -> void:
# Capture turn-00 (pre-any-action world state).
var initial_view: Dictionary = _request_view()
_rehydrate_view(initial_view)
await _flush_render()
_capture_screenshot(0)
_turn_records.append({
"turn": 0,
"claude_actions": [],
"end_turn_events": [],
"scores": _snapshot_scores(initial_view),
})
for turn_idx: int in range(1, _max_turns + 1):
var record: Dictionary = {
"turn": turn_idx,
"claude_actions": [],
"end_turn_events": [],
"scores": [],
}
var attempted_signatures: Dictionary = {}
var last_view: Dictionary = {}
for iter: int in range(MAX_ACTIONS_PER_TURN):
# 1) View request — record on the wire.
var view: Dictionary = _request_view()
last_view = view
# 2) Pick action (or force EndTurn at budget limit).
var action: Dictionary
if iter + 1 == MAX_ACTIONS_PER_TURN:
action = {"type": "end_turn"}
else:
action = _pick_claude_action(view, attempted_signatures)
var sig: String = _action_signature(action)
attempted_signatures[sig] = true
# 3) Apply.
var envelope: Dictionary = _apply_action(action)
record["claude_actions"].append({
"action": action,
"ok": envelope.get("ok", false),
"event_count": int((envelope.get("events", []) as Array).size()),
})
var is_end_turn: bool = action.get("type", "") == "end_turn"
if is_end_turn:
record["end_turn_events"] = envelope.get("events", [])
last_view = envelope.get("view", {}) as Dictionary
break
# Rehydrate from the post-EndTurn view (or the last seen view).
_rehydrate_view(last_view)
await _flush_render()
record["scores"] = _snapshot_scores(last_view)
_turn_records.append(record)
if turn_idx % _screenshot_every == 0 or turn_idx == _max_turns:
_capture_screenshot(turn_idx)
# GameOver check — any event with type=game_over terminates.
if _events_contain_game_over(record["end_turn_events"]):
break
_write_recap()
print("PROOF_DONE turns=%d screenshots=%d output=%s" % [
_turn_records.size(), _captured_turns.size(), _output_dir_abs
])
# Single-line marker the wrapper script greps for.
print("OUTPUT_DIR_ABS:%s" % _output_dir_abs)
get_tree().quit(0)
# ── Claude policy (port of pick_claude_action) ────────────────────────────
func _pick_claude_action(view: Dictionary, blacklist: Dictionary) -> Dictionary:
# Priority 1 — FoundCity.
for unit: Dictionary in (view.get("units", []) as Array):
for entry: Dictionary in (unit.get("legal_actions", []) as Array):
var act: Dictionary = entry.get("action", {}) as Dictionary
if act.get("type", "") == "found_city":
if not blacklist.has(_action_signature(act)):
return act
# Priority 2 — QueueProduction(dwarf_warrior).
for city: Dictionary in (view.get("cities", []) as Array):
for entry: Dictionary in (city.get("legal_actions", []) as Array):
var act: Dictionary = entry.get("action", {}) as Dictionary
if act.get("type", "") == "queue_production" and String(act.get("item", "")) == "dwarf_warrior":
if not blacklist.has(_action_signature(act)):
return act
# Priority 3 — any QueueProduction.
for city: Dictionary in (view.get("cities", []) as Array):
for entry: Dictionary in (city.get("legal_actions", []) as Array):
var act: Dictionary = entry.get("action", {}) as Dictionary
if act.get("type", "") == "queue_production":
if not blacklist.has(_action_signature(act)):
return act
# Priority 4a — Move.
for unit: Dictionary in (view.get("units", []) as Array):
for entry: Dictionary in (unit.get("legal_actions", []) as Array):
var act: Dictionary = entry.get("action", {}) as Dictionary
if act.get("type", "") == "move":
if not blacklist.has(_action_signature(act)):
return act
# Priority 4b — Fortify.
for unit: Dictionary in (view.get("units", []) as Array):
for entry: Dictionary in (unit.get("legal_actions", []) as Array):
var act: Dictionary = entry.get("action", {}) as Dictionary
if act.get("type", "") == "fortify":
if not blacklist.has(_action_signature(act)):
return act
return {"type": "end_turn"}
func _action_signature(action: Dictionary) -> String:
var t: String = String(action.get("type", "unknown"))
match t:
"found_city":
return "found:%s" % String(action.get("unit_id", ""))
"queue_production":
return "queue:%s:%s" % [String(action.get("city_id", "")), String(action.get("item", ""))]
"move":
var to: Array = action.get("to", []) as Array
return "move:%s:%s" % [String(action.get("unit_id", "")), str(to)]
"fortify":
return "fortify:%s" % String(action.get("unit_id", ""))
"end_turn":
return "end_turn"
_:
return "other:%s" % t
# ── Wire I/O ──────────────────────────────────────────────────────────────
func _request_view() -> Dictionary:
_wire_request_id += 1
var req_id: int = _wire_request_id
var req: Dictionary = {"type": "view", "id": req_id}
_wire_append(req)
var view_json: String = String(_api.view_json(_claude_slot))
var view: Dictionary = JSON.parse_string(view_json) as Dictionary
if view == null:
push_error("view_json returned non-Dictionary payload")
view = {}
var resp: Dictionary = {"ok": true, "id": req_id, "view": view}
_wire_append(resp)
return view
func _apply_action(action: Dictionary) -> Dictionary:
_wire_request_id += 1
var req_id: int = _wire_request_id
var req: Dictionary = {"type": "act", "id": req_id, "action": action}
_wire_append(req)
var action_json: String = JSON.stringify(action)
var envelope_str: String = String(_api.apply_action_json(_claude_slot, action_json))
var envelope: Dictionary = JSON.parse_string(envelope_str) as Dictionary
if envelope == null:
envelope = {"ok": false, "error": {"code": "internal", "message": "non-JSON envelope"}}
envelope["id"] = req_id
_wire_append(envelope)
return envelope
func _wire_append(value: Dictionary) -> void:
var f: FileAccess = FileAccess.open(_transcript_path, FileAccess.READ_WRITE)
if f == null:
f = FileAccess.open(_transcript_path, FileAccess.WRITE)
if f == null:
return
f.seek_end()
f.store_line(JSON.stringify(value))
f.close()
_wire_lines += 1
# ── Rehydration ───────────────────────────────────────────────────────────
func _rehydrate_view(view: Dictionary) -> void:
## Rebuild PlayerScript / CityScript / UnitScript instances on
## GameState.players[] from the omniscient PlayerView snapshot, then
## push the flattened arrays into the renderers.
##
## PlayerView shape (per mc-player-api/src/view.rs):
## { turn, player, current_player, phase, is_human_turn,
## cities: [{ id, name, position: [c,r], owner, ... }],
## units: [{ id, type, position: [c,r], owner, hp, ... }],
## tiles: [...], score: {...}, ... }
##
## We don't reconstruct economy / research / civics — renderers only
## consume positions, owner indices, hp, type ids. Score is pulled
## into the recap directly from `view.score`.
if view.is_empty():
return
# Ensure GameState.players has _num_players slots populated.
while GameState.players.size() < _num_players:
var p: PlayerScript = PlayerScript.new()
var idx: int = GameState.players.size()
p.index = idx
p.is_human = (idx == _claude_slot)
p.player_name = "Slot %d" % idx
p.race_id = "dwarf"
p.color = SLOT_COLORS[idx % SLOT_COLORS.size()]
GameState.players.append(p)
# Wipe per-turn arrays — they get rebuilt from the view.
for p_var: Variant in GameState.players:
if p_var is PlayerScript:
(p_var as PlayerScript).units = []
(p_var as PlayerScript).cities = []
# Cities.
for city_dict: Dictionary in (view.get("cities", []) as Array):
var owner_idx: int = int(city_dict.get("owner", -1))
if owner_idx < 0 or owner_idx >= GameState.players.size():
continue
var owner_player: PlayerScript = GameState.players[owner_idx] as PlayerScript
if owner_player == null:
continue
var pos: Vector2i = _wire_hex_to_vec(city_dict.get("position", []))
var c: CityScript = CityScript.new(String(city_dict.get("id", "")))
c.owner_index = owner_idx
c.position = pos
if "city_name" in c:
c.city_name = String(city_dict.get("name", ""))
if "is_capital" in c:
c.is_capital = bool(city_dict.get("is_capital", false))
if "population" in c:
var pop_val: int = int(city_dict.get("population", 1))
c.population = max(1, pop_val)
owner_player.cities.append(c)
# Units.
for unit_dict: Dictionary in (view.get("units", []) as Array):
var owner_idx2: int = int(unit_dict.get("owner", -1))
if owner_idx2 < 0 or owner_idx2 >= GameState.players.size():
continue
var owner_player2: PlayerScript = GameState.players[owner_idx2] as PlayerScript
if owner_player2 == null:
continue
var pos2: Vector2i = _wire_hex_to_vec(unit_dict.get("position", []))
var type_id: String = String(unit_dict.get("type", "dwarf_warrior"))
var u: UnitScript = UnitScript.new(type_id, owner_idx2, pos2)
u.id = String(unit_dict.get("id", ""))
u.hp = int(unit_dict.get("hp", 1))
u.max_hp = int(unit_dict.get("max_hp", u.hp))
u.movement_remaining = int(unit_dict.get("movement_left", 0))
u.max_movement = int(unit_dict.get("movement_max", u.movement_remaining))
u.is_fortified = bool(unit_dict.get("fortified", false))
owner_player2.units.append(u)
# Aggregate + push into renderers.
var all_units: Array = []
var all_cities: Array = []
for p_var2: Variant in GameState.players:
if p_var2 is PlayerScript:
all_units.append_array((p_var2 as PlayerScript).units)
all_cities.append_array((p_var2 as PlayerScript).cities)
if _unit_renderer != null:
_unit_renderer.call("sync_units", all_units)
if _city_renderer != null:
_city_renderer.call("sync_cities", all_cities)
func _wire_hex_to_vec(raw: Variant) -> Vector2i:
if raw is Array and (raw as Array).size() >= 2:
var arr: Array = raw as Array
return Vector2i(int(arr[0]), int(arr[1]))
return Vector2i.ZERO
# ── Render flush + screenshot ─────────────────────────────────────────────
func _flush_render() -> void:
# Three frames is enough for the renderers' _draw queues to flush
# and for the viewport texture to update.
for _i: int in range(3):
await get_tree().process_frame
func _capture_screenshot(turn_idx: int) -> void:
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("viewport image null at turn %d" % turn_idx)
return
var path: String = "%s/turn-%02d.png" % [_output_dir_abs, turn_idx]
var err: Error = image.save_png(path)
if err == OK:
_captured_turns.append(turn_idx)
print("SCREENSHOT_TURN:%02d:%s" % [turn_idx, path])
else:
push_error("save_png failed turn=%d err=%s" % [turn_idx, error_string(err)])
# ── Recap ─────────────────────────────────────────────────────────────────
func _snapshot_scores(view: Dictionary) -> Array:
## Claude's score is in view.score; for AI slots we'd need separate
## view_json calls. To keep wire-symmetric with the headless harness
## (which only queries Claude's view), we only emit Claude's score here.
## AI activity is captured via end_turn events instead.
var s: Dictionary = view.get("score", {}) as Dictionary
return [{
"slot": _claude_slot,
"gold": int(s.get("gold_total", 0)),
"cities": int(s.get("city_count", 0)),
"units": int(s.get("unit_count", 0)),
"score_estimate": int(s.get("score_estimate", 0)),
}]
func _events_contain_game_over(events: Variant) -> bool:
if not (events is Array):
return false
for ev_var: Variant in (events as Array):
if ev_var is Dictionary and String((ev_var as Dictionary).get("type", "")) == "game_over":
return true
return false
func _write_recap() -> void:
var lines: PackedStringArray = PackedStringArray()
lines.append("# p2-72 Option B — Claude-vs-AI Render Proof")
lines.append("")
lines.append("- Seed: %d" % _seed)
lines.append("- Players: %d (Claude slot: %d)" % [_num_players, _claude_slot])
lines.append("- Map size: %s" % _map_size)
lines.append("- Turns driven: %d" % (_turn_records.size() - 1))
lines.append("- Screenshots captured: %d" % _captured_turns.size())
lines.append("- Wire transcript lines: %d" % _wire_lines)
lines.append("")
lines.append("## Dual-state acknowledgement")
lines.append("")
lines.append("`GdPlayerApi` is the source of truth. GDScript entities are a per-turn")
lines.append("rehydrated VIEW only. See proof scene docstring for details.")
lines.append("")
lines.append("## Per-turn log")
lines.append("")
lines.append("| Turn | Claude actions | Claude gold | Cities | Units | Score | EndTurn events |")
lines.append("|------|---------------:|------------:|-------:|------:|------:|---------------:|")
for rec: Dictionary in _turn_records:
var sigs: PackedStringArray = PackedStringArray()
for a: Dictionary in (rec.get("claude_actions", []) as Array):
var act: Dictionary = a.get("action", {}) as Dictionary
sigs.append(_action_signature(act))
var scores: Array = rec.get("scores", []) as Array
var s0: Dictionary = scores[0] as Dictionary if scores.size() > 0 else {}
var et_events: Array = rec.get("end_turn_events", []) as Array
lines.append("| %d | %s | %d | %d | %d | %d | %d |" % [
int(rec.get("turn", 0)),
" / ".join(sigs),
int(s0.get("gold", 0)),
int(s0.get("cities", 0)),
int(s0.get("units", 0)),
int(s0.get("score_estimate", 0)),
et_events.size(),
])
var f: FileAccess = FileAccess.open(_recap_path, FileAccess.WRITE)
if f != null:
f.store_string("\n".join(lines) + "\n")
f.close()
# ── Boot helpers ported from claude_player_main.gd ────────────────────────
func _scan_land_tiles(grid: RefCounted, w: int, h: int) -> Array[Vector2i]:
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]:
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 _apply_runtime_units_catalog(gs: RefCounted) -> void:
const UNITS_DIR: String = "res://public/resources/units"
var dir: DirAccess = DirAccess.open(UNITS_DIR)
if dir == null:
push_error("could not open " + UNITS_DIR)
return
var raw_parts: PackedStringArray = PackedStringArray()
dir.list_dir_begin()
var fname: String = dir.get_next()
while fname != "":
if (
not dir.current_is_dir()
and fname.ends_with(".json")
and not fname.ends_with(".schema.json")
):
var path: String = UNITS_DIR + "/" + fname
var f: FileAccess = FileAccess.open(path, FileAccess.READ)
if f != null:
var stripped: String = f.get_as_text().strip_edges()
f.close()
if stripped.length() > 0:
var head: String = stripped.substr(0, 1)
if head == "[":
var inner: String = stripped.substr(1, stripped.length() - 2).strip_edges()
if inner.length() > 0:
raw_parts.append(inner)
elif head == "{":
raw_parts.append(stripped)
fname = dir.get_next()
dir.list_dir_end()
if raw_parts.is_empty():
push_error("no unit JSON files harvested from " + UNITS_DIR)
return
var json: String = "[" + ",".join(raw_parts) + "]"
gs.set_units_runtime_catalog_json(json)
func _apply_ai_catalogs() -> void:
var AiTurnBridgeState: Script = load("res://engine/src/modules/ai/ai_turn_bridge_state.gd")
if AiTurnBridgeState == null:
push_error("could not load AiTurnBridgeState")
return
var unit_catalog: Array = AiTurnBridgeState.build_unit_catalog()
var building_catalog: Array = AiTurnBridgeState.build_building_catalog()
_api.set_units_catalog_json(JSON.stringify(unit_catalog))
_api.set_buildings_catalog_json(JSON.stringify(building_catalog))
var diff_mult: float = AiTurnBridgeState.load_difficulty_threshold_mult()
_api.set_difficulty_threshold_mult(diff_mult)
func _apply_ai_personalities(gs: RefCounted, num_players: int) -> void:
const PERSONALITIES_PATH: String = "res://public/games/age-of-dwarves/data/ai_personalities.json"
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():
push_error("could not read ai_personalities.json")
return
var personalities: Dictionary = JSON.parse_string(json_text) as Dictionary
if personalities.is_empty():
push_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():
return
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
gs.set_player_personality_json(slot, clan_id, json_text)
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)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://engine/scenes/tests/claude_vs_ai_render_proof.gd" id="1"]
[node name="ClaudeVsAiRenderProof" type="Node2D"]
script = ExtResource("1")