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

317 lines
16 KiB
GDScript

extends Node
## Phase 9 acceptance: ordinary paid commands, real input, both factions and modes.
const Unit = preload("res://scripts/combat/unit.gd")
const AI = preload("res://scripts/ai/simple_ai.gd")
var checks: int = 0
var failures: Array[String] = []
var matches: Array = []
var fixtures: Array[Node] = []
var main: Node3D
var pictures: bool = false
func check(value: bool, label: String) -> void:
checks += 1
if not value:
failures.append(label)
printerr("FACTION_FAIL: ", label)
func run(scene: Node3D) -> void:
main = scene
pictures = DisplayServer.get_name() != "headless"
WaveManager.set_physics_process(false)
check(main.world.has_node("VerdantRift/LivingRiver"),"New landscape and animated river instantiated")
for map_path: String in ["oak-albedo-4k", "oak-height-4k"]:
var texture: Texture2D = load("res://assets/materials/phase10/"+map_path+".png")
check(texture != null and texture.get_size() == Vector2(4096,4096),"Native 4K map included: "+map_path)
Soundscape.enabled = false
main.battle.render_enabled = pictures
_test_abilities()
for id: String in DataCatalog.UNITS:
if not id.begins_with("w"):
continue
var model: Node3D = AssetLibrary.instantiate_fighter(id)
check(model != null and model.has_method("pose"), id+" articulated model")
# Skeletal remakes are judged by form and motion; rigid HD models keep the 20k floor.
var poly_min: int = 14000 if model.has_method("update_combat") else 20000
check(AssetLibrary._models[id].triangles > poly_min and AssetLibrary._models[id].triangles <= 180000,id+" authored geometry within budget")
if model.has_method("update_combat"):
check(model.skeleton.find_bone("Thigh.L") >= 0 and model.skeleton.find_bone("Thigh.R") >= 0,id+" weighted leg bones")
else:
check(model.parts.has("LegL") and model.parts.has("LegR"),id+" independent roots")
model.pose(.4,true,.2,.2,0)
model.free()
await _test_interface()
if "--ui-only" not in OS.get_cmdline_user_args():
await _play("Wurzelbund gegen Glutpakt", "wurzelbund", "glutpakt", GameState.MODE_EXPEDITION, true)
await _play("Glutpakt gegen Wurzelbund", "glutpakt", "wurzelbund", GameState.MODE_EXPEDITION)
await _play("Wurzelbund Spiegel", "wurzelbund", "wurzelbund", GameState.MODE_EXPEDITION)
await _play("Wurzelbund Kurzpartie", "wurzelbund", "wurzelbund", GameState.MODE_SHORT)
var path: String = "user://phase10-verification.json"
var file := FileAccess.open(path, FileAccess.WRITE)
file.store_string(JSON.stringify({"checks":checks,"failures":failures,"matches":matches,"renderer":RenderingServer.get_current_rendering_method(),"engine":Engine.get_version_info().string},"\t"))
file.close()
print("FACTION_PASS: %d checks; %d failures; %s" % [checks,failures.size(),ProjectSettings.globalize_path(path)])
await Soundscape.quit_game(0 if failures.is_empty() else 1)
func fixture(id: String, uid: int, team: int = 0, attacker: bool = false) -> CombatUnit:
var unit := Unit.new()
var data: Dictionary = DataCatalog.get_unit(id) if DataCatalog.UNITS.has(id) else DataCatalog.ENEMIES[id].duplicate(true)
unit.setup(data,uid,team,attacker,Vector2i.ZERO,0,false)
fixtures.append(unit)
return unit
func _test_abilities() -> void:
for id: String in ["w03","w04"]:
var tank := fixture(id,1)
tank.combat_time = 5
tank.ability_component.step(tank,[tank],0)
check(tank.ability_component.ready_time == 5,id+" does not spend heal on full HP")
tank.hp -= 150
var before: float = tank.hp
tank.ability_component.step(tank,[tank],0)
check(is_equal_approx(tank.hp-before,100 if id == "w04" else 35),id+" self heal")
check(tank.ability_component.ready_time == 10,id+" five-second cooldown")
tank.ability_component.step(tank,[tank],0)
check(is_equal_approx(tank.hp-before,100 if id == "w04" else 35),id+" cannot heal twice in same instant")
for id: String in ["w09","w10"]:
var healer := fixture(id,10)
var near := fixture("w03",11)
var urgent := fixture("w03",12)
var third := fixture("w03",13)
var far := fixture("w03",14)
var dead := fixture("w03",15)
var rival := fixture("w03",16,1)
var foe := fixture("e02",17,0,true)
var army: Array = [healer,near,urgent,third,far,dead,rival,foe]
for ally in army:
ally.hp = ally.max_hp*.5
healer.hp = healer.max_hp
near.position.x = .5
urgent.position.x = 3.5
urgent.hp = 1
third.position.x = 2
far.position.x = 5
dead.alive = false
healer.combat_time = 4
var before: float = near.hp
healer.ability_component.step(healer,army,0)
check(urgent.hp == (81 if id == "w10" else 66),id+" prioritizes lowest HP ratio")
check(near.hp == before+(80 if id == "w10" else 0),id+" target limit")
check(far.hp == far.max_hp*.5 and rival.hp == rival.max_hp*.5 and foe.hp == foe.max_hp*.5 and not dead.alive,id+" respects range, lane, allegiance and death")
check(healer.ability_component.ready_time == 8,id+" four-second cooldown")
healer.combat_time = 8
urgent.apply_effect("rift_wound","healing_reduction",.30,4)
var hp: float = urgent.hp
healer.ability_component.step(healer,army,0)
check(is_equal_approx(urgent.hp-hp,(80 if id == "w10" else 65)*.70),id+" healing reduction applies")
for id: String in ["w01","w02"]:
var root := fixture(id,20)
var victim := fixture("e08",21,0,true)
root.ability_component.on_hit(root,victim,[root,victim],2)
check(victim.effects.is_empty(),id+" no early proc")
root.ability_component.on_hit(root,victim,[root,victim],3)
check(is_equal_approx(victim.movement_factor(),.85 if id == "w02" else .90),id+" boss movement slow is halved")
check(is_equal_approx(victim.attack_speed_factor(),.95 if id == "w02" else 1.0),id+" attack slow")
victim.advance_status(3)
check(victim.effects.is_empty(),id+" slow expires")
for id: String in ["w05","w06"]:
var archer := fixture(id,30)
var victim := fixture("e08",31,0,true)
victim.shield = 0
archer.ability_component.on_hit(archer,victim,[archer,victim],3)
check(victim.dots.is_empty(),id+" waits until fourth hit")
archer.ability_component.on_hit(archer,victim,[archer,victim],4)
victim.apply_dot("burn",4,3,0)
check(victim.dots.has("burn") and victim.dots.has("seed_burn"),id+" independent burn families")
victim.dots.erase("burn")
var hp: float = victim.hp
for i in 4:
victim.advance_status(1)
check(is_equal_approx(hp-victim.hp,(10 if id == "w06" else 3)*4*1.30),id+" four scaled damage ticks")
check(victim.dots.is_empty(),id+" DOT expires")
for id: String in ["w07","w08"]:
var spore := fixture(id,40)
var primary := fixture("e02",41,0,true)
var army: Array = [spore,primary]
var targets: Array = []
for i in 6:
var victim := fixture("e02",42+i,0,true)
victim.position.x = .1*(i+1)
targets.append(victim)
army.append(victim)
spore.apply_effect("warmth","damage",.3,4)
spore.ability_component.on_hit(spore,primary,army,3)
var maximum: int = 4 if id == "w08" else 3
for i in 6:
var damage: float = float(spore.data.damage)*(.8 if id == "w08" else .6) if i < maximum else 0
check(is_equal_approx(targets[i].max_hp-targets[i].hp,damage),id+" splash priority and unbuffed damage %d" % i)
check(primary.hp == primary.max_hp,id+" primary excluded from extra splash")
var archer := fixture("w05",80)
var a := fixture("e02",81,0,true)
var b := fixture("e02",82,0,true)
archer.target = a
for i in 3: archer.attack_component.step(archer,[archer,a,b],1)
archer.target = b
archer.attack_component.step(archer,[archer,a,b],1)
check(archer.attack_component.hit_count == 4 and b.dots.has("seed_burn"),"Target changes retain hit counters")
for unit: Node in fixtures: unit.free()
fixtures.clear()
func _test_interface() -> void:
GameState.reset_match()
GameState.factions = ["wurzelbund","glutpakt"]
GameState.timed_build = false
GameState.select_mode(GameState.MODE_EXPEDITION)
main.hud._faction_pickers[0].select(1)
main.hud._faction_pickers[1].select(0)
main.hud._refresh()
await capture("menu")
main.hud._overlay_action.pressed.emit()
check(GameState.phase == GameState.Phase.BUILD,"Menu starts selected match")
WaveManager.advance(120)
check(GameState.phase == GameState.Phase.BUILD,"Untimed building does not start unexpectedly")
main.hud.request_wave_start()
check(GameState.phase == GameState.Phase.BUILD,"Empty formation gets build guidance")
check(not GameState.select_faction(0,"glutpakt"),"Faction locked during active match")
var before: Dictionary = EconomyManager.get_team(0)
check(not EconomyManager.apply_command(0,{"type":"build","type_id":"g01","cell":Vector2i.ZERO}).ok,"Foreign faction purchase rejected")
check(EconomyManager.get_team(0) == before,"Rejected order preserves complete economy")
main.hud._worker_button.pressed.emit()
check(EconomyManager.get_team(0).pending_workers == 1 and EconomyManager.get_team(0).workers == 1,"Worker waits until next build phase")
check(EconomyManager.get_team(0).sparks == 10,"Worker cannot produce on purchase")
main.hud._undo.pressed.emit()
check(EconomyManager.get_team(0) == before,"Undo cancels worker and refunds full price")
main.hud._starter_button.pressed.emit()
check(EconomyManager.get_team(0).gold == 35 and EconomyManager.get_team(0).roster.size() == 3,"Wurzelbund starter costs exactly 185")
check(main.hud._shop_buttons.has("w01") and not main.hud._shop_buttons.has("g01"),"Shop follows selected faction")
await capture("build")
main.hud.base_requested.emit()
check(main._base_view and main.camera.size == 14,"Base button focuses workers and crystal")
await capture("base")
main.hud.base_requested.emit()
check(not main._base_view,"Base button returns to formation")
main.hud.request_wave_start()
check(GameState.phase == GameState.Phase.COMBAT,"Ready starts combat")
check(not main.hud._cards.visible and main.hud._shop_panel.offset_top == -84,"Combat collapses shop and frees board space")
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT: break
WaveManager.advance(1.0/60.0)
if step == 420: await capture("combat")
check(GameState.phase == GameState.Phase.RESULTS,"Real starter resolves first wave")
WaveManager.advance(60)
check(GameState.phase == GameState.Phase.RESULTS,"Untimed result waits for the player")
main.hud.request_wave_start()
check(GameState.wave == 2,"Next-wave button opens next build")
check(main.hud._cards.visible and main.hud._shop_panel.offset_top == -212,"Next build restores full shop")
var gold: int = EconomyManager.get_team(0).gold
main.hud._shop_buttons["w01"].pressed.emit()
check(is_instance_valid(main._ghost),"Build selection creates model preview")
var point: Vector2 = main.camera.unproject_position(Vector3(-14,0,3))
# Real input route: this cell must be in the visible play area, not beneath HUD.
await get_tree().process_frame
var move := InputEventMouseMotion.new()
move.position = point
get_viewport().push_input(move,true)
await get_tree().process_frame
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.position = point
click.pressed = true
get_viewport().push_input(click,true)
await get_tree().process_frame
click.pressed = false
get_viewport().push_input(click,true)
check(EconomyManager.get_team(0).gold == gold-50,"Visible-cell mouse input builds with real gold")
check(main.build_type.is_empty() and main.selected_uid > 0,"Single placement selects newly built unit")
check(not is_instance_valid(main._ghost),"Placement clears ghost")
await capture("selection")
if pictures:
var clip: ScrollContainer = main.hud._upgrade.get_parent().get_parent()
check(clip.get_global_rect().encloses(main.hud._upgrade.get_global_rect()), "Upgrade action visible without scrolling")
main.hud._undo.pressed.emit()
check(EconomyManager.get_team(0).gold == gold,"Undo refunds placed unit")
main.hud._worker_button.pressed.emit()
var pending: int = EconomyManager.get_team(0).pending_workers
main.hud.request_wave_start()
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT: break
WaveManager.advance(1.0/60.0)
main.hud.request_wave_start()
check(pending == 1 and EconomyManager.get_team(0).workers == 2 and EconomyManager.get_team(0).pending_workers == 0,"Ordered worker activates once next wave")
check(EconomyManager.get_team(0).sparks == 40,"Two workers produce exactly twenty new sparks")
main.hud._send_picker.select(0)
main.hud._send_button.pressed.emit()
main.hud._refresh()
check(EconomyManager.reserved_sparks(0) == 20 and main.hud._sparks.text == "20","Sends and free-resource readout agree")
main.hud._cancel_send.pressed.emit()
check(EconomyManager.reserved_sparks(0) == 0,"Cancel releases reserved resources")
await capture("economy")
if pictures:
# Presentation-only stress fixture; the real economic state is untouched.
main.hud._refresh_send_queue({"send_queue":["s01","s02","s03","s04","s05","s01","s02","s03"]})
await get_tree().process_frame
await get_tree().process_frame
check(main.hud._queue_scroll.get_global_rect().end.y <= get_viewport().get_visible_rect().end.y, "Eight send chips stay inside the screen")
check(main.hud._queue_scroll.size.y < 50, "Send queue scrolls horizontally instead of growing over controls")
main.hud._refresh_send_queue(EconomyManager.get_team(0))
if pictures:
var original: Vector2i = get_window().size
get_window().size = Vector2i(1152,720)
await capture("minimum-window")
var rect := Rect2(Vector2.ZERO,get_viewport().get_visible_rect().size)
for control: Control in [main.hud._worker_button,main.hud._ready_button,main.hud._starter_button,main.hud._send_button,main.hud._shop_buttons.w09]:
check(rect.encloses(control.get_global_rect()),"Minimum window contains "+control.name)
get_window().size = original
GameState.reset_match()
GameState.factions = ["glutpakt","wurzelbund"]
GameState.timed_build = true
main._start_match()
main._starter()
check(EconomyManager.get_team(0).gold == 40,"Glutpakt starter keeps 180 gold price")
WaveManager.advance(45)
check(GameState.phase == GameState.Phase.COMBAT,"Optional timed mode still auto-starts")
main.hud.menu_requested.emit()
check(GameState.phase == GameState.Phase.MENU and EconomyManager.get_team(0).roster.is_empty(),"New game returns to faction choice and clears old match")
func _play(title: String, own: String, rival: String, mode: String, screenshots: bool = false) -> void:
GameState.reset_match()
GameState.factions = [own,rival]
GameState.select_mode(mode)
GameState.timed_build = false
GameState.ai_level = GameState.AI_STANDARD
main.battle.render_enabled = pictures and screenshots
WaveManager.start_match()
var ai := AI.new()
var waves: Array = []
for number: int in range(1,DataCatalog.wave_count()+1):
check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD,title+" build %d" % number)
ai.plan(0,number,WaveManager.rival_snapshot(),GameState.last_reports[0],GameState.AI_STANDARD)
for team: int in range(2):
var state: Dictionary = EconomyManager.get_team(team)
check(state.gold >= 0 and EconomyManager.reserved_sparks(team) <= mini(state.sparks,EconomyManager.send_budget(number)),title+" paid commands %d/%d" % [number,team])
for entry: Dictionary in state.roster:
check(str(entry.type_id).begins_with("w" if GameState.factions[team] == "wurzelbund" else "g"),title+" roster ownership")
WaveManager.ready_for_battle()
for step: int in range(5401):
if GameState.phase != GameState.Phase.COMBAT: break
WaveManager.advance(1.0/60.0)
if screenshots and step == 360 and number in [5,10,15,20]: await capture("wave-%02d" % number)
check(GameState.phase in [GameState.Phase.RESULTS,GameState.Phase.GAME_OVER],title+" resolves %d" % number)
waves.append({"wave":number,"core_hp":GameState.core_hp.duplicate(),"seconds":main.battle.elapsed,"leaks":main.battle.leaks.duplicate(),"player_gold":EconomyManager.get_team(0).gold,"roster":EconomyManager.get_team(0).roster})
if GameState.phase == GameState.Phase.GAME_OVER: break
WaveManager.ready_for_battle()
check(GameState.phase == GameState.Phase.GAME_OVER,title+" concludes")
if own == rival:
check(GameState.outcome == GameState.DRAW,title+" symmetric outcome")
check(GameState.wave == DataCatalog.wave_count(),title+" reaches finale with ordinary resources")
matches.append({"title":title,"outcome":GameState.outcome,"waves":waves})
print("FACTION_MATCH: ",title," wave ",GameState.wave," cores ",GameState.core_hp)
func capture(label: String) -> void:
if not pictures: return
await get_tree().process_frame
main.hud._refresh()
await get_tree().create_timer(.22).timeout
await RenderingServer.frame_post_draw
DirAccess.make_dir_recursive_absolute("user://phase10-captures")
check(get_viewport().get_texture().get_image().save_png("user://phase10-captures/"+label+".png") == OK,"capture "+label)