#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
extends SceneTree
|
|
|
|
|
|
|
|
|
|
# Headless gameplay test: run with
|
|
|
|
|
# godot --headless -s res://tests/test_waves_attack_buildings.gd
|
|
|
|
|
# Forgejo #101 (#97c): "Waves tries to attack built buildings."
|
|
|
|
|
#
|
|
|
|
|
# Before this every enemy component asked one question - which player SHIP
|
|
|
|
|
# is nearest - so a structure was scenery they flew straight through. A
|
|
|
|
|
# Building is also a plain Node2D, so there was nothing to collide with even
|
|
|
|
|
# if one had tried.
|
|
|
|
|
#
|
|
|
|
|
# Covers:
|
|
|
|
|
# A. an enemy with no ship anywhere near it goes for a building
|
|
|
|
|
# B. a ship in its face still wins: the fleet must be able to defend a
|
|
|
|
|
# structure by interposing itself, and an enemy that ignored the
|
|
|
|
|
# vessels shooting it to reach a depot slightly closer reads as broken
|
|
|
|
|
# C. a chaser that reaches a building damages it, and spends itself doing
|
|
|
|
|
# so - the same bargain it makes ramming a vessel
|
|
|
|
|
# D. an enemy parked on a structure keeps hurting it, rather than landing
|
|
|
|
|
# one hit on entry and then sitting there harmlessly
|
|
|
|
|
# E. an enemy projectile damages a structure
|
|
|
|
|
# F. enough of it levels the building, and the node goes with the record
|
2026-08-23 21:38:09 +02:00
|
|
|
# G. #113: a WAVE enemy converges on the nearest structure from any
|
|
|
|
|
# distance, where one placed by a map author keeps the detection radius
|
|
|
|
|
# that stops it reading as omniscient
|
#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
|
|
|
|
|
var game_state: Node
|
|
|
|
|
var main: Node
|
|
|
|
|
var fleet: Node
|
|
|
|
|
var field: Node
|
|
|
|
|
var chaser: Node2D
|
|
|
|
|
var sniper: Node2D
|
|
|
|
|
var target_building: Node2D
|
|
|
|
|
# Held separately: the node is freed when the building comes down, and the
|
|
|
|
|
# last stage still has to ask GameState about the record it left behind.
|
|
|
|
|
var target_id: int = 0
|
|
|
|
|
var stage: int = 0
|
|
|
|
|
var elapsed: float = 0.0
|
|
|
|
|
var hp_watermark: int = 0
|
|
|
|
|
var destroyed_fired: bool = false
|
2026-08-23 21:38:09 +02:00
|
|
|
var roamer: Node2D
|
|
|
|
|
var raider: Node2D
|
|
|
|
|
var far_id: int = 0
|
|
|
|
|
var roamer_from: float = 0.0
|
|
|
|
|
var raider_from: float = 0.0
|
#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _initialize() -> void:
|
|
|
|
|
# GameState is an autoload: a bare GameState.<instance member> does not
|
|
|
|
|
# compile in a SceneTree script, so the node is fetched by name instead.
|
|
|
|
|
game_state = root.get_node("GameState")
|
|
|
|
|
game_state.buildings.clear()
|
|
|
|
|
game_state.extractors.clear()
|
|
|
|
|
game_state.transporters.clear()
|
|
|
|
|
game_state.reset_progression()
|
|
|
|
|
game_state.reset_waves()
|
|
|
|
|
game_state.rocks = 5000
|
|
|
|
|
main = load("res://world/Main.tscn").instantiate()
|
|
|
|
|
root.add_child(main)
|
|
|
|
|
current_scene = main
|
|
|
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
field = main.get_node("BuildingField")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
|
|
|
print("TEST_WAVES_ATTACK_BUILDINGS: FAIL (%s)" % message)
|
|
|
|
|
quit(1)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _spawn_enemy(enemy_id: String, at: Vector2) -> Node2D:
|
|
|
|
|
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
|
|
|
|
|
enemy.enemy_id = enemy_id
|
|
|
|
|
enemy.position = at
|
|
|
|
|
main.add_child(enemy)
|
|
|
|
|
return enemy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _building_of(type: String) -> Node2D:
|
|
|
|
|
for b in field.spawned:
|
|
|
|
|
if is_instance_valid(b) and not b.is_queued_for_deletion() and b.building_type == type:
|
|
|
|
|
return b
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
|
|
|
elapsed += delta
|
|
|
|
|
match stage:
|
|
|
|
|
0:
|
|
|
|
|
if elapsed < 0.3:
|
|
|
|
|
return false
|
|
|
|
|
main.get_node("EnemySpawner").set_process(false)
|
|
|
|
|
main.get_node("AsteroidField").set_process(false)
|
|
|
|
|
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
|
|
|
|
|
if is_instance_valid(hazard):
|
|
|
|
|
hazard.queue_free()
|
|
|
|
|
# The fleet is parked far away for A, C and D. It comes back for B.
|
|
|
|
|
fleet.spawn_ships(1, Vector2(-9000.0, 0.0),
|
|
|
|
|
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
|
|
|
|
fleet.ships[0].move_target = fleet.ships[0].position
|
|
|
|
|
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0))
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 1
|
|
|
|
|
return false
|
|
|
|
|
1:
|
|
|
|
|
if elapsed < 0.2:
|
|
|
|
|
return false
|
|
|
|
|
target_building = _building_of(GameState.BUILDING_SHIP_CRAFTER)
|
|
|
|
|
if target_building == null:
|
|
|
|
|
return _fail("no crafter was placed")
|
|
|
|
|
target_id = int(target_building.building_id)
|
|
|
|
|
|
|
|
|
|
# --- A. with no ship near, a building is the target ----------
|
|
|
|
|
var targets: Script = load("res://enemies/EnemyTargets.gd")
|
|
|
|
|
var from: Vector2 = Vector2(600.0, 0.0)
|
|
|
|
|
var picked: Node2D = targets.nearest_target(main, from)
|
|
|
|
|
if picked != target_building:
|
|
|
|
|
return _fail("an enemy 600px from a crafter and 9600px from the fleet picked %s" \
|
|
|
|
|
% ("nothing" if picked == null else str(picked.name)))
|
|
|
|
|
print("with the fleet 9600px away, the crafter is the target")
|
|
|
|
|
|
|
|
|
|
# --- B. a ship in its face still wins ------------------------
|
|
|
|
|
# Deliberately FURTHER from the enemy than the building is: the
|
|
|
|
|
# rule is not "nearest thing", it is "the fleet you are fighting".
|
|
|
|
|
var guard: Node2D = fleet.ships[0]
|
|
|
|
|
guard.position = from + Vector2(0.0, 500.0)
|
|
|
|
|
guard.move_target = guard.position
|
|
|
|
|
var defended: Node2D = targets.nearest_target(main, from)
|
|
|
|
|
if defended != guard:
|
|
|
|
|
return _fail("a vessel 500px away lost priority to a building 600px away - the fleet cannot screen anything")
|
|
|
|
|
guard.position = Vector2(-9000.0, 0.0)
|
|
|
|
|
guard.move_target = guard.position
|
|
|
|
|
print("a vessel inside %.0fpx outranks a nearer building" % targets.SHIP_PRIORITY_RANGE)
|
|
|
|
|
|
|
|
|
|
# --- C. a chaser spends itself on the structure --------------
|
|
|
|
|
hp_watermark = game_state.building_hp(target_id)
|
|
|
|
|
chaser = _spawn_enemy("chaser", target_building.global_position)
|
|
|
|
|
chaser.destroyed.connect(func(): destroyed_fired = true)
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 2
|
|
|
|
|
return false
|
|
|
|
|
2:
|
|
|
|
|
if elapsed < 1.2:
|
|
|
|
|
return false
|
|
|
|
|
var now: int = game_state.building_hp(target_id)
|
|
|
|
|
if now >= hp_watermark:
|
|
|
|
|
return _fail("a chaser sat on the crafter and it took no damage")
|
|
|
|
|
if is_instance_valid(chaser) and not chaser.is_queued_for_deletion():
|
|
|
|
|
return _fail("the chaser survived ramming a structure")
|
|
|
|
|
if not destroyed_fired:
|
|
|
|
|
return _fail("the chaser vanished without firing `destroyed`")
|
|
|
|
|
print("a chaser took the crafter from %d to %d hp and died doing it" % [hp_watermark, now])
|
|
|
|
|
|
|
|
|
|
# --- D. a besieger keeps working ----------------------------
|
|
|
|
|
sniper = _spawn_enemy("sniper", target_building.global_position)
|
|
|
|
|
sniper.speed = 0.0
|
|
|
|
|
hp_watermark = game_state.building_hp(target_id)
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 3
|
|
|
|
|
return false
|
|
|
|
|
3:
|
|
|
|
|
if elapsed < 2.5:
|
|
|
|
|
return false
|
|
|
|
|
var now: int = game_state.building_hp(target_id)
|
|
|
|
|
var strikes: int = hp_watermark - now
|
|
|
|
|
if not is_instance_valid(sniper):
|
|
|
|
|
return _fail("the sniper died on contact - it is not marked dies_on_contact")
|
|
|
|
|
if strikes <= sniper.definition.contact_damage:
|
|
|
|
|
return _fail("%.1fs of siege did %d damage - that is one hit on entry, not a siege" \
|
|
|
|
|
% [elapsed, strikes])
|
|
|
|
|
print("%.1fs parked on the crafter cost it %d hp" % [elapsed, strikes])
|
|
|
|
|
sniper.queue_free()
|
|
|
|
|
|
|
|
|
|
# --- E. a projectile hits a structure ------------------------
|
|
|
|
|
hp_watermark = game_state.building_hp(target_id)
|
|
|
|
|
var projectile: Area2D = load("res://enemies/EnemyProjectile.tscn").instantiate()
|
|
|
|
|
projectile.position = target_building.global_position + Vector2(0.0, -140.0)
|
|
|
|
|
projectile.damage = 3
|
|
|
|
|
root.add_child(projectile)
|
|
|
|
|
projectile.launch(Vector2(0.0, 1.0))
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 4
|
|
|
|
|
return false
|
|
|
|
|
4:
|
|
|
|
|
if elapsed < 1.2:
|
|
|
|
|
return false
|
|
|
|
|
var now: int = game_state.building_hp(target_id)
|
|
|
|
|
if now >= hp_watermark:
|
|
|
|
|
return _fail("an enemy bolt passed straight through the crafter")
|
|
|
|
|
print("an enemy bolt took %d hp off the crafter" % (hp_watermark - now))
|
|
|
|
|
|
|
|
|
|
# --- F. enough of it levels the place ------------------------
|
|
|
|
|
game_state.damage_building(target_id,
|
|
|
|
|
game_state.building_hp(target_id) - 1)
|
|
|
|
|
var last: Node2D = _spawn_enemy("chaser", target_building.global_position)
|
|
|
|
|
last.speed = 0.0
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 5
|
|
|
|
|
return false
|
|
|
|
|
5:
|
|
|
|
|
if elapsed < 1.2:
|
|
|
|
|
return false
|
|
|
|
|
if not game_state.find_building(target_id).is_empty():
|
|
|
|
|
return _fail("the crafter survived on its last hit point")
|
|
|
|
|
if _building_of(GameState.BUILDING_SHIP_CRAFTER) != null:
|
|
|
|
|
return _fail("the crafter node outlived the record")
|
|
|
|
|
print("the crafter came down, and its node went with it")
|
|
|
|
|
|
2026-08-23 21:38:09 +02:00
|
|
|
# --- G. a wave arrives with somewhere to be -----------------
|
|
|
|
|
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0))
|
|
|
|
|
far_id = int(game_state.buildings[-1].id)
|
|
|
|
|
# Well past every component's detection_range.
|
|
|
|
|
var far_away: Vector2 = Vector2(6000.0, 0.0)
|
|
|
|
|
roamer = _spawn_enemy("chaser", far_away)
|
|
|
|
|
raider = _spawn_enemy("chaser", far_away + Vector2(0.0, 400.0))
|
|
|
|
|
raider.relentless = true
|
|
|
|
|
roamer_from = roamer.global_position.distance_to(Vector2.ZERO)
|
|
|
|
|
raider_from = raider.global_position.distance_to(Vector2.ZERO)
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 6
|
|
|
|
|
return false
|
|
|
|
|
6:
|
|
|
|
|
if elapsed < 1.5:
|
|
|
|
|
return false
|
|
|
|
|
if not is_instance_valid(raider) or not is_instance_valid(roamer):
|
|
|
|
|
return _fail("one of the two test chasers is gone")
|
|
|
|
|
var raider_now: float = raider.global_position.distance_to(Vector2.ZERO)
|
|
|
|
|
var roamer_now: float = roamer.global_position.distance_to(Vector2.ZERO)
|
|
|
|
|
if raider_now >= raider_from - 100.0:
|
|
|
|
|
return _fail("a wave chaser %.0fpx from a crafter did not close on it" % raider_from)
|
|
|
|
|
if roamer_now < roamer_from - 20.0:
|
|
|
|
|
return _fail("a chaser nobody sent moved %.0fpx toward a crafter far outside its detection range" \
|
|
|
|
|
% (roamer_from - roamer_now))
|
|
|
|
|
print("wave chaser closed %.0fpx from %.0f away; a placed one held station" \
|
|
|
|
|
% [raider_from - raider_now, raider_from])
|
|
|
|
|
|
#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
print("TEST_WAVES_ATTACK_BUILDINGS: PASS")
|
|
|
|
|
quit(0)
|
|
|
|
|
return true
|
|
|
|
|
return false
|