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)