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()