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>
211 lines
12 KiB
GDScript
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)
|