feat(@projects/@magic-civilization): ✨ add seeded auto-play test directory structure
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9f2f8f2eae
commit
d72b837eeb
6 changed files with 342 additions and 90 deletions
|
|
@ -57,6 +57,10 @@ var _total_cities_founded: int = 0
|
|||
var _total_cities_captured: int = 0
|
||||
var _turn_first_combat: int = -1
|
||||
var _turn_first_city_captured: int = -1
|
||||
# Buffered event log — flushed to events.jsonl once per turn
|
||||
var _event_buffer: Array[Dictionary] = []
|
||||
# Guards against writing terminal outcome line twice (e.g. victory during max_turns path)
|
||||
var _final_line_written: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -75,7 +79,15 @@ func _ready() -> void:
|
|||
_seed_set = true
|
||||
seed(_seed)
|
||||
GameState.game_settings["seed"] = _seed
|
||||
print("AutoPlay: seed=%d" % _seed)
|
||||
var now: Dictionary = Time.get_datetime_dict_from_system(true)
|
||||
_start_stamp = "%04d%02d%02dT%02d%02d%02dZ" % [
|
||||
now["year"], now["month"], now["day"],
|
||||
now["hour"], now["minute"], now["second"],
|
||||
]
|
||||
_game_dir = _output_dir.path_join("game_%s_seed%d" % [_start_stamp, _seed])
|
||||
DirAccess.make_dir_recursive_absolute(_game_dir.path_join("saves"))
|
||||
print("AutoPlay: seed=%d stamp=%s dir=%s" % [_seed, _start_stamp, _game_dir])
|
||||
_write_meta()
|
||||
|
||||
_start_time = Time.get_unix_time_from_system()
|
||||
EventBus.victory_achieved.connect(_on_victory)
|
||||
|
|
@ -83,6 +95,10 @@ func _ready() -> void:
|
|||
EventBus.city_starved.connect(_on_city_starved)
|
||||
EventBus.city_founded.connect(_on_city_founded)
|
||||
EventBus.city_captured.connect(_on_city_captured)
|
||||
EventBus.city_grew.connect(_on_city_grew)
|
||||
EventBus.tech_researched.connect(_on_tech_researched)
|
||||
EventBus.unit_created.connect(_on_unit_created)
|
||||
EventBus.unit_destroyed.connect(_on_unit_destroyed)
|
||||
|
||||
|
||||
func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> void:
|
||||
|
|
@ -96,34 +112,56 @@ func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> voi
|
|||
_turn_first_combat = _turn_count
|
||||
var defender_killed: bool = result.get("defender_killed", false) == true
|
||||
var attacker_killed: bool = result.get("attacker_killed", false) == true
|
||||
var atk_idx: int = -1
|
||||
var def_idx: int = -1
|
||||
if attacker != null and attacker.get("owner") != null:
|
||||
var atk_idx: int = int(attacker.get("owner"))
|
||||
atk_idx = int(attacker.get("owner"))
|
||||
_ensure_stats(atk_idx)
|
||||
if defender_killed:
|
||||
_stats[atk_idx]["kills"] = int(_stats[atk_idx].get("kills", 0)) + 1
|
||||
if attacker_killed:
|
||||
_stats[atk_idx]["units_lost"] = int(_stats[atk_idx].get("units_lost", 0)) + 1
|
||||
if defender != null and defender.get("owner") != null:
|
||||
var def_idx: int = int(defender.get("owner"))
|
||||
def_idx = int(defender.get("owner"))
|
||||
_ensure_stats(def_idx)
|
||||
if defender_killed:
|
||||
_stats[def_idx]["units_lost"] = int(_stats[def_idx].get("units_lost", 0)) + 1
|
||||
if attacker_killed:
|
||||
_stats[def_idx]["kills"] = int(_stats[def_idx].get("kills", 0)) + 1
|
||||
_append_event({
|
||||
"type": "combat_resolved",
|
||||
"attacker_player": atk_idx,
|
||||
"defender_player": def_idx,
|
||||
"atk_damage": int(result.get("attacker_damage", 0)),
|
||||
"def_damage": int(result.get("defender_damage", 0)),
|
||||
"atk_killed": attacker_killed,
|
||||
"def_killed": defender_killed,
|
||||
})
|
||||
|
||||
|
||||
func _on_city_starved(city: Variant, _new_pop: int) -> void:
|
||||
func _on_city_starved(city: Variant, new_pop: int) -> void:
|
||||
if city == null or city.get("owner") == null:
|
||||
return
|
||||
var idx: int = int(city.get("owner"))
|
||||
_starved_this_turn[idx] = true
|
||||
_append_event({
|
||||
"type": "city_starved",
|
||||
"player": idx,
|
||||
"city": str(city.get("city_name")) if city.get("city_name") != null else "",
|
||||
"pop": new_pop,
|
||||
})
|
||||
|
||||
|
||||
func _on_city_founded(_city: Variant, _player_index: int) -> void:
|
||||
func _on_city_founded(city: Variant, player_index: int) -> void:
|
||||
_total_cities_founded += 1
|
||||
_append_event({
|
||||
"type": "city_founded",
|
||||
"player": player_index,
|
||||
"city": str(city.get("city_name")) if city != null and city.get("city_name") != null else "",
|
||||
})
|
||||
|
||||
|
||||
func _on_city_captured(_city: Variant, old_owner: int, new_owner: int) -> void:
|
||||
func _on_city_captured(city: Variant, old_owner: int, new_owner: int) -> void:
|
||||
_total_cities_captured += 1
|
||||
if _turn_first_city_captured < 0:
|
||||
_turn_first_city_captured = _turn_count
|
||||
|
|
@ -137,6 +175,53 @@ func _on_city_captured(_city: Variant, old_owner: int, new_owner: int) -> void:
|
|||
_stats[old_owner]["cities_lost"] = (
|
||||
int(_stats[old_owner].get("cities_lost", 0)) + 1
|
||||
)
|
||||
_append_event({
|
||||
"type": "city_captured",
|
||||
"old_owner": old_owner,
|
||||
"new_owner": new_owner,
|
||||
"city": str(city.get("city_name")) if city != null and city.get("city_name") != null else "",
|
||||
})
|
||||
|
||||
|
||||
func _on_city_grew(city: Variant, new_pop: int) -> void:
|
||||
if city == null:
|
||||
return
|
||||
var idx: int = int(city.get("owner")) if city.get("owner") != null else -1
|
||||
_append_event({
|
||||
"type": "city_grew",
|
||||
"player": idx,
|
||||
"city": str(city.get("city_name")) if city.get("city_name") != null else "",
|
||||
"pop": new_pop,
|
||||
})
|
||||
|
||||
|
||||
func _on_tech_researched(tech_id: String, player_index: int) -> void:
|
||||
_append_event({
|
||||
"type": "tech_researched",
|
||||
"player": player_index,
|
||||
"tech": tech_id,
|
||||
})
|
||||
|
||||
|
||||
func _on_unit_created(unit: Variant, player_index: int) -> void:
|
||||
if unit == null:
|
||||
return
|
||||
_append_event({
|
||||
"type": "unit_created",
|
||||
"player": player_index,
|
||||
"unit": str(unit.get("type_id")) if unit.get("type_id") != null else "",
|
||||
})
|
||||
|
||||
|
||||
func _on_unit_destroyed(unit: Variant, _killer: Variant) -> void:
|
||||
if unit == null:
|
||||
return
|
||||
var idx: int = int(unit.get("owner")) if unit.get("owner") != null else -1
|
||||
_append_event({
|
||||
"type": "unit_destroyed",
|
||||
"player": idx,
|
||||
"unit": str(unit.get("type_id")) if unit.get("type_id") != null else "",
|
||||
})
|
||||
|
||||
|
||||
func _ensure_stats(player_index: int) -> void:
|
||||
|
|
@ -164,7 +249,7 @@ func _on_victory(player_index: int, victory_type: String) -> void:
|
|||
"victory_type": victory_type,
|
||||
})
|
||||
_screenshot("victory_turn_%03d" % _turn_count)
|
||||
_write_result("victory")
|
||||
_finalize_run()
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
|
|
@ -258,8 +343,8 @@ func _process(_delta: float) -> void:
|
|||
if _frame == 5:
|
||||
_screenshot("final_turn_%03d" % _turn_count)
|
||||
print("AutoPlay: finished — %d turns, victory=%s" % [_turn_count, _victory])
|
||||
var outcome: String = "victory" if _victory else "max_turns"
|
||||
_write_result(outcome)
|
||||
_outcome = "victory" if _victory else "max_turns"
|
||||
_finalize_run()
|
||||
get_tree().quit(0 if _victory else 1)
|
||||
|
||||
if _turn_count >= _max_turns and _state != "done":
|
||||
|
|
@ -585,6 +670,9 @@ func _play_turn() -> void:
|
|||
u.is_fortified = true
|
||||
u.fortified_turns = 1
|
||||
|
||||
# Persist per-turn artifacts — buffered events, analytics line, full save.
|
||||
_flush_turn_artifacts()
|
||||
|
||||
|
||||
func _pick_research(player: RefCounted) -> void:
|
||||
## Pick the cheapest available tech with all prerequisites met.
|
||||
|
|
@ -1266,16 +1354,17 @@ func _find_node_by_name(node: Node, target_name: String) -> Node:
|
|||
func _fail(msg: String) -> void:
|
||||
push_error("AutoPlay: FAIL — %s" % msg)
|
||||
_screenshot("error")
|
||||
_write_result("defeat")
|
||||
_outcome = "defeat"
|
||||
_finalize_run()
|
||||
get_tree().quit(1)
|
||||
|
||||
|
||||
# ── Invariants & Result Writer ───────────────────────────────────────
|
||||
# ── Invariants, Event Log, Turn Stats, Saves ─────────────────────────
|
||||
|
||||
func _check_invariants(player: RefCounted) -> void:
|
||||
## Per-turn invariant checks and peak/milestone tracking for the current
|
||||
## player. Violations are captured into `_violations` without aborting —
|
||||
## failures are reported in the JSON summary so the batch runner can grade runs.
|
||||
## failures are reported in turn_stats.jsonl so the batch runner can grade runs.
|
||||
var idx: int = player.index
|
||||
var pop: int = 0
|
||||
for c: Variant in player.cities:
|
||||
|
|
@ -1378,10 +1467,55 @@ func _build_player_stats() -> Dictionary:
|
|||
return out
|
||||
|
||||
|
||||
func _write_result(outcome: String) -> void:
|
||||
## Write structured result JSON. Idempotent — subsequent calls no-op.
|
||||
if _result_written:
|
||||
func _write_meta() -> void:
|
||||
## Write meta.json once at start-of-run. Captures seed + settings snapshot.
|
||||
if not _seed_set:
|
||||
return
|
||||
var meta: Dictionary = {
|
||||
"seed": _seed,
|
||||
"start_stamp": _start_stamp,
|
||||
"game_settings": GameState.game_settings.duplicate(true),
|
||||
"schema_version": 1,
|
||||
}
|
||||
var path: String = _game_dir.path_join("meta.json")
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("AutoPlay: cannot open %s for writing" % path)
|
||||
return
|
||||
file.store_string(JSON.stringify(meta, " "))
|
||||
file.close()
|
||||
|
||||
|
||||
func _append_event(event: Dictionary) -> void:
|
||||
## Buffer an event; flushed once per turn in _flush_turn_artifacts().
|
||||
if not _seed_set:
|
||||
return
|
||||
event["turn"] = _turn_count
|
||||
_event_buffer.append(event)
|
||||
|
||||
|
||||
func _flush_events() -> void:
|
||||
## Append all buffered events to events.jsonl as newline-delimited JSON.
|
||||
## Opens in READ_WRITE + seek_end to preserve prior turns' lines.
|
||||
if not _seed_set or _event_buffer.is_empty():
|
||||
return
|
||||
var path: String = _game_dir.path_join("events.jsonl")
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ_WRITE)
|
||||
if file == null:
|
||||
# First write — file doesn't exist yet
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("AutoPlay: cannot open %s for writing" % path)
|
||||
return
|
||||
file.seek_end()
|
||||
for event: Dictionary in _event_buffer:
|
||||
file.store_line(JSON.stringify(event))
|
||||
file.close()
|
||||
_event_buffer.clear()
|
||||
|
||||
|
||||
func _append_turn_stats(outcome: String) -> void:
|
||||
## Append one JSON line describing the current turn's state.
|
||||
if not _seed_set:
|
||||
return
|
||||
_result_written = true
|
||||
|
|
@ -1393,9 +1527,8 @@ func _write_result(outcome: String) -> void:
|
|||
"turn_first_combat": _turn_first_combat,
|
||||
"turn_first_city_captured": _turn_first_city_captured,
|
||||
}
|
||||
var result: Dictionary = {
|
||||
"seed": _seed,
|
||||
"turns_played": _turn_count,
|
||||
var line: Dictionary = {
|
||||
"turn": _turn_count,
|
||||
"outcome": outcome,
|
||||
"winner_index": _victory_winner,
|
||||
"victory_type": _victory_type,
|
||||
|
|
@ -1404,12 +1537,42 @@ func _write_result(outcome: String) -> void:
|
|||
"player_stats": _build_player_stats(),
|
||||
"invariant_violations": _violations,
|
||||
}
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
var path: String = _output_dir.path_join("result_%d.json" % _seed)
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
var path: String = _game_dir.path_join("turn_stats.jsonl")
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ_WRITE)
|
||||
if file == null:
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("AutoPlay: cannot open %s for writing" % path)
|
||||
return
|
||||
file.store_string(JSON.stringify(result, " "))
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify(line))
|
||||
file.close()
|
||||
print("AutoPlay: result written — %s" % path)
|
||||
|
||||
|
||||
func _save_turn_snapshot() -> void:
|
||||
## Write full GameState serialization for this turn.
|
||||
if not _seed_set:
|
||||
return
|
||||
var save_path: String = _game_dir.path_join("saves/turn_%04d.save" % _turn_count)
|
||||
var err: Error = SaveManager.save_to_path(save_path)
|
||||
if err != OK:
|
||||
push_error("AutoPlay: save failed (%s): %s" % [error_string(err), save_path])
|
||||
|
||||
|
||||
func _flush_turn_artifacts() -> void:
|
||||
## End-of-turn persistence: events, turn_stats line, save snapshot.
|
||||
## Cheap to skip for unseeded runs (each callee short-circuits on _seed_set).
|
||||
_flush_events()
|
||||
_append_turn_stats(_outcome)
|
||||
_save_turn_snapshot()
|
||||
|
||||
|
||||
func _finalize_run() -> void:
|
||||
## Terminal persistence: flush any trailing events, write one final
|
||||
## turn_stats line with the terminal outcome. Idempotent — guarded by
|
||||
## `_final_line_written` so max_turns→victory overlap doesn't double-write.
|
||||
if not _seed_set or _final_line_written:
|
||||
return
|
||||
_final_line_written = true
|
||||
_flush_events()
|
||||
_append_turn_stats(_outcome)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# autoplay-batch.sh — Run auto_play N times with different seeds and collect result JSON files.
|
||||
# autoplay-batch.sh — Run auto_play N times with different seeds and collect per-game output dirs.
|
||||
#
|
||||
# Usage: tools/autoplay-batch.sh [count=3] [turn_limit=500] [results_dir=/tmp/autoplay_batch]
|
||||
#
|
||||
# Output layout:
|
||||
# <results_dir>/game_<stamp>_seed<N>/
|
||||
# meta.json
|
||||
# turn_stats.jsonl
|
||||
# events.jsonl
|
||||
# game.log
|
||||
# weston.log (local only)
|
||||
# *.save (per-turn saves, if configured)
|
||||
#
|
||||
# Environment:
|
||||
# AUTOPLAY_HOST — If set (e.g. "lilith@apricot.local"), run each game via SSH using
|
||||
# /tmp/run_ap3.sh on the remote host and scp results back.
|
||||
|
|
@ -39,9 +48,12 @@ fi
|
|||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
echo "============================================================"
|
||||
echo "Autoplay Batch: $COUNT games, turn_limit=$TURN_LIMIT"
|
||||
echo "Results: $RESULTS_DIR"
|
||||
echo "Stamp: $STAMP"
|
||||
if [ -n "$AUTOPLAY_HOST" ]; then
|
||||
echo "Mode: remote SSH ($AUTOPLAY_HOST)"
|
||||
else
|
||||
|
|
@ -51,7 +63,6 @@ echo "Safety timeout: ${SAFETY_TIMEOUT}s per game"
|
|||
echo "============================================================"
|
||||
|
||||
_kill_stale_procs() {
|
||||
# Kill stale weston/godot from previous runs (local only)
|
||||
pkill -f "weston.*godot-headless" 2>/dev/null || true
|
||||
pkill -f "org.godotengine.Godot" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
|
|
@ -59,7 +70,7 @@ _kill_stale_procs() {
|
|||
|
||||
_run_local() {
|
||||
local seed="$1"
|
||||
local seed_dir="$2"
|
||||
local game_dir="$2"
|
||||
|
||||
if ! command -v flatpak >/dev/null 2>&1; then
|
||||
echo "ERROR: flatpak not installed. Set AUTOPLAY_HOST to run on a remote Linux host." >&2
|
||||
|
|
@ -71,7 +82,7 @@ _run_local() {
|
|||
echo "[seed $seed] Starting weston (headless)..."
|
||||
WESTON_SOCKET="godot-headless-$$"
|
||||
weston --backend=headless --socket="$WESTON_SOCKET" --width=1920 --height=1080 \
|
||||
>"$seed_dir/weston.log" 2>&1 &
|
||||
>"$game_dir/weston.log" 2>&1 &
|
||||
WESTON_PID=$!
|
||||
sleep 1
|
||||
|
||||
|
|
@ -84,12 +95,12 @@ _run_local() {
|
|||
--env=AUTO_PLAY=true \
|
||||
--env=AUTO_PLAY_SEED="$seed" \
|
||||
--env=AUTO_PLAY_TURN_LIMIT="$TURN_LIMIT" \
|
||||
--env=AUTO_PLAY_DIR="$seed_dir" \
|
||||
--env=AUTO_PLAY_DIR="$game_dir" \
|
||||
org.godotengine.Godot \
|
||||
--path "$GAME_DIR" \
|
||||
--rendering-method gl_compatibility \
|
||||
--headless \
|
||||
>"$seed_dir/game.log" 2>&1 || {
|
||||
>"$game_dir/game.log" 2>&1 || {
|
||||
local exit_code=$?
|
||||
echo "[seed $seed] Godot exited with code $exit_code" >&2
|
||||
}
|
||||
|
|
@ -100,17 +111,15 @@ _run_local() {
|
|||
|
||||
_run_remote() {
|
||||
local seed="$1"
|
||||
local seed_dir="$2"
|
||||
local game_dir="$2"
|
||||
|
||||
echo "[seed $seed] Running via SSH on $AUTOPLAY_HOST..."
|
||||
|
||||
# Build a remote results dir that run_ap3.sh can write to (not /tmp — Flatpak sandbox)
|
||||
local remote_seed_dir
|
||||
remote_seed_dir="\$HOME/tmp/autoplay_batch/seed_${seed}"
|
||||
local remote_game_dir="\$HOME/tmp/autoplay_batch/game_${STAMP}_seed${seed}"
|
||||
|
||||
ssh "$AUTOPLAY_HOST" "
|
||||
set -euo pipefail
|
||||
mkdir -p '$remote_seed_dir'
|
||||
mkdir -p '$remote_game_dir'
|
||||
if [ ! -f /tmp/run_ap3.sh ]; then
|
||||
echo 'ERROR: /tmp/run_ap3.sh not found on $AUTOPLAY_HOST' >&2
|
||||
exit 1
|
||||
|
|
@ -118,14 +127,14 @@ _run_remote() {
|
|||
AUTO_PLAY=true \
|
||||
AUTO_PLAY_SEED='$seed' \
|
||||
AUTO_PLAY_TURN_LIMIT='$TURN_LIMIT' \
|
||||
AUTO_PLAY_DIR='$remote_seed_dir' \
|
||||
bash /tmp/run_ap3.sh >'$remote_seed_dir/game.log' 2>&1
|
||||
AUTO_PLAY_DIR='$remote_game_dir' \
|
||||
bash /tmp/run_ap3.sh >'$remote_game_dir/game.log' 2>&1
|
||||
" || {
|
||||
echo "[seed $seed] SSH run exited with error — see $seed_dir/game.log after scp" >&2
|
||||
echo "[seed $seed] SSH run exited with error — see $game_dir/game.log after scp" >&2
|
||||
}
|
||||
|
||||
echo "[seed $seed] Fetching results from $AUTOPLAY_HOST..."
|
||||
scp -r "$AUTOPLAY_HOST:\$HOME/tmp/autoplay_batch/seed_${seed}/." "$seed_dir/" \
|
||||
scp -r "$AUTOPLAY_HOST:\$HOME/tmp/autoplay_batch/game_${STAMP}_seed${seed}/." "$game_dir/" \
|
||||
>/dev/null 2>&1 || {
|
||||
echo "WARNING: scp failed for seed $seed — result may be missing" >&2
|
||||
}
|
||||
|
|
@ -136,26 +145,34 @@ _run_remote() {
|
|||
FAILED_SEEDS=()
|
||||
|
||||
for seed in $(seq 1 "$COUNT"); do
|
||||
seed_dir="$RESULTS_DIR/seed_${seed}"
|
||||
mkdir -p "$seed_dir"
|
||||
game_dir="$RESULTS_DIR/game_${STAMP}_seed${seed}"
|
||||
mkdir -p "$game_dir"
|
||||
echo ""
|
||||
echo "[$(date +%H:%M:%S)] === Game $seed/$COUNT (seed=$seed) ==="
|
||||
echo "[seed $seed] Output dir: $game_dir"
|
||||
|
||||
if [ -n "$AUTOPLAY_HOST" ]; then
|
||||
_run_remote "$seed" "$seed_dir"
|
||||
_run_remote "$seed" "$game_dir"
|
||||
else
|
||||
_run_local "$seed" "$seed_dir"
|
||||
_run_local "$seed" "$game_dir"
|
||||
fi
|
||||
|
||||
# Look for timestamped result file (result_<stamp>_seed<N>.json) or legacy (result_<N>.json)
|
||||
result_file="$(ls -1 "$seed_dir"/result_*_seed${seed}.json 2>/dev/null | tail -n1)"
|
||||
if [ -z "$result_file" ] && [ -f "$seed_dir/result_${seed}.json" ]; then
|
||||
result_file="$seed_dir/result_${seed}.json"
|
||||
fi
|
||||
if [ -n "$result_file" ] && [ -f "$result_file" ]; then
|
||||
echo "[seed $seed] OK — result written to $result_file"
|
||||
# Check for meta.json + non-empty turn_stats.jsonl as canonical success indicators
|
||||
meta_ok=false
|
||||
stats_ok=false
|
||||
[ -f "$game_dir/meta.json" ] && meta_ok=true
|
||||
[ -f "$game_dir/turn_stats.jsonl" ] && [ -s "$game_dir/turn_stats.jsonl" ] && stats_ok=true
|
||||
|
||||
if $meta_ok && $stats_ok; then
|
||||
line_count="$(wc -l < "$game_dir/turn_stats.jsonl" | tr -d ' ')"
|
||||
echo "[seed $seed] OK — meta.json present, turn_stats.jsonl has $line_count line(s)"
|
||||
else
|
||||
echo "[seed $seed] MISSING result file (no result_*_seed${seed}.json found)" >&2
|
||||
if ! $meta_ok; then
|
||||
echo "[seed $seed] MISSING meta.json" >&2
|
||||
fi
|
||||
if ! $stats_ok; then
|
||||
echo "[seed $seed] MISSING or empty turn_stats.jsonl (game may have crashed)" >&2
|
||||
fi
|
||||
FAILED_SEEDS+=("$seed")
|
||||
fi
|
||||
done
|
||||
|
|
@ -165,13 +182,13 @@ done
|
|||
echo ""
|
||||
echo "============================================================"
|
||||
PRODUCED=$(( COUNT - ${#FAILED_SEEDS[@]} ))
|
||||
echo "Batch complete: $PRODUCED/$COUNT games produced result.json"
|
||||
echo "Batch complete: $PRODUCED/$COUNT games produced turn_stats.jsonl"
|
||||
echo "Results: $RESULTS_DIR"
|
||||
echo "============================================================"
|
||||
|
||||
if [ ${#FAILED_SEEDS[@]} -gt 0 ]; then
|
||||
echo "ERROR: Missing result.json for seeds: ${FAILED_SEEDS[*]}" >&2
|
||||
echo " Check game.log in each seed dir for details." >&2
|
||||
echo "ERROR: No turn_stats.jsonl for seeds: ${FAILED_SEEDS[*]}" >&2
|
||||
echo " Check game.log in each game dir for details." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ from autoplay_validate import load_schema, validate # noqa: E402
|
|||
|
||||
TURN_STATS_SCHEMA_NAME = "turn-stats-line"
|
||||
EVENTS_SCHEMA_NAME = "events-line"
|
||||
META_SCHEMA_NAME = "meta"
|
||||
|
||||
EVENT_TYPES = [
|
||||
"city_founded", "city_captured", "city_grew", "city_starved",
|
||||
"tech_researched", "unit_created", "unit_destroyed", "combat_resolved", "victory",
|
||||
]
|
||||
|
||||
|
||||
def find_game_dirs(results_dir: Path) -> tuple[list[tuple[int, Path]], list[int]]:
|
||||
|
|
@ -88,6 +94,26 @@ def _count_jsonl_lines(path: Path) -> int:
|
|||
return sum(1 for l in text.splitlines() if l.strip())
|
||||
|
||||
|
||||
def _count_events_by_type(path: Path) -> dict[str, int]:
|
||||
"""Read events.jsonl, count occurrences per event type."""
|
||||
counts: dict[str, int] = {}
|
||||
try:
|
||||
text = path.read_text()
|
||||
except OSError:
|
||||
return counts
|
||||
for raw in text.splitlines():
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
t = obj.get("type", "<unknown>")
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
AGGREGATE_FIELDS = [
|
||||
"total_combats",
|
||||
"total_cities_founded",
|
||||
|
|
@ -108,9 +134,12 @@ PLAYER_FIELDS = [
|
|||
]
|
||||
|
||||
|
||||
def extract_row(seed: int, data: dict[str, Any], event_count: int) -> dict[str, Any]:
|
||||
def extract_row(
|
||||
seed: int, data: dict[str, Any], event_counts: dict[str, int]
|
||||
) -> dict[str, Any]:
|
||||
# turn-stats-line uses "turn" not "turns_played"
|
||||
turn = data.get("turn", data.get("turns_played", -1))
|
||||
total_events = sum(event_counts.values())
|
||||
row: dict[str, Any] = {
|
||||
"seed": seed,
|
||||
"outcome": data["outcome"],
|
||||
|
|
@ -118,8 +147,10 @@ def extract_row(seed: int, data: dict[str, Any], event_count: int) -> dict[str,
|
|||
"winner_index": data["winner_index"],
|
||||
"victory_type": data["victory_type"],
|
||||
"wall_clock_sec": round(float(data["wall_clock_sec"]), 2),
|
||||
"event_count": event_count,
|
||||
"event_count": total_events,
|
||||
}
|
||||
for et in EVENT_TYPES:
|
||||
row[f"evt_{et}"] = event_counts.get(et, 0)
|
||||
for f in AGGREGATE_FIELDS:
|
||||
row[f"agg_{f}"] = data["aggregate"][f]
|
||||
player_stats: dict[str, Any] = data["player_stats"]
|
||||
|
|
@ -136,6 +167,7 @@ def csv_fieldnames() -> list[str]:
|
|||
"seed", "outcome", "turns_played", "winner_index",
|
||||
"victory_type", "wall_clock_sec", "event_count",
|
||||
]
|
||||
fields += [f"evt_{et}" for et in EVENT_TYPES]
|
||||
fields += [f"agg_{f}" for f in AGGREGATE_FIELDS]
|
||||
for pid in ("0", "1"):
|
||||
fields += [f"p{pid}_{f}" for f in PLAYER_FIELDS]
|
||||
|
|
@ -190,6 +222,13 @@ def run_assertions(
|
|||
"AI may be pacifist or unreachable."
|
||||
)
|
||||
|
||||
no_turns = [r for r in rows if r["turns_played"] < 1]
|
||||
if no_turns:
|
||||
failures.append(
|
||||
f"{len(no_turns)} game(s) have turns_played < 1 — "
|
||||
"game may have crashed before completing a turn."
|
||||
)
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
|
|
@ -230,6 +269,11 @@ def print_summary(rows: list[dict[str, Any]], out: Any = sys.stderr) -> None:
|
|||
f"median event_count: {median_int([r['event_count'] for r in rows])}",
|
||||
file=out,
|
||||
)
|
||||
print("event counts by type (total across all games):", file=out)
|
||||
for et in EVENT_TYPES:
|
||||
total = sum(r.get(f"evt_{et}", 0) for r in rows)
|
||||
if total > 0:
|
||||
print(f" {et}: {total}", file=out)
|
||||
total_v = sum(r["invariant_violations"] for r in rows)
|
||||
print(f"invariant violations (total): {total_v}", file=out)
|
||||
|
||||
|
|
@ -266,13 +310,27 @@ def main(argv: list[str]) -> int:
|
|||
return 1
|
||||
|
||||
ts_schema = load_schema(TURN_STATS_SCHEMA_NAME)
|
||||
meta_schema = load_schema(META_SCHEMA_NAME)
|
||||
rows: list[dict[str, Any]] = []
|
||||
schema_errors: dict[Path, list[str]] = {}
|
||||
|
||||
for seed, game_dir in found:
|
||||
meta_path = game_dir / "meta.json"
|
||||
turn_stats_path = game_dir / "turn_stats.jsonl"
|
||||
events_path = game_dir / "events.jsonl"
|
||||
|
||||
# Validate meta.json
|
||||
if not meta_path.exists():
|
||||
schema_errors[meta_path] = ["meta.json missing"]
|
||||
else:
|
||||
try:
|
||||
meta_data = json.loads(meta_path.read_text())
|
||||
meta_errs = validate(meta_data, meta_schema)
|
||||
if meta_errs:
|
||||
schema_errors[meta_path] = meta_errs
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
schema_errors[meta_path] = [f"cannot load meta.json: {e}"]
|
||||
|
||||
# Fast path: read only the last line of turn_stats.jsonl
|
||||
last_line = _read_last_jsonl_line(turn_stats_path)
|
||||
if last_line is None:
|
||||
|
|
@ -290,8 +348,8 @@ def main(argv: list[str]) -> int:
|
|||
schema_errors[turn_stats_path] = errs
|
||||
continue
|
||||
|
||||
event_count = _count_jsonl_lines(events_path) if events_path.exists() else 0
|
||||
rows.append(extract_row(seed, data, event_count))
|
||||
event_counts = _count_events_by_type(events_path) if events_path.exists() else {}
|
||||
rows.append(extract_row(seed, data, event_counts))
|
||||
|
||||
if deep:
|
||||
# Read .save files only with --deep
|
||||
|
|
|
|||
|
|
@ -2,25 +2,26 @@
|
|||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://magic-civilization/schemas/autoplay/events-line.json",
|
||||
"title": "AutoPlay Events Line",
|
||||
"description": "One JSONL line appended to events.jsonl for each notable game event.",
|
||||
"description": "One JSONL line appended to events.jsonl for each notable game event. additionalProperties: true — per-type fields are best-effort.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["turn", "event_type"],
|
||||
"additionalProperties": true,
|
||||
"required": ["turn", "type"],
|
||||
"properties": {
|
||||
"turn": { "type": "integer", "minimum": 0 },
|
||||
"event_type": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Machine-readable event identifier, e.g. 'combat', 'city_founded', 'city_captured', 'tech_researched', 'unit_died'"
|
||||
},
|
||||
"player_index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Index of the player who triggered the event, if applicable"
|
||||
},
|
||||
"detail": {
|
||||
"type": "object",
|
||||
"description": "Optional event-specific payload. Schema is open — any additional keys allowed.",
|
||||
"additionalProperties": true
|
||||
"enum": [
|
||||
"city_founded",
|
||||
"city_captured",
|
||||
"city_grew",
|
||||
"city_starved",
|
||||
"tech_researched",
|
||||
"unit_created",
|
||||
"unit_destroyed",
|
||||
"combat_resolved",
|
||||
"victory"
|
||||
],
|
||||
"description": "Machine-readable event identifier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,22 @@
|
|||
"title": "AutoPlay Game Meta",
|
||||
"description": "meta.json written once when a game directory is created. Identifies the run.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["seed", "turn_limit", "stamp", "game_dir"],
|
||||
"additionalProperties": true,
|
||||
"required": ["seed", "start_stamp", "game_settings", "schema_version"],
|
||||
"properties": {
|
||||
"seed": { "type": "integer", "minimum": 0 },
|
||||
"turn_limit": { "type": "integer", "minimum": 1 },
|
||||
"stamp": {
|
||||
"start_stamp": {
|
||||
"type": "string",
|
||||
"description": "ISO-8601 timestamp or compact YYYYMMDD_HHMMSS string identifying this run"
|
||||
"description": "ISO-8601 or compact YYYYMMDD_HHMMSS timestamp when the game started"
|
||||
},
|
||||
"game_dir": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the game output directory on the host that ran the game"
|
||||
"game_settings": {
|
||||
"type": "object",
|
||||
"description": "Key/value game configuration (turn_limit, player count, map size, etc.)"
|
||||
},
|
||||
"godot_version": { "type": "string" },
|
||||
"platform": { "type": "string" }
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Monotonically increasing version of the output schema"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,25 @@
|
|||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://magic-civilization/schemas/autoplay/save.json",
|
||||
"title": "AutoPlay Save Envelope",
|
||||
"description": "Lenient envelope-only schema for turn_<N>.save files. Validates only the outer structure — full save internals are not validated here.",
|
||||
"description": "Lenient envelope-only schema for saves/turn_NNNN.save files. Validates only the outer structure + top-level game_state keys.",
|
||||
"type": "object",
|
||||
"required": ["turn", "seed"],
|
||||
"additionalProperties": true,
|
||||
"required": ["version", "timestamp", "game_state"],
|
||||
"properties": {
|
||||
"turn": { "type": "integer", "minimum": 0 },
|
||||
"seed": { "type": "integer", "minimum": 0 },
|
||||
"version": { "type": "string" },
|
||||
"timestamp": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
"version": { "type": "integer", "minimum": 1 },
|
||||
"timestamp": {
|
||||
"type": "integer",
|
||||
"description": "Unix epoch seconds when the save was written"
|
||||
},
|
||||
"game_state": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": ["turn_number", "players", "layers"],
|
||||
"properties": {
|
||||
"turn_number": { "type": "integer", "minimum": 0 },
|
||||
"players": { "type": "array" },
|
||||
"layers": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue