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

168 lines
8.5 KiB
GDScript

extends Node
## Acceptance against production rules, also runnable from the exported program.
const AI = preload("res://scripts/ai/simple_ai.gd")
const Unit = preload("res://scripts/combat/unit.gd")
var checks: int = 0
var failures: Array[String] = []
var matches: Array[Dictionary] = []
var main: Node3D
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures.append(message)
printerr("EXPEDITION_FAIL: ", message)
func capture(label: String) -> void:
if DisplayServer.get_name() == "headless":
return
await get_tree().process_frame
await get_tree().create_timer(.25).timeout
await RenderingServer.frame_post_draw
DirAccess.make_dir_recursive_absolute("user://phase8-captures")
check(get_viewport().get_texture().get_image().save_png("user://phase8-captures/"+label+".png") == OK, "Capture " + label)
func run(scene: Node3D) -> void:
main = scene
WaveManager.set_physics_process(false)
Soundscape.enabled = false
main.battle.render_enabled = DisplayServer.get_name() != "headless"
GameState.reset_match()
GameState.factions = ["glutpakt", "glutpakt"]
GameState.timed_build = true
main.hud._mode_picker.item_selected.emit(1)
check(DataCatalog.wave_count() == 8 and DataCatalog.get_wave(9).is_empty(), "Short match remains eight waves")
check(DataCatalog.get_wave(8).groups.has("e08"), "Short finale remains Riegelkoloss")
main.hud._mode_picker.item_selected.emit(0)
check(DataCatalog.wave_count() == 20 and "Zwanzig" in main.hud._overlay_title.text, "Menu selects the twenty-wave expedition")
check(DataCatalog.get_wave(0).is_empty() and DataCatalog.get_wave(21).is_empty(), "Wave bounds reject out-of-range requests")
for number: int in range(1,21):
var wave: Dictionary = DataCatalog.get_wave(number)
check(wave.count == DataCatalog.get_attackers(number).size() and not wave.hint.is_empty(), "Wave %d has composition and counter hint" % number)
if number % 5 == 0:
check(wave.groups.has("e%02d" % (6+number/5)), "Boss appears at wave %d" % number)
if number > 1:
var previous: Dictionary = DataCatalog.get_wave(number-1)
check(wave.hp_factor > previous.hp_factor and wave.damage_factor > previous.damage_factor, "Wave %d scales upward" % number)
for id: String in ["e05","e06","e09","e10"]:
var model: Node3D = AssetLibrary.instantiate_fighter(id)
check(model != null and model.has_method("pose"), id + " has its own articulated model")
if model != null:
check(AssetLibrary._models[id].triangles > 15000, id + " has modeled detail")
var baked: ArrayMesh = AssetLibrary.bake_mesh(model)
check(baked.get_aabb().size.y > 1.5, id + " has correct world scale")
model.pose(.2,true,.14,.3,0)
model.free()
_test_abilities()
await capture("menu")
await _play("Standard gegen Standard",GameState.AI_STANDARD,GameState.AI_STANDARD,GameState.DRAW,true)
await _play("Standard gegen Lernmodus",GameState.AI_STANDARD,GameState.AI_LEARNING,0)
await _play("Leeres Baufeld gegen Standard",-1,GameState.AI_STANDARD,1)
main.hud._mode_picker.item_selected.emit(1)
main.hud._overlay_action.pressed.emit()
check(GameState.wave == 1 and DataCatalog.wave_count() == 8 and GameState.core_hp == [3000,3000], "Restart after expedition can select a fresh short match")
check(EconomyManager.get_team(0).gold == 220 and EconomyManager.get_team(0).roster.is_empty(), "Restart clears all expedition purchases")
check(not GameState.select_mode(GameState.MODE_EXPEDITION) and DataCatalog.wave_count() == 8, "Match length cannot change during an active match")
var file := FileAccess.open("user://phase8-verification.json",FileAccess.WRITE)
file.store_string(JSON.stringify({"checks":checks,"failures":failures,"matches":matches,"engine":Engine.get_version_info().string},"\t"))
file.close()
print("EXPEDITION_PASS: %d checks; %d failures; report %s" % [checks,failures.size(),ProjectSettings.globalize_path("user://phase8-verification.json")])
await Soundscape.quit_game(0 if failures.is_empty() else 1)
func _play(title: String, player_level: int, rival_level: int, expected: int, pictures: bool = false) -> void:
GameState.reset_match()
GameState.select_mode(GameState.MODE_EXPEDITION)
GameState.ai_level = rival_level
WaveManager.start_match()
var ai := AI.new()
var waves: Array[Dictionary] = []
for number: int in range(1,21):
check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD,title + " build %d" % number)
if player_level >= 0:
ai.plan(0,number,WaveManager.rival_snapshot(),GameState.last_reports[0],player_level)
var plan: Dictionary = EconomyManager.get_team(0)
check(plan.gold >= 0 and EconomyManager.reserved_sparks(0) <= mini(plan.sparks,EconomyManager.send_budget(number)),title + " paid purchases and sends %d" % number)
if number == 20:
var result: Dictionary = EconomyManager.apply_command(0,{"type":"worker"})
check(not result.ok and "letzten Welle" in result.message,"No worker investment in the final wave")
WaveManager.ready_for_battle()
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT:
break
WaveManager.advance(1.0/60.0)
if pictures and step == 280 and number in [9,15,20]:
await capture("wave-%02d" % number)
check(GameState.phase in [GameState.Phase.RESULTS,GameState.Phase.GAME_OVER],title + " settled %d" % number)
waves.append({"wave":number,"seconds":main.battle.elapsed,"core_hp":GameState.core_hp.duplicate(),"leaks":main.battle.leaks.duplicate(),"gold":plan.gold,"workers":plan.workers,"pending_workers":plan.pending_workers,"sends":plan.send_queue,"formation":_roster(plan.roster)})
if GameState.phase == GameState.Phase.GAME_OVER:
break
WaveManager.ready_for_battle()
check(GameState.outcome == expected,title + " expected outcome")
if player_level == rival_level:
check(GameState.wave == 20 and GameState.core_hp[0] > 0,"Symmetric match reaches and resolves the real final boss")
if pictures:
await capture("result")
matches.append({"title":title,"outcome":GameState.outcome,"waves":waves})
print("EXPEDITION_MATCH: ",title,"; wave ",GameState.wave,"; cores ",GameState.core_hp,"; outcome ",GameState.outcome)
func _roster(entries: Array) -> Array:
var result: Array = []
for entry: Dictionary in entries:
result.append({"id":entry.type_id,"uid":entry.uid,"cell":[entry.cell.x,entry.cell.y],"invested":entry.invested})
return result
func _unit(id: String, uid: int, lane: int = 0, attacker: bool = true) -> Node3D:
var unit := Unit.new()
add_child(unit)
var data: Dictionary = DataCatalog.ENEMIES[id].duplicate(true) if attacker else DataCatalog.get_unit(id)
unit.setup(data,uid,lane,attacker,Vector2i(-1,-1),0,false)
return unit
func _test_abilities() -> void:
var healer := _unit("e06",1)
var ally := _unit("e05",2)
var wrong_lane := _unit("e05",3,1)
ally.hp = 100
wrong_lane.hp = 10
healer.combat_time = 6
healer.data.hp_factor = 2.0
healer.ability_component.step(healer,[healer,ally,wrong_lane],0)
check(is_equal_approx(ally.hp,160) and is_equal_approx(wrong_lane.hp,10),"Knotenrufer heals 30 times wave factor in its own lane")
healer.ability_component.step(healer,[healer,ally],0)
check(is_equal_approx(ally.hp,160),"Healing observes its six-second cooldown")
for unit: Node3D in [healer,ally,wrong_lane]: unit.free()
var judge := _unit("e09",1)
var victims: Array = []
for i: int in range(5):
var unit := _unit("g03",10+i,0,false)
unit.position.x = .2*i
victims.append(unit)
judge.combat_time = 6
judge.ability_component.step(judge,[judge]+victims,0)
for i: int in range(5):
check(victims[i].effects.has("dissonance") == (i<3),"Maskenrichter slow target limit %d" % i)
victims[i].advance_status(3.0)
check(not victims[i].effects.has("dissonance"),"Maskenrichter slow expires %d" % i)
victims[i].free()
judge.free()
for id: String in ["e05","e10"]:
var source := _unit(id,1)
var primary := _unit("g03",10,0,false)
var extras: Array = []
for i: int in range(6):
var unit := _unit("g03",20+i,0,false)
unit.data.ability = ""
unit.data.armor_type = source.data.attack_type
unit.position.x = .1*(i+1)
extras.append(unit)
var hp: float = extras[0].hp
source.ability_component.on_hit(source,primary,[source,primary]+extras,3)
check(is_equal_approx(extras[0].hp,hp),id + " no splash before fourth hit")
source.ability_component.on_hit(source,primary,[source,primary]+extras,4)
for i: int in range(6):
var loss: float = source.data.damage*(.40 if id=="e05" else .60)*1.3 if i<(2 if id=="e05" else 4) else 0.0
check(is_equal_approx(extras[i].hp,hp-loss),id+" exact fourth-hit splash target %d"%i)
extras[i].free()
check(primary.hp == primary.max_hp,id+" splash excludes primary")
primary.free()
source.free()