diff --git a/PlanetSetups/ResearchLabSetup.tres b/PlanetSetups/ResearchLabSetup.tres index 1095e9c..24e84c4 100644 --- a/PlanetSetups/ResearchLabSetup.tres +++ b/PlanetSetups/ResearchLabSetup.tres @@ -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 diff --git a/PlanetSetups/gas_default.tres b/PlanetSetups/gas_default.tres index fa053cc..86256db 100644 --- a/PlanetSetups/gas_default.tres +++ b/PlanetSetups/gas_default.tres @@ -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 diff --git a/PlanetSetups/researtch_lab_garrison.tres b/PlanetSetups/researtch_lab_garrison.tres index 75a2dc9..20284ad 100644 --- a/PlanetSetups/researtch_lab_garrison.tres +++ b/PlanetSetups/researtch_lab_garrison.tres @@ -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 diff --git a/PlanetSetups/rocky_default.tres b/PlanetSetups/rocky_default.tres index 625ef8a..1ed2ef0 100644 --- a/PlanetSetups/rocky_default.tres +++ b/PlanetSetups/rocky_default.tres @@ -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 diff --git a/boss/BossEncounter.gd b/boss/BossEncounter.gd index 4cf4bde..1e65ee8 100644 --- a/boss/BossEncounter.gd +++ b/boss/BossEncounter.gd @@ -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 = "" diff --git a/core/GameState.gd b/core/GameState.gd index d819507..e63092d 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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 diff --git a/core/Quests.gd b/core/Quests.gd index 0715f27..844e646 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -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 diff --git a/surface/CaptureLabel.gd b/surface/CaptureLabel.gd index 0b84210..b972f9e 100644 --- a/surface/CaptureLabel.gd +++ b/surface/CaptureLabel.gd @@ -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 diff --git a/surface/CapturePoint.gd b/surface/CapturePoint.gd deleted file mode 100644 index 4767aa4..0000000 --- a/surface/CapturePoint.gd +++ /dev/null @@ -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) diff --git a/surface/CapturePoint.gd.uid b/surface/CapturePoint.gd.uid deleted file mode 100644 index 54f1d5f..0000000 --- a/surface/CapturePoint.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dwt256xl1txjo diff --git a/surface/CapturePoint.tscn b/surface/CapturePoint.tscn deleted file mode 100644 index 6a754c0..0000000 --- a/surface/CapturePoint.tscn +++ /dev/null @@ -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 diff --git a/surface/PlanetSetup.gd b/surface/PlanetSetup.gd index ca5a6a6..c03e718 100644 --- a/surface/PlanetSetup.gd +++ b/surface/PlanetSetup.gd @@ -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 diff --git a/surface/PlanetSetupEditor.gd b/surface/PlanetSetupEditor.gd index 41b2501..3b75f89 100644 --- a/surface/PlanetSetupEditor.gd +++ b/surface/PlanetSetupEditor.gd @@ -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/.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: diff --git a/surface/PlanetSetupHost.gd b/surface/PlanetSetupHost.gd index 3a90cc6..6e34dde 100644 --- a/surface/PlanetSetupHost.gd +++ b/surface/PlanetSetupHost.gd @@ -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) diff --git a/surface/PlanetSurfaceController.gd b/surface/PlanetSurfaceController.gd index 9a0ce19..6a4b359 100644 --- a/surface/PlanetSurfaceController.gd +++ b/surface/PlanetSurfaceController.gd @@ -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) diff --git a/tests/_capture_points_preview.gd b/tests/_capture_points_preview.gd deleted file mode 100644 index 70cb03c..0000000 --- a/tests/_capture_points_preview.gd +++ /dev/null @@ -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) diff --git a/tests/_capture_points_preview.gd.uid b/tests/_capture_points_preview.gd.uid deleted file mode 100644 index 3c81cd3..0000000 --- a/tests/_capture_points_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://njieetp3l0xc diff --git a/tests/_capture_points_preview.tscn b/tests/_capture_points_preview.tscn deleted file mode 100644 index 49c82d1..0000000 --- a/tests/_capture_points_preview.tscn +++ /dev/null @@ -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") diff --git a/tests/_setup_editor_preview.gd b/tests/_setup_editor_preview.gd index 6ec2c84..8948efa 100644 --- a/tests/_setup_editor_preview.gd +++ b/tests/_setup_editor_preview.gd @@ -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() diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd index 795e171..73c123a 100644 --- a/tests/test_artillery_enemy.gd +++ b/tests/test_artillery_enemy.gd @@ -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 diff --git a/tests/test_black_hole.gd b/tests/test_black_hole.gd index 4cacf50..1f4783f 100644 --- a/tests/test_black_hole.gd +++ b/tests/test_black_hole.gd @@ -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()) diff --git a/tests/test_boss_planet.gd b/tests/test_boss_planet.gd index 4ae90dc..e85e5c3 100644 --- a/tests/test_boss_planet.gd +++ b/tests/test_boss_planet.gd @@ -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: diff --git a/tests/test_capture_points.gd b/tests/test_capture_points.gd deleted file mode 100644 index 17d68c0..0000000 --- a/tests/test_capture_points.gd +++ /dev/null @@ -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 diff --git a/tests/test_capture_points.gd.uid b/tests/test_capture_points.gd.uid deleted file mode 100644 index e0c46e3..0000000 --- a/tests/test_capture_points.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cmordwxwbr5ca diff --git a/tests/test_conquered_planet_clear.gd b/tests/test_conquered_planet_clear.gd index dd28280..63a7d32 100644 --- a/tests/test_conquered_planet_clear.gd +++ b/tests/test_conquered_planet_clear.gd @@ -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) diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 0cf3223..ab6a40b 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -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() diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index b4c20fa..bb715ba 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -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]) diff --git a/tests/test_lab_research_reward.gd b/tests/test_lab_research_reward.gd index 82948bd..aacb0a9 100644 --- a/tests/test_lab_research_reward.gd +++ b/tests/test_lab_research_reward.gd @@ -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]) diff --git a/tests/test_planet_conquest_gate.gd b/tests/test_planet_conquest_gate.gd index a9f864b..91c0659 100644 --- a/tests/test_planet_conquest_gate.gd +++ b/tests/test_planet_conquest_gate.gd @@ -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: diff --git a/tests/test_planet_garrison.gd b/tests/test_planet_garrison.gd new file mode 100644 index 0000000..e642316 --- /dev/null +++ b/tests/test_planet_garrison.gd @@ -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 diff --git a/tests/test_planet_map_assignment.gd b/tests/test_planet_map_assignment.gd index c510555..29708aa 100644 --- a/tests/test_planet_map_assignment.gd +++ b/tests/test_planet_map_assignment.gd @@ -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") diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd index 1fb8dc8..435eeae 100644 --- a/tests/test_planet_setups.gd +++ b/tests/test_planet_setups.gd @@ -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() diff --git a/tests/test_research_lab_extractor_gate.gd b/tests/test_research_lab_extractor_gate.gd index 8ef3d09..751c5f8 100644 --- a/tests/test_research_lab_extractor_gate.gd +++ b/tests/test_research_lab_extractor_gate.gd @@ -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) diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd index 0981714..c88c393 100644 --- a/tests/test_research_points.gd +++ b/tests/test_research_points.gd @@ -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") diff --git a/tests/test_setup_editor.gd b/tests/test_setup_editor.gd index ff49512..05c82de 100644 --- a/tests/test_setup_editor.gd +++ b/tests/test_setup_editor.gd @@ -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/.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") diff --git a/tests/test_setup_editor_delete.gd b/tests/test_setup_editor_delete.gd index e779bb0..6cd0bd0 100644 --- a/tests/test_setup_editor_delete.gd +++ b/tests/test_setup_editor_delete.gd @@ -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". diff --git a/tests/test_setup_file_identity.gd b/tests/test_setup_file_identity.gd index 9d963ba..74fdaf4 100644 --- a/tests/test_setup_file_identity.gd +++ b/tests/test_setup_file_identity.gd @@ -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)): diff --git a/tests/test_surface_feature_depth.gd b/tests/test_surface_feature_depth.gd index c6f2af3..c9630ba 100644 --- a/tests/test_surface_feature_depth.gd +++ b/tests/test_surface_feature_depth.gd @@ -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 diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index 5cc3efa..ace2ab7 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -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 diff --git a/ui/PaletteEntryButton.gd b/ui/PaletteEntryButton.gd index 7d4fb4b..a0b3517 100644 --- a/ui/PaletteEntryButton.gd +++ b/ui/PaletteEntryButton.gd @@ -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 diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index db2178b..e22a80e 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -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 diff --git a/world/OctopusField.gd.uid b/world/OctopusField.gd.uid new file mode 100644 index 0000000..3e62004 --- /dev/null +++ b/world/OctopusField.gd.uid @@ -0,0 +1 @@ +uid://dmnl1gfg50tn7