From c4e2ee717806d3de761c27d2c5dd02ba1aaddde2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:22:10 +0200 Subject: [PATCH] Add capture points and the planet conquest loop (#43) CapturePoint tracks neutral/blocked/capturing/captured state by polling proximity each frame (a ship in capture_radius, no enemy within enemy_clear_radius), draining gradually rather than resetting when interrupted, and calling GameState.mark_point_captured() on completion. PlanetSetupHost now instantiates the real CapturePoint scene per authored point (restoring already-captured state immediately on re-landing) and spawns each setup's authored enemies, replacing the placeholder markers from #42. CaptureLabel is a small self-polling HUD (same convention as ResourceCounter) showing "CAPTURED n/m" or "CONQUERED" with a one-shot fade on completion, themed per surface profile alongside the existing bank/fleet captions. test_planet_atmosphere_transition.gd's "no enemies on a landed surface" assumption no longer holds now that setups can place guard enemies by design - narrowed the check to what it actually needs to prove: no space-side EnemySpawner/AsteroidField node leaks across the scene swap. Part of the #40 conquest-mechanic breakdown. --- CaptureLabel.gd | 32 ++++ CaptureLabel.gd.uid | 1 + CapturePoint.gd | 113 +++++++++++ CapturePoint.gd.uid | 1 + CapturePoint.tscn | 8 + GameState.gd | 4 + PlanetSetupHost.gd | 43 +++-- PlanetSurface.tscn | 17 +- PlanetSurfaceController.gd | 2 +- tests/_capture_points_preview.gd | 54 ++++++ tests/_capture_points_preview.gd.uid | 1 + tests/_capture_points_preview.tscn | 6 + tests/test_capture_points.gd | 210 +++++++++++++++++++++ tests/test_capture_points.gd.uid | 1 + tests/test_planet_atmosphere_transition.gd | 25 ++- tests/test_planet_setups.gd | 25 ++- 16 files changed, 509 insertions(+), 34 deletions(-) create mode 100644 CaptureLabel.gd create mode 100644 CaptureLabel.gd.uid create mode 100644 CapturePoint.gd create mode 100644 CapturePoint.gd.uid create mode 100644 CapturePoint.tscn create mode 100644 tests/_capture_points_preview.gd create mode 100644 tests/_capture_points_preview.gd.uid create mode 100644 tests/_capture_points_preview.tscn create mode 100644 tests/test_capture_points.gd create mode 100644 tests/test_capture_points.gd.uid diff --git a/CaptureLabel.gd b/CaptureLabel.gd new file mode 100644 index 0000000..1c6bd07 --- /dev/null +++ b/CaptureLabel.gd @@ -0,0 +1,32 @@ +extends Label + +# Polls GameState directly, same convention as ResourceCounter/GasCounter, +# 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". + +var _was_conquered: bool = false + + +func _process(_delta: float) -> void: + var planet_id: String = GameState.planet_id + var total: int = GameState.total_capture_points(planet_id) + if total <= 0: + visible = false + return + visible = true + + var conquered: bool = GameState.is_planet_conquered(planet_id) + if conquered: + text = "CONQUERED" + if not _was_conquered: + _flash() + else: + text = "CAPTURED %d/%d" % [GameState.captured_point_count(planet_id), total] + _was_conquered = conquered + + +func _flash() -> void: + modulate.a = 0.0 + var tween: Tween = create_tween() + tween.tween_property(self, "modulate:a", 1.0, 1.2) diff --git a/CaptureLabel.gd.uid b/CaptureLabel.gd.uid new file mode 100644 index 0000000..9f9afa6 --- /dev/null +++ b/CaptureLabel.gd.uid @@ -0,0 +1 @@ +uid://dxuwfa5j7oh80 diff --git a/CapturePoint.gd b/CapturePoint.gd new file mode 100644 index 0000000..eb1f447 --- /dev/null +++ b/CapturePoint.gd @@ -0,0 +1,113 @@ +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 +@export var capture_time: float = 8.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: + 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/CapturePoint.gd.uid b/CapturePoint.gd.uid new file mode 100644 index 0000000..54f1d5f --- /dev/null +++ b/CapturePoint.gd.uid @@ -0,0 +1 @@ +uid://dwt256xl1txjo diff --git a/CapturePoint.tscn b/CapturePoint.tscn new file mode 100644 index 0000000..39dbe89 --- /dev/null +++ b/CapturePoint.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://CapturePoint.gd" id="1"] + +[node name="CapturePoint" type="Area2D"] +script = ExtResource("1") +monitoring = false +monitorable = false diff --git a/GameState.gd b/GameState.gd index 5487299..0516b14 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1107,6 +1107,10 @@ func captured_point_count(planet_key: String) -> int: return count +func total_capture_points(planet_key: String) -> int: + return capture_state(planet_key).get("captured", []).size() + + func _process(delta: float) -> void: # Runs in the autoload, so mining and transport continue while the player # is on a planet's surface or nowhere near either. diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index f2df9c5..1b79167 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -1,14 +1,13 @@ class_name PlanetSetupHost extends Node2D -# Instantiates the contents of a PlanetSetup onto a landed surface. Capture -# points are placeholder markers here; #43 replaces them with the real -# CapturePoint scene through the same apply_setup() entry point, and enemy -# spawning is filled in by the enemy-foundation ticket (#44). Keeping this -# seam stable is the point of this ticket. +# 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. -const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0) -const MARKER_RADIUS: float = 36.0 +const CAPTURE_POINT_SCENE: PackedScene = preload("res://CapturePoint.tscn") +const ENEMY_SCENE: PackedScene = preload("res://Enemy.tscn") var setup: Resource @@ -19,15 +18,25 @@ func apply_setup(new_setup: Resource) -> void: child.queue_free() if setup == null: return - for point in setup.capture_points: - _add_capture_point_marker(point) + + GameState.register_capture_points(GameState.planet_id, setup.capture_points.size()) + for i in range(setup.capture_points.size()): + _add_capture_point(i, setup.capture_points[i]) + for spawn in setup.enemy_spawns: + _add_enemy(spawn) -func _add_capture_point_marker(at: Vector2) -> void: - var marker: NeonPiece = NeonPiece.create(self, at, MARKER_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)) * MARKER_RADIUS) - marker.add_polygon(points, 0.6) - marker.queue_redraw() +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 + add_child(enemy) diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index c9d7a26..82626a5 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=3] +[gd_scene load_steps=17 format=3] [ext_resource type="Script" path="res://GameController.gd" id="1"] [ext_resource type="Script" path="res://LunarBackground.gd" id="2"] @@ -12,6 +12,7 @@ [ext_resource type="Script" path="res://CargoCounter.gd" id="12"] [ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"] [ext_resource type="Script" path="res://GasBombController.gd" id="14"] +[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("3") @@ -132,6 +133,20 @@ theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1) theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85) theme_override_constants/outline_size = 5 +[node name="CaptureLabel" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 24 +offset_top = 286 +offset_right = 696 +offset_bottom = 326 +theme_override_font_sizes/font_size = 28 +theme_override_colors/font_color = Color(0.08, 0.16, 0.2, 1) +theme_override_colors/font_outline_color = Color(0.9, 0.95, 0.96, 0.8) +theme_override_constants/outline_size = 4 +horizontal_alignment = 1 +text = "" +script = ExtResource("15") + [node name="ExitButton" type="Control" parent="UI"] anchor_top = 1.0 anchor_bottom = 1.0 diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 7569796..c984c65 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -44,7 +44,7 @@ func _compose_surface(profile: Resource) -> void: atmosphere.set_shader_parameter("base_color", profile.base_color) atmosphere.set_shader_parameter("crater_color", profile.crater_color) atmosphere.set_shader_parameter("highlight_color", profile.highlight_color) - for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]: + for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]: var caption: Label = get_node(caption_path) caption.add_theme_color_override("font_color", profile.caption_color) caption.add_theme_color_override("font_outline_color", profile.caption_outline_color) diff --git a/tests/_capture_points_preview.gd b/tests/_capture_points_preview.gd new file mode 100644 index 0000000..29a3ddf --- /dev/null +++ b/tests/_capture_points_preview.gd @@ -0,0 +1,54 @@ +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://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://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. + var layout: Array = [Vector2(300, 150), Vector2(-300, 150), Vector2(0, -280)] + 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 new file mode 100644 index 0000000..3c81cd3 --- /dev/null +++ b/tests/_capture_points_preview.gd.uid @@ -0,0 +1 @@ +uid://njieetp3l0xc diff --git a/tests/_capture_points_preview.tscn b/tests/_capture_points_preview.tscn new file mode 100644 index 0000000..49c82d1 --- /dev/null +++ b/tests/_capture_points_preview.tscn @@ -0,0 +1,6 @@ +[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/test_capture_points.gd b/tests/test_capture_points.gd new file mode 100644 index 0000000..1f281b7 --- /dev/null +++ b/tests/test_capture_points.gd @@ -0,0 +1,210 @@ +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://CapturePoint.gd") + PLANET_SETUP_SCRIPT = load("res://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://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://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://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://CapturePoint.tscn").instantiate() + point.point_index = 0 + 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") + + 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 new file mode 100644 index 0000000..e0c46e3 --- /dev/null +++ b/tests/test_capture_points.gd.uid @@ -0,0 +1 @@ +uid://cmordwxwbr5ca diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index 84da9cb..109bfa2 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -6,10 +6,14 @@ extends SceneTree # scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place # overlay: standing on a planet shows the prompt, pressing it fades out # and swaps to PlanetSurface.tscn with the SAME surviving ship count, -# where no asteroid/enemy ever exists (not hidden - structurally absent, -# since PlanetSurface.tscn has no such nodes at all) and the fleet is -# still fully flyable. Exiting fades back to Main.tscn with the fleet -# restored at the world position it left from. +# where no asteroid ever exists and no space-side EnemySpawner/AsteroidField +# node carries over (structurally absent - PlanetSurface.tscn has no such +# nodes at all), and the fleet is still fully flyable. Landed enemies MAY +# still exist post-#43 (PlanetSetupHost places authored ones to guard +# capture points), which is a different thing from the space-side spawner +# leaking across the swap - see the structural check below. Exiting fades +# back to Main.tscn with the fleet restored at the world position it left +# from. var main_scene: Node var fleet: Node @@ -163,11 +167,16 @@ func _process(delta: float) -> bool: var ship_count_ok: bool = surface_fleet.ships.size() == 5 var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty() - var no_enemies: bool = get_nodes_in_group("enemies").is_empty() - print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [ - ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies + # Landed enemies from an authored PlanetSetup are expected + # post-#43 (they guard capture points) - what this test actually + # owes is that the SPACE-SIDE spawner nodes never carry across, + # checked structurally rather than by assuming zero enemies. + var no_space_spawners: bool = current_scene.get_node_or_null("EnemySpawner") == null \ + and current_scene.get_node_or_null("AsteroidField") == null + print("ship_count_ok=%s (count=%d) no_asteroids=%s no_space_spawners=%s" % [ + ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_space_spawners ]) - if not (ship_count_ok and composition_ok and no_asteroids and no_enemies): + if not (ship_count_ok and composition_ok and no_asteroids and no_space_spawners): print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)") quit(1) return true diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd index 9a735e7..723be8b 100644 --- a/tests/test_planet_setups.gd +++ b/tests/test_planet_setups.gd @@ -12,6 +12,15 @@ extends SceneTree 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. +var PLANET_SETUP_SCRIPT: Script +var CAPTURE_POINT_SCRIPT: Script + var game_state: Node var rocky_setup: Resource var stage: int = 0 @@ -20,6 +29,8 @@ var pending_surface: Node func _initialize() -> void: + PLANET_SETUP_SCRIPT = load("res://PlanetSetup.gd") + CAPTURE_POINT_SCRIPT = load("res://CapturePoint.gd") # --- 1. determinism and profile filtering --- rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") if rocky_setup == null: @@ -42,7 +53,7 @@ func _initialize() -> void: # --- 2. dropping a new .tres is picked up by rescan() --- var before_count: int = PlanetSetups.setups_for_profile("rocky").size() - var scratch: Resource = PlanetSetup.new() + var scratch: Resource = PLANET_SETUP_SCRIPT.new() scratch.setup_id = "_test_scratch_setup" scratch.display_name = "Scratch" scratch.surface_profile_id = "rocky" @@ -89,14 +100,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 marker_count: int = 0 + var point_count: int = 0 for child in host.get_children(): - if child is NeonPiece: - marker_count += 1 - if marker_count != rocky_setup.capture_points.size(): + if child.get_script() == CAPTURE_POINT_SCRIPT: + point_count += 1 + if point_count != rocky_setup.capture_points.size(): pending_surface.free() - return _fail("host placed %d markers for %d capture points" % [ - marker_count, rocky_setup.capture_points.size() + return _fail("host placed %d capture points for %d authored ones" % [ + point_count, rocky_setup.capture_points.size() ]) pending_surface.free()