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)