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>
533 lines
25 KiB
GDScript
533 lines
25 KiB
GDScript
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()
|