Take planets by killing what defends them

#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
This commit is contained in:
Loïc Wisniewski 2026-09-05 13:31:25 +02:00
commit 6e13b6397c
42 changed files with 540 additions and 749 deletions

View file

@ -1,57 +0,0 @@
extends Node2D
# Preloaded by path rather than an `is CapturePoint` check - this preview
# runs through screenshot.sh, which (unlike run_tests.sh) does not run an
# import pass first, so the global class_name cache may not be warm yet.
const CAPTURE_POINT_SCRIPT = preload("res://surface/CapturePoint.gd")
func _ready() -> void:
GameState.planet_name = "Kepler's Rest"
GameState.planet_id = "chunk:3:7"
GameState.planet_definition_id = "rocky"
GameState.surviving_ship_count = 3
GameState.rocks = 128
var surface: Node = load("res://surface/PlanetSurface.tscn").instantiate()
surface.get_node("SurfaceController").fade_duration = 0.0
add_child(surface)
# Zoomed out so all three ~220-radius capture rings fit the frame with
# margin, regardless of wherever the real setup's points happen to sit.
surface.get_node("Camera2D").zoom = Vector2(0.55, 0.55)
var host: Node = surface.get_node("SurfaceFeatures/PlanetSetupHost")
var points: Array = []
for child in host.get_children():
if child.get_script() == CAPTURE_POINT_SCRIPT:
points.append(child)
# Lay the three points out in a compact, clearly-separated triangle for
# the screenshot, rather than the real (far-flung) authored positions.
# Point 0 sits at the origin - exactly where the fleet spawns - so the
# screenshot also proves the #50 z_index fix: the ring must not occlude
# the ships parked on top of it.
var layout: Array = [Vector2(0, 0), Vector2(-300, 150), Vector2(300, -200)]
for i in range(mini(points.size(), layout.size())):
points[i].position = layout[i]
# Show all three states at once: captured, mid-fill, and blocked. Each
# point's own _process() would otherwise immediately recompute state
# from the real (empty, this far from any ship) fleet/enemy proximity
# and overwrite these on the very next frame, so freeze it after setting
# the display state - same technique as freezing enemy.speed for the
# gas bomber screenshot.
if points.size() >= 1:
points[0].mark_captured()
points[0].set_process(false)
GameState.mark_point_captured(GameState.planet_id, points[0].point_index)
if points.size() >= 2:
points[1].state = CAPTURE_POINT_SCRIPT.State.CAPTURING
points[1].progress = 0.55
points[1].queue_redraw()
points[1].set_process(false)
if points.size() >= 3:
points[2].state = CAPTURE_POINT_SCRIPT.State.BLOCKED
points[2].progress = 0.3
points[2].queue_redraw()
points[2].set_process(false)

View file

@ -1 +0,0 @@
uid://njieetp3l0xc

View file

@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_capture_points_preview.gd" id="1"]
[node name="CapturePointsPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -5,11 +5,11 @@ func _ready() -> void:
var editor: Node = load("res://surface/PlanetSetupEditor.tscn").instantiate()
add_child(editor)
editor.new_setup("preview_setup", "Preview Setup", "rocky")
editor.add_capture_point(Vector2(300, 150))
editor.add_capture_point(Vector2(-250, 300))
editor.add_enemy("chaser", Vector2(300, 150))
editor.add_enemy("chaser", Vector2(-250, 300))
editor.add_enemy("chaser", Vector2(200, -300))
editor.add_enemy("sniper", Vector2(-300, -150))
editor.add_enemy("artillery", Vector2(0, 350))
editor.selected_item = {"kind": "capture", "index": 0}
editor.selected_item = {"kind": "enemy", "index": 0}
editor._rebuild_markers()
editor._refresh_selected_label()

View file

@ -17,7 +17,7 @@ extends SceneTree
# 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_capture_points.gd
# can happen before autoloads are registered. See test_planet_garrison.gd
# for the full explanation.
var ARTILLERY_SHELL_SCRIPT: Script

View file

@ -14,7 +14,7 @@ extends SceneTree
# C. landing on a black hole spawns no resource provider and no capture
# points at all (void's features_scene is null, and it has no
# authored PlanetSetup) - yet the planet is immediately conquered
# (register_capture_points treats zero required points as vacuous),
# (a garrison of zero is vacuously already beaten),
# so an extractor is buildable there right away
# D. an extractor on a black hole mines dark matter locally exactly like
# any other extractor, and a hub transporter banks it through the
@ -132,7 +132,7 @@ func _process(delta: float) -> bool:
# resolved at compile time, before autoloads are registered. Same
# hazard the header of test_planet_setups.gd documents.
if not game_state.is_planet_conquered(game_state.planet_id) \
and int(game_state.total_capture_points(game_state.planet_id)) == 0:
and int(game_state.garrison_total(game_state.planet_id)) == 0:
return _fail("a black hole with no capture points required is not marked conquered")
print("no resource provider; %d authored feature host(s)" % feature_host.get_child_count())

View file

@ -108,7 +108,7 @@ func _process(delta: float) -> bool:
return _fail("landing did not compose BossEncounter")
if surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") != null:
return _fail("boss planet was routed through configurable PlanetSetup")
if state.total_capture_points(state.planet_id) != 1 \
if state.garrison_total(state.planet_id) != 1 \
or state.is_planet_conquered(state.planet_id):
return _fail("boss planet did not start as one unfinished persistent objective")
if surface.get_node("UI/CaptureLabel").visible:

View file

@ -1,214 +0,0 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_capture_points.gd
# Verifies:
# 1. PlanetSetupHost restores an already-captured point immediately on
# (re-)composition, instead of restarting its fill
# 2. a ship parked in range with no enemies nearby fills a point to
# CAPTURED in about capture_time seconds, and GameState reflects it
# (captured_point_count, is_planet_conquered - which is what
# BuildController's extractor gate reads, see test_planet_conquest_gate)
# 3. an enemy within enemy_clear_radius blocks further progress
# (state == BLOCKED) without erasing what was already filled
# 4. moving out of range drains progress gradually, not instantly, and
# never below what a completed point already holds
# 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 - and CapturePoint.gd
# references GameState in a function body, which GDScript resolves at
# compile time. preload()ing it from inside _initialize() defers that
# until after the engine has finished booting, same as every plain load()
# call elsewhere in this file.
var CAPTURE_POINT_SCRIPT: Script
var PLANET_SETUP_SCRIPT: Script
var game_state: Node
var main: Node
var fleet: Node
var point: Node
var enemy: Node
var planet_id: String = "test_capture_planet"
var stage: int = 0
var elapsed: float = 0.0
var progress_before_block: float = 0.0
var progress_before_drain: float = 0.0
func _initialize() -> void:
CAPTURE_POINT_SCRIPT = load("res://surface/CapturePoint.gd")
PLANET_SETUP_SCRIPT = load("res://surface/PlanetSetup.gd")
game_state = root.get_node("GameState")
game_state.planet_capture.clear()
# --- 1. restoration: an already-captured point stays captured -----
var restore_planet_id: String = "test_capture_restore_planet"
var fake_setup: Resource = PLANET_SETUP_SCRIPT.new()
fake_setup.setup_id = "test_restore"
fake_setup.surface_profile_id = "rocky"
fake_setup.capture_points = PackedVector2Array([Vector2(0, 0), Vector2(500, 0)])
game_state.planet_id = restore_planet_id
var host1: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
host1.apply_setup(fake_setup)
game_state.mark_point_captured(restore_planet_id, 0) # simulates a completed prior visit
var host2: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
host2.apply_setup(fake_setup)
var restored: Node = null
var fresh: Node = null
for child in host2.get_children():
if child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 0:
restored = child
elif child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 1:
fresh = child
if restored == null or restored.state != CAPTURE_POINT_SCRIPT.State.CAPTURED or restored.progress < 0.999:
return _fail("re-landing did not restore an already-captured point")
if fresh == null or fresh.state == CAPTURE_POINT_SCRIPT.State.CAPTURED:
return _fail("an uncaptured point came back already CAPTURED")
# --- 2-4. capture mechanics, in a live scene with a real fleet -----
game_state.planet_capture.clear()
game_state.planet_id = planet_id
game_state.register_capture_points(planet_id, 1)
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 200
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()
point = load("res://surface/CapturePoint.tscn").instantiate()
point.point_index = 0
if point.capture_time > 4.0:
return _fail("default capture still takes %.1fs" % point.capture_time)
point.capture_time = 1.0
point.capture_radius = 200.0
point.enemy_clear_radius = 300.0
point.drain_rate = 0.5
point.position = Vector2(4000, 0) # well clear of spawn-time activity
main.add_child(point)
var far_away: Vector2 = point.position + Vector2(2500, 0)
for ship in fleet.ships:
ship.position = far_away
fleet.move_to(far_away)
elapsed = 0.0
stage = 1
return false
1:
# Out of range: no progress at all.
if elapsed < 0.3:
return false
if point.progress > 0.001 or point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
return _fail("progress rose while no ship was in capture_radius")
for ship in fleet.ships:
ship.position = point.position
fleet.move_to(point.position)
elapsed = 0.0
stage = 2
return false
2:
# Partway through capture_time: rising, not yet complete.
if elapsed < point.capture_time * 0.4:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURING or point.progress <= 0.05:
return _fail("point did not start filling with a ship in range (state=%d progress=%.2f)" % [
point.state, point.progress
])
# An enemy shows up within enemy_clear_radius: must block further
# fill without erasing what's already there. A bare "enemies"
# group member (not a real Enemy.tscn) is deliberate: CapturePoint
# only cares that something is in the group and in range, and
# this avoids a real chaser/weapon exchange destroying the fleet
# ships this test also needs alive in later stages.
enemy = Node2D.new()
enemy.add_to_group("enemies")
enemy.position = point.position + Vector2(50, 0)
main.add_child(enemy)
progress_before_block = point.progress
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.5:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.BLOCKED:
return _fail("point did not report BLOCKED with an enemy nearby (state=%d)" % point.state)
if point.progress > progress_before_block + 0.01:
return _fail("progress rose while an enemy was inside enemy_clear_radius")
# Enemy leaves and the ship backs off out of range: progress
# should drain, but gradually - not snap to zero.
enemy.queue_free()
var far_away: Vector2 = point.position + Vector2(2500, 0)
for ship in fleet.ships:
ship.position = far_away
fleet.move_to(far_away)
progress_before_drain = point.progress
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if point.progress >= progress_before_drain - 0.001:
return _fail("progress did not drain while out of range")
if point.progress <= 0.001:
return _fail("progress dropped to zero instantly instead of draining gradually")
if point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
return _fail("point state did not return to NEUTRAL while draining out of range")
# Move back in and let it finish this time.
for ship in fleet.ships:
ship.position = point.position
fleet.move_to(point.position)
elapsed = 0.0
stage = 5
return false
5:
# Draining first means completion now takes longer than a bare
# capture_time - give it a generous margin.
if elapsed < point.capture_time * 2.0 + 0.5:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURED:
return _fail("point never reached CAPTURED (state=%d progress=%.2f after %.2fs)" % [
point.state, point.progress, elapsed
])
if game_state.captured_point_count(planet_id) != 1:
return _fail("GameState.captured_point_count did not record the capture")
# is_planet_conquered is exactly what BuildController's extractor
# gate reads (see test_planet_conquest_gate.gd) - not re-tested
# through the build UI here to avoid duplicating that coverage.
if not game_state.is_planet_conquered(planet_id):
return _fail("single-point planet was not marked conquered")
if point.z_index >= 0:
return _fail("capture point does not draw behind the fleet/enemies (z_index=%d)" % point.z_index)
print("TEST_CAPTURE_POINTS: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_CAPTURE_POINTS: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1 +0,0 @@
uid://cmordwxwbr5ca

View file

@ -3,20 +3,22 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_conquered_planet_clear.gd
# A planet's garrison was destroyed to take it, so it must not be standing
# there again the next time the player lands. Capture points already came
# back marked captured; the enemies came back alive, which read as the
# conquest having quietly been undone.
# there again the next time the player lands.
#
# #211 made the garrison the whole of conquest: killing the last defender
# takes the planet, and there are no capture points beside them any more.
#
# Covers:
# A. an unconquered planet lays out its authored enemies, as before
# B. once every capture point is taken, re-landing places no enemies - and
# still places the capture points, marked captured
# C. a setup that authors enemies but NO capture points keeps them. Such a
# planet counts as vacuously conquered (nothing to capture), so keying
# only off the conquered flag would delete a garrison nobody has beaten
# B. killing the last of them conquers it, and re-landing places none
# C. a setup with NO enemies counts as vacuously conquered - there is
# nothing there to beat
# D. a half-cleared planet the player left comes back whole, and its count
# with it, rather than falling to a single kill on the next visit
const PLANET_KEY: String = "test_conquered_planet_clear"
const OPEN_PLANET_KEY: String = "test_conquered_planet_clear_open"
const LEFT_PLANET_KEY: String = "test_conquered_planet_clear_left"
var game_state: Node
var host_scene: PackedScene
@ -41,42 +43,54 @@ func _process(delta: float) -> bool:
# --- A. unconquered: the garrison is there ---------------------------
game_state.planet_id = PLANET_KEY
game_state.planet_definition_id = "rocky"
var setup: Resource = _make_setup(2, 3)
var setup: Resource = _make_setup(3)
var enemies: int = _apply_and_count_enemies(setup)
if enemies != 3:
return _fail("an unconquered planet placed %d of its 3 authored enemies" % enemies)
if game_state.is_planet_conquered(PLANET_KEY):
return _fail("a planet with 2 uncaptured points reported itself conquered")
return _fail("a planet with 3 defenders standing reported itself conquered")
print("unconquered planet: 3 enemies placed")
# --- B. conquered: the garrison stays destroyed ----------------------
game_state.mark_point_captured(PLANET_KEY, 0)
game_state.mark_point_captured(PLANET_KEY, 1)
# --- B. the last kill takes it, and it stays taken -------------------
game_state.mark_garrison_enemy_destroyed(PLANET_KEY)
game_state.mark_garrison_enemy_destroyed(PLANET_KEY)
if game_state.is_planet_conquered(PLANET_KEY):
return _fail("the planet fell with a defender still standing")
game_state.mark_garrison_enemy_destroyed(PLANET_KEY)
if not game_state.is_planet_conquered(PLANET_KEY):
return _fail("capturing every point did not conquer the planet")
return _fail("killing the whole garrison did not conquer the planet")
var host: Node = host_scene.instantiate()
root.add_child(host)
host.apply_setup(setup)
var remaining: int = _count_enemies(host)
var points: int = _count_capture_points(host)
host.free()
var remaining: int = _apply_and_count_enemies(setup)
if remaining != 0:
return _fail("re-landing on a conquered planet placed %d enemies" % remaining)
if points != 2:
return _fail("a conquered planet placed %d of its 2 capture points - conquest should clear the garrison, not the map" % points)
print("conquered planet: 0 enemies, both capture points still placed")
print("conquered planet: last kill took it, re-landing places nobody")
# --- C. a setup with nothing to capture keeps its enemies ------------
# --- C. nothing to fight is nothing to take --------------------------
game_state.planet_id = OPEN_PLANET_KEY
var open_setup: Resource = _make_setup(0, 2)
var open_setup: Resource = _make_setup(0)
var open_enemies: int = _apply_and_count_enemies(open_setup)
if not game_state.is_planet_conquered(OPEN_PLANET_KEY):
return _fail("a setup with no capture points was expected to register as vacuously conquered, and did not - so this case no longer tests what it claims to")
if open_enemies != 2:
return _fail("a setup with no capture points lost %d of its 2 enemies to the conquest rule" \
% (2 - open_enemies))
print("setup with no capture points: both enemies kept, despite counting as conquered")
return _fail("a setup with no enemies was expected to be vacuously conquered")
if open_enemies != 0:
return _fail("a setup authoring no enemies placed %d" % open_enemies)
print("setup with no garrison: vacuously conquered, nobody placed")
# --- D. leaving mid-fight does not bank the kills --------------------
# The garrison is re-placed in full on the next landing, so its count
# has to start again with it - otherwise a planet left at one-from-
# clear would fall to a single kill against three fresh defenders.
game_state.planet_id = LEFT_PLANET_KEY
var left_setup: Resource = _make_setup(3)
_apply_and_count_enemies(left_setup)
game_state.mark_garrison_enemy_destroyed(LEFT_PLANET_KEY)
game_state.mark_garrison_enemy_destroyed(LEFT_PLANET_KEY)
var returned: int = _apply_and_count_enemies(left_setup)
if returned != 3:
return _fail("returning to a half-cleared planet placed %d of 3 defenders" % returned)
game_state.mark_garrison_enemy_destroyed(LEFT_PLANET_KEY)
if game_state.is_planet_conquered(LEFT_PLANET_KEY):
return _fail("one kill took a planet defended by three - the count did not reset with the garrison")
print("returning mid-fight: the garrison and its count both start again")
print("TEST_CONQUERED_PLANET_CLEAR: PASS")
quit(0)
@ -84,14 +98,10 @@ func _process(delta: float) -> bool:
return false
func _make_setup(capture_count: int, enemy_count: int) -> Resource:
func _make_setup(enemy_count: int) -> Resource:
var setup: Resource = load("res://surface/PlanetSetup.gd").new()
setup.setup_id = "in_memory"
setup.surface_profile_id = "rocky"
var points: PackedVector2Array = PackedVector2Array()
for i in range(capture_count):
points.append(Vector2(200.0 * float(i + 1), 0.0))
setup.capture_points = points
var spawn_script: Script = load("res://surface/PlanetSetupSpawn.gd")
for i in range(enemy_count):
var spawn: Resource = spawn_script.new()
@ -121,16 +131,6 @@ func _count_enemies(host: Node) -> int:
return count
func _count_capture_points(host: Node) -> int:
var count: int = 0
for child in host.get_children():
if child.is_in_group("enemies"):
continue
if "point_index" in child:
count += 1
return count
func _fail(message: String) -> bool:
print("TEST_CONQUERED_PLANET_CLEAR: FAIL (%s)" % message)
quit(1)

View file

@ -51,8 +51,8 @@ func _initialize() -> void:
# This test is about the planet-proximity/duplicate rules, not conquest
# (see test_planet_conquest_gate), so pre-conquer the target planet.
var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
game_state.register_capture_points(target_planet_id, 1)
game_state.mark_point_captured(target_planet_id, 0)
game_state.register_planet_garrison(target_planet_id, 1)
game_state.mark_garrison_enemy_destroyed(target_planet_id)
# Extractors are gated behind research (see test_skill_tree). This test
# is about the planet-proximity rule, so grant the research up front.
game_state.reset_progression()

View file

@ -408,6 +408,27 @@ func _process(delta: float) -> bool:
print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1))
if not director.whiteout_teleported:
return _fail("the screen reached white without teleporting the fleet")
# #209: the prologue's charted space belongs to a place 16000
# units away. Carried over, it also completed "chart 30 sectors"
# before the player had flown anywhere.
#
# Not "empty": the tracker charts the neighbourhood around the
# fleet the moment it arrives, so what is asserted is that
# nothing REMOTE survived - every chunk still on the map is one
# the survivors can see from where they now are.
# Measured from where the fleet actually IS: the tracker charts a
# neighbourhood around the formation's centroid, which sits a
# little off the spawn point, and the survivor drifts after
# arriving. Two sectors of slack over the sensor's own radius
# covers both without admitting anything from the convoy's route,
# which is thousands of sectors away.
var here: Vector2i = WorldGen.chunk_coords(fleet.get_centroid())
var reach: int = 1 + 2
for chunk in game_state.explored_chunks:
var away: int = maxi(absi(chunk.x - here.x), absi(chunk.y - here.y))
if away > reach:
return _fail("chunk %s, %d sectors from the survivors, survived the breach" \
% [chunk, away])
if fleet.get_formation_origin().distance_to(director.DEFAULT_SPAWN) > 1.0:
return _fail("whiteout revealed the fleet at %s instead of default spawn %s" \
% [fleet.get_formation_origin(), director.DEFAULT_SPAWN])

View file

@ -40,7 +40,6 @@ func _process(delta: float) -> bool:
setup.setup_id = "reward_probe"
setup.display_name = "Reward Probe"
setup.surface_profile_id = "rocky"
setup.capture_points = PackedVector2Array([Vector2(10, 10), Vector2(-10, -10)])
setup.research_point_reward = 5
var path: String = "user://test_reward_probe.tres"
if ResourceSaver.save(setup, path) != OK:
@ -55,9 +54,9 @@ func _process(delta: float) -> bool:
# A research_lab planet's own definition pays a flat 1.
var flat_rate: int = int(load("res://planets/ResearchLabPlanetDefinition.tres").conquest_research_points)
game_state.research_points = 0
game_state.register_capture_points("hard_lab", 2, "research_lab", 6)
game_state.mark_point_captured("hard_lab", 0)
game_state.mark_point_captured("hard_lab", 1)
game_state.register_planet_garrison("hard_lab", 2, "research_lab", 6)
game_state.mark_garrison_enemy_destroyed("hard_lab")
game_state.mark_garrison_enemy_destroyed("hard_lab")
if game_state.research_points != 6:
return _fail("a hard lab authored at 6 points awarded %d" % game_state.research_points)
if flat_rate >= 6:
@ -66,8 +65,8 @@ func _process(delta: float) -> bool:
# --- C. silence still means the type's rate ------------------
game_state.research_points = 0
game_state.register_capture_points("plain_lab", 1, "research_lab", 0)
game_state.mark_point_captured("plain_lab", 0)
game_state.register_planet_garrison("plain_lab", 1, "research_lab", 0)
game_state.mark_garrison_enemy_destroyed("plain_lab")
if game_state.research_points != flat_rate:
return _fail("a setup with no authored reward paid %d, expected the type's %d" \
% [game_state.research_points, flat_rate])

View file

@ -3,10 +3,10 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_conquest_gate.gd
# Verifies:
# 1. GameState starts every planet unconquered, and register_capture_points
# is idempotent (a second call with the same total doesn't wipe progress)
# 2. mark_point_captured only conquers once every point is captured, and
# the planet_conquered signal fires exactly once
# 1. GameState starts every planet unconquered, and re-registering a
# garrison on a conquered planet cannot un-conquer it
# 2. a planet falls only when its LAST defender does, and the
# planet_conquered signal fires exactly once, carrying what it paid
# 3. over an unconquered planet, the extractor build option is disabled,
# the info popup explains why, and confirming spends nothing and builds
# nothing
@ -22,6 +22,9 @@ var target_chunk: Vector2i
var stage: int = 0
var elapsed: float = 0.0
var conquered_signal_count: int = 0
# #212 needs the award to travel WITH the signal, so whatever announces
# conquest reports a number that was actually paid.
var conquered_signal_points: int = -1
func _initialize() -> void:
@ -57,22 +60,47 @@ func _initialize() -> void:
# Unit-level checks on GameState itself, before any scene involvement.
if game_state.is_planet_conquered(target_planet_id):
return _fail("a fresh planet reported conquered")
game_state.register_capture_points(target_planet_id, 3)
game_state.mark_point_captured(target_planet_id, 0)
game_state.register_capture_points(target_planet_id, 3) # idempotent re-registration
if game_state.captured_point_count(target_planet_id) != 1:
return _fail("re-registering wiped already-captured progress")
game_state.planet_conquered.connect(func(_id): conquered_signal_count += 1)
game_state.mark_point_captured(target_planet_id, 1)
game_state.register_planet_garrison(target_planet_id, 3)
if game_state.garrison_remaining(target_planet_id) != 3:
return _fail("a garrison of 3 registered as %d" \
% game_state.garrison_remaining(target_planet_id))
game_state.planet_conquered.connect(func(_id, points):
conquered_signal_count += 1
conquered_signal_points = points)
game_state.mark_garrison_enemy_destroyed(target_planet_id)
game_state.mark_garrison_enemy_destroyed(target_planet_id)
if game_state.is_planet_conquered(target_planet_id):
return _fail("conquered with points still missing")
game_state.mark_point_captured(target_planet_id, 2)
return _fail("conquered with a defender still standing")
game_state.mark_garrison_enemy_destroyed(target_planet_id)
if not game_state.is_planet_conquered(target_planet_id):
return _fail("all points captured but planet not marked conquered")
# Marking an already-captured point again must not double-fire the signal.
game_state.mark_point_captured(target_planet_id, 2)
return _fail("the whole garrison is down but the planet is not conquered")
# A stray kill reported after the planet has fallen must not fire again.
game_state.mark_garrison_enemy_destroyed(target_planet_id)
# Nor may re-landing hand the planet back: registration is refused
# outright once it is taken.
game_state.register_planet_garrison(target_planet_id, 3)
if not game_state.is_planet_conquered(target_planet_id):
return _fail("re-registering a garrison un-conquered a planet already taken")
if conquered_signal_count != 1:
return _fail("planet_conquered fired %d times, expected exactly 1" % conquered_signal_count)
# This planet was registered with no definition and no override, so the
# conquest paid nothing - and the signal has to say so rather than leave
# the announcement to guess.
if conquered_signal_points != 0:
return _fail("an unrewarded conquest reported %d research points" \
% conquered_signal_points)
# A rewarded one reports what it paid.
var paid_planet_id: String = target_planet_id + ":paid"
var points_before: int = game_state.research_points
game_state.register_planet_garrison(paid_planet_id, 1, "", 4)
conquered_signal_points = -1
game_state.mark_garrison_enemy_destroyed(paid_planet_id)
if conquered_signal_points != 4:
return _fail("a 4-point conquest reported %d" % conquered_signal_points)
if game_state.research_points != points_before + 4:
return _fail("the reported points were not the ones actually banked")
print("conquest reports the research points it paid")
# Reset conquest for the scene-level gate check below - this run tests
# the unconquered path first, then conquers it.
@ -134,9 +162,9 @@ func _process(delta: float) -> bool:
popup.visible = false
# Now conquer it and confirm the same flow succeeds.
game_state.register_capture_points(planet.planet_id, 2)
game_state.mark_point_captured(planet.planet_id, 0)
game_state.mark_point_captured(planet.planet_id, 1)
game_state.register_planet_garrison(planet.planet_id, 2)
game_state.mark_garrison_enemy_destroyed(planet.planet_id)
game_state.mark_garrison_enemy_destroyed(planet.planet_id)
controller._on_build_pressed()
if not button.enabled:

View file

@ -0,0 +1,143 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_garrison.gd
# Forgejo #211: taking a planet is killing what defends it. The capture
# points this replaced - stand in a ring, wait out a timer, once per ring -
# are gone, so what has to be proven is that the FIGHT reaches the conquest
# state, through the enemies' own deaths rather than through a bookkeeping
# call a test makes on their behalf.
#
# test_conquered_planet_clear.gd covers the counting rules around landing and
# re-landing. This one covers the wiring: a real Enemy, destroyed the way
# anything destroys one, moves the planet toward being held.
#
# Covers:
# A. an authored garrison registers itself when the surface is composed
# B. killing one defender is progress, not conquest
# C. killing the last one conquers the planet, exactly once, and the
# research award rides along with the signal
# D. an enemy that dies after the planet has already fallen changes nothing
# 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 - and these scripts reference
# GameState in function bodies, which GDScript resolves at compile time.
var PLANET_SETUP_SCRIPT: Script
var SPAWN_SCRIPT: Script
var game_state: Node
var host: Node
var planet_id: String = "test_garrison_planet"
var conquered_count: int = 0
var conquered_points: int = -1
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
PLANET_SETUP_SCRIPT = load("res://surface/PlanetSetup.gd")
SPAWN_SCRIPT = load("res://surface/PlanetSetupSpawn.gd")
game_state = root.get_node("GameState")
game_state.planet_capture.clear()
game_state.planet_id = planet_id
game_state.planet_definition_id = "rocky"
game_state.planet_conquered.connect(func(_id, points):
conquered_count += 1
conquered_points = points)
var setup: Resource = PLANET_SETUP_SCRIPT.new()
setup.setup_id = "test_garrison"
setup.surface_profile_id = "rocky"
# Worth something, so the award travelling with the signal is checked on a
# planet that actually pays.
setup.research_point_reward = 3
for i in range(3):
var spawn: Resource = SPAWN_SCRIPT.new()
spawn.enemy_id = "chaser"
spawn.position = Vector2(0.0, 300.0 * float(i + 1))
setup.enemy_spawns.append(spawn)
host = load("res://surface/PlanetSetupHost.tscn").instantiate()
root.add_child(host)
host.apply_setup(setup)
func _defenders() -> Array:
var out: Array = []
for child in host.get_children():
if is_instance_valid(child) and child.is_in_group("enemies") \
and not child.is_queued_for_deletion():
out.append(child)
return out
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.2:
return false
match stage:
0:
# --- A. the garrison registered itself -----------------------
var defenders: Array = _defenders()
if defenders.size() != 3:
return _fail("the surface placed %d defenders for 3 authored" % defenders.size())
if game_state.garrison_total(planet_id) != 3 \
or game_state.garrison_remaining(planet_id) != 3:
return _fail("a 3-strong garrison registered as %d of %d" % [
game_state.garrison_remaining(planet_id),
game_state.garrison_total(planet_id)])
print("3 defenders placed, and the planet knows it")
# --- B. one down is not conquest -----------------------------
var points_before: int = game_state.research_points
defenders[0].take_damage(defenders[0].hp)
if game_state.garrison_remaining(planet_id) != 2:
return _fail("one kill left %d of the garrison standing, expected 2" \
% game_state.garrison_remaining(planet_id))
if game_state.is_planet_conquered(planet_id):
return _fail("the planet fell to the first kill")
if game_state.research_points != points_before:
return _fail("an unfinished conquest paid out")
print("one defender down: 2 left, planet still theirs")
# --- C. the last one takes it --------------------------------
defenders[1].take_damage(defenders[1].hp)
defenders[2].take_damage(defenders[2].hp)
if not game_state.is_planet_conquered(planet_id):
return _fail("the whole garrison is dead and the planet is not ours")
if conquered_count != 1:
return _fail("planet_conquered fired %d times, expected once" % conquered_count)
if conquered_points != 3:
return _fail("the conquest reported %d research points, expected the authored 3" \
% conquered_points)
if game_state.research_points != points_before + 3:
return _fail("the reported award was not the one banked")
print("last defender down: planet taken, 3 research points paid once")
elapsed = 0.0
stage = 1
return false
1:
# --- D. a straggler after the fact changes nothing -----------
# A shot already in flight, or a bomb still blooming, can kill
# something a frame after the planet has fallen.
var points_after: int = game_state.research_points
game_state.mark_garrison_enemy_destroyed(planet_id)
if conquered_count != 1:
return _fail("a kill after conquest fired planet_conquered again")
if game_state.research_points != points_after:
return _fail("a kill after conquest paid the reward a second time")
print("a late kill is absorbed: no second conquest, no second payment")
print("TEST_PLANET_GARRISON: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_PLANET_GARRISON: FAIL (%s)" % message)
quit(1)
return true

View file

@ -145,7 +145,6 @@ func _plant_pool() -> bool:
setup.setup_id = pool_id
setup.display_name = pool_id
setup.surface_profile_id = "rocky"
setup.capture_points = PackedVector2Array([Vector2(100.0, 100.0)])
if ResourceSaver.save(setup, "res://PlanetSetups/%s.tres" % pool_id) != OK:
_remove_scratch()
_fail("could not plant the scratch map pool")

View file

@ -8,18 +8,18 @@ extends SceneTree
# 2. dropping a new .tres into res://PlanetSetups/ makes it appear via
# PlanetSetups.rescan() with no other code change
# 3. landing on a rocky/gas planet composes a PlanetSetupHost carrying the
# resolved setup, with one marker per capture point
# resolved setup, placing one enemy per authored spawn
const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres"
# 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, and CapturePoint.gd
# references GameState in a function body (resolved at compile time). See
# test_capture_points.gd for the full explanation.
# can happen before autoloads are registered, and the scripts loaded here
# reference GameState in function bodies (resolved at compile time). See
# test_planet_garrison.gd for the full explanation.
var PLANET_SETUP_SCRIPT: Script
var CAPTURE_POINT_SCRIPT: Script
var ENEMY_SCRIPT: Script
var game_state: Node
var rocky_setup: Resource
@ -30,7 +30,7 @@ var pending_surface: Node
func _initialize() -> void:
PLANET_SETUP_SCRIPT = load("res://surface/PlanetSetup.gd")
CAPTURE_POINT_SCRIPT = load("res://surface/CapturePoint.gd")
ENEMY_SCRIPT = load("res://enemies/Enemy.gd")
# --- 1. determinism and profile filtering ---
rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky")
if rocky_setup == null:
@ -65,7 +65,6 @@ func _initialize() -> void:
scratch.setup_id = "_test_scratch_setup"
scratch.display_name = "Scratch"
scratch.surface_profile_id = "rocky"
scratch.capture_points = PackedVector2Array([Vector2(100, 100)])
var save_err: int = ResourceSaver.save(scratch, SCRATCH_SETUP_PATH)
if save_err != OK:
return _fail("could not save the scratch setup for the pickup check (err %d)" % save_err)
@ -108,14 +107,14 @@ func _process(delta: float) -> bool:
if host.setup != rocky_setup:
pending_surface.free()
return _fail("host did not receive the deterministically resolved setup")
var point_count: int = 0
var enemy_count: int = 0
for child in host.get_children():
if child.get_script() == CAPTURE_POINT_SCRIPT:
point_count += 1
if point_count != rocky_setup.capture_points.size():
if child.get_script() == ENEMY_SCRIPT:
enemy_count += 1
if enemy_count != rocky_setup.enemy_spawns.size():
pending_surface.free()
return _fail("host placed %d capture points for %d authored ones" % [
point_count, rocky_setup.capture_points.size()
return _fail("host placed %d enemies for %d authored spawns" % [
enemy_count, rocky_setup.enemy_spawns.size()
])
pending_surface.free()

View file

@ -44,8 +44,8 @@ func _initialize() -> void:
target_planet_id = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
# This test is about the resource-id gate, not conquest itself (see
# test_planet_conquest_gate) - pre-conquer the target directly.
game_state.register_capture_points(target_planet_id, 1, "research_lab")
game_state.mark_point_captured(target_planet_id, 0)
game_state.register_planet_garrison(target_planet_id, 1, "research_lab")
game_state.mark_garrison_enemy_destroyed(target_planet_id)
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)

View file

@ -98,34 +98,34 @@ func _initialize() -> void:
if int(lab_definition.conquest_research_points) != 0:
return _fail("the research_lab planet type still grants %d point(s) of its own - the reward belongs to the authored map now" \
% int(lab_definition.conquest_research_points))
game_state.register_capture_points("test_lab_no_reward", 1, "research_lab")
game_state.mark_point_captured("test_lab_no_reward", 0)
game_state.register_planet_garrison("test_lab_no_reward", 1, "research_lab")
game_state.mark_garrison_enemy_destroyed("test_lab_no_reward")
if game_state.research_points != 0:
return _fail("a lab whose map states no reward still awarded %d" % game_state.research_points)
print("the planet type grants nothing by itself; an unpriced map awards nothing")
# What an authored map is worth is passed through as the override, exactly
# as PlanetSetupHost does from the setup's research_point_reward.
game_state.register_capture_points("test_lab_planet", 2, "research_lab", 1)
game_state.register_planet_garrison("test_lab_planet", 2, "research_lab", 1)
if game_state.is_planet_conquered("test_lab_planet"):
return _fail("a lab with 2 real capture points started conquered")
game_state.mark_point_captured("test_lab_planet", 0)
game_state.mark_garrison_enemy_destroyed("test_lab_planet")
if game_state.research_points != 0:
return _fail("a research point was awarded before every point was captured")
game_state.mark_point_captured("test_lab_planet", 1)
game_state.mark_garrison_enemy_destroyed("test_lab_planet")
if game_state.research_points != 1:
return _fail("conquering a research_lab did not award the 1 point its map is worth: %d" % game_state.research_points)
if totals_seen != [1]:
return _fail("research_points_changed did not fire exactly once with the new total: %s" % [totals_seen])
print("conquering a research_lab awarded exactly what its map is worth, once")
game_state.mark_point_captured("test_lab_planet", 0) # already captured - redundant re-mark
game_state.mark_garrison_enemy_destroyed("test_lab_planet") # already beaten - redundant kill
if game_state.research_points != 1:
return _fail("re-marking an already-captured point re-awarded a research point: %d" % game_state.research_points)
print("double-award guarded against")
game_state.register_capture_points("test_rocky_planet", 1, "rocky")
game_state.mark_point_captured("test_rocky_planet", 0)
game_state.register_planet_garrison("test_rocky_planet", 1, "rocky")
game_state.mark_garrison_enemy_destroyed("test_rocky_planet")
if game_state.research_points != 1:
return _fail("conquering a non-research_lab planet awarded a research point: %d" % game_state.research_points)
print("a non-research_lab planet's conquest never awards a point")

View file

@ -5,14 +5,14 @@ extends SceneTree
# Drives PlanetSetupEditor's model directly (place/move/delete/save/reload)
# rather than through touch input - see the ticket's own note that the
# touch layer can stay out of the headless test. Verifies:
# 1. placing capture points and enemies updates current_setup
# 1. placing enemies updates current_setup
# 2. moving and deleting update it correctly, including index shifting
# 3. saving writes a real res://PlanetSetups/<id>.tres and the registry
# picks it up
# 4. reopening that file in a fresh editor instance round-trips the
# exact points/enemies, and they can be moved/deleted again
# 5. the palette has one entry per EnemyDefinitions entry plus the
# capture point, generated from the registry rather than hardcoded
# enemy kind, generated from the registry rather than hardcoded
const TEST_SETUP_ID: String = "test_editor_setup"
const TEST_SETUP_PATH: String = "res://PlanetSetups/test_editor_setup.tres"
@ -37,32 +37,29 @@ func _process(delta: float) -> bool:
if elapsed < 0.1:
return false
var expected_palette_size: int = 1 + EnemyDefinitions.all().size()
if editor._palette_buttons.size() != expected_palette_size:
return _fail("palette has %d entries, expected 1 + %d enemy definitions" % [
# #211 took the capture-point tile out: the palette is exactly the
# enemy registry now.
if editor._palette_buttons.size() != EnemyDefinitions.all().size():
return _fail("palette has %d entries, expected %d enemy definitions" % [
editor._palette_buttons.size(), EnemyDefinitions.all().size()
])
editor.new_setup(TEST_SETUP_ID, "Test Editor Setup", "rocky")
editor.add_capture_point(Vector2(100, 200))
editor.add_capture_point(Vector2(-300, 50))
editor.add_capture_point(Vector2(0, -400))
editor.add_enemy("chaser", Vector2(100, 200))
editor.add_enemy("chaser", Vector2(500, 500))
editor.add_enemy("sniper", Vector2(-500, -500))
if editor.current_setup.capture_points.size() != 3:
return _fail("placing 3 capture points did not land 3")
if editor.current_setup.enemy_spawns.size() != 3:
return _fail("placing 3 enemies did not land 3")
editor.move_enemy(0, Vector2(150, 250))
if editor.current_setup.enemy_spawns[0].position != Vector2(150, 250):
return _fail("move_enemy did not update the position")
editor.remove_enemy(1) # removes the second chaser
if editor.current_setup.enemy_spawns.size() != 2:
return _fail("placing 2 enemies did not land 2")
editor.move_capture_point(0, Vector2(150, 250))
if editor.current_setup.capture_points[0] != Vector2(150, 250):
return _fail("move_capture_point did not update the position")
editor.remove_enemy(0) # removes the chaser spawn
if editor.current_setup.enemy_spawns.size() != 1:
return _fail("remove_enemy did not shrink enemy_spawns")
if editor.current_setup.enemy_spawns[0].enemy_id != "sniper":
if editor.current_setup.enemy_spawns[1].enemy_id != "sniper":
return _fail("remove_enemy removed the wrong entry (index did not shift)")
var result: Dictionary = editor.save_setup()
@ -97,20 +94,19 @@ func _process(delta: float) -> bool:
return false
if not editor.load_setup(TEST_SETUP_ID):
return _fail("reopening the saved setup failed")
var points: PackedVector2Array = editor.current_setup.capture_points
var spawns: Array = editor.current_setup.enemy_spawns
if points.size() != 3 or points[0] != Vector2(150, 250) \
or points[1] != Vector2(-300, 50) or points[2] != Vector2(0, -400):
return _fail("reopened capture points did not match what was saved: %s" % [points])
if spawns.size() != 1 or spawns[0].enemy_id != "sniper" or spawns[0].position != Vector2(-500, -500):
if spawns.size() != 2 or spawns[0].enemy_id != "chaser" \
or spawns[0].position != Vector2(150, 250) \
or spawns[1].enemy_id != "sniper" \
or spawns[1].position != Vector2(-500, -500):
return _fail("reopened enemy spawns did not match what was saved")
# The reopened items can still be moved and deleted.
editor.move_enemy(0, Vector2(-600, -600))
if editor.current_setup.enemy_spawns[0].position != Vector2(-600, -600):
return _fail("could not move an item after reopening")
editor.remove_capture_point(1)
if editor.current_setup.capture_points.size() != 2:
editor.remove_enemy(1)
if editor.current_setup.enemy_spawns.size() != 1:
return _fail("could not delete an item after reopening")
print("TEST_SETUP_EDITOR: PASS")

View file

@ -60,11 +60,11 @@ func _process(delta: float) -> bool:
# Two setups, so deleting one still leaves a populated list - a panel that
# empties would hide itself and mask a wrong-row deletion.
editor.new_setup(KEEP_ID, "Delete Keeper", "rocky")
editor.add_capture_point(Vector2(10, 20))
editor.add_enemy("chaser", Vector2(10, 20))
if not editor.save_setup().ok:
return _fail("could not save the keeper setup")
editor.new_setup(SCRATCH_ID, "Delete Scratch", "rocky")
editor.add_capture_point(Vector2(30, 40))
editor.add_enemy("chaser", Vector2(30, 40))
if not editor.save_setup().ok:
return _fail("could not save the scratch setup")
@ -123,8 +123,8 @@ func _process(delta: float) -> bool:
return _fail("deleting the open setup failed: %s" % outcome.message)
if String(editor.current_setup.setup_id) == KEEP_ID:
return _fail("the editor is still holding '%s' after deleting it - the next save would put the file straight back" % KEEP_ID)
if not editor.current_setup.capture_points.is_empty():
return _fail("the replacement setup carried the deleted one's capture points over")
if not editor.current_setup.enemy_spawns.is_empty():
return _fail("the replacement setup carried the deleted one's enemies over")
print("deleting the open setup left a fresh '%s' being edited" % editor.current_setup.setup_id)
# Deleting something that is not there is refused, not silently "ok".

View file

@ -74,7 +74,6 @@ func _process(delta: float) -> bool:
scratch.setup_id = SCRATCH_ID
scratch.display_name = "Identity Scratch"
scratch.surface_profile_id = "rocky"
scratch.capture_points = PackedVector2Array([Vector2(10.0, 20.0)])
scratch.research_point_reward = 1
if ResourceSaver.save(scratch, SCRATCH_FILE) != OK:
return _fail("could not write the scratch setup")
@ -116,7 +115,7 @@ func _process(delta: float) -> bool:
# --- E. a NEW setup still lands under its id --------------------------
editor.new_setup(NEW_ID, "Identity New", "rocky")
editor.add_capture_point(Vector2(20.0, 30.0))
editor.add_enemy("chaser", Vector2(20.0, 30.0))
if not editor.save_setup().ok:
return _fail("saving a brand-new setup failed")
if not FileAccess.file_exists(ProjectSettings.globalize_path(NEW_PATH)):

View file

@ -15,8 +15,7 @@ extends SceneTree
#
# Covers:
# A. the gas giant's vent field draws behind the ships (the #91 report)
# B. the same for every other drawing feature on every profile, rocks and
# capture rings included
# B. the same for every other drawing feature on every profile
# C. the fleet's own ships are where the comparison says they are, so a
# future change that pushed the ships back instead cannot make A and B
# pass by moving the goalposts
@ -106,8 +105,14 @@ func _process(delta: float) -> bool:
return _fail("ships are at z=%d, not 0 - this test's premise moved" % ship_z)
var features: Array = _drawing_descendants(surface.get_node("SurfaceFeatures"), [])
# A profile is allowed to have no scenery of its own - the research lab
# had none but the capture rings, and #211 took those away. The rule is
# about features that EXIST, so an empty profile is skipped rather than
# failed; the run-wide check below is what stops this test quietly
# checking nothing at all.
if features.is_empty():
return _fail("%s has no drawing surface features to check" % game_state.planet_definition_id)
print("%s: no drawing surface features of its own" \
% game_state.planet_definition_id)
for feature in features:
var z: int = _effective_z(feature)
if z >= ship_z:
@ -120,6 +125,8 @@ func _process(delta: float) -> bool:
checked += features.size()
if planets.is_empty():
if checked == 0:
return _fail("no profile had a single drawing feature - this test proved nothing")
print("TEST_SURFACE_FEATURE_DEPTH: PASS (%d features checked)" % checked)
quit(0)
return true

View file

@ -118,6 +118,38 @@ func _state_cases() -> bool:
return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES])
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
% [small.size(), large.size(), heavy_kinds.size()])
# --- #218: a starting fleet meets one kind of enemy, however long it
# takes. Strength alone could not promise this - it counts the wave
# NUMBER too, so simply surviving used to unlock lancers and snipers
# against a fleet that had grown by nothing.
var patient: int = game_state.wave_number
for wave in [1, 5, 20, 60]:
game_state.wave_number = wave
for size in range(1, GameState.BASE_FLEET_SIZE + 1):
for id in game_state.wave_composition(size):
if id != "chaser":
game_state.wave_number = patient
return _fail("wave %d against a fleet of %d fields a %s" \
% [wave, size, id])
game_state.wave_number = patient
print("a fleet of %d or fewer meets chasers only, at every wave number" \
% GameState.BASE_FLEET_SIZE)
# And each vessel past the starting three opens exactly one more kind, so
# the ramp is progressive rather than a cliff.
var previous_kinds: int = 1
for size in range(GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 4):
var kinds_here: Dictionary = {}
for id in game_state.wave_composition(size):
kinds_here[id] = true
if kinds_here.size() < previous_kinds:
return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \
% [size, kinds_here.size(), previous_kinds])
previous_kinds = kinds_here.size()
if previous_kinds < 3:
return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds)
print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds)
game_state.reset_waves()
return false