saumwacht-godot/tools/verify_phase5.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

211 lines
12 KiB
GDScript

extends Node
## Real eight-wave matches, runtime asset batching and visual acceptance captures.
## -- --capture renders the production scene; otherwise no display is required.
const AI = preload("res://scripts/ai/simple_ai.gd")
const STEP: float = 1.0 / 60.0
var checks: int = 0
var failures: Array[String] = []
var capture: bool = false
var main: Node3D
var battle: BattleController
var records: Array[Dictionary] = []
var phase_id: String = "phase9-short" if "--phase9" in OS.get_cmdline_user_args() else "phase8-short" if "--phase8" in OS.get_cmdline_user_args() else "phase7" if "--phase7" in OS.get_cmdline_user_args() else ("phase6" if "--phase6" in OS.get_cmdline_user_args() else "phase5")
func _ready() -> void:
WaveManager.set_physics_process(false)
capture = "--capture" in OS.get_cmdline_user_args()
call_deferred("_run")
func _check(value: bool, message: String) -> void:
checks += 1
if not value:
failures.append(message)
printerr("FAIL: ", message)
func _run() -> void:
GameState.reset_match()
GameState.factions = ["glutpakt", "glutpakt"]
GameState.timed_build = true
GameState.select_mode(GameState.MODE_SHORT)
_check_assets()
if capture:
main = load("res://scenes/main.tscn").instantiate()
add_child(main)
battle = main.battle
await _capture("menu")
else:
battle = BattleController.new()
battle.render_enabled = false
add_child(battle)
WaveManager.attach_battle(battle)
await _play("Standard gegen Lernmodus", GameState.AI_STANDARD, GameState.AI_LEARNING, 0, true)
await _play("Standard gegen Standard", GameState.AI_STANDARD, GameState.AI_STANDARD, GameState.DRAW)
await _play("Leeres Baufeld gegen Standard", -1, GameState.AI_STANDARD, 1)
# Restart after a real loss restores controls, budget, workers and the first wave.
WaveManager.start_match()
_check(GameState.wave == 1 and GameState.phase == GameState.Phase.BUILD, "Restart begins wave one")
_check(GameState.core_hp == [3000,3000] and EconomyManager.get_team(0).gold == 220, "Restart restores normal starting state")
_check(EconomyManager.get_team(0).roster.is_empty() and EconomyManager.get_team(0).workers == 1, "Restart clears roster and workers")
_check(AssetLibrary.missing.is_empty(), "No required visual assets missing")
var output: String = "res://docs/" + phase_id + "-verification.json"
var file := FileAccess.open(output, FileAccess.WRITE)
file.store_string(JSON.stringify({"wave_count": DataCatalog.wave_count(), "checks":checks,
"failures":failures,"matches":records,"capture":capture}, "\t"))
file.close()
_write_balance_report()
print("PHASE5_PASS: %d checks; %d real-budget matches; %d failures" % [checks, records.size(), failures.size()])
await Soundscape.quit_game(0 if failures.is_empty() else 1)
func _check_assets() -> void:
var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://assets/phase4_manifest.json"))
var original_parts: int = 0
var runtime_surfaces: int = 0
for record: Dictionary in manifest.assets:
var visual: MeshInstance3D = AssetLibrary.instantiate_model(str(record.id)) as MeshInstance3D
_check(visual != null, "Runtime model exists: " + str(record.id))
if visual == null:
continue
var triangles: int = 0
for surface: int in range(visual.mesh.get_surface_count()):
var arrays: Array = visual.mesh.surface_get_arrays(surface)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
triangles += (indices.size() if not indices.is_empty() else vertices.size()) / 3
_check(triangles == int(record.triangles), "Batching preserves triangles: " + str(record.id))
var expected_low := Vector3(record.bounds_godot.min[0],record.bounds_godot.min[1],record.bounds_godot.min[2])
var expected_high := Vector3(record.bounds_godot.max[0],record.bounds_godot.max[1],record.bounds_godot.max[2])
_check(visual.get_aabb().position.distance_to(expected_low) < .001 and visual.get_aabb().end.distance_to(expected_high) < .001,
"Batching preserves bounds: " + str(record.id))
original_parts += int(record.mesh_parts)
runtime_surfaces += visual.mesh.get_surface_count()
visual.free()
print("RUNTIME_BATCHING: ", original_parts, " original parts -> ", runtime_surfaces, " material surfaces across catalog assets")
for id: String in DataCatalog.UNITS.keys() + DataCatalog.SENDS.keys() + ["worker","res_gold","res_sparks","res_income","res_core"]:
_check(AssetLibrary.icon(id) != null, "Icon exists: " + id)
for id: String in DataCatalog.UNITS:
var unit := CombatUnit.new()
add_child(unit)
unit.setup(DataCatalog.get_unit(id),1,0,false)
_check(unit.model_loaded, "Guardian uses actual model: " + id)
unit.play_attack(Vector3(1,0,1))
unit.advance_status(.05)
unit.refresh_visuals()
var other := CombatUnit.new()
add_child(other)
other.setup(DataCatalog.get_unit(id),2,0,false)
_check(is_zero_approx(other.attack_flash), "Visual effects are per instance: " + id)
unit.free()
other.free()
for id: String in ["e01","e02","e03","e04","e07","e08"]:
var unit := CombatUnit.new()
add_child(unit)
unit.setup(DataCatalog.ENEMIES[id],2000000,0,true)
_check(unit.model_loaded, "Wave enemy has its own visual: " + id)
unit.free()
var fallback := CombatUnit.new()
add_child(fallback)
fallback.setup({"id":"unknown","ability":""},1,0,false)
fallback.refresh_visuals()
_check(not fallback.model_loaded and fallback.get_child_count() > 0, "Missing optional model has a working fallback")
fallback.free()
_check(DataCatalog.get_wave(9).is_empty() and DataCatalog.get_attackers(9).is_empty(), "No ninth wave")
_check(DataCatalog.WAVES[4].has("e07") and DataCatalog.WAVES[7].has("e08"), "Both boss waves are defined")
func _play(title: String, player_level: int, rival_level: int, expected: int, screenshots: bool = false) -> void:
GameState.ai_level = rival_level
WaveManager.start_match()
var player := AI.new()
var waves: Array[Dictionary] = []
var previous_roster: Dictionary = {}
for number: int in range(1, DataCatalog.wave_count() + 1):
_check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD, title + ": build boundary")
if GameState.phase != GameState.Phase.BUILD:
break
if player_level >= 0:
player.plan(0,number,WaveManager.rival_snapshot(),GameState.last_reports[0],player_level)
var plan: Dictionary = EconomyManager.get_team(0)
var purchases: Array[String] = []
for entry: Dictionary in plan.roster:
if previous_roster.get(entry.uid, "") != entry.type_id:
purchases.append(str(DataCatalog.get_unit(entry.type_id).display_name) + (" ↑" if previous_roster.has(entry.uid) else ""))
previous_roster[entry.uid] = entry.type_id
for team: int in range(2):
var own: Dictionary = EconomyManager.get_team(team)
_check(own.gold >= 0 and own.sparks >= 0, title + ": orders use real resources")
_check(EconomyManager.reserved_sparks(team) <= mini(own.sparks, EconomyManager.send_budget(number)), title + ": sends paid within budget")
if capture and screenshots and number in [1,5,8]:
main.viewed_team = 0
main.hud.viewed_team = 0
main._focus.x = -9.0
main._place_camera()
main.selected_uid = int(EconomyManager.get_team(0).roster[0].uid)
main.hud.selected_uid = main.selected_uid
await _capture("build-%02d" % number)
var before_hp: Array = GameState.core_hp.duplicate()
WaveManager.ready_for_battle()
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT:
break
WaveManager.advance(STEP)
if capture and screenshots and number in [5,8] and step == 330:
await _capture("boss-%02d" % number)
_check(GameState.phase in [GameState.Phase.RESULTS,GameState.Phase.GAME_OVER], title + ": battle terminates")
waves.append({"wave":number,"core_hp":GameState.core_hp.duplicate(),
"purchases":purchases,"workers_ordered":plan.pending_workers,"sends":plan.send_queue,
"damage":[before_hp[0]-GameState.core_hp[0],before_hp[1]-GameState.core_hp[1]],
"seconds":battle.elapsed,"leaks":battle.leaks.duplicate(),
"gold":[EconomyManager.get_team(0).gold,EconomyManager.get_team(1).gold],
"roster":[EconomyManager.get_team(0).roster.size(),EconomyManager.get_team(1).roster.size()]})
if GameState.phase == GameState.Phase.GAME_OVER:
break
WaveManager.ready_for_battle()
_check(GameState.phase == GameState.Phase.GAME_OVER and GameState.outcome == expected, title + ": expected match result")
if player_level >= 0:
_check(GameState.wave == 8, title + ": survives all eight waves without injected resources")
if capture and screenshots:
await _capture("victory")
if capture and player_level < 0:
await _capture("defeat")
records.append({"title":title,"outcome":GameState.outcome,"waves":waves})
print("SLICE_MATCH: ",title,"; wave ",GameState.wave,"; outcome ",GameState.outcome,"; cores ",GameState.core_hp)
func _write_balance_report() -> void:
var lines: Array[String] = ["# Glutpakt · Daten und vollständige Testpartien", "",
"Aus den aktuellen Produktionsdaten und `tools/verify_phase5.gd` erzeugt. Alle Partien verwenden 220 Startgold, 3.000 Kern-HP und reguläre Käufe/Sends; feste Schritte von 1/60 s. KI gegen KI ist ein reproduzierbarer Funktionstest, keine Erfolgsquote für menschliche Spieler.", "",
"## Einheiten", "", "Jeder Grundangriff erfolgt einmal pro Sekunde. DPS hier ohne Brand, vierten Treffer, Flächenschaden oder Verstärkung. Bei Upgrades ist der angegebene Preis der Aufpreis zur Basiseinheit.", "",
"| ID | Einheit | Kauf / Aufpreis | HP | Grund-DPS | Reichweite | Schaden | Rüstung | Fähigkeit |",
"| --- | --- | ---: | ---: | ---: | ---: | --- | --- | --- |"]
for id: String in DataCatalog.UNITS:
var data: Dictionary = DataCatalog.get_unit(id)
lines.append("| %s | %s | %d | %d | %d | %.1f | %s | %s | %s |" % [id,data.display_name,data.cost,data.max_hp,data.damage,data.attack_range,
DataCatalog.DAMAGE_NAMES[data.attack_type],DataCatalog.ARMOR_NAMES[data.armor_type],data.ability_description])
lines.append_array(["", "## Wellen", "", "| Welle | Titel | Gruppen | HP-Faktor | Schadensfaktor |", "| ---: | --- | --- | ---: | ---: |"])
for number: int in range(1,DataCatalog.wave_count()+1):
var wave: Dictionary = DataCatalog.get_wave(number)
lines.append("| %d | %s | %s | %.3f | %.3f |" % [number,wave.title,wave.description,wave.hp_factor,wave.damage_factor])
for record: Dictionary in records:
lines.append_array(["", "## " + str(record.title), "", "| Welle | Dauer s | Kern-HP 0 / 1 | Lecks 0 / 1 | Gold nach Kampf 0 / 1 | Wächter 0 / 1 |", "| ---: | ---: | ---: | ---: | ---: | ---: |"])
for wave: Dictionary in record.waves:
lines.append("| %d | %.1f | %d / %d | %d / %d | %d / %d | %d / %d |" % [wave.wave,wave.seconds,wave.core_hp[0],wave.core_hp[1],wave.leaks[0],wave.leaks[1],wave.gold[0],wave.gold[1],wave.roster[0],wave.roster[1]])
lines.append("\nErgebnis: " + ("Gleichstand" if record.outcome == -1 else "Front %d gewinnt" % record.outcome) + ".")
lines.append_array(["", "## Nachvollziehbarer Kaufplan gegen den Lernmodus", "", "Aufwertungen sind mit ↑ markiert. Die KI stellt Nahkämpfer vorn, Fernkämpfer zwei Reihen dahinter und Unterstützer dazwischen auf. Dies ist der tatsächlich bezahlte Plan der ersten Testpartie; andere Formationen und Eingaben können andere Ergebnisse liefern.", "",
"| Bauphase | Käufe / Aufwertungen | Arbeiter bestellt | Söldner-IDs |", "| ---: | --- | ---: | --- |"])
for wave: Dictionary in records[0].waves:
lines.append("| %d | %s | %d | %s |" % [wave.wave,", ".join(wave.purchases),wave.workers_ordered,", ".join(wave.sends)])
var file := FileAccess.open("res://docs/" + phase_id + "-balance.md",FileAccess.WRITE)
file.store_string("\n".join(lines) + "\n")
file.close()
func _capture(label: String) -> void:
if not capture:
return
# Let the normal presentation catch up to the independently stepped simulation.
await get_tree().process_frame
await get_tree().create_timer(.3).timeout
await RenderingServer.frame_post_draw
var directory: String = "res://docs/screenshots/" + phase_id
DirAccess.make_dir_recursive_absolute(directory)
var error: Error = get_viewport().get_texture().get_image().save_png(directory + "/" + label + ".png")
_check(error == OK, "Screenshot saved: " + label)
print("PHASE5_CAPTURED: ",label)