diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 753a7973..2bd61b69 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -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) diff --git a/tools/autoplay-batch.sh b/tools/autoplay-batch.sh index 91f5b7c4..f9166202 100755 --- a/tools/autoplay-batch.sh +++ b/tools/autoplay-batch.sh @@ -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: +# /game__seed/ +# 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__seed.json) or legacy (result_.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 diff --git a/tools/autoplay-report.py b/tools/autoplay-report.py index 03930cff..75ab7626 100755 --- a/tools/autoplay-report.py +++ b/tools/autoplay-report.py @@ -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", "") + 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 diff --git a/tools/schemas/autoplay/events-line.json b/tools/schemas/autoplay/events-line.json index 7c652338..3c093dfb 100644 --- a/tools/schemas/autoplay/events-line.json +++ b/tools/schemas/autoplay/events-line.json @@ -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" } } } diff --git a/tools/schemas/autoplay/meta.json b/tools/schemas/autoplay/meta.json index 27c4d598..284f829a 100644 --- a/tools/schemas/autoplay/meta.json +++ b/tools/schemas/autoplay/meta.json @@ -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" + } } } diff --git a/tools/schemas/autoplay/save.json b/tools/schemas/autoplay/save.json index f2d10392..b4709fe0 100644 --- a/tools/schemas/autoplay/save.json +++ b/tools/schemas/autoplay/save.json @@ -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_.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" } + } + } + } }