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

@ -38,6 +38,5 @@ script = ExtResource("2")
setup_id = "research_lab_1"
display_name = "Research Lab Garrison"
surface_profile_id = "research_lab"
capture_points = PackedVector2Array(-450.0, -60.0, 460.0, -40.0, 0.0, 480.0)
enemy_spawns = Array[Resource]([SubResource("Resource_ta1"), SubResource("Resource_ta2"), SubResource("Resource_ta3"), SubResource("Resource_ta4"), SubResource("Resource_ta5"), SubResource("Resource_ta6")])
research_point_reward = 1

View file

@ -46,6 +46,5 @@ script = ExtResource("1")
setup_id = "gas_default"
display_name = "Gas Default"
surface_profile_id = "gas"
capture_points = PackedVector2Array(600, -200, -550, -250)
enemy_spawns = Array[Resource]([SubResource("Resource_che8f"), SubResource("Resource_js1ws"), SubResource("Resource_y4at1"), SubResource("Resource_rosps"), SubResource("Resource_68rmi"), SubResource("Resource_yx160"), SubResource("Resource_clcbd"), SubResource("Resource_yk26s"), SubResource("Resource_7m420")])
research_point_reward = 1

View file

@ -41,6 +41,5 @@ script = ExtResource("2_oxc3b")
setup_id = "researtch_lab_garrison"
display_name = "Researtch Lab Garrison"
surface_profile_id = "research_lab"
capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480)
enemy_spawns = Array[Resource]([SubResource("Resource_p34gm"), SubResource("Resource_oxc3b"), SubResource("Resource_ujs3j"), SubResource("Resource_4h5ny"), SubResource("Resource_1djs2"), SubResource("Resource_isvqt"), SubResource("Resource_ff4yc")])
research_point_reward = 1

View file

@ -31,6 +31,5 @@ position = Vector2(-985.817, -129.31296)
script = ExtResource("1")
setup_id = "rocky_default"
display_name = "Rocky Default"
capture_points = PackedVector2Array(700, 100, -593.01917, 1.8398132)
enemy_spawns = Array[Resource]([SubResource("Resource_kkunv"), SubResource("Resource_gk6ea"), SubResource("Resource_ob70o"), SubResource("Resource_np188"), SubResource("Resource_ifyky"), SubResource("Resource_e10vg")])
research_point_reward = 1

View file

@ -21,10 +21,9 @@ var encounter_active: bool = false
func _ready() -> void:
# One persistent objective lets boss victory flow through the same conquest
# state as authored capture-point maps without pretending the boss is a
# configurable capture point.
game_state.register_capture_points(game_state.planet_id, 1,
# A garrison of one lets boss victory flow through the same conquest state
# as an authored map without pretending the boss is an ordinary defender.
game_state.register_planet_garrison(game_state.planet_id, 1,
game_state.planet_definition_id)
if game_state.is_planet_conquered(game_state.planet_id):
boss.suppress_as_defeated()
@ -151,7 +150,7 @@ func _on_boss_health_changed(current: int, maximum: int) -> void:
func _on_boss_defeated() -> void:
game_state.encounter_locks_movement = false
camera.offset = Vector2.ZERO
game_state.mark_point_captured(game_state.planet_id, 0)
game_state.mark_garrison_enemy_destroyed(game_state.planet_id)
health_bar.value = 0
status_label.text = "THE WARDEN IS DEAD · PLANET CONQUERED"
hint_label.text = ""

View file

@ -2817,31 +2817,40 @@ func _advance_distorder_drain(delta: float, boost_in_use: bool = true) -> void:
boost_depleted_notice = true
# Planet conquest: a planet must have every one of its capture points held
# before an extractor can be built there (see BuildController's placement
# gates). Keyed by the same stable planet_id as `extractors`, entirely
# separate from it - a planet can be conquered long before anyone builds an
# extractor on it, or never get one at all.
# Planet conquest: a planet is held once its garrison is dead, and only then
# can an extractor be built there (see BuildController's placement gates).
# Keyed by the same stable planet_id as `extractors`, entirely separate from
# it - a planet can be conquered long before anyone builds an extractor on
# it, or never get one at all.
#
# #211 replaced capture points - stand in a ring, wait out a timer, once per
# point - with the fight itself. Taking a planet is now exactly "kill what is
# defending it", which is what players were doing anyway before walking to
# the rings.
var planet_capture: Dictionary = {}
signal planet_conquered(planet_id: String)
# #212: carries the research points the conquest actually paid, so whatever
# announces it does not have to reconstruct the award from the definition and
# the setup's override and get a different answer.
signal planet_conquered(planet_id: String, research_points_awarded: int)
func register_capture_points(planet_key: String, total: int, definition_id: String = "",
reward_override: int = 0) -> void:
# Idempotent: landing again on a planet must not wipe points already
# captured on a previous visit.
if planet_capture.has(planet_key):
# Declares what is defending a planet. Called every time the surface is
# composed, because the garrison is re-placed every time the player lands:
# the count has to start again with the enemies it counts, or a planet left
# half-cleared would fall to a single kill on the next visit.
#
# A planet already conquered is left alone entirely - its garrison is dead
# and stays dead.
func register_planet_garrison(planet_key: String, enemy_count: int,
definition_id: String = "", reward_override: int = 0) -> void:
var existing: Dictionary = planet_capture.get(planet_key, {})
if bool(existing.get("conquered", false)):
return
var captured: Array = []
captured.resize(total)
captured.fill(false)
# A planet demanding zero points is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so
# this is the only registration it ever gets) - every planet WITH real
# points still starts unconquered, exactly as before. The planet definition
# supplies an optional conquest reward, avoiding planet-id special cases in
# the capture system.
# A planet defending itself with nothing is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so this
# is the only registration it ever gets). The planet definition supplies an
# optional conquest reward, avoiding planet-id special cases here.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
# A map may be worth more than its planet type's flat rate - a harder
@ -2849,63 +2858,64 @@ func register_capture_points(planet_key: String, total: int, definition_id: Stri
# is. Zero means the setup did not express an opinion.
if reward_override > 0:
point_reward = reward_override
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
"research_point_reward": point_reward, "research_awarded": false}
planet_capture[planet_key] = {
"remaining": enemy_count,
"total": enemy_count,
"conquered": enemy_count <= 0,
# Carried across landings: the reward is paid once, and a planet
# re-registered on a second visit must not become payable again.
"research_point_reward": point_reward,
"research_awarded": bool(existing.get("research_awarded", false)),
}
func capture_state(planet_key: String) -> Dictionary:
return planet_capture.get(planet_key, {"captured": [], "conquered": false})
return planet_capture.get(planet_key,
{"remaining": 0, "total": 0, "conquered": false})
func is_point_captured(planet_key: String, index: int) -> bool:
var captured: Array = capture_state(planet_key).get("captured", [])
if index < 0 or index >= captured.size():
return false
return bool(captured[index])
# How much of the garrison is still standing, and how big it was. Both are
# for the surface readout; conquest itself is decided below.
func garrison_remaining(planet_key: String) -> int:
return int(capture_state(planet_key).get("remaining", 0))
func mark_point_captured(planet_key: String, index: int) -> void:
func garrison_total(planet_key: String) -> int:
return int(capture_state(planet_key).get("total", 0))
# One defender down. Called from the enemy's own destroyed signal, so it
# counts kills by whatever means - a beam, a bomb, a ramming hull.
func mark_garrison_enemy_destroyed(planet_key: String) -> void:
if not planet_capture.has(planet_key):
return
var state: Dictionary = planet_capture[planet_key]
var captured: Array = state.captured
if index < 0 or index >= captured.size():
if bool(state.conquered):
return
captured[index] = true
if state.conquered:
return
if captured.all(func(c): return bool(c)):
state.remaining = maxi(int(state.get("remaining", 0)) - 1, 0)
if int(state.remaining) <= 0:
state.conquered = true
planet_conquered.emit(planet_key)
# #63: definition-authored research points, exactly once per planet.
# state.conquered flipping above already makes a second call to this
# whole function a no-op via the early return above it, but
# research_awarded is its own explicit guard on the award itself
# regardless of how conquest was reached.
var reward: int = int(state.get("research_point_reward", 0))
var awarded: int = 0
if reward > 0 and not bool(state.get("research_awarded", false)):
state.research_awarded = true
awarded = reward
research_points += reward
research_points_changed.emit(research_points)
# Emitted AFTER the award, so a listener that reports the reward is
# reporting one that has already been paid (#212).
planet_conquered.emit(planet_key, awarded)
func is_planet_conquered(planet_key: String) -> bool:
return bool(capture_state(planet_key).get("conquered", false))
func captured_point_count(planet_key: String) -> int:
var captured: Array = capture_state(planet_key).get("captured", [])
var count: int = 0
for c in captured:
if c:
count += 1
return count
func total_capture_points(planet_key: String) -> int:
return capture_state(planet_key).get("captured", []).size()
# --- waves (#100) ----------------------------------------------------------
#
# #97 replaces the roaming spawner - one enemy every few seconds, forever -
@ -2973,13 +2983,22 @@ func wave_strength(fleet_size: int) -> int:
# Spent most-expensive-first, so a large budget buys heavier enemies rather
# than an ever-larger swarm of chasers. min_strength keeps the openings
# honest: the first waves are chasers whatever the arithmetic says.
# than an ever-larger swarm of chasers.
#
# Two gates, not one. min_strength was meant to keep the openings honest, but
# strength counts the wave NUMBER as well, so a player who had grown nothing
# still met lancers and then snipers simply by surviving - #218's "the first
# waves are too complex".
#
# min_fleet is the gate that actually holds: an enemy type stays out of the
# roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE
# the player meets chasers and nothing else, however long they take, and each
# vessel past the starting three opens exactly one new kind of trouble.
const WAVE_ROSTER: Array = [
{"id": "artillery", "cost": 9, "min_strength": 26},
{"id": "sniper", "cost": 6, "min_strength": 16},
{"id": "lancer", "cost": 4, "min_strength": 12},
{"id": "chaser", "cost": 3, "min_strength": 0},
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0},
]
const WAVE_MAX_ENEMIES: int = 18
@ -2989,7 +3008,8 @@ func wave_composition(fleet_size: int) -> PackedStringArray:
var strength: int = budget
var out: PackedStringArray = PackedStringArray()
for entry in WAVE_ROSTER:
if strength < int(entry.min_strength):
if strength < int(entry.min_strength) \
or fleet_size < int(entry.get("min_fleet", 0)):
continue
var cost: int = int(entry.cost)
# The cheapest tier is last and takes whatever is left, so the budget

View file

@ -10,7 +10,7 @@ extends RefCounted
#
# 1. Nothing here may ask for something the player cannot yet reach. The
# sharp edge is RESEARCH POINTS - they come only from conquering a
# planet (GameState.mark_point_captured), so every node with a
# planet (GameState.mark_garrison_enemy_destroyed), so every node with a
# research_points cost sits after `conquest` below. Ore-only nodes -
# Shipyards, Logistics - may come before it.
# 2. A quest names ONE goal, and the brief says what it will take. Asking

View file

@ -1,9 +1,9 @@
extends Label
# Polls GameState directly, same convention as ResourceReadout, so it stays
# correct without anyone having to push updates into it. Hidden
# entirely when the current planet has no capture points (e.g. no setup
# resolved for it) rather than showing "0/0".
# correct without anyone having to push updates into it. Hidden entirely when
# the current planet has no garrison (e.g. no setup resolved for it) rather
# than showing "0/0".
# A direct script preload, not the bare "PlanetDefinitions" global class_name
# - the same load-order hazard PlanetSurfaceController's setup_host comment
@ -20,7 +20,7 @@ func _process(_delta: float) -> void:
visible = false
return
var planet_id: String = GameState.planet_id
var total: int = GameState.total_capture_points(planet_id)
var total: int = GameState.garrison_total(planet_id)
if total <= 0:
visible = false
return
@ -39,7 +39,8 @@ func _process(_delta: float) -> void:
if not _was_conquered:
_flash()
else:
text = "CAPTURED %d/%d" % [GameState.captured_point_count(planet_id), total]
# #211: what is left to fight, counted down, rather than rings held.
text = "GARRISON %d/%d" % [GameState.garrison_remaining(planet_id), total]
_was_conquered = conquered

View file

@ -1,121 +0,0 @@
class_name CapturePoint
extends Area2D
# One point on a landed planet's surface that must be held (fleet ship
# nearby, no enemy nearby) for capture_time seconds. Progress is polled by
# distance each frame rather than through physics signals - same idiom as
# RockField/GasVentField - since no collision response is needed, only
# proximity.
signal captured
enum State { NEUTRAL, BLOCKED, CAPTURING, CAPTURED }
@export var point_index: int = 0
@export var capture_radius: float = 220.0
# #177: holding an already-cleared point should resolve briskly. Eight seconds
# made every point feel like waiting after the fight was over.
@export var capture_time: float = 4.0
@export var enemy_clear_radius: float = 700.0
@export var drain_rate: float = 0.5
const NEUTRAL_COLOR: Color = Color(0.55, 0.85, 1.0)
const BLOCKED_COLOR: Color = Color(1.0, 0.3, 0.3)
const CAPTURING_COLOR: Color = Color(0.4, 1.0, 0.55)
const CAPTURED_COLOR: Color = Color(1.0, 0.85, 0.3)
const CORE_RADIUS: float = 60.0
var progress: float = 0.0
var state: int = State.NEUTRAL
func _ready() -> void:
# Behind the fleet and enemies, same convention as every other world
# object that must never occlude them (Planet.gd, Rock.gd, Building.gd,
# Hauler.gd, Transporter.gd, VesselDelivery.gd all set this in _ready()).
# "Is an enemy inside my capture radius" is the core read of this
# mechanic, so a ship or enemy sitting in the ring must stay fully visible.
z_index = -1
queue_redraw()
# Called by PlanetSetupHost when this point was already captured on a
# previous visit, so re-landing restores it instead of starting the fill
# over. Permanent for the run - _process() below returns early once
# CAPTURED and never re-evaluates it.
func mark_captured() -> void:
progress = 1.0
state = State.CAPTURED
queue_redraw()
func _process(delta: float) -> void:
if state == State.CAPTURED:
return
var enemy_nearby: bool = _enemy_nearby()
var ship_in_range: bool = _ship_in_range()
if enemy_nearby:
state = State.BLOCKED
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
elif ship_in_range:
state = State.CAPTURING
progress = minf(progress + delta / capture_time, 1.0)
if progress >= 1.0:
state = State.CAPTURED
GameState.mark_point_captured(GameState.planet_id, point_index)
captured.emit()
else:
state = State.NEUTRAL
progress = maxf(progress - drain_rate * delta / capture_time, 0.0)
queue_redraw()
func _ship_in_range() -> bool:
for ship in get_tree().get_nodes_in_group("player_ships"):
if is_instance_valid(ship) and global_position.distance_to(ship.global_position) <= capture_radius:
return true
return false
func _enemy_nearby() -> bool:
for enemy in get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(enemy) and global_position.distance_to(enemy.global_position) <= enemy_clear_radius:
return true
return false
func _state_color() -> Color:
match state:
State.BLOCKED:
return BLOCKED_COLOR
State.CAPTURING:
return CAPTURING_COLOR
State.CAPTURED:
return CAPTURED_COLOR
_:
return NEUTRAL_COLOR
func _draw() -> void:
var color: Color = _state_color()
# Outer ring: the actual capture radius, faint - tells the player how
# close they need to stay without dominating the screen.
_draw_ring(capture_radius, 0.0, TAU, color, 0.35)
# Inner beacon: fixed size regardless of capture_radius, so it reads
# clearly even when zoomed out. A filling arc sweeps clockwise from the
# top as progress advances, same "fill toward completion" idea as
# VesselGasBomberComponent's hull fill, adapted to a ring since a
# capture point has no hull.
_draw_ring(CORE_RADIUS, 0.0, TAU, color, 1.0)
if progress > 0.001:
var sweep: float = TAU * progress
_draw_ring(CORE_RADIUS, -PI / 2.0, -PI / 2.0 + sweep, color, 1.0, 10.0)
func _draw_ring(radius: float, from_angle: float, to_angle: float, color: Color, alpha: float,
core_width: float = 6.0) -> void:
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.16 * alpha), core_width * 2.6, true)
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(color, 0.5 * alpha), core_width, true)
draw_arc(Vector2.ZERO, radius, from_angle, to_angle, 64, Color(1.0, 0.96, 0.9, 0.9 * alpha), core_width * 0.3, true)

View file

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

View file

@ -1,8 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://surface/CapturePoint.gd" id="1"]
[node name="CapturePoint" type="Area2D"]
script = ExtResource("1")
monitoring = false
monitorable = false

View file

@ -1,7 +1,7 @@
class_name PlanetSetup
extends Resource
# A hand-authored capture-point/enemy layout for a planet surface. Chosen
# A hand-authored enemy layout for a planet surface. Chosen
# deterministically per planet (see PlanetSetups.setup_for_planet) rather
# than generated - #40 wants curated setups, not procedural ones - and
# stored as a committed .tres under res://PlanetSetups/, not in a save.
@ -11,13 +11,14 @@ extends Resource
# Which PlanetSurfaceProfiles id (e.g. "rocky", "gas") this setup was
# authored for; only setups matching a planet's own profile are eligible.
@export var surface_profile_id: String = "rocky"
# Surface-local positions (the fleet lands at Vector2.ZERO).
@export var capture_points: PackedVector2Array = PackedVector2Array()
# Surface-local positions (the fleet lands at Vector2.ZERO). #211 removed the
# capture_points that used to sit beside these: the garrison IS the objective
# now, so the enemies are the whole of what a setup places.
@export var enemy_spawns: Array[Resource] = []
# Research points awarded for conquering this map, set in the map editor.
# A harder lab is worth more, and how hard a lab IS depends on the layout
# authored here - how many capture points, how many and what kind of
# defenders - which the planet type cannot know. 0 means "use whatever the
# authored here - how many and what kind of defenders - which the planet
# type cannot know. 0 means "use whatever the
# planet type awards", so every setup written before this existed keeps its
# old value rather than silently dropping to nothing.
@export var research_point_reward: int = 0

View file

@ -1,7 +1,7 @@
extends Node2D
# Authoring tool for PlanetSetup resources (#40/#48): place/move/delete
# capture points and enemy spawns for a chosen surface profile, then save
# enemy spawns for a chosen surface profile, then save
# to res://PlanetSetups/<id>.tres so the game picks it up the same way it
# picks up every other authored setup. Deliberately does not touch
# GameState - this tool only edits committed resources, never gameplay
@ -38,7 +38,6 @@ const ORIGIN_COLOR: Color = Color(1.0, 0.9, 0.4)
# cross-class - a global class_name lookup at parse time needs the class
# cache already warm (see #42/#44's load-order notes), which a plain
# local const sidesteps entirely.
const CAPTURE_MARKER_COLOR: Color = Color(0.55, 0.85, 1.0)
const PALETTE_STEP: float = 92.0
@onready var camera: Camera2D = $Camera2D
@ -68,7 +67,11 @@ var current_setup: Resource
# saved. Saving writes HERE rather than to a path derived from setup_id - see
# PlanetSetups.path_for_id for why deriving it forks a duplicate.
var current_setup_path: String = ""
var selected_palette: String = "capture_point"
# #211 took the capture points out of the palette, so the opening tool is the
# first enemy kind. Resolved in _build_palette() rather than hardcoded here,
# so removing an enemy definition cannot leave the editor pointing at a tool
# that no longer exists.
var selected_palette: String = ""
var selected_item: Dictionary = {}
var _profile_ids: Array = []
var _profile_cursor: int = 0
@ -130,7 +133,7 @@ func new_setup(setup_id: String, display_name: String, profile_id: String) -> vo
current_setup.setup_id = setup_id
current_setup.display_name = display_name
current_setup.surface_profile_id = profile_id
# capture_points/enemy_spawns are left at PlanetSetup's own declared
# enemy_spawns is left at PlanetSetup's own declared
# defaults (already empty) rather than reassigned here - assigning a
# plain `[]` to enemy_spawns from outside PlanetSetup.gd's own file
# fails at runtime, since the compiler here only sees current_setup as
@ -220,34 +223,6 @@ func delete_setup(setup_id: String) -> Dictionary:
return {"ok": true, "message": "Deleted '%s'." % setup_id}
# --- model: capture points ---------------------------------------------
func add_capture_point(at: Vector2) -> int:
if current_setup == null:
return -1
var points: PackedVector2Array = current_setup.capture_points
points.append(at)
current_setup.capture_points = points
_rebuild_markers()
return points.size() - 1
func move_capture_point(index: int, at: Vector2) -> void:
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
return
current_setup.capture_points[index] = at
_rebuild_markers()
func remove_capture_point(index: int) -> void:
if current_setup == null or index < 0 or index >= current_setup.capture_points.size():
return
var points: PackedVector2Array = current_setup.capture_points
points.remove_at(index)
current_setup.capture_points = points
_rebuild_markers()
# --- model: enemy spawns ------------------------------------------------
func add_enemy(enemy_id: String, at: Vector2) -> int:
@ -300,24 +275,10 @@ func _rebuild_markers() -> void:
child.queue_free()
if current_setup == null:
return
for i in range(current_setup.capture_points.size()):
_add_capture_marker(i, current_setup.capture_points[i])
for i in range(current_setup.enemy_spawns.size()):
_add_enemy_marker(i, current_setup.enemy_spawns[i])
func _add_capture_marker(index: int, at: Vector2) -> void:
var is_selected: bool = selected_item.get("kind", "") == "capture" and selected_item.get("index", -1) == index
var color: Color = Color(1.0, 0.95, 0.9) if is_selected else CAPTURE_MARKER_COLOR
var marker: NeonPiece = NeonPiece.create(item_host, at, color)
var points: PackedVector2Array = PackedVector2Array()
for i in range(16):
var angle: float = float(i) / 16.0 * TAU
points.append(Vector2(cos(angle), sin(angle)) * 60.0)
marker.add_polygon(points, 0.6)
marker.queue_redraw()
func _add_enemy_marker(index: int, spawn: Resource) -> void:
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
var is_selected: bool = selected_item.get("kind", "") == "enemy" and selected_item.get("index", -1) == index
@ -335,16 +296,22 @@ func _add_enemy_marker(index: int, spawn: Resource) -> void:
# --- palette ---------------------------------------------------------------
func _build_palette() -> void:
var entries: Array = [{"id": "capture_point", "is_point": true, "definition": null}]
var entries: Array = []
for definition in ENEMY_DEFINITIONS.all():
entries.append({"id": definition.enemy_id, "is_point": false, "definition": definition})
entries.append({"id": definition.enemy_id, "definition": definition})
if entries.is_empty():
return
# The palette opens on its first tool. Before #211 that was the capture
# point, which was always present; an enemy list could in principle be
# reordered, so the default is read off the list itself.
if selected_palette == "" or not entries.any(func(e): return e.id == selected_palette):
selected_palette = String(entries[0].id)
var x: float = 0.0
for entry in entries:
var button: Control = PALETTE_ENTRY_BUTTON_SCRIPT.new()
button.position = Vector2(x, 0)
button.size = Vector2(84, 84)
button.is_capture_point = entry.is_point
button.enemy_definition = entry.definition
button.selected = entry.id == selected_palette
button.pressed.connect(_on_palette_pressed.bind(entry.id))
@ -360,8 +327,7 @@ func _on_palette_pressed(id: String) -> void:
func _refresh_palette_selection() -> void:
for button in _palette_buttons:
var entry_id: String = "capture_point" if button.is_capture_point else button.enemy_definition.enemy_id
button.selected = entry_id == selected_palette
button.selected = String(button.enemy_definition.enemy_id) == selected_palette
# --- UI handlers -------------------------------------------------------
@ -468,10 +434,7 @@ func _on_delete_pressed() -> void:
if selected_item.is_empty():
_set_status("Nothing selected to delete.")
return
if selected_item.kind == "capture":
remove_capture_point(selected_item.index)
else:
remove_enemy(selected_item.index)
remove_enemy(selected_item.index)
selected_item = {}
_refresh_selected_label()
@ -506,21 +469,15 @@ func _refresh_selected_label() -> void:
selected_label.text = ""
return
var index: int = int(selected_item.index)
if selected_item.kind == "capture":
selected_label.text = "Selected: Capture Point #%d" % (index + 1)
else:
var spawn: Resource = current_setup.enemy_spawns[index]
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1]
var spawn: Resource = current_setup.enemy_spawns[index]
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1]
# --- placement/selection helpers ----------------------------------------
func _place_selected(world_pos: Vector2) -> void:
if selected_palette == "capture_point":
add_capture_point(world_pos)
else:
add_enemy(selected_palette, world_pos)
add_enemy(selected_palette, world_pos)
func _find_marker_near(world_pos: Vector2) -> Dictionary:
@ -528,11 +485,6 @@ func _find_marker_near(world_pos: Vector2) -> Dictionary:
return {}
var best: Dictionary = {}
var best_dist: float = HIT_RADIUS
for i in range(current_setup.capture_points.size()):
var d: float = world_pos.distance_to(current_setup.capture_points[i])
if d <= best_dist:
best_dist = d
best = {"kind": "capture", "index": i}
for i in range(current_setup.enemy_spawns.size()):
var d: float = world_pos.distance_to(current_setup.enemy_spawns[i].position)
if d <= best_dist:
@ -544,10 +496,7 @@ func _find_marker_near(world_pos: Vector2) -> Dictionary:
func _move_selected(world_pos: Vector2) -> void:
if dragging_item.is_empty():
return
if dragging_item.kind == "capture":
move_capture_point(dragging_item.index, world_pos)
else:
move_enemy(dragging_item.index, world_pos)
move_enemy(dragging_item.index, world_pos)
func _screen_to_world(screen_pos: Vector2) -> Vector2:

View file

@ -1,12 +1,14 @@
class_name PlanetSetupHost
extends Node2D
# Instantiates the contents of a PlanetSetup onto a landed surface: one
# CapturePoint per authored point - registering/restoring conquest progress
# through GameState so re-landing on a partly-taken planet keeps its
# progress - and one enemy per authored spawn.
# Instantiates the contents of a PlanetSetup onto a landed surface: one enemy
# per authored spawn, registered with GameState as the planet's garrison so
# that killing the last of them takes the planet.
#
# #211 removed the capture points this used to place alongside them. Holding a
# ring for four seconds, once per ring, was a second task laid on top of the
# fight the player had already won; conquest is the fight now.
const CAPTURE_POINT_SCENE: PackedScene = preload("res://surface/CapturePoint.tscn")
const ENEMY_SCENE: PackedScene = preload("res://enemies/Enemy.tscn")
var setup: Resource
@ -19,42 +21,26 @@ func apply_setup(new_setup: Resource) -> void:
if setup == null:
return
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(),
GameState.planet_definition_id, int(setup.research_point_reward))
for i in range(setup.capture_points.size()):
_add_capture_point(i, setup.capture_points[i])
if _garrison_is_defeated():
# Registered with the count about to be placed, every landing. A planet
# already taken keeps that status and gets no garrison at all.
GameState.register_planet_garrison(GameState.planet_id,
setup.enemy_spawns.size(), GameState.planet_definition_id,
int(setup.research_point_reward))
if GameState.is_planet_conquered(GameState.planet_id):
return
for spawn in setup.enemy_spawns:
_add_enemy(spawn)
# A conquered planet stays conquered. Its garrison was destroyed to take it,
# so re-landing must not quietly restore it - the capture points already come
# back marked captured, and enemies standing on a planet the player owns read
# as the conquest having been undone.
#
# Guarded on there being capture points to take, not on the conquered flag
# alone: register_capture_points() treats a setup with none as vacuously
# conquered (see PlanetSurfaceController's note on unregistered planets), and
# a setup that authored enemies but no capture points is a garrison nobody
# can ever have beaten. Suppressing those would delete them on first sight.
func _garrison_is_defeated() -> bool:
return not setup.capture_points.is_empty() \
and GameState.is_planet_conquered(GameState.planet_id)
func _add_capture_point(index: int, at: Vector2) -> void:
var point: Node = CAPTURE_POINT_SCENE.instantiate()
point.point_index = index
point.position = at
add_child(point)
if GameState.is_point_captured(GameState.planet_id, index):
point.mark_captured()
func _add_enemy(spawn: Resource) -> void:
var enemy: Node = ENEMY_SCENE.instantiate()
enemy.enemy_id = spawn.enemy_id
enemy.position = spawn.position
# The planet is captured by the count reaching zero, so the tally is bound
# to the enemy's own death rather than to whatever killed it. Bound to the
# id captured NOW: the player may have left the surface by the time a
# straggler dies, and the planet that was being fought over is this one.
var planet_key: String = GameState.planet_id
enemy.destroyed.connect(func():
GameState.mark_garrison_enemy_destroyed(planet_key))
add_child(enemy)

View file

@ -4,6 +4,9 @@ const SURFACE_PROFILES = preload("res://surface/PlanetSurfaceProfiles.gd")
const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd")
const PLANET_SETUPS = preload("res://surface/PlanetSetups.gd")
const SETUP_HOST_SCENE: PackedScene = preload("res://surface/PlanetSetupHost.tscn")
# The crew's own log, the same voice the prologue uses for the things that
# happen TO them rather than the things they are told (#212).
const CONQUEST_SPEAKER: String = "KESTREL - LOG"
# Bootstraps the surviving fleet, including each vessel type, on this scene's
# own startup (from GameState, since this is a fresh scene tree - nothing here
@ -81,9 +84,16 @@ func _compose_surface(profile: Resource) -> void:
# capture_state()'s fallback for an unregistered planet_id defaults
# to conquered=false (see test_planet_conquest_gate.gd's own "a
# fresh planet reported conquered" check), which would otherwise
# permanently block extractor placement here - register_capture_
# points() treats a total of 0 as vacuously already conquered.
GameState.register_capture_points(GameState.planet_id, 0)
# permanently block extractor placement here - a garrison of zero is
# vacuously already beaten.
GameState.register_planet_garrison(GameState.planet_id, 0)
# #212: conquest is otherwise announced only by a caption changing and a
# HUD number the player has no reason to be watching at that moment. The
# surface already carries a narration banner, so the moment gets the same
# treatment every other turn of the story does.
if not GameState.planet_conquered.is_connected(_on_planet_conquered):
GameState.planet_conquered.connect(_on_planet_conquered)
if profile.encounter_scene != null:
var encounter: Node = profile.encounter_scene.instantiate()
@ -105,3 +115,19 @@ func _on_exit_pressed() -> void:
func _return_to_space() -> void:
get_tree().change_scene_to_file("res://world/Main.tscn")
# The banner is a sibling under UI and may not exist in a stripped-down test
# harness, so this is fetched rather than held and is allowed to be missing.
func _on_planet_conquered(planet_id: String, research_points_awarded: int) -> void:
if planet_id != GameState.planet_id:
return
var banner: Node = get_node_or_null("../UI/NarrationBanner")
if banner == null:
return
banner.say("The last of the garrison is down. This world is ours.",
CONQUEST_SPEAKER)
# Said even when it is none, because "this one paid nothing" is the
# information a player deciding where to go next actually needs.
banner.say("Research points recovered: %d." % research_points_awarded,
CONQUEST_SPEAKER)

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

View file

@ -1,22 +1,19 @@
class_name PaletteEntryButton
extends Control
# One selectable tile in the setup editor's palette: either the generic
# capture-point marker, or one enemy definition's own hull/colour. Drawn
# directly from the definition rather than a fixed icon set, so a new
# EnemyDefinitions entry shows up automatically with no editor code change.
# One selectable tile in the setup editor's palette: one enemy definition's
# own hull and colour. Drawn directly from the definition rather than a fixed
# icon set, so a new EnemyDefinitions entry shows up automatically with no
# editor code change. #211 removed the capture-point tile that used to sit
# alongside them.
signal pressed
@export var is_capture_point: bool = false
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@export var enemy_definition: Resource
@export var selected: bool = false: set = set_selected
const RING_COLOR: Color = Color(0.55, 0.85, 1.0)
func set_selected(value: bool) -> void:
selected = value
queue_redraw()
@ -44,13 +41,7 @@ func _draw() -> void:
var frame_color: Color = Color(1.0, 0.95, 0.9) if selected else Color(0.4, 0.45, 0.5)
NeonDraw.draw_glowing_polygon(self, frame, frame_color, 0.85 if selected else 0.5)
if is_capture_point:
var points: PackedVector2Array = PackedVector2Array()
for i in range(16):
var angle: float = float(i) / 16.0 * TAU
points.append(center + Vector2(cos(angle), sin(angle)) * (size.x * 0.28))
NeonDraw.draw_glowing_polygon(self, points, RING_COLOR, 0.8)
elif enemy_definition != null:
if enemy_definition != null:
# Same routine as the marker and the enemy, so the button previews
# what will actually be placed.
var scale_factor: float = (size.x * 0.5) / 28.0 * 0.6

View file

@ -498,6 +498,13 @@ func _complete_whiteout() -> void:
return
whiteout_teleported = true
fleet.teleport_to(DEFAULT_SPAWN)
# #209: the convoy charted its way north for the whole prologue, and none
# of that map is anywhere near where the breach drops the survivors. Left
# standing it is a map of somewhere else, and it also handed the player
# "chart 30 sectors" already finished the moment the quest was offered.
# After the teleport, so a chunk recorded at the old position on this same
# frame goes with it.
GameState.explored_chunks.clear()
camera.global_position = fleet.get_centroid()
camera.reset_physics_interpolation()
camera.offset = Vector2.ZERO

View file

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