saumwacht-godot/scripts/qa/export_smoke.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

281 lines
15 KiB
GDScript

extends Node
## Opt-in QA entry point. Runs inside the actual release PCK with ordinary budgets.
const AI = preload("res://scripts/ai/simple_ai.gd")
const RELEASE_MODELS: Dictionary = {
"w04": ["res://assets/phase11_manifest.json", "res://assets/characters/ringborke.glb", 27],
"worker": ["res://assets/phase12_manifest.json", "res://assets/characters/arbeiter.glb", 27],
"g04": ["res://assets/phase15_manifest.json", "res://assets/characters/basaltbastion.glb", 17],
"w10": ["res://assets/phase16_manifest.json", "res://assets/characters/quellgeflecht.glb", 17],
"g05": ["res://assets/phase18_manifest.json", "res://assets/characters/funkenlanze.glb", 26],
"g06": ["res://assets/phase19_manifest.json", "res://assets/characters/glasballiste.glb", 20],
}
const FIGHTER_CLIPS: Array[String] = ["idle", "start", "walk", "stop", "attack_a", "attack_b", "hit", "ability", "death"]
const WORKER_CLIPS: Array[String] = ["idle", "start", "walk", "stop", "strike", "pickup", "deposit", "return", "hit", "death"]
var checks: int = 0
var failures: Array[String] = []
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures.append(message)
printerr("SMOKE_FAIL: ", message)
func triangles(mesh: Mesh) -> int:
var total: int = 0
for index: int in range(mesh.get_surface_count()):
var arrays: Array = mesh.surface_get_arrays(index)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
total += (indices.size() if not indices.is_empty() else vertices.size()) / 3
return total
func key(code: Key) -> void:
var event := InputEventKey.new()
event.keycode = code
event.pressed = true
get_viewport().push_input(event, true)
event = event.duplicate()
event.pressed = false
get_viewport().push_input(event, true)
func capture(label: String) -> void:
if DisplayServer.get_name() == "headless":
return
await get_tree().process_frame
await get_tree().create_timer(.22).timeout
await RenderingServer.frame_post_draw
var directory: String = "user://phase8-smoke"
DirAccess.make_dir_recursive_absolute(directory)
check(get_viewport().get_texture().get_image().save_png(directory + "/" + label + ".png") == OK, "Capture " + label)
func check_release_assets() -> void:
# Branching on has_method("update_combat") also lets the rigid fallback pass.
# Require the release's exact manifest selection and the instantiated skin/clips.
for path: String in ["res://assets/phase3_manifest.json", "res://assets/phase4_manifest.json", "res://assets/phase11_manifest.json", "res://assets/phase12_manifest.json", "res://assets/phase15_manifest.json", "res://assets/phase16_manifest.json", "res://assets/phase18_manifest.json", "res://assets/phase19_manifest.json", "res://assets/menu_stage_manifest.json", "res://assets/audio/manifest.json", "res://assets/icons/manifest.json"]:
check(FileAccess.file_exists(path), "Packaged runtime manifest " + path)
if FileAccess.file_exists(path):
check(JSON.parse_string(FileAccess.get_file_as_string(path)) is Dictionary, "Valid runtime manifest " + path)
# Phase 20A menu: emblems, control icons, fonts and the diorama must ship in the package.
for path: String in ["res://assets/ui/emblems/saumwacht.svg", "res://assets/ui/emblems/glutpakt.svg", "res://assets/ui/emblems/wurzelbund.svg",
"res://assets/ui/controls/toggle_on.svg", "res://assets/ui/controls/toggle_off.svg", "res://assets/ui/controls/choice_on.svg", "res://assets/ui/controls/choice_off.svg"]:
check(load(path) is Texture2D, "Packaged menu graphic " + path.get_file())
for path: String in ["res://assets/fonts/Cinzel-VF.ttf", "res://assets/fonts/Alegreya-VF.ttf", "res://assets/fonts/Alegreya-Italic-VF.ttf"]:
check(load(path) is Font, "Packaged menu font " + path.get_file())
check(load("res://assets/environment/menu-stage.glb") is PackedScene, "Packaged menu diorama")
for id: String in RELEASE_MODELS:
var expected: Array = RELEASE_MODELS[id]
var record: Dictionary = AssetLibrary._models.get(id, {})
var selected: bool = record.get("skeletal", false) and record.get("resource_path", "") == expected[1]
check(selected, id + " selects the current skeletal release asset")
check(ResourceLoader.exists(expected[1]), id + " skeletal resource is packaged")
if not selected or not ResourceLoader.exists(expected[1]):
continue
var a: Node3D = AssetLibrary.instantiate_fighter(id)
var b: Node3D = AssetLibrary.instantiate_fighter(id)
check(a is AssetLibrary.SkeletalFigure and b is AssetLibrary.SkeletalFigure, id + " instantiates the skeletal runtime")
if not (a is AssetLibrary.SkeletalFigure and b is AssetLibrary.SkeletalFigure):
if a != null:
a.free()
if b != null:
b.free()
continue
add_child(a)
add_child(b)
check(a.skeleton != null and a.player != null, id + " imports skeleton and animation player")
if a.skeleton == null or a.player == null or b.skeleton == null or b.player == null:
a.free()
b.free()
continue
check(a.skeleton.get_bone_count() == expected[2], id + " has the current authored bone count")
check(a.skeleton != b.skeleton and a.player != b.player, id + " skeleton and playback are per instance")
var weighted_meshes: int = 0
for mesh: MeshInstance3D in a.find_children("*", "MeshInstance3D", true, false):
if mesh.skin != null and mesh.get_node_or_null(mesh.skeleton) == a.skeleton:
weighted_meshes += 1
check(weighted_meshes > 0, id + " mesh binds to the imported skeleton")
var required: Array[String] = WORKER_CLIPS if id == "worker" else FIGHTER_CLIPS
for clip: String in required:
check(a.clips.has(clip), id + " imports authored clip " + clip)
if a.clips.has(clip):
var animation: Animation = a.player.get_animation(a.clips[clip])
check(animation.length > .1 and animation.get_track_count() > 0, id + " has playable tracks for " + clip)
if a.clips.has("walk"):
var own_rest: Array[Transform3D] = []
var other_rest: Array[Transform3D] = []
for bone: int in range(a.skeleton.get_bone_count()):
own_rest.append(a.skeleton.get_bone_pose(bone))
other_rest.append(b.skeleton.get_bone_pose(bone))
a.preview("walk", a.clip_length("walk") * .25)
var changed: bool = false
var independent: bool = true
for bone: int in range(a.skeleton.get_bone_count()):
changed = changed or not a.skeleton.get_bone_pose(bone).is_equal_approx(own_rest[bone])
independent = independent and b.skeleton.get_bone_pose(bone).is_equal_approx(other_rest[bone])
check(changed, id + " clip drives actual bone poses")
check(independent, id + " playback leaves the other instance unchanged")
if id == "worker":
check(a is AssetLibrary.WorkerFigure and a.has_method("update_work"), "Worker uses the current work-cycle runtime")
if a is AssetLibrary.WorkerFigure and required.all(func(clip: String) -> bool: return a.clips.has(clip)):
var seen: Dictionary = {}
var contacts: int = 0
for step: int in range(ceili(a.cycle_length() * 60.0)):
a.update_work(1.0 / 60.0)
seen[a.current_clip] = true
if a.consume_strike_contact():
contacts += 1
for clip: String in ["strike", "pickup", "deposit", "return", "idle"]:
check(seen.has(clip), "Worker work cycle plays " + clip)
check(contacts == 1, "Worker emits one tool contact per complete work cycle")
a.free()
b.free()
func run(main: Node3D) -> void:
GameState.reset_match()
GameState.factions = ["glutpakt", "glutpakt"]
GameState.timed_build = true
GameState.select_mode(GameState.MODE_SHORT)
WaveManager.set_physics_process(false)
Soundscape.enabled = false
await get_tree().process_frame
check_release_assets()
if not failures.is_empty():
printerr("EXPORT_SMOKE_FAIL: %d release asset failures" % failures.size())
await Soundscape.quit_game(1)
return
for id: String in DataCatalog.UNITS:
var a: Node3D = AssetLibrary.instantiate_fighter(id)
var b: Node3D = AssetLibrary.instantiate_fighter(id)
var static_model := AssetLibrary.instantiate_model(id) as MeshInstance3D
check(a != null and a.has_method("pose"), id + " has articulated model")
if a != null and a.has_method("pose"):
var mesh: ArrayMesh = AssetLibrary.bake_mesh(a)
check(triangles(mesh) == triangles(static_model.mesh), id + " rig retains every triangle")
# Skeletal remakes are judged by form and motion, not a raw poly floor.
var poly_floor: int = 14000 if a.has_method("update_combat") else 20000
check(triangles(mesh) > poly_floor, id + " has high-detail geometry")
check(mesh.get_aabb().position.distance_to(static_model.get_aabb().position) < .001 and mesh.get_aabb().end.distance_to(static_model.get_aabb().end) < .001, id + " neutral pose retains original bounds")
if a.has_method("update_combat"):
check(a.skeleton != b.skeleton, id + " skeleton is per instance")
check(a.clips.size() >= 9, id + " contains authored animation clips")
a.free()
b.free()
static_model.free()
continue
check(a.parts.has("LegL") and a.parts.has("LegR"), id + " has two separate leg joints")
var other_leg: Transform3D = b.parts.LegL.transform
a.pose(.25, true, .14, .4, .12)
check(absf(a.parts.LegL.rotation.x) > .005 and a.parts.LegL.rotation.x * a.parts.LegR.rotation.x < 0, id + " legs step in opposition")
check(b.parts.LegL.transform == other_leg, id + " animation does not move another unit")
check(a.parts.LegL.mesh == b.parts.LegL.mesh, id + " immutable joint geometry is shared")
check(a.parts.LegL.material_overlay != b.parts.LegL.material_overlay, id + " hit tint is per instance")
a.free()
b.free()
static_model.free()
for id: String in ["e01","e02","e03","e04","e07","e08"]:
var figure: Node3D = AssetLibrary.instantiate_fighter(id)
var still: Node3D = AssetLibrary.instantiate_model(id)
var baked: ArrayMesh = AssetLibrary.bake_mesh(figure)
check(figure.has_method("pose") and triangles(baked) > 10000, id + " articulated high-detail enemy")
check(triangles(baked) == triangles(still.mesh), id + " enemy rig retains every triangle")
check(baked.get_aabb().end.distance_to(still.get_aabb().end) < .001, id + " enemy neutral bounds")
figure.free()
still.free()
for id: String in Soundscape.CLIPS:
var stream := load("res://assets/audio/" + id + ".wav") as AudioStreamWAV
check(stream != null and stream.get_length() > .1, "Packaged audio " + id)
for id: String in DataCatalog.UNITS.keys() + DataCatalog.SENDS.keys() + ["worker", "res_gold", "res_sparks", "res_income", "res_core"]:
check(AssetLibrary.icon(id) != null, "Packaged icon " + id)
check(AssetLibrary.missing.is_empty(), "No missing models")
await capture("menu")
main.hud._overlay_action.pressed.emit()
main.hud._starter_button.pressed.emit()
check(GameState.phase == GameState.Phase.BUILD and EconomyManager.get_team(0).gold == 40, "Menu and paid starter work in package")
main.hud._ready_button.grab_focus()
key(KEY_V)
check(main.hud.model_inspector.visible and GameState.paused, "Model inspector pauses the match")
var inspect_remaining: float = WaveManager.remaining
WaveManager.advance(2)
key(KEY_SPACE)
check(main.hud.model_inspector.visible and WaveManager.remaining == inspect_remaining and GameState.phase == GameState.Phase.BUILD, "Inspector blocks ready shortcut and simulation")
main.hud.model_inspector._selector.item_selected.emit(3)
check(main.hud.model_inspector.model_id == "g04", "Inspector switches to selected upgrade")
main.hud.model_inspector._rotate_button.button_pressed = false
await capture("model-g04")
main.hud.model_inspector._select_model("g10")
await capture("model-g10")
main.hud.model_inspector._select_model("w10")
await capture("model-w10")
main.hud.model_inspector._select_model("worker")
await capture("model-worker")
key(KEY_ESCAPE)
check(not main.hud.model_inspector.visible and not GameState.paused and main.hud._ready_button.has_focus(), "Inspector restores running state and focus")
key(KEY_P)
key(KEY_V)
key(KEY_V)
check(GameState.paused, "Inspector preserves a pre-existing pause")
key(KEY_P)
key(KEY_F1)
check(main.hud.settings_panel.visible and GameState.paused, "Options pause the match")
var remaining: float = WaveManager.remaining
WaveManager.advance(2)
key(KEY_SPACE)
check(WaveManager.remaining == remaining and GameState.phase == GameState.Phase.BUILD, "Options block simulation and ready shortcut")
await capture("options")
key(KEY_ESCAPE)
check(not main.hud.settings_panel.visible and not GameState.paused, "Closing options restores running state")
key(KEY_P)
key(KEY_F1)
key(KEY_ESCAPE)
check(GameState.paused, "Options preserve a pre-existing pause")
key(KEY_P)
var basis: Basis = main.camera.basis
key(KEY_E)
check(main.camera.basis != basis, "Orbit key rotates camera")
key(KEY_R)
check(main.camera.basis.is_equal_approx(basis), "Reset restores default angle")
# Round trip in an isolated config file; restore the user's actual settings.
var path: String = Preferences.storage_path
var volume: float = Preferences.volume
var muted: bool = Preferences.muted
Preferences.storage_path = "user://phase8-smoke-preferences.cfg"
Preferences.volume = .37
Preferences.muted = true
check(Preferences.save_settings() == OK, "Settings save")
Preferences.volume = .9
Preferences.muted = false
Preferences.read_settings()
check(is_equal_approx(Preferences.volume, .37) and Preferences.muted, "Settings survive reload")
DirAccess.remove_absolute(Preferences.storage_path)
Preferences.storage_path = path
Preferences.volume = volume
Preferences.muted = muted
Preferences.apply()
await capture("build")
key(KEY_SPACE)
check(GameState.phase == GameState.Phase.COMBAT, "Ready starts combat")
for step: int in range(200):
WaveManager.advance(1.0/60.0)
await capture("combat")
# One full acceptance match, using normal production rules and resources.
GameState.ai_level = GameState.AI_LEARNING
WaveManager.start_match()
var player := AI.new()
for number: int in range(1, 9):
check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD, "Wave %d build boundary" % number)
player.plan(0, number, WaveManager.rival_snapshot(), GameState.last_reports[0], GameState.AI_STANDARD)
WaveManager.ready_for_battle()
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT:
break
WaveManager.advance(1.0/60.0)
if number == 8 and step == 330:
await capture("boss")
check(GameState.phase in [GameState.Phase.RESULTS, GameState.Phase.GAME_OVER], "Wave %d settles" % number)
if GameState.phase == GameState.Phase.GAME_OVER:
break
WaveManager.ready_for_battle()
check(GameState.wave == 8 and GameState.outcome == 0, "Actual eight-wave package match is winnable")
await capture("victory")
print("%s: %d checks; %d failures; engine %s; cores %s" % ["EXPORT_SMOKE_PASS" if failures.is_empty() else "EXPORT_SMOKE_FAIL", checks, failures.size(), Engine.get_version_info().string, str(GameState.core_hp)])
print("SMOKE_CAPTURES: ", ProjectSettings.globalize_path("user://phase8-smoke"))
await Soundscape.quit_game(0 if failures.is_empty() else 1)