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>
281 lines
15 KiB
GDScript
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)
|