feat(scene): Introduce seeded randomness and test state management for AutoPlay test system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-14 16:59:58 -07:00
parent dbaac35fe6
commit f1e40faec8

View file

@ -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)