extends Node ## Integration checks: run as a scene so the production autoloads are available. ## godot --headless --path saumwacht-godot res://tests/test_runner.tscn const BattleScript = preload("res://scripts/combat/battle_controller.gd") const UnitScript = preload("res://scripts/combat/unit.gd") const AIScript = preload("res://scripts/ai/simple_ai.gd") var checks: int = 0 var failures: Array[String] = [] func _ready() -> void: WaveManager.set_physics_process(false) call_deferred("_run") func _run() -> void: GameState.reset_match() GameState.factions = ["glutpakt", "glutpakt"] GameState.timed_build = true GameState.select_mode(GameState.MODE_SHORT) _test_workers_and_production() _test_formation_accounting() _test_send_commit_and_settlement() _test_ai_learning_level() _test_ai_standard_level() _test_worker_limit_and_last_wave() _test_damage_matrix() _test_dot_tick_and_refresh() _test_shield_replacement() _test_fourth_hit_bonus() _test_splash_limit_and_lane_isolation() _test_identity_and_formation_reset() _test_leaks_and_timeout() _test_core_resolution() _test_slice_lifecycle() if failures.is_empty(): print("PASS: %d checks across economy, combat and the full match lifecycle." % checks) else: for failure: String in failures: printerr("FAIL: " + failure) printerr("%d / %d checks failed." % [failures.size(), checks]) get_tree().quit(0 if failures.is_empty() else 1) func _check(condition: bool, message: String) -> void: checks += 1 if not condition: failures.append(message) func _command(command: Dictionary, expected: bool = true) -> Dictionary: var result: Dictionary = EconomyManager.apply_command(0, command) _check(bool(result.get("ok", false)) == expected, "Command %s expected ok=%s; received %s" % [command, expected, result]) return result func _open_economy_to_wave(last_wave: int) -> void: GameState.reset_match() EconomyManager.reset_match() for number: int in range(1, last_wave + 1): GameState.wave = number GameState.set_phase(GameState.Phase.BUILD) EconomyManager.open_build(number) if number < last_wave: EconomyManager.freeze(number) EconomyManager.settle(0, number, 0) EconomyManager.settle(1, number, 0) func _test_workers_and_production() -> void: _open_economy_to_wave(1) var initial: Dictionary = EconomyManager.get_team(0) _check(initial.gold == 220 and initial.sparks == 10 and initial.income == 20, "Starting economy includes exactly one initial worker production.") _check(initial.workers == 1 and initial.pending_workers == 0, "One worker starts active.") EconomyManager.open_build(1) _check(EconomyManager.get_team(0).sparks == 10, "Reopening the same build phase cannot produce twice.") _command({"type": "worker"}) var first: Dictionary = EconomyManager.get_team(0) _check(first.gold == 150 and first.workers == 1 and first.pending_workers == 1, "First worker costs 70 and activates in the next build phase.") _check(first.sparks == 10, "A new worker cannot produce in its purchase phase.") _command({"type": "worker"}) _check(EconomyManager.get_team(0).gold == 60 and EconomyManager.get_team(0).pending_workers == 2, "A second purchase immediately uses the 90 gold price tier.") _command({"type": "worker"}, false) _command({"type": "undo"}) _check(EconomyManager.get_team(0) == first, "Undo restores the whole worker purchase state.") EconomyManager.freeze(1) EconomyManager.settle(0, 1, 0) GameState.wave = 2 GameState.set_phase(GameState.Phase.BUILD) EconomyManager.open_build(2) var next: Dictionary = EconomyManager.get_team(0) _check(next.workers == 2 and next.pending_workers == 0 and next.sparks == 30, "Pending worker activates once and both workers produce in wave 2.") _command({"type": "undo"}, false) _command({"type": "queue_send", "type_id": "s01"}, false) func _test_formation_accounting() -> void: _open_economy_to_wave(1) _command({"type": "build", "type_id": "g02", "cell": Vector2i(0, 0)}, false) _command({"type": "build", "type_id": "g01", "cell": Vector2i(-1, 0)}, false) _command({"type": "build", "type_id": "g01", "cell": Vector2i(6, 0)}, false) _command({"type": "build", "type_id": "g01", "cell": Vector2i(0, 8)}, false) _command({"type": "build", "type_id": "g01", "cell": Vector2i(0, 0)}) var bought: Dictionary = EconomyManager.get_team(0) var uid: int = int(bought.roster[0].uid) _check(bought.gold == 175 and bought.roster.size() == 1 and bought.roster[0].invested == 45, "Buying records the unit's actual investment and debits gold.") _command({"type": "build", "type_id": "g03", "cell": Vector2i(0, 0)}, false) _check(EconomyManager.get_team(0) == bought, "An occupied-cell rejection has no economic side effects.") var snapshot: Dictionary = EconomyManager.get_team(0) snapshot.roster[0].invested = 999999 snapshot.gold = -1 _check(EconomyManager.get_team(0) == bought, "External snapshots cannot mutate the authoritative economy.") _command({"type": "upgrade", "uid": uid}) var upgraded: Dictionary = EconomyManager.get_team(0) _check(upgraded.gold == 85 and upgraded.roster[0].type_id == "g02" and upgraded.roster[0].invested == 135 and upgraded.roster[0].uid == uid, "Upgrade replaces in place, preserves identity and adds only the extra price.") _command({"type": "sell", "uid": uid}) _check(EconomyManager.get_team(0).gold == 186 and EconomyManager.get_team(0).roster.is_empty(), "Selling refunds floor(135 * 0.75) = 101 gold and frees the cell.") _command({"type": "undo"}) _check(EconomyManager.get_team(0) == upgraded, "Undo sale restores upgraded unit and gold.") _command({"type": "undo"}) _check(EconomyManager.get_team(0) == bought, "Undo upgrade restores the original investment.") _command({"type": "move", "uid": uid, "cell": Vector2i(5, 7)}) _check(EconomyManager.get_team(0).gold == 175 and EconomyManager.get_team(0).roster[0].cell == Vector2i(5, 7), "Moving is free and accepts the last valid grid cell.") _command({"type": "undo"}) _command({"type": "target", "uid": uid, "policy": 1}) _check(EconomyManager.get_team(0).roster[0].target_policy == 1, "Targeting policy is persisted in the formation.") EconomyManager.freeze(1) GameState.set_phase(GameState.Phase.COMBAT) var frozen: Dictionary = EconomyManager.get_team(0) for mutation: Dictionary in [ {"type": "build", "type_id": "g01", "cell": Vector2i(1, 0)}, {"type": "upgrade", "uid": uid}, {"type": "sell", "uid": uid}, {"type": "move", "uid": uid, "cell": Vector2i(1, 0)}, {"type": "worker"}, {"type": "queue_send", "type_id": "s01"}, {"type": "cancel_send", "index": 0}, {"type": "target", "uid": uid, "policy": 2}, {"type": "undo"}, ]: _command(mutation, false) _check(EconomyManager.get_team(0) == frozen, "Every economic mutation is rejected during combat.") func _test_send_commit_and_settlement() -> void: _open_economy_to_wave(3) var before: Dictionary = EconomyManager.get_team(0) _check(before.sparks == 30, "Unspent sparks accumulate once per build phase.") _command({"type": "queue_send", "type_id": "s01"}) var reserved: Dictionary = EconomyManager.get_team(0) _check(reserved.sparks == 30 and reserved.income == 20 and reserved.send_queue.size() == 1, "Queuing reserves sparks without spending them or changing income.") _command({"type": "queue_send", "type_id": "s01"}, false) _command({"type": "cancel_send", "index": 0}) _check(EconomyManager.get_team(0).send_queue.is_empty() and EconomyManager.get_team(0).sparks == 30, "Cancel releases the entire reservation.") _command({"type": "undo"}) _check(EconomyManager.get_team(0) == reserved, "Undo cancellation restores its reservation.") var queues: Array = EconomyManager.freeze(3) _check(queues.size() == 2 and queues[0].size() == 1 and queues[0][0] == "s01", "Freeze returns the send queue for the current combat wave.") var committed: Dictionary = EconomyManager.get_team(0) _check(committed.sparks == 10 and committed.income == 23 and committed.send_queue.is_empty(), "Freeze spends the reservation and permanently increases income once.") EconomyManager.freeze(3) _check(EconomyManager.get_team(0) == committed, "Repeated freeze cannot spend or grant income twice.") EconomyManager.settle(0, 3, 7) var paid: Dictionary = EconomyManager.get_team(0) _check(paid.gold == committed.gold + 55 + 6 + 23 + 7, "Current-wave settlement includes the newly committed income and kill bounty.") EconomyManager.settle(0, 3, 777) _check(EconomyManager.get_team(0) == paid, "Duplicate settlement cannot award gold again, even with a changed bounty.") func _ai_wave(number: int, ai, level: int) -> Dictionary: # Mirrors the WaveManager flow: open, plan against a frozen snapshot, commit. # Returns the state as planned, before freeze clears the send queue. GameState.wave = number GameState.set_phase(GameState.Phase.BUILD) EconomyManager.open_build(number) ai.plan(1, number, EconomyManager.get_team(0), GameState.last_reports[1], level) var own: Dictionary = EconomyManager.get_team(1) _check(int(own.gold) >= 0 and int(own.sparks) >= 0, "AI level %d keeps gold and sparks non-negative in wave %d." % [level, number]) _check(EconomyManager.reserved_sparks(1) <= EconomyManager.send_budget(number), "AI level %d never reserves more than the wave send budget." % level) EconomyManager.freeze(number) EconomyManager.settle(0, number, 0) EconomyManager.settle(1, number, 5000) return own func _test_ai_learning_level() -> void: var selected_level: int = GameState.ai_level GameState.reset_match() EconomyManager.reset_match() var ai = AIScript.new() _ai_wave(1, ai, GameState.AI_LEARNING) var opening: Dictionary = EconomyManager.get_team(1) _check(opening.roster.size() == 3 and opening.workers == 1, "Learning AI opens with the fixed three-unit formation.") for number: int in range(2, 9): var own: Dictionary = _ai_wave(number, ai, GameState.AI_LEARNING) _check(int(own.workers) + int(own.pending_workers) <= 4, "Learning AI never exceeds four workers (wave %d)." % number) _check(own.roster.size() <= 12, "Learning AI keeps its fixed small board size (wave %d)." % number) if number % 2 == 0: _check(own.send_queue.is_empty(), "Learning AI sends only in every second allowed wave (wave %d)." % number) elif number >= 3: _check(not own.send_queue.is_empty(), "Learning AI spends sparks on every odd allowed wave (wave %d)." % number) _check(GameState.ai_level == selected_level, "The learning fixture never silently changes the selected match level.") func _test_ai_standard_level() -> void: GameState.reset_match() EconomyManager.reset_match() var ai = AIScript.new() var exceeded_learning_cap := false for number: int in range(1, 9): var own: Dictionary = _ai_wave(number, ai, GameState.AI_STANDARD) if number >= 3: exceeded_learning_cap = exceeded_learning_cap \ or int(own.workers) + int(own.pending_workers) > 4 if number >= 5: _check(not own.send_queue.is_empty(), "Standard AI queues sends in every allowed wave (wave %d)." % number) _check(exceeded_learning_cap, "Standard AI grows its worker economy beyond the learning cap.") func _test_worker_limit_and_last_wave() -> void: _open_economy_to_wave(1) # Fund the cap boundary fixture directly; real-budget runs live in the slice acceptance test. EconomyManager.teams[0].gold = 10000 for number: int in range(1, DataCatalog.wave_count()): while true: var state: Dictionary = EconomyManager.get_team(0) var count: int = int(state.workers + state.pending_workers) var price: int = 70 + 20 * (count - 1) if count >= 8 or int(state.gold) < price: break _command({"type": "worker"}) EconomyManager.freeze(number) EconomyManager.settle(0, number, 0) GameState.wave = number + 1 GameState.set_phase(GameState.Phase.BUILD) EconomyManager.open_build(number + 1) var capped: Dictionary = EconomyManager.get_team(0) _check(capped.workers == 8 and capped.pending_workers == 0, "Worker count never exceeds eight.") _check(capped.sparks == 400, "Stored sparks cap at 400 and discard overflow.") _command({"type": "worker"}, false) _open_economy_to_wave(DataCatalog.wave_count()) _check(EconomyManager.get_team(0).workers == 1, "Last-wave restriction fixture still has free worker capacity.") _command({"type": "worker"}, false) func _test_damage_matrix() -> void: var factors: Array = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30]] for attack_type: int in range(3): for armor_type: int in range(3): var definition: Dictionary = DataCatalog.get_unit("g01") definition.max_hp = 10000.0 definition.armor_type = armor_type definition.ability = "" var unit = UnitScript.new() add_child(unit) unit.setup(definition, 1, 0, false, Vector2i.ZERO, 0, false) unit.receive_damage(100.0, attack_type) _check(is_equal_approx(10000.0 - unit.hp, 100.0 * float(factors[attack_type][armor_type])), "Damage matrix attack=%d armor=%d applies its expected factor." % [attack_type, armor_type]) unit.free() var shielded = UnitScript.new() add_child(shielded) var neutral: Dictionary = DataCatalog.get_unit("g01") neutral.ability = "" shielded.setup(neutral, 1, 0, false, Vector2i.ZERO, 0, false) shielded.shield = 30.0 shielded.receive_damage(50.0, 1) _check(is_equal_approx(shielded.hp, 240.0) and is_zero_approx(shielded.shield), "Shields absorb post-matrix damage before HP.") shielded.free() func _test_leaks_and_timeout() -> void: var battle = BattleScript.new() battle.render_enabled = false add_child(battle) var attacker: Dictionary = DataCatalog.get_send("s01", 5) attacker.speed = 0.0 attacker.damage = 0.0 attacker.ability = "" attacker.leak_value = 55 battle.begin_battle([[], []], [[attacker], [attacker.duplicate(true)]], 5) for unit in battle.units: unit.hp = unit.max_hp * 0.5 battle.elapsed = 89.99 var result: Dictionary = battle.step(1.0 / 60.0) _check(bool(result.finished), "Combat timeout finishes both lanes.") _check(result.leak_damage == [36, 36], "Timeout leaks use ceil(55 * 1.28 * 0.5) = 36 on both lanes.") _check(result.bounties == [0, 0] and result.leaks == [1, 1], "Timeout leaks yield no kill bounty.") var repeated: Dictionary = battle.step(1.0 / 60.0) _check(repeated.leak_damage == [0, 0] and repeated.bounties == [0, 0] and repeated.leaks == [1, 1], "Finished combat cannot repeat leak damage or rewards.") battle.free() func _fixture_unit(type_id: String, stable_id: int, lane: int, attacker: bool, overrides: Dictionary = {}) -> CombatUnit: var definition: Dictionary = DataCatalog.get_unit(type_id) definition.merge(overrides, true) var unit = UnitScript.new() add_child(unit) unit.setup(definition, stable_id, lane, attacker, Vector2i.ZERO, 0, false) return unit func _test_dot_tick_and_refresh() -> void: var unit = _fixture_unit("g01", 1, 0, false, {"ability": ""}) # Durchschlag against Leicht is neutral, so each tick removes exactly 4 HP. unit.apply_dot("test_burn", 4.0, 3.0, 1) _check(is_equal_approx(unit.hp, 260.0), "Applying a DoT cannot inflict an immediate tick.") unit.advance_status(0.99) _check(is_equal_approx(unit.hp, 260.0), "DoT waits one full second before its first tick.") unit.advance_status(0.01) _check(is_equal_approx(unit.hp, 256.0), "DoT inflicts its first tick at exactly one second.") unit.advance_status(1.0) _check(is_equal_approx(unit.hp, 252.0), "DoT inflicts its second tick at two seconds.") unit.advance_status(1.0) _check(is_equal_approx(unit.hp, 248.0) and unit.dots.is_empty(), "Three-second DoT includes the third boundary tick and then expires.") unit.advance_status(1.0) _check(is_equal_approx(unit.hp, 248.0), "Expired DoT cannot tick a fourth time.") unit.setup(unit.data, 1, 0, false, Vector2i.ZERO, 0, false) unit.apply_dot("test_burn", 4.0, 3.0, 1) unit.advance_status(0.5) unit.apply_dot("test_burn", 4.0, 3.0, 1) _check(is_equal_approx(unit.hp, 260.0) and unit.dots.size() == 1, "Refreshing the same DoT neither stacks a second copy nor ticks immediately.") unit.advance_status(0.5) _check(is_equal_approx(unit.hp, 256.0), "DoT refresh preserves the original one-second tick rhythm.") unit.advance_status(2.0) _check(is_equal_approx(unit.hp, 248.0), "A larger status step executes each due DoT tick exactly once.") unit.advance_status(0.5) unit.advance_status(1.0) _check(is_equal_approx(unit.hp, 248.0) and unit.dots.is_empty(), "Extending the DoT at half a second creates no extra expiry tick.") unit.free() func _test_shield_replacement() -> void: var unit = _fixture_unit("g01", 1, 0, false, {"ability": ""}) unit.grant_shield(100.0, 4.0) unit.advance_status(1.0) unit.grant_shield(60.0, 8.0) _check(is_equal_approx(unit.shield, 100.0) and is_equal_approx(unit.shield_remaining, 3.0), "A weaker shield neither stacks nor extends the stronger shield's duration.") unit.grant_shield(100.0, 8.0) _check(is_equal_approx(unit.shield, 100.0) and is_equal_approx(unit.shield_remaining, 3.0), "An equal shield does not replace the strongest remaining shield.") unit.receive_damage(50.0, 1) unit.grant_shield(60.0, 2.0) _check(is_equal_approx(unit.hp, 260.0) and is_equal_approx(unit.shield, 60.0) and is_equal_approx(unit.shield_remaining, 2.0), "Replacement compares remaining shield strength and replaces, rather than adds, protection.") unit.advance_status(2.0) _check(is_zero_approx(unit.shield), "A timed replacement shield expires at its own boundary.") unit.free() func _test_fourth_hit_bonus() -> void: var lancer = _fixture_unit("g05", 1, 0, false) var victim = _fixture_unit("g01", 2000000, 0, true, {"ability": "", "max_hp": 1000.0}) victim.position = Vector3(0, 0, 1) lancer.target = victim var combatants: Array = [lancer, victim] lancer.attack_component.step(lancer, combatants, 0.99) _check(is_equal_approx(victim.hp, 1000.0) and lancer.attack_component.hit_count == 0, "Entering attack range does not grant an instant first attack.") lancer.attack_component.step(lancer, combatants, 0.01) lancer.attack_component.step(lancer, combatants, 1.0) lancer.attack_component.step(lancer, combatants, 1.0) _check(is_equal_approx(victim.hp, 928.0) and lancer.attack_component.hit_count == 3, "The first three Funkenlanze hits each inflict 24 neutral damage.") lancer.attack_component.step(lancer, combatants, 1.0) _check(is_equal_approx(victim.hp, 892.0) and lancer.attack_component.hit_count == 4, "Fourth hit inflicts 36 damage and increments the primary hit counter only once.") lancer.attack_component.step(lancer, combatants, 1.0) _check(is_equal_approx(victim.hp, 868.0) and lancer.attack_component.hit_count == 5, "The bonus cannot recurse or shift the following ordinary attack.") lancer.free() victim.free() func _test_splash_limit_and_lane_isolation() -> void: var source = _fixture_unit("g07", 1, 0, false) # Impuls against Schwer is neutral. A basic hit is 23; each splash is 6.9. var target_overrides: Dictionary = {"ability": "", "armor_type": 1, "max_hp": 1000.0} var primary = _fixture_unit("g01", 2000000, 0, true, target_overrides) primary.position = Vector3(0, 0, 1) source.target = primary var combatants: Array = [source, primary] var extras: Array = [] for index: int in range(4): var extra = _fixture_unit("g01", 2000001 + index, 0, true, target_overrides) extra.position = primary.position + Vector3(float(index + 1) * 0.1, 0, 0) extras.append(extra) combatants.append(extra) var other_lane = _fixture_unit("g01", 2100000, 1, true, target_overrides) other_lane.position = primary.position var ally = _fixture_unit("g01", 2, 0, false, target_overrides) ally.position = primary.position combatants.append(other_lane) combatants.append(ally) source.attack_component.step(source, combatants, 1.0) _check(is_equal_approx(primary.hp, 977.0), "Splash excludes its primary target from bonus damage.") for index: int in range(3): _check(is_equal_approx(extras[index].hp, 993.1), "Splash applies exactly 30 percent damage to nearby extra target %d." % index) _check(is_equal_approx(extras[3].hp, 1000.0), "Glutpakt splash stops after three additional targets.") _check(is_equal_approx(other_lane.hp, 1000.0) and is_equal_approx(ally.hp, 1000.0), "Splash ignores the other lane and allied guardians even when their positions overlap.") _check(source.attack_component.hit_count == 1, "Splash events do not advance the primary attack counter.") for unit in combatants: unit.free() func _test_identity_and_formation_reset() -> void: var battle = BattleScript.new() battle.render_enabled = false add_child(battle) var rosters: Array = [ [{"uid": 1, "type_id": "g01", "cell": Vector2i(2, 3), "invested": 45, "target_policy": 2}], [{"uid": 1000001, "type_id": "g01", "cell": Vector2i.ZERO, "invested": 45, "target_policy": 0}], ] var attackers: Array = [DataCatalog.get_attackers(1), DataCatalog.get_attackers(1)] battle.begin_battle(rosters, attackers, 1) var identities: Dictionary = {} var all_unique: bool = true var guardian: CombatUnit for unit in battle.units: if identities.has(unit.uid): all_unique = false identities[unit.uid] = true if unit.uid == 1: guardian = unit _check(all_unique and identities.size() == 18, "Both guardian teams and all 16 attackers have distinct stable IDs.") _check(identities.has(1000001) and identities.has(2000001), "Lane-zero attackers cannot reuse the team-one guardian namespace.") guardian.hp = 13.0 guardian.grant_shield(80.0, 50.0) guardian.apply_effect("temporary", "damage", 0.15, 50.0) guardian.apply_dot("burn", 4.0, 3.0, 1) guardian.attack_component.progress = 0.75 guardian.attack_component.hit_count = 99 guardian.ability_component.ready_time = 42.0 guardian.combat_time = 20.0 guardian.target = battle.units[2] guardian.position = Vector3(0, 0, 10) guardian.alive = false battle.show_formation(rosters) var restored: CombatUnit = battle.units[0] _check(restored.alive and is_equal_approx(restored.hp, 260.0) and is_zero_approx(restored.shield), "Formation restoration revives guardians at full HP and clears combat shields.") _check(restored.effects.is_empty() and restored.dots.is_empty() and restored.target == null, "Formation restoration discards buffs, DoTs and old targets.") _check(restored.attack_component.hit_count == 0 and is_zero_approx(restored.attack_component.progress) and is_equal_approx(restored.ability_component.ready_time, 6.0) and is_zero_approx(restored.combat_time), "Formation restoration resets attack counts, cooldowns and ability timers.") _check(restored.uid == 1 and restored.home_cell == Vector2i(2, 3) and restored.target_policy == 2 and restored.position == Vector3(-10, 0, -1) and int(rosters[0][0].invested) == 45, "Restoration preserves identity, build cell, target policy and investment.") _check(battle.units.size() == 2, "Rebuilding a formation removes every prior-wave attacker.") battle.free() func _test_core_resolution() -> void: GameState.reset_match() GameState.apply_core_damage([4000, 4000]) _check(GameState.core_hp == [0, 0], "Core damage clamps both cores to zero in one batch.") _check(GameState.decide_winner(false) == -1, "Both cores dying in the same tick is a draw.") GameState.reset_match() _check(GameState.core_hp == [3000, 3000] and GameState.outcome == -2 and not GameState.paused and GameState.speed == 1.0, "A new match clears damage, outcome, pause and speed.") _check(GameState.decide_winner(false) == -2, "Equal surviving cores do not end an earlier wave.") GameState.apply_core_damage([0, 100]) _check(GameState.decide_winner(true) == 0, "Final wave selects the surviving core with more HP.") GameState.reset_match() _check(GameState.decide_winner(true) == -1, "Equal surviving cores draw after the final wave.") func _test_slice_lifecycle() -> void: var battle = BattleScript.new() battle.render_enabled = false add_child(battle) WaveManager.attach_battle(battle) WaveManager.start_match() # Keep this lifecycle fixture alive regardless of balance. Shorten only the # controller clock; each boundary still executes a real production tick. GameState.core_hp = [3000000, 3000000] for number: int in range(1, DataCatalog.wave_count() + 1): _check(GameState.wave == number and GameState.phase == GameState.Phase.BUILD, "Wave %d begins in BUILD." % number) if GameState.phase != GameState.Phase.BUILD: break WaveManager.ready_for_battle() _check(GameState.phase == GameState.Phase.COMBAT, "Ready enters COMBAT for wave %d." % number) battle.elapsed = 89.99 WaveManager.advance(1.0 / 30.0) _check(GameState.phase == GameState.Phase.RESULTS or GameState.phase == GameState.Phase.GAME_OVER, "Finishing wave %d reaches results or match end." % number) if GameState.phase == GameState.Phase.RESULTS: WaveManager.advance(4.1) _check(GameState.wave == DataCatalog.wave_count() and GameState.phase == GameState.Phase.GAME_OVER and GameState.outcome != -2, "Final wave terminates the complete match with a resolved outcome.") WaveManager.start_match() _check(GameState.wave == 1 and GameState.phase == GameState.Phase.BUILD and GameState.core_hp == [3000, 3000], "Restart after the last wave creates a fresh playable first wave.") battle.free()