docs(scene): 📝 Add detailed auto-play testing documentation with usage and file structure for auto_play.gd test file

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-14 18:07:20 -07:00
parent 126ba6381e
commit 03d17555be

View file

@ -3,6 +3,13 @@ extends Node
## Registered as autoload, only activates when AUTO_PLAY env var is set.
##
## Usage: AUTO_PLAY=true AUTO_PLAY_DIR=/tmp godot --path src/game
##
## Seeded runs (AUTO_PLAY_SEED=N) produce a directory per game:
## ${AUTO_PLAY_DIR}/game_<stamp>_seed<N>/
## meta.json — one-time run metadata
## turn_stats.jsonl — per-turn analytics (append)
## events.jsonl — append-only event log
## saves/turn_NNNN.save — full GameState.serialize() per turn
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const PathfinderScript = preload("res://engine/src/map/pathfinder.gd")
@ -27,13 +34,26 @@ var _seed: int = 0
var _seed_set: bool = false
var _start_time: float = 0.0
var _start_stamp: String = ""
var _game_dir: String = ""
var _victory_winner: int = -1
var _victory_type: String = ""
var _outcome: String = "in_progress"
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
# Per-player cumulative/peak tracking — keyed by player_index → Dictionary of ints
var _stats: Dictionary = {}
# Per-player prev-turn snapshot for invariant checks
var _prev_turn_stats: Dictionary = {}
var _starved_this_turn: Dictionary = {}
var _violations: Array[String] = []
# Game-wide aggregate counters (not per-player)
var _total_combats: int = 0
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:
@ -57,12 +77,21 @@ func _ready() -> void:
now["year"], now["month"], now["day"],
now["hour"], now["minute"], now["second"],
]
print("AutoPlay: seed=%d stamp=%s" % [_seed, _start_stamp])
_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)
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)
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:
@ -71,28 +100,135 @@ 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
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)
_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"))
def_idx = 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
_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:
_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:
_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
)
_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:
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:
@ -101,8 +237,13 @@ func _on_victory(player_index: int, victory_type: String) -> void:
_victory_type = victory_type
_outcome = "victory"
print("AutoPlay: VICTORY! Player %d wins via %s on turn %d" % [player_index, victory_type, _turn_count])
_append_event({
"type": "victory",
"player": player_index,
"victory_type": victory_type,
})
_screenshot("victory_turn_%03d" % _turn_count)
_write_result()
_finalize_run()
get_tree().quit(0)
@ -182,7 +323,7 @@ func _process(_delta: float) -> void:
_screenshot("final_turn_%03d" % _turn_count)
print("AutoPlay: finished — %d turns, victory=%s" % [_turn_count, _victory])
_outcome = "victory" if _victory else "max_turns"
_write_result()
_finalize_run()
get_tree().quit(0 if _victory else 1)
if _turn_count >= _max_turns and _state != "done":
@ -418,8 +559,8 @@ func _play_turn() -> void:
u.is_fortified = true
u.fortified_turns = 1
# Incremental JSON snapshot — file always reflects current live state.
_write_result()
# Persist per-turn artifacts — buffered events, analytics line, full save.
_flush_turn_artifacts()
func _pick_research(player: RefCounted) -> void:
@ -1014,16 +1155,16 @@ func _fail(msg: String) -> void:
push_error("AutoPlay: FAIL — %s" % msg)
_screenshot("error")
_outcome = "defeat"
_write_result()
_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 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 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:
@ -1034,6 +1175,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))
@ -1042,66 +1184,194 @@ 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
func _write_result() -> void:
## Write structured result JSON. Called every turn + at exit — file is
## always up-to-date mid-game. Only writes when AUTO_PLAY_SEED is set.
func _write_meta() -> void:
## Write meta.json once at start-of-run. Captures seed + settings snapshot.
if not _seed_set:
return
var wall_clock: float = Time.get_unix_time_from_system() - _start_time
var result: Dictionary = {
var meta: 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,
"start_stamp": _start_stamp,
"game_settings": GameState.game_settings.duplicate(true),
"schema_version": 1,
}
DirAccess.make_dir_recursive_absolute(_output_dir)
var path: String = _output_dir.path_join("result_%s_seed%d.json" % [_start_stamp, _seed])
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(result, " "))
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
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 line: Dictionary = {
"turn": _turn_count,
"outcome": outcome,
"winner_index": _victory_winner,
"victory_type": _victory_type,
"wall_clock_sec": wall_clock,
"aggregate": aggregate,
"player_stats": _build_player_stats(),
"invariant_violations": _violations,
}
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.seek_end()
file.store_line(JSON.stringify(line))
file.close()
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)