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

@ -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