feat(@projects): ✨ add ai vs claudio render test suite
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
77c6c4219d
commit
cc927240fb
4 changed files with 898 additions and 0 deletions
|
|
@ -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).
|
||||
|
|
|
|||
97
scripts/p2-72-option-b-render.sh
Executable file
97
scripts/p2-72-option-b-render.sh
Executable 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
|
||||
782
src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd
Normal file
782
src/game/engine/scenes/tests/claude_vs_ai_render_proof.gd
Normal 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)
|
||||
|
|
@ -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")
|
||||
Loading…
Add table
Reference in a new issue