The previous ten tickets shipped with almost no coverage - only #109, #113, #115 and #117 touched a test. That was a gap, not a decision: everything before it had its behaviour pinned, and #116 in particular added a whole subsystem nothing was watching. Four new files and one extended case: test_spacial_workshop #116 - the repair economy end to end: the cost of a repair scales with the damage, the workshop asks logistics for that ore and stops at its buffer, a drone spends when it LEAVES so two cannot double-charge one wall, and a target levelled in flight sends its load home rather than swallowing it test_desktop_controls #118/#121 - both driven through the real routing, because the routing is what was missing; a test calling handle_mouse_button() itself would have passed against both reports test_enemy_bomber #114 - it flies, it faces where it flies, its bombs arrive, and it bombs structures with no fleet in sight test_menu_polish #111/#112/#119/#120 - what each panel shows test_wave_system (case G) #110 - the dev button, and that it does not exist outside dev mode One assertion worth recording: the craft caption is checked as an EXACT match against the vessel's display name rather than by hunting for resource words in it. The first version failed on "Gas Bomber" - a vessel named after a resource, and always will be. What #111 removed was the price spelled out underneath, not the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
6.6 KiB
GDScript
192 lines
6.6 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_enemy_bomber.gd
|
|
# Forgejo #114: the enemy bomber - the artillery enemy, the one that lobs an
|
|
# area shell.
|
|
#
|
|
# It was a siege piece that crept at 20px/s and threw a shell taking five
|
|
# seconds to cross the gap, which read as a thing that was stuck rather than
|
|
# a thing that was dangerous: by the time a shell landed the fleet had
|
|
# wandered off, and the tower was still approaching.
|
|
#
|
|
# Covers:
|
|
# A. it closes on a target at a speed worth calling flying, and faces
|
|
# where it is going
|
|
# B. its bombs travel fast enough to arrive while the target is roughly
|
|
# where they were aimed
|
|
# C. the bomb damages a player vessel it lands on
|
|
# D. and a BUILDING it lands on - it used to hit only player_ships, which
|
|
# was already out of step with #101
|
|
# E. it bombs a structure with no fleet in sight, through the shared
|
|
# targeting rather than asking Fleet directly
|
|
# F. its hull is its own, not a copy of another enemy's
|
|
|
|
const MIN_FLYING_SPEED: float = 90.0
|
|
const MIN_SHELL_SPEED: float = 350.0
|
|
|
|
var game_state: Node
|
|
var main: Node
|
|
var fleet: Node
|
|
var field: Node
|
|
var bomber: Node2D
|
|
var target_ship: Node2D
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
var start_distance: float = 0.0
|
|
var building_id: int = 0
|
|
var hp_before: int = 0
|
|
|
|
|
|
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.intro_active = false
|
|
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_ENEMY_BOMBER: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|
|
|
|
|
|
func _spawn_bomber(at: Vector2) -> Node2D:
|
|
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
|
|
enemy.enemy_id = "artillery"
|
|
enemy.position = at
|
|
main.add_child(enemy)
|
|
return enemy
|
|
|
|
|
|
func _shells() -> Array:
|
|
var out: Array = []
|
|
for shell in get_nodes_in_group("artillery_shells"):
|
|
if is_instance_valid(shell) and not shell.is_queued_for_deletion():
|
|
out.append(shell)
|
|
return out
|
|
|
|
|
|
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()
|
|
|
|
# --- F. its own silhouette -------------------------------
|
|
var definitions: Script = load("res://enemies/EnemyDefinitions.gd")
|
|
var bomber_def: Resource = definitions.get_definition("artillery")
|
|
for other in definitions.all():
|
|
if other.enemy_id == "artillery":
|
|
continue
|
|
if other.hull_points == bomber_def.hull_points:
|
|
return _fail("the bomber shares its hull with the %s" % other.enemy_id)
|
|
if bomber_def.speed < MIN_FLYING_SPEED:
|
|
return _fail("the bomber's top speed is %.0f - that is still creeping" \
|
|
% bomber_def.speed)
|
|
print("bomber: %d hull points of its own, %.0fpx/s top speed" \
|
|
% [bomber_def.hull_points.size() / 2, bomber_def.speed])
|
|
|
|
fleet.spawn_ships(1, Vector2.ZERO,
|
|
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
|
target_ship = fleet.ships[0]
|
|
target_ship.move_target = target_ship.position
|
|
bomber = _spawn_bomber(Vector2(700.0, 0.0))
|
|
start_distance = bomber.global_position.distance_to(target_ship.global_position)
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
# --- A. it flies, and faces where it is going ------------
|
|
if elapsed < 1.0:
|
|
return false
|
|
if not is_instance_valid(bomber):
|
|
return _fail("the bomber is gone before it could be measured")
|
|
var closed: float = start_distance \
|
|
- bomber.global_position.distance_to(target_ship.global_position)
|
|
var speed: float = closed / elapsed
|
|
if speed < MIN_FLYING_SPEED:
|
|
return _fail("the bomber closed at %.0fpx/s - #114 asks for an aircraft" % speed)
|
|
var heading: Vector2 = target_ship.global_position - bomber.global_position
|
|
var drift: float = absf(angle_difference(bomber.rotation, heading.angle() + PI / 2.0))
|
|
if drift > PI / 3.0:
|
|
return _fail("it flew %.0f degrees away from where it was pointing" \
|
|
% rad_to_deg(drift))
|
|
print("closed %.0fpx in %.1fs (%.0fpx/s), nose within %.0f degrees" \
|
|
% [closed, elapsed, speed, rad_to_deg(drift)])
|
|
|
|
# --- B/C. the bomb arrives, and it hurts -----------------
|
|
var component: Node = bomber.get_component("artillery")
|
|
if component.shell_speed < MIN_SHELL_SPEED:
|
|
return _fail("its bombs travel at %.0f - too slow to arrive" % component.shell_speed)
|
|
component.cooldown_left = 0.0
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
if _shells().is_empty():
|
|
if elapsed > 6.0:
|
|
return _fail("the bomber never dropped a bomb")
|
|
return false
|
|
print("bomb away, travelling at %.0fpx/s" % _shells()[0].shell_speed)
|
|
elapsed = 0.0
|
|
stage = 3
|
|
return false
|
|
3:
|
|
if elapsed < 4.0:
|
|
return false
|
|
if is_instance_valid(target_ship) and not target_ship.is_queued_for_deletion():
|
|
return _fail("a bomb landed on a stationary vessel and it survived")
|
|
print("the vessel it was aimed at is gone")
|
|
|
|
# --- D/E. and it bombs what the player has built ---------
|
|
for hazard in get_nodes_in_group("enemies"):
|
|
if is_instance_valid(hazard):
|
|
hazard.queue_free()
|
|
# The fleet is parked far away, so nothing here is about ships.
|
|
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.ZERO)
|
|
building_id = int(game_state.buildings[-1].id)
|
|
elapsed = 0.0
|
|
stage = 4
|
|
return false
|
|
4:
|
|
if elapsed < 0.3:
|
|
return false
|
|
hp_before = game_state.building_hp(building_id)
|
|
bomber = _spawn_bomber(Vector2(600.0, 0.0))
|
|
bomber.speed = 0.0
|
|
bomber.get_component("artillery").cooldown_left = 0.0
|
|
elapsed = 0.0
|
|
stage = 5
|
|
return false
|
|
5:
|
|
if elapsed < 5.0:
|
|
return false
|
|
var now: int = game_state.building_hp(building_id)
|
|
if now >= hp_before:
|
|
return _fail("the bomber shelled a crafter with no fleet in sight and did nothing")
|
|
print("with the fleet 9000px away it bombed the crafter: %d -> %d hp" % [hp_before, now])
|
|
|
|
print("TEST_ENEMY_BOMBER: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|