#211 conquest is the fight now: a planet registers the garrison its setup authors, each defender's own death counts against it, and the last one takes the world. The capture points - stand in a ring, wait out a timer, once per ring - are gone, along with the editor palette that placed them. #212 conquest opens a dialog on the surface naming the research points recovered; the award travels with the signal, after it is banked. #209 the prologue's charted sectors go with the breach, so the map the survivors keep is of where they now are - and "chart 30 sectors" is no longer complete before they have flown anywhere. #218 enemy variety is gated on the FLEET, not on wave strength: a starting fleet meets chasers only at any wave number, and each vessel past three opens one more kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
209 lines
8 KiB
GDScript
209 lines
8 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_artillery_enemy.gd
|
|
# Verifies:
|
|
# 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it
|
|
# is launched - and the warning is the shell's flight plus the impact
|
|
# cross drawn at its target, not a charge-up before anything happens
|
|
# 2. a ship that leaves the blast radius before impact takes no damage;
|
|
# one that stays takes the full hit
|
|
# 3. a shielded ship inside the blast survives, losing exactly one charge
|
|
# 4. the impact point is fixed at fire time - moving the fleet after the
|
|
# shot is launched does not redirect it (tested through the real
|
|
# EnemyArtilleryComponent, since that's what could tempt "aim at the
|
|
# fleet's live position" instead of its position at fire time)
|
|
|
|
# Resolved via load() inside _initialize(), NOT a top-level `const ...
|
|
# preload(...)` - a top-level preload in a script-mode ENTRY point (-s
|
|
# this_file.gd) evaluates during this file's own early compilation, which
|
|
# can happen before autoloads are registered. See test_planet_garrison.gd
|
|
# for the full explanation.
|
|
var ARTILLERY_SHELL_SCRIPT: Script
|
|
|
|
var game_state: Node
|
|
var main: Node
|
|
var fleet: Node
|
|
var shell: Node
|
|
var ship_inside: Node
|
|
var ship_leaves: Node
|
|
var shield_ship: Node
|
|
var far_ship: Node
|
|
var artillery: Node
|
|
var art_component: Node
|
|
var live_shell: Node
|
|
var recorded_target: Vector2 = Vector2.ZERO
|
|
var impact_point: Vector2 = Vector2.ZERO
|
|
var travel_time: float = 0.0
|
|
var origin: Vector2 = Vector2.ZERO
|
|
var stage2_wait: float = 0.0
|
|
var stage5_wait: float = 0.0
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
|
|
|
|
func _initialize() -> void:
|
|
ARTILLERY_SHELL_SCRIPT = load("res://enemies/ArtilleryShell.gd")
|
|
game_state = root.get_node("GameState")
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.transporters.clear()
|
|
game_state.stored_vessels.clear()
|
|
game_state.build_time_scale = 0.02
|
|
game_state.reset_progression()
|
|
game_state.rocks = 2000
|
|
main = load("res://world/Main.tscn").instantiate()
|
|
root.add_child(main)
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
if elapsed < 0.1:
|
|
return false
|
|
main.get_node("EnemySpawner").set_process(false)
|
|
main.get_node("AsteroidField").set_process(false)
|
|
for a in get_nodes_in_group("asteroids"):
|
|
a.queue_free()
|
|
|
|
impact_point = Vector2(5000, 0)
|
|
fleet.spawn_ships(1, impact_point)
|
|
ship_inside = fleet.ships[0]
|
|
ship_inside.position = impact_point
|
|
|
|
fleet.add_ship(GameState.VESSEL_INTERCEPTOR)
|
|
ship_leaves = fleet.ships[fleet.ships.size() - 1]
|
|
ship_leaves.position = impact_point + Vector2(100, 0)
|
|
|
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
|
shield_ship.position = impact_point + Vector2(-100, 0)
|
|
|
|
origin = impact_point + Vector2(400, 400)
|
|
travel_time = origin.distance_to(impact_point) / 300.0
|
|
shell = load("res://enemies/ArtilleryShell.tscn").instantiate()
|
|
shell.shell_speed = 300.0
|
|
shell.aoe_radius = 220.0
|
|
shell.damage = 3
|
|
main.add_child(shell)
|
|
shell.launch(origin, impact_point)
|
|
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
# The shell must already be travelling - there is no charge-up
|
|
# phase to sit in any more - and it must have moved off the
|
|
# tower under its own speed.
|
|
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING:
|
|
return _fail("the shell is not in the air on the frame after firing")
|
|
if elapsed < travel_time * 0.4:
|
|
return false
|
|
if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point):
|
|
return _fail("the shell has not closed any distance on its target")
|
|
# The warning the player actually gets: a cross on the ground at
|
|
# the point of impact. Drawing cannot be asserted headlessly, so
|
|
# this checks what it is drawn FROM - the aim point is fixed and
|
|
# the marker has a real size.
|
|
if not shell.target.is_equal_approx(impact_point):
|
|
return _fail("the impact marker would not be drawn at the point the shell was aimed at")
|
|
if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0:
|
|
return _fail("the impact cross has no size, so nothing marks the landing point")
|
|
if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \
|
|
or not is_instance_valid(shield_ship):
|
|
return _fail("damage landed before the shell arrived")
|
|
|
|
# Ship B clears the blast radius now, well before impact.
|
|
ship_leaves.position = impact_point + Vector2(2000, 0)
|
|
# Captured once, as a plain float: the shell frees itself once
|
|
# it resolves, so reading off it from inside the wait condition
|
|
# below (evaluated every frame) would touch a freed object on
|
|
# any frame after that.
|
|
stage2_wait = travel_time + 1.5
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
# Enough time for the remaining travel and the full explosion
|
|
# visual to resolve.
|
|
if elapsed < stage2_wait:
|
|
return false
|
|
if is_instance_valid(ship_inside):
|
|
return _fail("a ship that stayed inside the blast radius survived")
|
|
if not is_instance_valid(ship_leaves):
|
|
return _fail("a ship that left the blast radius before impact was still hit")
|
|
if not is_instance_valid(shield_ship):
|
|
return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit")
|
|
if shield_ship.shield_charges != 0:
|
|
return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" %
|
|
shield_ship.shield_charges)
|
|
print("stayed=destroyed left=survived shielded=absorbed-one-charge")
|
|
|
|
# --- Part B: the impact point does not home onto a fleet that
|
|
# moves after the shot is fired ---------------------------------
|
|
fleet.spawn_ships(1, Vector2(9000, 0))
|
|
far_ship = fleet.ships[0]
|
|
artillery = load("res://enemies/Enemy.tscn").instantiate()
|
|
artillery.enemy_id = "artillery"
|
|
artillery.position = far_ship.position + Vector2(300, 0)
|
|
main.add_child(artillery)
|
|
art_component = artillery.get_component("artillery")
|
|
art_component.cooldown_left = 0.0
|
|
art_component.fire_cooldown = 999.0 # only the one shot for this check
|
|
elapsed = 0.0
|
|
stage = 3
|
|
return false
|
|
3:
|
|
if elapsed < 0.15:
|
|
return false
|
|
# The shell is added to the tree root (a sibling of main, not
|
|
# its child - see EnemyArtilleryComponent.tick()), so it has to
|
|
# be found via its group, not main.get_children().
|
|
var shells: Array = get_nodes_in_group("artillery_shells")
|
|
if shells.is_empty():
|
|
return _fail("artillery enemy did not fire when in range and off cooldown")
|
|
live_shell = shells[0]
|
|
recorded_target = live_shell.target
|
|
|
|
far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target
|
|
elapsed = 0.0
|
|
stage = 4
|
|
return false
|
|
4:
|
|
# Check the target field WHILE the shell is still alive - it
|
|
# frees itself right after exploding, so this has to happen
|
|
# before that, not after.
|
|
if elapsed < 0.3:
|
|
return false
|
|
if not is_instance_valid(live_shell):
|
|
return _fail("shell resolved implausibly fast - timing assumption broke")
|
|
if live_shell.target != recorded_target:
|
|
return _fail("shell target changed after the fleet moved - it homed instead of staying fixed")
|
|
# Captured once, as a plain float: far_ship's own weapon can
|
|
# destroy the artillery enemy (and with it art_component) well
|
|
# before this wait ends, so the condition below must not keep
|
|
# re-reading a property off a node that might already be freed.
|
|
# No charge-up to wait out any more - just flight time plus the
|
|
# explosion visual.
|
|
stage5_wait = (300.0 / 180.0) + 1.5
|
|
elapsed = 0.0
|
|
stage = 5
|
|
return false
|
|
5:
|
|
if elapsed < stage5_wait:
|
|
return false
|
|
if not is_instance_valid(far_ship):
|
|
return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred")
|
|
print("shell held its fire-time target and did not home onto the fleeing ship")
|
|
print("TEST_ARTILLERY_ENEMY: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|