From cc927240fbf2e96862db139c8a9c4bc8fa18650c Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 12 May 2026 11:40:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects):=20=E2=9C=A8=20add=20ai=20vs=20?= =?UTF-8?q?claudio=20render=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- ...72a-gdgamestate-canonical-render-source.md | 13 + scripts/p2-72-option-b-render.sh | 97 +++ .../scenes/tests/claude_vs_ai_render_proof.gd | 782 ++++++++++++++++++ .../tests/claude_vs_ai_render_proof.tscn | 6 + 4 files changed, 898 insertions(+) create mode 100755 scripts/p2-72-option-b-render.sh create mode 100644 src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd create mode 100644 src/game/engine/scenes/tests/claude_vs_ai_render_proof.tscn diff --git a/.project/objectives/p2-72a-gdgamestate-canonical-render-source.md b/.project/objectives/p2-72a-gdgamestate-canonical-render-source.md index 30db92f1..d888b576 100644 --- a/.project/objectives/p2-72a-gdgamestate-canonical-render-source.md +++ b/.project/objectives/p2-72a-gdgamestate-canonical-render-source.md @@ -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` 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> 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). diff --git a/scripts/p2-72-option-b-render.sh b/scripts/p2-72-option-b-render.sh new file mode 100755 index 00000000..0173a7f0 --- /dev/null +++ b/scripts/p2-72-option-b-render.sh @@ -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 diff --git a/src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd b/src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd new file mode 100644 index 00000000..2d781ef1 --- /dev/null +++ b/src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd @@ -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) diff --git a/src/game/engine/scenes/tests/claude_vs_ai_render_proof.tscn b/src/game/engine/scenes/tests/claude_vs_ai_render_proof.tscn new file mode 100644 index 00000000..639bed7b --- /dev/null +++ b/src/game/engine/scenes/tests/claude_vs_ai_render_proof.tscn @@ -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")