saumwacht-godot/tools/balance_sim.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

164 lines
7.4 KiB
GDScript

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