diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 4f63792f..a73686e4 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -57,22 +57,6 @@ 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 - -# Test harness state (AUTO_PLAY_SEED path) -var _seed: int = 0 -var _seed_set: bool = false -var _start_time: float = 0.0 -var _victory_winner: int = -1 -var _victory_type: String = "" -var _result_written: bool = false -var _stats: Dictionary = {} # player_index → {combats:int, units_lost:int} -var _prev_turn_stats: Dictionary = {} # player_index → {pop:int, gold:int, mil:int} -var _starved_this_turn: Dictionary = {} # player_index → bool -var _violations: Array[String] = [] func _ready() -> void: @@ -97,6 +81,8 @@ func _ready() -> void: EventBus.victory_achieved.connect(_on_victory) EventBus.combat_resolved.connect(_on_combat) EventBus.city_starved.connect(_on_city_starved) + EventBus.city_founded.connect(_on_city_founded) + EventBus.city_captured.connect(_on_city_captured) func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> void: @@ -105,16 +91,25 @@ func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> voi str(result.get("defender_hp", "N/A")), str(result.get("defender_killed", "N/A")), ]) + _total_combats += 1 + if _turn_first_combat < 0: + _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 if attacker != null and attacker.get("owner") != null: var atk_idx: int = int(attacker.get("owner")) _ensure_stats(atk_idx) - _stats[atk_idx]["combats"] = int(_stats[atk_idx].get("combats", 0)) + 1 + 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")) _ensure_stats(def_idx) - _stats[def_idx]["combats"] = int(_stats[def_idx].get("combats", 0)) + 1 - if result.get("defender_killed", false) == true: + 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 func _on_city_starved(city: Variant, _new_pop: int) -> void: @@ -124,9 +119,38 @@ func _on_city_starved(city: Variant, _new_pop: int) -> void: _starved_this_turn[idx] = true +func _on_city_founded(_city: Variant, _player_index: int) -> void: + _total_cities_founded += 1 + + +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 + if new_owner >= 0: + _ensure_stats(new_owner) + _stats[new_owner]["cities_captured"] = ( + int(_stats[new_owner].get("cities_captured", 0)) + 1 + ) + if old_owner >= 0: + _ensure_stats(old_owner) + _stats[old_owner]["cities_lost"] = ( + int(_stats[old_owner].get("cities_lost", 0)) + 1 + ) + + func _ensure_stats(player_index: int) -> void: if not _stats.has(player_index): - _stats[player_index] = {"combats": 0, "units_lost": 0} + _stats[player_index] = { + "kills": 0, + "units_lost": 0, + "cities_captured": 0, + "cities_lost": 0, + "pop_peak": 0, + "gold_peak": 0, + "turn_first_pop_3": -1, + "turn_first_pop_4": -1, + } func _on_victory(player_index: int, victory_type: String) -> void: @@ -1249,9 +1273,9 @@ func _fail(msg: String) -> void: # ── Invariants & Result Writer ─────────────────────────────────────── func _check_invariants(player: RefCounted) -> void: - ## Per-turn invariant checks for the current player. Captures violations - ## into `_violations` without aborting — failures are reported in the - ## JSON summary so the batch runner can grade runs. + ## 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. var idx: int = player.index var pop: int = 0 for c: Variant in player.cities: @@ -1262,6 +1286,7 @@ func _check_invariants(player: RefCounted) -> void: if u.is_alive() and u.get("can_found_city") != true: mil += 1 + # Invariant checks (require prior-turn baseline) if _prev_turn_stats.has(idx): var prev: Dictionary = _prev_turn_stats[idx] var prev_pop: int = int(prev.get("pop", pop)) @@ -1270,41 +1295,85 @@ func _check_invariants(player: RefCounted) -> void: "turn_%d: player %d pop dropped %d→%d without starvation event" % [_turn_count, idx, prev_pop, pop] ) - var techs: int = player.researched_techs.size() - var floor_val: int = -max(5, techs * 3) + var techs_prev: int = player.researched_techs.size() + var floor_val: int = -max(5, techs_prev * 3) if gold < floor_val: _violations.append( "turn_%d: player %d gold=%d below deficit floor %d" % [_turn_count, idx, gold, floor_val] ) + # Peak/milestone tracking + _ensure_stats(idx) + var pstat: Dictionary = _stats[idx] + if pop > int(pstat.get("pop_peak", 0)): + pstat["pop_peak"] = pop + if gold > int(pstat.get("gold_peak", 0)): + pstat["gold_peak"] = gold + if int(pstat.get("turn_first_pop_3", -1)) < 0 and pop >= 3: + pstat["turn_first_pop_3"] = _turn_count + if int(pstat.get("turn_first_pop_4", -1)) < 0 and pop >= 4: + pstat["turn_first_pop_4"] = _turn_count + _prev_turn_stats[idx] = {"pop": pop, "gold": gold, "mil": mil} _starved_this_turn[idx] = false -func _build_final_stats() -> Dictionary: +func _build_player_stats() -> Dictionary: + var game_map: RefCounted = GameState.get_game_map() var out: Dictionary = {} for p: Variant in GameState.players: var idx: int = int(p.index) var pop: int = 0 var tiles: int = 0 + var buildings: int = 0 + var food_total: float = 0.0 + var production_total: float = 0.0 for c: Variant in p.cities: pop += int(c.population) tiles += int(c.owned_tiles.size()) + buildings += int(c.buildings.size()) + if game_map != null: + var tile_json: String = BuildableHelperScript.build_tile_yields_json( + c, game_map + ) + var cy: Dictionary = c.get_yields(tile_json) + food_total += float(cy.get("food", 0)) + production_total += float(cy.get("production", 0)) var mil: int = 0 for u: Variant in p.units: if u.is_alive() and u.get("can_found_city") != true: mil += 1 _ensure_stats(idx) + var pstat: Dictionary = _stats[idx] + var happiness: int = int(p.get("happiness")) if p.get("happiness") != null else 0 + var gpt: int = int(p.get("gold_per_turn")) if p.get("gold_per_turn") != null else 0 + var gold: int = int(p.get("gold")) if p.get("gold") != null else 0 + # Keep peak in sync with current (final-stats is also called at exit) + if pop > int(pstat.get("pop_peak", 0)): + pstat["pop_peak"] = pop + if gold > int(pstat.get("gold_peak", 0)): + pstat["gold_peak"] = gold out[str(idx)] = { "pop": pop, + "pop_peak": int(pstat.get("pop_peak", pop)), "mil": mil, "cities": int(p.cities.size()), - "gold": int(p.get("gold")) if p.get("gold") != null else 0, + "cities_captured": int(pstat.get("cities_captured", 0)), + "cities_lost": int(pstat.get("cities_lost", 0)), + "gold": gold, + "gold_peak": int(pstat.get("gold_peak", gold)), + "gold_per_turn": gpt, "techs": int(p.researched_techs.size()), "tiles": tiles, - "combats": int(_stats[idx].get("combats", 0)), - "units_lost": int(_stats[idx].get("units_lost", 0)), + "buildings": buildings, + "happiness": happiness, + "food_total": food_total, + "production_total": production_total, + "kills": int(pstat.get("kills", 0)), + "units_lost": int(pstat.get("units_lost", 0)), + "turn_first_pop_3": int(pstat.get("turn_first_pop_3", -1)), + "turn_first_pop_4": int(pstat.get("turn_first_pop_4", -1)), } return out @@ -1317,6 +1386,13 @@ func _write_result(outcome: String) -> void: return _result_written = true var wall_clock: float = Time.get_unix_time_from_system() - _start_time + var aggregate: Dictionary = { + "total_combats": _total_combats, + "total_cities_founded": _total_cities_founded, + "total_cities_captured": _total_cities_captured, + "turn_first_combat": _turn_first_combat, + "turn_first_city_captured": _turn_first_city_captured, + } var result: Dictionary = { "seed": _seed, "turns_played": _turn_count, @@ -1324,6 +1400,7 @@ func _write_result(outcome: String) -> void: "winner_index": _victory_winner, "victory_type": _victory_type, "wall_clock_sec": wall_clock, + "aggregate": aggregate, "final_stats": _build_final_stats(), "invariant_violations": _violations, } diff --git a/tools/autoplay-batch.sh b/tools/autoplay-batch.sh index c63d2ab1..91f5b7c4 100755 --- a/tools/autoplay-batch.sh +++ b/tools/autoplay-batch.sh @@ -147,11 +147,15 @@ for seed in $(seq 1 "$COUNT"); do _run_local "$seed" "$seed_dir" fi - result_file="$seed_dir/result_${seed}.json" - if [ -f "$result_file" ]; then + # 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" else - echo "[seed $seed] MISSING result_${seed}.json (Task 1 may not be complete, or game crashed)" >&2 + echo "[seed $seed] MISSING result file (no result_*_seed${seed}.json found)" >&2 FAILED_SEEDS+=("$seed") fi done diff --git a/tools/autoplay-report.py b/tools/autoplay-report.py index 978a68d6..716930a9 100755 --- a/tools/autoplay-report.py +++ b/tools/autoplay-report.py @@ -21,7 +21,10 @@ from typing import Any def find_result_files(results_dir: Path) -> list[tuple[int, Path]]: - """Return (seed, path) pairs for all result_*.json files, sorted by seed.""" + """Return (seed, path) pairs for all result_*.json files, sorted by seed. + Matches filenames of the form result__seed.json under + seed_/ subdirectories. Picks the most recent (lexicographic max) if + multiple stamps exist for the same seed.""" results: list[tuple[int, Path]] = [] for seed_dir in sorted(results_dir.glob("seed_*")): if not seed_dir.is_dir(): @@ -30,8 +33,15 @@ def find_result_files(results_dir: Path) -> list[tuple[int, Path]]: if not seed_str.isdigit(): continue seed = int(seed_str) - result_file = seed_dir / f"result_{seed}.json" - results.append((seed, result_file)) + candidates = sorted(seed_dir.glob(f"result_*_seed{seed}.json")) + if not candidates: + # Fall back to legacy naming for backward compatibility + legacy = seed_dir / f"result_{seed}.json" + if legacy.exists(): + candidates = [legacy] + if not candidates: + continue + results.append((seed, candidates[-1])) return results diff --git a/tools/autoplay-result-schema.json b/tools/autoplay-result-schema.json index 6221a5eb..153fd94d 100644 --- a/tools/autoplay-result-schema.json +++ b/tools/autoplay-result-schema.json @@ -13,7 +13,7 @@ "victory_type", "wall_clock_sec", "aggregate", - "player_stats", + "final_stats", "invariant_violations" ], "properties": { @@ -44,9 +44,9 @@ "turn_first_city_captured": { "type": "integer", "minimum": -1 } } }, - "player_stats": { + "final_stats": { "type": "object", - "description": "Map of player_index (as string) to per-player stats. Rewritten every turn — not final until outcome != 'in_progress'.", + "description": "Map of player_index (as string) to per-player stats", "propertyNames": { "pattern": "^[0-9]+$" }, "additionalProperties": { "$ref": "#/definitions/player_stats" diff --git a/tools/autoplay-validate.py b/tools/autoplay-validate.py old mode 100755 new mode 100644 index 0596d59d..5ac28058 --- a/tools/autoplay-validate.py +++ b/tools/autoplay-validate.py @@ -1,31 +1,22 @@ #!/usr/bin/env python3 """ -JSON Schema validator for autoplay output files. +Minimal JSON Schema validator for autoplay-result-schema.json. -Implements the subset of draft-07 used by the schemas: +Implements the subset of draft-07 used by the schema: type, required, additionalProperties, properties, propertyNames.pattern, - minimum, enum, items, pattern, $ref (local only). + minimum, enum, items, $ref (local only). stdlib only — no pip installs. Usage: - # Validate a single JSON file against a named schema: - python3 tools/autoplay-validate.py --schema meta path/to/meta.json + from autoplay_validate import load_schema, validate + schema = load_schema() + errors = validate(data, schema) + if errors: ... - # Validate every line of a JSONL file independently: - python3 tools/autoplay-validate.py --schema turn-stats-line --jsonl path/to/turn_stats.jsonl - - # Legacy: validate against the flat result schema (default): +CLI: python3 tools/autoplay-validate.py path/to/result.json - -Exits 0 if all valid, 1 with errors to stderr, 2 on usage error. - -Available schema names (--schema): - turn-stats-line tools/schemas/autoplay/turn-stats-line.json - meta tools/schemas/autoplay/meta.json - events-line tools/schemas/autoplay/events-line.json - save tools/schemas/autoplay/save.json - result tools/autoplay-result-schema.json (legacy flat schema) + exits 0 if valid, 1 with errors to stderr otherwise. """ from __future__ import annotations @@ -35,26 +26,10 @@ import sys from pathlib import Path from typing import Any -TOOLS_DIR = Path(__file__).parent -SCHEMAS_DIR = TOOLS_DIR / "schemas" / "autoplay" - -SCHEMA_PATHS: dict[str, Path] = { - "result": TOOLS_DIR / "autoplay-result-schema.json", - "turn-stats-line": SCHEMAS_DIR / "turn-stats-line.json", - "meta": SCHEMAS_DIR / "meta.json", - "events-line": SCHEMAS_DIR / "events-line.json", - "save": SCHEMAS_DIR / "save.json", -} - -_DEFAULT_SCHEMA = "result" +SCHEMA_PATH = Path(__file__).parent / "autoplay-result-schema.json" -def load_schema(name: str = _DEFAULT_SCHEMA) -> dict[str, Any]: - path = SCHEMA_PATHS.get(name) - if path is None: - raise ValueError( - f"unknown schema {name!r}. Available: {', '.join(sorted(SCHEMA_PATHS))}" - ) +def load_schema(path: Path = SCHEMA_PATH) -> dict[str, Any]: with path.open() as f: return json.load(f) @@ -71,6 +46,7 @@ _TYPE_CHECKS: dict[str, type | tuple[type, ...]] = { def _resolve_ref(ref: str, root: dict[str, Any]) -> dict[str, Any]: + # Local refs only: "#/definitions/foo" if not ref.startswith("#/"): raise ValueError(f"only local refs supported, got {ref!r}") node: Any = root @@ -95,7 +71,7 @@ def _validate( if expected is None: errors.append(f"{path}: unknown schema type {t!r}") return errors - # bool is a subclass of int in Python; reject booleans as numbers. + # JSON has no separate int — bool is a subclass of int in Python; reject booleans as numbers. if t in ("integer", "number") and isinstance(value, bool): errors.append(f"{path}: expected {t}, got boolean") return errors @@ -103,7 +79,9 @@ def _validate( errors.append(f"{path}: expected integer, got float {value}") return errors if not isinstance(value, expected): - errors.append(f"{path}: expected {t}, got {type(value).__name__}") + errors.append( + f"{path}: expected {t}, got {type(value).__name__}" + ) return errors if "enum" in schema: @@ -112,13 +90,13 @@ def _validate( if "minimum" in schema and isinstance(value, (int, float)): if value < schema["minimum"]: - errors.append(f"{path}: {value} < minimum {schema['minimum']}") + errors.append( + f"{path}: {value} < minimum {schema['minimum']}" + ) if "pattern" in schema and isinstance(value, str): if not re.match(schema["pattern"], value): - errors.append( - f"{path}: {value!r} does not match pattern {schema['pattern']!r}" - ) + errors.append(f"{path}: {value!r} does not match pattern {schema['pattern']!r}") if t == "object" and isinstance(value, dict): props: dict[str, Any] = schema.get("properties", {}) @@ -151,43 +129,25 @@ def _validate( def validate(data: Any, schema: dict[str, Any] | None = None) -> list[str]: - """Validate data against schema. Returns list of error strings (empty = valid).""" + """Validate data against the schema. Returns list of error strings (empty = valid).""" s = schema if schema is not None else load_schema() return _validate(data, s, s, "$") -def _validate_file(path: Path, schema: dict[str, Any], jsonl: bool) -> int: - """Validate one file. Returns error count.""" +def _main(argv: list[str]) -> int: + if len(argv) < 2: + print("usage: autoplay-validate.py [ ...]", file=sys.stderr) + return 2 + schema = load_schema() total_errors = 0 - try: - text = path.read_text() - except OSError as e: - print(f"{path}: cannot read ({e})", file=sys.stderr) - return 1 - - if jsonl: - for lineno, raw in enumerate(text.splitlines(), start=1): - raw = raw.strip() - if not raw: - continue - try: - data = json.loads(raw) - except json.JSONDecodeError as e: - print(f"{path}:{lineno}: invalid JSON ({e})", file=sys.stderr) - total_errors += 1 - continue - errs = validate(data, schema) - if errs: - total_errors += len(errs) - print(f"{path}:{lineno}: {len(errs)} error(s)", file=sys.stderr) - for e in errs: - print(f" {e}", file=sys.stderr) - else: + for p in argv[1:]: + path = Path(p) try: - data = json.loads(text) - except json.JSONDecodeError as e: - print(f"{path}: invalid JSON ({e})", file=sys.stderr) - return 1 + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as e: + print(f"{path}: cannot load ({e})", file=sys.stderr) + total_errors += 1 + continue errs = validate(data, schema) if errs: total_errors += len(errs) @@ -196,61 +156,6 @@ def _validate_file(path: Path, schema: dict[str, Any], jsonl: bool) -> int: print(f" {e}", file=sys.stderr) else: print(f"{path}: OK", file=sys.stderr) - - if jsonl and total_errors == 0: - print(f"{path}: OK", file=sys.stderr) - - return total_errors - - -def _main(argv: list[str]) -> int: - args = argv[1:] - - schema_name = _DEFAULT_SCHEMA - jsonl = False - files: list[str] = [] - - i = 0 - while i < len(args): - a = args[i] - if a == "--schema": - i += 1 - if i >= len(args): - print("ERROR: --schema requires a value", file=sys.stderr) - return 2 - schema_name = args[i] - elif a == "--jsonl": - jsonl = True - elif a.startswith("--schema="): - schema_name = a[len("--schema="):] - elif a.startswith("-"): - print(f"ERROR: unknown flag {a!r}", file=sys.stderr) - return 2 - else: - files.append(a) - i += 1 - - if not files: - print( - "usage: autoplay-validate.py [--schema NAME] [--jsonl] [ ...]", - file=sys.stderr, - ) - print( - f" schemas: {', '.join(sorted(SCHEMA_PATHS))}", - file=sys.stderr, - ) - return 2 - - try: - schema = load_schema(schema_name) - except ValueError as e: - print(f"ERROR: {e}", file=sys.stderr) - return 2 - - total_errors = 0 - for f in files: - total_errors += _validate_file(Path(f), schema, jsonl) - return 1 if total_errors else 0