extends Node ## Headless balance sweep over complete AI-vs-AI matches. ## Run: godot --headless --path . res://tools/balance_sim.tscn ## Mirrors the WaveManager flow with both seats driven by SimpleAI levels; ## stepping, economy and combat are the production implementations. const AIScript = preload("res://scripts/ai/simple_ai.gd") const BattleScript = preload("res://scripts/combat/battle_controller.gd") const STEP: float = 1.0 / 60.0 const LEVEL_NAMES: Array[String] = ["Lernmodus", "Standard"] var violations: Array[String] = [] var _public_snapshots: Array = [{}, {}] func _ready() -> void: WaveManager.set_physics_process(false) call_deferred("_run") func _run() -> void: var started := Time.get_ticks_msec() var lines: Array[String] = [] lines.append("# Saumwacht · Balance-Sweep") lines.append("") lines.append("Erzeugt von `tools/balance_sim.gd`; KI-Partien mit der aktuellen") lines.append("Wirtschaft und Kampfsimulation (fester 1/60-Schritt, feste Startzustände).") for matchup: Array in [ ["Standard gegen Standard", GameState.AI_STANDARD, GameState.AI_STANDARD], ["Lernmodus gegen Standard", GameState.AI_LEARNING, GameState.AI_STANDARD], ]: lines.append_array(_play_match(matchup[0], matchup[1], matchup[2])) lines.append("") lines.append("Keine Verletzung der im Sweep geprüften Invarianten." if violations.is_empty() else "VERLETZUNGEN: " + "; ".join(violations)) print("\n".join(lines)) print("Balance-Sweep fertig in %.1f s." % ((Time.get_ticks_msec() - started) / 1000.0)) get_tree().quit(0 if violations.is_empty() else 1) func _play_match(title: String, level0: int, level1: int) -> Array[String]: GameState.reset_match() EconomyManager.reset_match() _public_snapshots = [EconomyManager.get_team(0), EconomyManager.get_team(1)] GameState.wave = 1 var battle = BattleScript.new() battle.render_enabled = false add_child(battle) var ai = AIScript.new() var lines: Array[String] = [] lines.append("") lines.append("## %s" % title) lines.append("") lines.append("| Welle | Dauer s | Lecks 0/1 | Kernschaden 0/1 | Abschussprämien 0/1 | Goldbestand 0/1 | Wächter 0/1 | Abgerechnet |") lines.append("| ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: |") var winner: int = GameState.ONGOING var total_leaks: Array[int] = [0, 0] var total_seconds: float = 0.0 while GameState.wave <= DataCatalog.wave_count(): var wave: int = GameState.wave var report: Dictionary = _play_wave(battle, ai, wave, level0, level1) total_leaks[0] += int(report.leaks[0]) total_leaks[1] += int(report.leaks[1]) total_seconds += float(report.combat_seconds) _check_state(wave) lines.append("| %d | %.1f | %d/%d | %d/%d | %d/%d | %d/%d | %d/%d | %s |" % [ wave, float(report.combat_seconds), report.leaks[0], report.leaks[1], report.core_damage[0], report.core_damage[1], report.bounty[0], report.bounty[1], EconomyManager.get_team(0).gold, EconomyManager.get_team(1).gold, EconomyManager.get_team(0).roster.size(), EconomyManager.get_team(1).roster.size(), "Ja" if report.settled else "Nein"]) winner = GameState.decide_winner(wave == DataCatalog.wave_count()) if winner != GameState.ONGOING: break GameState.wave += 1 var outcome: String match winner: GameState.DRAW: outcome = "Unentschieden" 0: outcome = "Front 0 gewinnt (%s)" % LEVEL_NAMES[level0] 1: outcome = "Front 1 gewinnt (%s)" % LEVEL_NAMES[level1] _: outcome = "Unbeendet" violations.append("%s: kein Matchende innerhalb der vorgesehenen Wellen." % title) lines.append("") lines.append("**Ergebnis nach %d Wellen:** %s · Kern %d/%d HP · Lecks gesamt %d/%d · mittlere Kampfdauer %.1f s" % [ GameState.wave, outcome, GameState.core_hp[0], GameState.core_hp[1], total_leaks[0], total_leaks[1], total_seconds / float(maxi(1, GameState.wave))]) battle.free() return lines func _play_wave(battle, ai, wave: int, level0: int, level1: int) -> Dictionary: EconomyManager.open_build(wave) ai.plan(0, wave, _public_snapshots[1], GameState.last_reports[0], level0) ai.plan(1, wave, _public_snapshots[0], GameState.last_reports[1], level1) _public_snapshots = [EconomyManager.get_team(0), EconomyManager.get_team(1)] _check_build_orders(wave) var outgoing: Array = EconomyManager.freeze(wave) var attackers: Array = [DataCatalog.get_attackers(wave), DataCatalog.get_attackers(wave)] var natural_count: int = attackers[0].size() for sender: int in range(2): for send_id: String in outgoing[sender]: attackers[1 - sender].append(DataCatalog.get_send(send_id, wave)) battle.begin_battle([EconomyManager.get_team(0).roster, EconomyManager.get_team(1).roster], attackers, wave) var report: Dictionary = {} var wave_core_damage: Array[int] = [0, 0] while true: report = battle.step(STEP) if report.leak_damage[0] > 0 or report.leak_damage[1] > 0: for team: int in range(2): wave_core_damage[team] += int(report.leak_damage[team]) GameState.apply_core_damage(report.leak_damage) if GameState.decide_winner(false) != GameState.ONGOING or bool(report.finished): break # WaveManager checks core death before settlement, including the final step. var did_settle: bool = GameState.decide_winner(false) == GameState.ONGOING and bool(report.finished) if did_settle: var reports: Array = [] for team: int in range(2): var settled: Dictionary = EconomyManager.settle(team, wave, int(report.bounties[team])) settled["leaks"] = report.leaks[team] settled["core_damage"] = wave_core_damage[team] settled["combat_seconds"] = report.elapsed settled["natural_count"] = natural_count settled["natural_leaks"] = report.natural_leaks[team] settled["front_lifetime"] = report.front_lifetime[team] reports.append(settled) GameState.last_reports = reports if float(report.elapsed) > 90.001: violations.append("Welle %d: Kampf über 90 s." % wave) return { "leaks": report.leaks, "core_damage": wave_core_damage, "bounty": report.bounties, "combat_seconds": report.elapsed, "settled": did_settle} func _check_build_orders(wave: int) -> void: for team: int in range(2): var own: Dictionary = EconomyManager.get_team(team) var reserved: int = EconomyManager.reserved_sparks(team) if own.send_queue.size() > EconomyManager.MAX_SENDS or reserved > EconomyManager.send_budget(wave): violations.append("Welle %d: Team %d überschreitet ein Sendlimit." % [wave, team]) if reserved > int(own.sparks): violations.append("Welle %d: Team %d reserviert mehr Funken als vorhanden." % [wave, team]) func _check_state(wave: int) -> void: for team: int in range(2): var own: Dictionary = EconomyManager.get_team(team) if int(own.gold) < 0 or int(own.sparks) < 0: violations.append("Welle %d: Team %d mit negativer Ressource." % [wave, team]) if own.roster.size() > EconomyManager.GRID_SIZE.x * EconomyManager.GRID_SIZE.y: violations.append("Welle %d: Team %d überfüllt das Baufeld." % [wave, team]) var occupied: Array[Vector2i] = [] for entry: Dictionary in own.roster: var cell: Vector2i = entry.cell if occupied.has(cell) or cell.x < 0 or cell.y < 0 or cell.x >= EconomyManager.GRID_SIZE.x or cell.y >= EconomyManager.GRID_SIZE.y: violations.append("Welle %d: Team %d hat eine ungültige Zellbelegung." % [wave, team]) occupied.append(cell) if int(own.workers) + int(own.pending_workers) > EconomyManager.MAX_WORKERS or int(own.sparks) > EconomyManager.SPARK_CAP: violations.append("Welle %d: Team %d überschreitet eine Ressourcengrenze." % [wave, team])