saumwacht-godot/tools/luneraria/stress_run.gd
Martin Stickelbroeck faf0fbdef0 M3: Balance, Kartenprops, sieben Volksrepräsentanten und Abnahme
Bots planen Items/Champions vor der Armee und helfen deterministisch,
damit Spiegelmatches fair bleiben. Boss und Profile neu abgestimmt;
Balance-, Stress- und Volks-Werkzeuge sowie Kartenprops ergänzt.
2026-09-28 00:41:05 +02:00

99 lines
5.6 KiB
GDScript

extends Node
## Stress tests, separate from balance: (1) very high wave indices must stay finite and
## capped; (2) a long mirror match must not grow memory or per-round cost. The QA round
## limit ends the run as "Testlimit erreicht / Partie nicht abgeschlossen".
## Run: godot --headless --path . res://tools/luneraria/stress_run.tscn
var checks: int = 0
var failures: Array[String] = []
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("STRESS_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func run() -> void:
var report := {"high_index": [], "long_run": {}}
for index: int in [100, 1000, 10000, 1000000]:
var state := LuneMatch.new()
state.setup({"seed": 5, "human_slot": -1})
state.round_number = index
for slot: int in range(6):
state.players[slot].draft.gold = 5000
LuneBot.plan(state, slot, "ausgewogen")
var started: int = Time.get_ticks_msec()
state.begin_combat()
var peak: int = state.combat.mobile_count()
var finite: bool = true
var steps: int = 0
while state.phase == LuneMatch.Phase.COMBAT:
state.step_combat()
steps += 1
if state.phase == LuneMatch.Phase.COMBAT:
peak = maxi(peak, state.combat.mobile_count())
if steps % 50 == 0:
for unit: LuneUnit in state.combat.units:
finite = finite and is_finite(unit.hp) and is_finite(unit.position.x) and is_finite(unit.position.y)
var cap: int = 6 * state.balance.wave_max_count + 6 * (state.balance.supply_cap + 1) + 6 * LuneContent.MAX_BOOKINGS + 2
check(finite, "wave %d: all values finite" % index)
check(peak <= cap, "wave %d: at most %d mobile units (peak %d)" % [index, cap, peak])
check(steps <= int(state.balance.combat_time_limit / state.balance.sim_step) + 1, "wave %d: combat phase terminates" % index)
var row := {"wave": index, "peak_mobile": peak, "steps": steps, "wall_ms": Time.get_ticks_msec() - started,
"end": state.last_settlement.get("end_reason", ""), "phase": "vorbei" if state.phase == LuneMatch.Phase.OVER else "läuft",
"monster_hp": LuneWaves.build(state.balance, index)[0].max_hp}
report.high_index.append(row)
print("STRESS_WAVE ", JSON.stringify(row))
# Long run over the whole loop (plan -> combat -> settlement). A normal mirror match ends
# in a draw after 7-14 waves, so this run uses a documented test fixture: before every
# round all towers are restored and both Ancients get 10^12 HP. It measures memory,
# object count and cost per round, not balance; the QA limit then ends the run as
# "Testlimit erreicht / Partie nicht abgeschlossen".
var state := LuneMatch.new()
state.setup({"seed": 3, "human_slot": -1, "bunds": ["wurzelbund", "wurzelbund", "wurzelbund", "wurzelbund", "wurzelbund", "wurzelbund"]})
var limit: int = 200
var round_ms: Array[float] = []
var memory: Array[float] = [Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0]
var objects: Array[float] = [Performance.get_monitor(Performance.OBJECT_COUNT)]
for definition: Dictionary in state.map.buildings:
if definition.tier == "core":
definition.max_hp = 1.0e12
while state.phase != LuneMatch.Phase.OVER and state.round_number <= limit:
for definition: Dictionary in state.map.buildings:
state.building_hp[definition.id] = float(definition.max_hp)
var started: int = Time.get_ticks_usec()
for slot: int in range(6):
LuneBot.plan(state, slot, "verteidigung")
state.begin_combat()
state.finish_combat()
round_ms.append((Time.get_ticks_usec() - started) / 1000.0)
if round_ms.size() % 25 == 0:
memory.append(Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0)
objects.append(Performance.get_monitor(Performance.OBJECT_COUNT))
print("STRESS_PROGRESS round %d · %.0f ms · %.1f MB · %d objects" % [state.round_number, round_ms[-1], memory[-1], objects[-1]])
var result: String = "Testlimit erreicht / Partie nicht abgeschlossen" if state.phase != LuneMatch.Phase.OVER else ("Unentschieden" if state.winner == LuneMatch.DRAW else "Sieg %s" % ("Blau" if state.winner == 0 else "Rot"))
var early: float = 0.0
var late: float = 0.0
var window: int = mini(10, round_ms.size())
for i: int in range(window):
early += round_ms[i] / float(window)
late += round_ms[round_ms.size() - 1 - i] / float(window)
check(round_ms.size() >= limit, "long run reaches the QA limit of %d rounds (%d played)" % [limit, round_ms.size()])
check(objects[-1] <= objects[0] * 1.05 + 100, "no object growth over %d rounds (%s)" % [round_ms.size(), str(objects)])
check(memory[-1] <= memory[0] * 1.10 + 8.0, "no memory growth over %d rounds (%s MB)" % [round_ms.size(), str(memory)])
check(result != "Sieg Blau" and result != "Sieg Rot", "mirror match never produces a one-sided winner")
var finite_gold: bool = state.players.all(func(p: Dictionary) -> bool: return int(p.gold) >= 0 and int(p.gold) < 1 << 40)
check(finite_gold, "gold stays a valid integer")
report.long_run = {"rounds": round_ms.size(), "result": result, "round_ms_first10": early, "round_ms_last10": late,
"memory_mb_every25": memory, "objects_every25": objects, "final_gold": state.players.map(func(p: Dictionary) -> int: return int(p.gold)),
"final_wave_monster_hp": LuneWaves.build(state.balance, state.round_number)[0].max_hp}
print("STRESS_LONG ", JSON.stringify(report.long_run))
report.checks = checks
report.failures = failures
var file := FileAccess.open("res://docs/luneraria/logs/m3/stress.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("STRESS_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
get_tree().quit(0 if failures.is_empty() else 1)