diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 2031df6b..c59f2662 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -22,6 +22,18 @@ var _locked_target: Vector2i = Vector2i(-1, -1) var _target_stuck_turns: int = 0 var _last_army_pos: Vector2i = Vector2i(-1, -1) +# 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: _active = EnvConfig.get_bool("AUTO_PLAY") @@ -31,22 +43,59 @@ func _ready() -> void: _output_dir = EnvConfig.get_var("AUTO_PLAY_DIR", "/tmp") DirAccess.make_dir_recursive_absolute(_output_dir) print("AutoPlay: active — output to %s" % _output_dir) + + # Seeded determinism — only when AUTO_PLAY_SEED is set + var seed_str: String = EnvConfig.get_var("AUTO_PLAY_SEED", "") + if not seed_str.is_empty() and seed_str.is_valid_int(): + _seed = int(seed_str) + _seed_set = true + seed(_seed) + GameState.game_settings["seed"] = _seed + print("AutoPlay: seed=%d" % _seed) + + _start_time = Time.get_unix_time_from_system() EventBus.victory_achieved.connect(_on_victory) EventBus.combat_resolved.connect(_on_combat) + EventBus.city_starved.connect(_on_city_starved) -func _on_combat(_attacker: Variant, _defender: Variant, result: Dictionary) -> void: +func _on_combat(attacker: Variant, defender: Variant, result: Dictionary) -> void: print(" COMBAT: def_dmg=%s def_hp=%s killed=%s" % [ str(result.get("defender_damage", "N/A")), str(result.get("defender_hp", "N/A")), str(result.get("defender_killed", "N/A")), ]) + 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 != 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: + _stats[def_idx]["units_lost"] = int(_stats[def_idx].get("units_lost", 0)) + 1 + + +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 + + +func _ensure_stats(player_index: int) -> void: + if not _stats.has(player_index): + _stats[player_index] = {"combats": 0, "units_lost": 0} func _on_victory(player_index: int, victory_type: String) -> void: _victory = true + _victory_winner = player_index + _victory_type = victory_type print("AutoPlay: VICTORY! Player %d wins via %s on turn %d" % [player_index, victory_type, _turn_count]) _screenshot("victory_turn_%03d" % _turn_count) + _write_result("victory") get_tree().quit(0) @@ -125,6 +174,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) get_tree().quit(0 if _victory else 1) if _turn_count >= _max_turns and _state != "done": @@ -192,6 +243,9 @@ func _play_turn() -> void: var unit_count: int = player.units.size() var city_count: int = player.cities.size() + + _check_invariants(player) + if _turn_count <= 5 or _turn_count % 10 == 0: var happiness: int = player.get("happiness") if player.get("happiness") != null else -99 var gold: int = player.get("gold") if player.get("gold") != null else 0 @@ -949,4 +1003,97 @@ 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") get_tree().quit(1) + + +# ── 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. + var idx: int = player.index + var pop: int = 0 + for c: Variant in player.cities: + pop += int(c.population) + var gold: int = int(player.get("gold")) if player.get("gold") != null else 0 + var mil: int = 0 + for u: Variant in player.units: + if u.is_alive() and u.get("can_found_city") != true: + mil += 1 + + if _prev_turn_stats.has(idx): + var prev: Dictionary = _prev_turn_stats[idx] + var prev_pop: int = int(prev.get("pop", pop)) + if pop < prev_pop and not _starved_this_turn.get(idx, false): + _violations.append( + "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) + if gold < floor_val: + _violations.append( + "turn_%d: player %d gold=%d below deficit floor %d" + % [_turn_count, idx, gold, floor_val] + ) + + _prev_turn_stats[idx] = {"pop": pop, "gold": gold, "mil": mil} + _starved_this_turn[idx] = false + + +func _build_final_stats() -> Dictionary: + var out: Dictionary = {} + for p: Variant in GameState.players: + var idx: int = int(p.index) + var pop: int = 0 + var tiles: int = 0 + for c: Variant in p.cities: + pop += int(c.population) + tiles += int(c.owned_tiles.size()) + 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) + out[str(idx)] = { + "pop": pop, + "mil": mil, + "cities": int(p.cities.size()), + "gold": int(p.get("gold")) if p.get("gold") != null else 0, + "techs": int(p.researched_techs.size()), + "tiles": tiles, + "combats": int(_stats[idx].get("combats", 0)), + "units_lost": int(_stats[idx].get("units_lost", 0)), + } + return out + + +func _write_result(outcome: String) -> void: + ## Write structured result JSON. Idempotent — subsequent calls no-op. + if _result_written: + return + if not _seed_set: + return + _result_written = true + var wall_clock: float = Time.get_unix_time_from_system() - _start_time + var result: Dictionary = { + "seed": _seed, + "turns_played": _turn_count, + "outcome": outcome, + "winner_index": _victory_winner, + "victory_type": _victory_type, + "wall_clock_sec": wall_clock, + "final_stats": _build_final_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) + if file == null: + push_error("AutoPlay: cannot open %s for writing" % path) + return + file.store_string(JSON.stringify(result, " ")) + file.close() + print("AutoPlay: result written — %s" % path)