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:
parent
126ba6381e
commit
03d17555be
1 changed files with 311 additions and 41 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue