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:
parent
dbaac35fe6
commit
f1e40faec8
1 changed files with 148 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue