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)