From a1ad6cc844d32210fc798b6365c7ff2d0670ce64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:41:19 +0200 Subject: [PATCH 1/5] Add planet conquest state and gate extractor placement (#41) Planets now need every capture point held before an extractor can go up there: GameState tracks per-planet capture progress keyed by the same stable planet_id as extractors, and BuildController's four extractor placement checks (menu enable, status text, info popup, confirm) all re-check conquest, not just proximity/duplication. Part of the #40 conquest-mechanic breakdown. --- BuildController.gd | 18 ++- GameState.gd | 60 ++++++++++ tests/test_extractor_building.gd | 6 + tests/test_planet_conquest_gate.gd | 157 +++++++++++++++++++++++++ tests/test_planet_conquest_gate.gd.uid | 1 + 5 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 tests/test_planet_conquest_gate.gd create mode 100644 tests/test_planet_conquest_gate.gd.uid diff --git a/BuildController.gd b/BuildController.gd index f316929..02fb933 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -543,10 +543,13 @@ func _refresh_build_status() -> void: # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. - if _planet_under_fleet() != null: - build_status.text = "Over %s - an extractor can be built here" % _planet_under_fleet().planet_name - else: + var planet: Node2D = _planet_under_fleet() + if planet == null: build_status.text = "" + elif not GameState.is_planet_conquered(planet.planet_id): + build_status.text = "Over %s - capture it before building an extractor" % planet.planet_name + else: + build_status.text = "Over %s - an extractor can be built here" % planet.planet_name func _refresh_build_option_states() -> void: @@ -554,7 +557,8 @@ func _refresh_build_option_states() -> void: var definition: Resource = entry[4] var enabled: bool = GameState.is_building_unlocked(definition.definition_id) if definition.placement_mode == "planet": - enabled = enabled and _planet_under_fleet() != null + var planet: Node2D = _planet_under_fleet() + enabled = enabled and planet != null and GameState.is_planet_conquered(planet.planet_id) entry[1].enabled = enabled entry[1].queue_redraw() @@ -593,6 +597,9 @@ func _show_building_info(type: String) -> void: if planet == null: lines.append("No planet in reach - fly over one.") can_build = false + elif not GameState.is_planet_conquered(planet.planet_id): + lines.append("%s is not captured yet - take all its capture points." % planet.planet_name) + can_build = false elif GameState.has_extractor_at(planet.planet_id): lines.append("%s already has an extractor." % planet.planet_name) can_build = false @@ -639,6 +646,9 @@ func _confirm_build() -> void: if planet == null or GameState.has_extractor_at(planet.planet_id): build_status.text = "No planet in reach any more" return + if not GameState.is_planet_conquered(planet.planet_id): + build_status.text = "%s is not captured yet" % planet.planet_name + return if not GameState.spend(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return diff --git a/GameState.gd b/GameState.gd index 3854f56..0f2a3db 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1020,6 +1020,66 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: return inventory +# 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. +var planet_capture: Dictionary = {} + +signal planet_conquered(planet_id: String) + + +func register_capture_points(planet_key: String, total: int) -> void: + # Idempotent: landing again on a planet must not wipe points already + # captured on a previous visit. + if planet_capture.has(planet_key): + return + var captured: Array = [] + captured.resize(total) + captured.fill(false) + planet_capture[planet_key] = {"captured": captured, "conquered": false} + + +func capture_state(planet_key: String) -> Dictionary: + return planet_capture.get(planet_key, {"captured": [], "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]) + + +func mark_point_captured(planet_key: String, index: int) -> 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(): + return + captured[index] = true + if state.conquered: + return + if captured.all(func(c): return bool(c)): + state.conquered = true + planet_conquered.emit(planet_key) + + +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 _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/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 0e52c21..656c2c5 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -42,6 +42,12 @@ func _initialize() -> void: game_state.rocks = 200 game_state.buildings.clear() game_state.extractors.clear() + game_state.planet_capture.clear() + # 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) # 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_planet_conquest_gate.gd b/tests/test_planet_conquest_gate.gd new file mode 100644 index 0000000..93b69ba --- /dev/null +++ b/tests/test_planet_conquest_gate.gd @@ -0,0 +1,157 @@ +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 +# 3. over an unconquered planet, the extractor build option is disabled, +# the info popup explains why, and confirming spends nothing and builds +# nothing +# 4. once conquered, the same build flow works and charges the cost + +var main_scene: Node +var fleet: Node +var game_state: Node +var controller: Node +var button: IconButton +var popup: Control +var target_chunk: Vector2i +var stage: int = 0 +var elapsed: float = 0.0 +var conquered_signal_count: int = 0 + + +func _initialize() -> void: + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + target_chunk = chunk + found = true + break + if found: + break + if not found: + return _fail("no planet chunk to test with") + + game_state = root.get_node("GameState") + game_state.rocks = 200 + game_state.buildings.clear() + game_state.extractors.clear() + game_state.planet_capture.clear() + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) + + var target_planet_id: String = "chunk:%d:%d" % [target_chunk.x, target_chunk.y] + + # 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) + 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) + 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) + if conquered_signal_count != 1: + return _fail("planet_conquered fired %d times, expected exactly 1" % conquered_signal_count) + + # Reset conquest for the scene-level gate check below - this run tests + # the unconquered path first, then conquers it. + game_state.planet_capture.clear() + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + controller = main_scene.get_node("BuildController") + button = main_scene.get_node("UI/BuildMenu/ExtractorButton") + popup = main_scene.get_node("UI/BuildMenu/InfoPopup") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + main_scene.get_node("AsteroidField").set_process(false) + main_scene.get_node("EnemySpawner").set_process(false) + var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 + for ship in fleet.ships: + ship.position = near + fleet.move_to(near) + elapsed = 0.0 + stage = 1 + return false + 1: + var planet_field: Node = main_scene.get_node("PlanetField") + if elapsed < planet_field.check_interval + 0.2: + return false + if not planet_field.populated_chunks.has(target_chunk): + return _fail("planet never spawned") + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + for ship in fleet.ships: + ship.position = planet.position + fleet.move_to(planet.position) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + var planet_field: Node = main_scene.get_node("PlanetField") + var planet: Node2D = planet_field.populated_chunks[target_chunk][0] + + controller._on_build_pressed() + if button.enabled: + return _fail("extractor option enabled over an unconquered planet") + controller._show_building_info(GameState.BUILDING_EXTRACTOR) + var explains_capture: bool = popup.visible \ + and "captur" in popup.get_node("Panel/MessageLabel").text.to_lower() + var offered: bool = popup.get_node("Panel/BuildButton").visible + if not explains_capture or offered: + return _fail("unconquered popup did not explain itself, or offered a build") + var rocks_before: int = game_state.rocks + controller._confirm_build() # must do nothing even if somehow pressed + if game_state.rocks != rocks_before or not game_state.extractors.is_empty(): + return _fail("build proceeded over an unconquered planet") + 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) + + controller._on_build_pressed() + if not button.enabled: + return _fail("extractor option stayed disabled after conquest") + controller._show_building_info(GameState.BUILDING_EXTRACTOR) + if not popup.get_node("Panel/BuildButton").visible: + return _fail("conquered planet did not offer a build") + controller._confirm_build() + var built: bool = game_state.has_extractor_at(planet.planet_id) + var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST + if not built or not charged: + return _fail("conquered planet did not build/charge correctly") + + print("TEST_PLANET_CONQUEST_GATE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PLANET_CONQUEST_GATE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_planet_conquest_gate.gd.uid b/tests/test_planet_conquest_gate.gd.uid new file mode 100644 index 0000000..236fedc --- /dev/null +++ b/tests/test_planet_conquest_gate.gd.uid @@ -0,0 +1 @@ +uid://k4l7u3r84jch From 5326852807655dc692269521cc298b5f4e2c025d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:41:37 +0200 Subject: [PATCH 2/5] Add planet setups as authored resources (#42) PlanetSetup/PlanetSetupSpawn define a curated capture-point/enemy layout for a surface profile; PlanetSetups scans res://PlanetSetups/ at first use (not a hardcoded preload table) so dropping in a new .tres is the only step needed to add one, and resolves the same setup for the same planet deterministically, same pattern as WorldGen.planet_data_at_chunk. PlanetSurfaceController composes a PlanetSetupHost onto the landed surface alongside the existing resource-provider feature scene. Ships two starter setups (rocky_default, gas_default) with placeholder capture-point markers; #43 replaces the markers with the real CapturePoint scene through the same apply_setup() entry point. Part of the #40 conquest-mechanic breakdown. --- PlanetSetup.gd | 16 ++++ PlanetSetup.gd.uid | 1 + PlanetSetupHost.gd | 33 +++++++ PlanetSetupHost.gd.uid | 1 + PlanetSetupHost.tscn | 6 ++ PlanetSetupSpawn.gd | 7 ++ PlanetSetupSpawn.gd.uid | 1 + PlanetSetups.gd | 69 +++++++++++++++ PlanetSetups.gd.uid | 1 + PlanetSetups/gas_default.tres | 27 ++++++ PlanetSetups/rocky_default.tres | 22 +++++ PlanetSurfaceController.gd | 21 ++++- tests/_setup_markers_preview.gd | 12 +++ tests/_setup_markers_preview.gd.uid | 1 + tests/_setup_markers_preview.tscn | 6 ++ tests/test_planet_setups.gd | 129 ++++++++++++++++++++++++++++ tests/test_planet_setups.gd.uid | 1 + 17 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 PlanetSetup.gd create mode 100644 PlanetSetup.gd.uid create mode 100644 PlanetSetupHost.gd create mode 100644 PlanetSetupHost.gd.uid create mode 100644 PlanetSetupHost.tscn create mode 100644 PlanetSetupSpawn.gd create mode 100644 PlanetSetupSpawn.gd.uid create mode 100644 PlanetSetups.gd create mode 100644 PlanetSetups.gd.uid create mode 100644 PlanetSetups/gas_default.tres create mode 100644 PlanetSetups/rocky_default.tres create mode 100644 tests/_setup_markers_preview.gd create mode 100644 tests/_setup_markers_preview.gd.uid create mode 100644 tests/_setup_markers_preview.tscn create mode 100644 tests/test_planet_setups.gd create mode 100644 tests/test_planet_setups.gd.uid diff --git a/PlanetSetup.gd b/PlanetSetup.gd new file mode 100644 index 0000000..fd81af1 --- /dev/null +++ b/PlanetSetup.gd @@ -0,0 +1,16 @@ +class_name PlanetSetup +extends Resource + +# A hand-authored capture-point/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. + +@export var setup_id: String = "" +@export var display_name: String = "" +# 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() +@export var enemy_spawns: Array[Resource] = [] diff --git a/PlanetSetup.gd.uid b/PlanetSetup.gd.uid new file mode 100644 index 0000000..8e9aef7 --- /dev/null +++ b/PlanetSetup.gd.uid @@ -0,0 +1 @@ +uid://d1govxko60n03 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd new file mode 100644 index 0000000..f2df9c5 --- /dev/null +++ b/PlanetSetupHost.gd @@ -0,0 +1,33 @@ +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. + +const MARKER_COLOR: Color = Color(0.55, 0.85, 1.0) +const MARKER_RADIUS: float = 36.0 + +var setup: Resource + + +func apply_setup(new_setup: Resource) -> void: + setup = new_setup + for child in get_children(): + child.queue_free() + if setup == null: + return + for point in setup.capture_points: + _add_capture_point_marker(point) + + +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() diff --git a/PlanetSetupHost.gd.uid b/PlanetSetupHost.gd.uid new file mode 100644 index 0000000..f65f628 --- /dev/null +++ b/PlanetSetupHost.gd.uid @@ -0,0 +1 @@ +uid://ckxmhx54fi5s1 diff --git a/PlanetSetupHost.tscn b/PlanetSetupHost.tscn new file mode 100644 index 0000000..f87cc83 --- /dev/null +++ b/PlanetSetupHost.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://PlanetSetupHost.gd" id="1"] + +[node name="PlanetSetupHost" type="Node2D"] +script = ExtResource("1") diff --git a/PlanetSetupSpawn.gd b/PlanetSetupSpawn.gd new file mode 100644 index 0000000..5918b8a --- /dev/null +++ b/PlanetSetupSpawn.gd @@ -0,0 +1,7 @@ +class_name PlanetSetupSpawn +extends Resource + +# One authored enemy placement within a PlanetSetup. + +@export var enemy_id: String = "chaser" +@export var position: Vector2 = Vector2.ZERO diff --git a/PlanetSetupSpawn.gd.uid b/PlanetSetupSpawn.gd.uid new file mode 100644 index 0000000..29438be --- /dev/null +++ b/PlanetSetupSpawn.gd.uid @@ -0,0 +1 @@ +uid://c8spn4nio735d diff --git a/PlanetSetups.gd b/PlanetSetups.gd new file mode 100644 index 0000000..81105a4 --- /dev/null +++ b/PlanetSetups.gd @@ -0,0 +1,69 @@ +class_name PlanetSetups +extends RefCounted + +# Registry of authored PlanetSetup resources. Deliberately scans +# res://PlanetSetups/ at first use instead of a hardcoded preload +# dictionary (contrast PlanetDefinitions.gd), so dropping a new .tres in +# that folder is the only step needed to add a setup - the map editor +# (#48) writes there directly. + +const SETUPS_DIR: String = "res://PlanetSetups" +# Compared against get_script() rather than an `is PlanetSetup` check - the +# same load-order hazard as PlanetSurfaceController's setup_host: a global +# class_name identifier can be looked up before an import pass has +# registered it, while a direct script preload never depends on that cache. +const PLANET_SETUP_SCRIPT: Script = preload("res://PlanetSetup.gd") + +static var _all: Array = [] +static var _scanned: bool = false + + +static func _ensure_scanned() -> void: + if _scanned: + return + _scanned = true + var dir: DirAccess = DirAccess.open(SETUPS_DIR) + if dir == null: + return + dir.list_dir_begin() + var file_name: String = dir.get_next() + while file_name != "": + if not dir.current_is_dir() and file_name.ends_with(".tres"): + var res: Resource = load(SETUPS_DIR + "/" + file_name) + if res != null and res.get_script() == PLANET_SETUP_SCRIPT: + _all.append(res) + file_name = dir.get_next() + dir.list_dir_end() + + +static func rescan() -> void: + # The map editor calls this after saving so a freshly written setup is + # visible without restarting the process. + _all = [] + _scanned = false + _ensure_scanned() + + +static func all() -> Array: + _ensure_scanned() + return _all + + +static func setups_for_profile(profile_id: String) -> Array: + var result: Array = [] + for setup in all(): + if setup.surface_profile_id == profile_id: + result.append(setup) + return result + + +static func setup_for_planet(planet_id: String, profile_id: String) -> Resource: + # Same pattern as WorldGen.planet_data_at_chunk: seeded purely from the + # planet's own stable id, so the same planet always resolves to the + # same setup from anywhere, with nothing stored. + var candidates: Array = setups_for_profile(profile_id) + if candidates.is_empty(): + return null + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = hash(planet_id) + return candidates[rng.randi() % candidates.size()] diff --git a/PlanetSetups.gd.uid b/PlanetSetups.gd.uid new file mode 100644 index 0000000..403182c --- /dev/null +++ b/PlanetSetups.gd.uid @@ -0,0 +1 @@ +uid://c5ndxvompklip diff --git a/PlanetSetups/gas_default.tres b/PlanetSetups/gas_default.tres new file mode 100644 index 0000000..bafb1de --- /dev/null +++ b/PlanetSetups/gas_default.tres @@ -0,0 +1,27 @@ +[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] + +[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] + +[sub_resource type="Resource" id="Resource_g1"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(500, -300) + +[sub_resource type="Resource" id="Resource_g2"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(-450, 400) + +[sub_resource type="Resource" id="Resource_g3"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(200, 700) + +[resource] +script = ExtResource("1") +setup_id = "gas_default" +display_name = "Gas Default" +surface_profile_id = "gas" +capture_points = PackedVector2Array(600, -200, -550, -250, 0, 650, -700, 450) +enemy_spawns = Array[Resource]([SubResource("Resource_g1"), SubResource("Resource_g2"), SubResource("Resource_g3")]) diff --git a/PlanetSetups/rocky_default.tres b/PlanetSetups/rocky_default.tres new file mode 100644 index 0000000..cd4767b --- /dev/null +++ b/PlanetSetups/rocky_default.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="PlanetSetup" load_steps=4 format=3] + +[ext_resource type="Script" path="res://PlanetSetup.gd" id="1"] +[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="2"] + +[sub_resource type="Resource" id="Resource_r1"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(450, 250) + +[sub_resource type="Resource" id="Resource_r2"] +script = ExtResource("2") +enemy_id = "chaser" +position = Vector2(-600, -350) + +[resource] +script = ExtResource("1") +setup_id = "rocky_default" +display_name = "Rocky Default" +surface_profile_id = "rocky" +capture_points = PackedVector2Array(700, 100, -650, 300, 50, -750) +enemy_spawns = Array[Resource]([SubResource("Resource_r1"), SubResource("Resource_r2")]) diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 294f5f1..7569796 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -2,6 +2,8 @@ extends Node2D const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd") const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +const PLANET_SETUPS = preload("res://PlanetSetups.gd") +const SETUP_HOST_SCENE: PackedScene = preload("res://PlanetSetupHost.tscn") # 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 @@ -46,10 +48,21 @@ func _compose_surface(profile: Resource) -> void: 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) - if profile.features_scene == null: - return - var features: Node = profile.features_scene.instantiate() - feature_host.add_child(features) + if profile.features_scene != null: + var features: Node = profile.features_scene.instantiate() + feature_host.add_child(features) + + var setup: Resource = PLANET_SETUPS.setup_for_planet(GameState.planet_id, profile.profile_id) + if setup != null: + # Untyped on purpose: a static PlanetSetupHost annotation here would + # need the global class_name cache to already know about it, which + # is only true after an editor/import pass has run (see #31/#35's + # load-order notes) - a plain Node reference plus duck-typed + # apply_setup() sidesteps that entirely, same as BuildingDefinitions + # and VesselDefinitions already do for their own resource types. + var setup_host: Node = SETUP_HOST_SCENE.instantiate() + feature_host.add_child(setup_host) + setup_host.apply_setup(setup) func _on_exit_pressed() -> void: diff --git a/tests/_setup_markers_preview.gd b/tests/_setup_markers_preview.gd new file mode 100644 index 0000000..8069d45 --- /dev/null +++ b/tests/_setup_markers_preview.gd @@ -0,0 +1,12 @@ +extends Node2D + + +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) diff --git a/tests/_setup_markers_preview.gd.uid b/tests/_setup_markers_preview.gd.uid new file mode 100644 index 0000000..8df3b02 --- /dev/null +++ b/tests/_setup_markers_preview.gd.uid @@ -0,0 +1 @@ +uid://dtpta85omp8cm diff --git a/tests/_setup_markers_preview.tscn b/tests/_setup_markers_preview.tscn new file mode 100644 index 0000000..99ed228 --- /dev/null +++ b/tests/_setup_markers_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_setup_markers_preview.gd" id="1"] + +[node name="SetupMarkersPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_planet_setups.gd b/tests/test_planet_setups.gd new file mode 100644 index 0000000..9a735e7 --- /dev/null +++ b/tests/test_planet_setups.gd @@ -0,0 +1,129 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_setups.gd +# Verifies: +# 1. setup_for_planet is deterministic per planet_id and only returns +# setups matching the requested surface profile +# 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 + +const SCRATCH_SETUP_PATH: String = "res://PlanetSetups/_test_scratch_setup.tres" + +var game_state: Node +var rocky_setup: Resource +var stage: int = 0 +var elapsed: float = 0.0 +var pending_surface: Node + + +func _initialize() -> void: + # --- 1. determinism and profile filtering --- + rocky_setup = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") + if rocky_setup == null: + return _fail("no rocky setup resolved for a rocky planet id") + if rocky_setup.surface_profile_id != "rocky": + return _fail("resolved setup does not match the requested profile") + var rocky_setup_again: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "rocky") + if rocky_setup != rocky_setup_again: + return _fail("setup_for_planet was not deterministic for the same planet id") + + var gas_setup: Resource = PlanetSetups.setup_for_planet("chunk:3:7", "gas") + if gas_setup == null: + return _fail("no gas setup resolved for the same planet id under the gas profile") + if gas_setup.surface_profile_id != "gas": + return _fail("gas request returned a mismatched-profile setup") + + for setup in PlanetSetups.setups_for_profile("rocky"): + if setup.surface_profile_id != "rocky": + return _fail("setups_for_profile('rocky') leaked a non-rocky setup") + + # --- 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() + 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) + PlanetSetups.rescan() + var after_count: int = PlanetSetups.setups_for_profile("rocky").size() + DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH)) + var import_file: String = SCRATCH_SETUP_PATH + ".import" + if FileAccess.file_exists(import_file): + DirAccess.remove_absolute(ProjectSettings.globalize_path(import_file)) + PlanetSetups.rescan() + if after_count != before_count + 1: + return _fail("registry did not pick up a newly saved setup (before=%d after=%d)" % [ + before_count, after_count + ]) + + # --- 3. landing composes a PlanetSetupHost with the resolved setup --- + # Deferred to _process(): a scene instantiated and added to the tree + # inside _initialize() has not had its _ready() chain flushed yet (this + # runs before the engine's first frame), so children added during + # PlanetSurfaceController._ready() are not observable until a frame has + # actually processed - see test_gas_system.gd for the same pattern. + game_state = root.get_node("GameState") + game_state.planet_name = "Setup Test" + game_state.planet_id = "chunk:3:7" + game_state.planet_definition_id = "rocky" + pending_surface = load("res://PlanetSurface.tscn").instantiate() + root.add_child(pending_surface) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + var host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") + if host == null: + pending_surface.free() + return _fail("landing did not compose a PlanetSetupHost") + if host.setup != rocky_setup: + pending_surface.free() + return _fail("host did not receive the deterministically resolved setup") + var marker_count: int = 0 + for child in host.get_children(): + if child is NeonPiece: + marker_count += 1 + if marker_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() + ]) + pending_surface.free() + + game_state.planet_definition_id = "gas_giant" + pending_surface = load("res://PlanetSurface.tscn").instantiate() + root.add_child(pending_surface) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.1: + return false + var gas_host: Node = pending_surface.get_node_or_null("SurfaceFeatures/PlanetSetupHost") + if gas_host == null or gas_host.setup.surface_profile_id != "gas": + pending_surface.free() + return _fail("gas landing did not compose a matching-profile setup host") + pending_surface.free() + + print("TEST_PLANET_SETUPS: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PLANET_SETUPS: FAIL (%s)" % message) + if FileAccess.file_exists(SCRATCH_SETUP_PATH): + DirAccess.remove_absolute(ProjectSettings.globalize_path(SCRATCH_SETUP_PATH)) + quit(1) + return true diff --git a/tests/test_planet_setups.gd.uid b/tests/test_planet_setups.gd.uid new file mode 100644 index 0000000..0f9cd41 --- /dev/null +++ b/tests/test_planet_setups.gd.uid @@ -0,0 +1 @@ +uid://d2ekw3y7ipgy2 From f14ee656ead0581a5ae3805ff72c9689e6452e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:43:29 +0200 Subject: [PATCH 3/5] Add enemy foundation: definitions, components, ranged damage (#44) Enemy.gd now resolves stats/hull/color from EnemyDefinition resources and composes behaviour from capability components (EnemyPursuitComponent holds today's chase-the-nearest-ship logic), the same shape Ship.gd already uses for vessels. Fleet lookups go through a "player_fleet" group instead of a hardcoded ../Fleet sibling path, so an enemy parented anywhere (e.g. under a planet surface's SurfaceFeatures) still finds it. Also closes a real gap: enemies previously had no way to damage the fleet at range at all (Ship only reacted to physical contact, Projectile.gd was player-only). Ship.take_damage() is now a proper entry point - contact damage routes through it unchanged - and EnemyProjectile lets an enemy component fire at player_ships. Both ranged enemy archetypes in #45/#46 build on this. Part of the #40 conquest-mechanic breakdown. --- ChaserEnemyDefinition.tres | 16 ++++ Enemy.gd | 76 ++++++++++--------- EnemyDefinition.gd | 19 +++++ EnemyDefinition.gd.uid | 1 + EnemyDefinitions.gd | 19 +++++ EnemyDefinitions.gd.uid | 1 + EnemyProjectile.gd | 45 +++++++++++ EnemyProjectile.gd.uid | 1 + EnemyProjectile.tscn | 12 +++ EnemyPursuitComponent.gd | 38 ++++++++++ EnemyPursuitComponent.gd.uid | 1 + EnemyPursuitComponent.tscn | 6 ++ Fleet.gd | 4 + Ship.gd | 22 ++++-- tests/_gas_bomb_aim_preview.gd | 2 +- tests/test_enemy_composition.gd | 78 +++++++++++++++++++ tests/test_enemy_composition.gd.uid | 1 + tests/test_enemy_projectile_damage.gd | 91 +++++++++++++++++++++++ tests/test_enemy_projectile_damage.gd.uid | 1 + tests/test_gas_bomber.gd | 7 +- 20 files changed, 396 insertions(+), 45 deletions(-) create mode 100644 ChaserEnemyDefinition.tres create mode 100644 EnemyDefinition.gd create mode 100644 EnemyDefinition.gd.uid create mode 100644 EnemyDefinitions.gd create mode 100644 EnemyDefinitions.gd.uid create mode 100644 EnemyProjectile.gd create mode 100644 EnemyProjectile.gd.uid create mode 100644 EnemyProjectile.tscn create mode 100644 EnemyPursuitComponent.gd create mode 100644 EnemyPursuitComponent.gd.uid create mode 100644 EnemyPursuitComponent.tscn create mode 100644 tests/test_enemy_composition.gd create mode 100644 tests/test_enemy_composition.gd.uid create mode 100644 tests/test_enemy_projectile_damage.gd create mode 100644 tests/test_enemy_projectile_damage.gd.uid diff --git a/ChaserEnemyDefinition.tres b/ChaserEnemyDefinition.tres new file mode 100644 index 0000000..90f4385 --- /dev/null +++ b/ChaserEnemyDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "chaser" +display_name = "Chaser" +max_hp = 3 +speed = 140.0 +acceleration = 260.0 +rotation_speed = 6.0 +hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0) +glow_color = Color(1, 0.15, 0.2, 1) +component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/Enemy.gd b/Enemy.gd index 5f1ee45..5d5e796 100644 --- a/Enemy.gd +++ b/Enemy.gd @@ -2,50 +2,53 @@ extends Area2D signal destroyed -@export var speed: float = 140.0 -@export var acceleration: float = 260.0 -@export var rotation_speed: float = 6.0 -@export var max_hp: int = 3 +const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd") -var velocity: Vector2 = Vector2.ZERO +@export var enemy_id: String = "chaser" + +var definition: Resource var hp: int -@onready var fleet: Node2D = get_node("../Fleet") - +# Mirrors Ship.gd's own max_speed/acceleration: copied from the definition +# once, then mutable, so a test/preview can pin an enemy in place (e.g. +# `enemy.speed = 0.0`) without needing to know about definitions at all. +var speed: float +var acceleration: float +var rotation_speed: float +var max_hp: int func _ready() -> void: - hp = max_hp add_to_group("enemies") + definition = ENEMY_DEFINITIONS.get_definition(enemy_id) + speed = definition.speed + acceleration = definition.acceleration + rotation_speed = definition.rotation_speed + max_hp = definition.max_hp + hp = max_hp + for component_scene in definition.component_scenes: + var component: Node = component_scene.instantiate() + add_child(component) + if component.has_method("setup"): + component.setup() queue_redraw() +func get_component(capability: String) -> Node: + for child in get_children(): + if child.get("capability_id") == capability: + return child + return null + + +func has_capability(capability: String) -> bool: + return get_component(capability) != null + + func _physics_process(delta: float) -> void: - var target_pos: Vector2 = _nearest_ship_position() - var to_target: Vector2 = target_pos - position - - if to_target.length() > 1.0: - var desired_velocity: Vector2 = to_target.normalized() * speed - velocity = velocity.move_toward(desired_velocity, acceleration * delta) - position += velocity * delta - - if velocity.length() > 5.0: - var target_angle: float = velocity.angle() + PI / 2.0 - rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) - - -func _nearest_ship_position() -> Vector2: - if fleet.ships.is_empty(): - return position - - var nearest: Node2D = fleet.ships[0] - var best_dist: float = position.distance_to(nearest.position) - for ship in fleet.ships: - var d: float = position.distance_to(ship.position) - if d < best_dist: - best_dist = d - nearest = ship - return nearest.position + for child in get_children(): + if child.has_method("tick"): + child.tick(delta) func take_damage(amount: int) -> void: @@ -57,8 +60,9 @@ func take_damage(amount: int) -> void: func _draw() -> void: - var points: PackedVector2Array = PackedVector2Array([ - Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0) + var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), ]) + var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2) # Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw. - NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0) + NeonDraw.draw_glowing_polygon(self, points, glow, 1.0) diff --git a/EnemyDefinition.gd b/EnemyDefinition.gd new file mode 100644 index 0000000..bb0927b --- /dev/null +++ b/EnemyDefinition.gd @@ -0,0 +1,19 @@ +class_name EnemyDefinition +extends Resource + +# Data-driven enemy stats and appearance, mirroring VesselDefinition's role +# for player ships. Enemy.gd instantiates component_scenes exactly like +# Ship.gd does, so behaviour (pursuit, ranged fire, ...) lives in capability +# components rather than in a type switch here. + +@export var enemy_id: String = "chaser" +@export var display_name: String = "Chaser" +@export var max_hp: int = 3 +@export var speed: float = 140.0 +@export var acceleration: float = 260.0 +@export var rotation_speed: float = 6.0 +@export var hull_points: PackedVector2Array = PackedVector2Array([ + Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0), +]) +@export var glow_color: Color = Color(1.0, 0.15, 0.2) +@export var component_scenes: Array[PackedScene] = [] diff --git a/EnemyDefinition.gd.uid b/EnemyDefinition.gd.uid new file mode 100644 index 0000000..884a36d --- /dev/null +++ b/EnemyDefinition.gd.uid @@ -0,0 +1 @@ +uid://bkgt6o1m12l0g diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd new file mode 100644 index 0000000..fa910b5 --- /dev/null +++ b/EnemyDefinitions.gd @@ -0,0 +1,19 @@ +class_name EnemyDefinitions +extends RefCounted + +const DEFAULT_ID: String = "chaser" +const ORDER: PackedStringArray = ["chaser"] +const DEFINITIONS: Dictionary = { + "chaser": preload("res://ChaserEnemyDefinition.tres"), +} + + +static func get_definition(enemy_id: String) -> Resource: + return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID]) + + +static func all() -> Array: + var result: Array = [] + for enemy_id in ORDER: + result.append(DEFINITIONS[enemy_id]) + return result diff --git a/EnemyDefinitions.gd.uid b/EnemyDefinitions.gd.uid new file mode 100644 index 0000000..736702a --- /dev/null +++ b/EnemyDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cfc4w3atpf8yj diff --git a/EnemyProjectile.gd b/EnemyProjectile.gd new file mode 100644 index 0000000..88d012d --- /dev/null +++ b/EnemyProjectile.gd @@ -0,0 +1,45 @@ +extends Area2D + +# Mirrors Projectile.gd, but travels the other direction: fired by an enemy +# component (see EnemyRangedWeaponComponent, #45) and damages player_ships. +# This is the half of ranged combat that never existed before #44 - enemies +# could previously only hurt the fleet by physical contact. + +@export var speed: float = 260.0 +@export var lifetime: float = 3.0 +@export var damage: int = 1 +@export var bolt_length: float = 20.0 +@export var color: Color = Color(1.0, 0.4, 0.35) + +var velocity: Vector2 = Vector2.ZERO +var age: float = 0.0 + + +func _ready() -> void: + add_to_group("enemy_projectiles") + area_entered.connect(_on_area_entered) + queue_redraw() + + +func launch(direction: Vector2) -> void: + velocity = direction.normalized() * speed + rotation = direction.angle() + PI / 2.0 + + +func _physics_process(delta: float) -> void: + position += velocity * delta + age += delta + if age >= lifetime: + queue_free() + + +func _on_area_entered(area: Area2D) -> void: + if area.is_in_group("player_ships"): + area.take_damage(damage) + queue_free() + + +func _draw() -> void: + var tip: Vector2 = Vector2(0, -bolt_length * 0.6) + var tail: Vector2 = Vector2(0, bolt_length * 0.4) + NeonDraw.draw_glowing_line(self, tail, tip, color, 1.0, 0.5) diff --git a/EnemyProjectile.gd.uid b/EnemyProjectile.gd.uid new file mode 100644 index 0000000..e5c290a --- /dev/null +++ b/EnemyProjectile.gd.uid @@ -0,0 +1 @@ +uid://b4hja3oj3rdrc diff --git a/EnemyProjectile.tscn b/EnemyProjectile.tscn new file mode 100644 index 0000000..798768c --- /dev/null +++ b/EnemyProjectile.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyProjectile.gd" id="1"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 4.0 + +[node name="EnemyProjectile" type="Area2D"] +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("1") diff --git a/EnemyPursuitComponent.gd b/EnemyPursuitComponent.gd new file mode 100644 index 0000000..661f432 --- /dev/null +++ b/EnemyPursuitComponent.gd @@ -0,0 +1,38 @@ +class_name EnemyPursuitComponent +extends Node + +# Chases the nearest player ship. Moved out of Enemy.gd verbatim so the +# behaviour is opt-in per definition rather than hardcoded into every enemy. + +var capability_id: String = "pursuit" +var velocity: Vector2 = Vector2.ZERO + + +func tick(delta: float) -> void: + var enemy: Node2D = get_parent() + var target_pos: Vector2 = _nearest_ship_position(enemy) + var to_target: Vector2 = target_pos - enemy.position + + if to_target.length() > 1.0: + var desired_velocity: Vector2 = to_target.normalized() * enemy.speed + velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) + enemy.position += velocity * delta + + if velocity.length() > 5.0: + var target_angle: float = velocity.angle() + PI / 2.0 + enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta) + + +func _nearest_ship_position(enemy: Node2D) -> Vector2: + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return enemy.position + + var nearest: Node2D = fleet.ships[0] + var best_dist: float = enemy.position.distance_to(nearest.position) + for ship in fleet.ships: + var d: float = enemy.position.distance_to(ship.position) + if d < best_dist: + best_dist = d + nearest = ship + return nearest.position diff --git a/EnemyPursuitComponent.gd.uid b/EnemyPursuitComponent.gd.uid new file mode 100644 index 0000000..30c1640 --- /dev/null +++ b/EnemyPursuitComponent.gd.uid @@ -0,0 +1 @@ +uid://n8wohkrnl42e diff --git a/EnemyPursuitComponent.tscn b/EnemyPursuitComponent.tscn new file mode 100644 index 0000000..8b68d67 --- /dev/null +++ b/EnemyPursuitComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"] + +[node name="EnemyPursuitComponent" type="Node"] +script = ExtResource("1") diff --git a/Fleet.gd b/Fleet.gd index 949c0ac..ba32666 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -32,6 +32,10 @@ var steer_direction: Vector2 = Vector2.ZERO func _ready() -> void: + # Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling + # path - enemies parented under different hosts (e.g. a planet surface's + # SurfaceFeatures) need this to find their target. + add_to_group("player_fleet") # Researching a vessel upgrade improves the ships already flying, not # just the next one built (#5). GameState.vessel_upgraded.connect(_on_vessel_upgraded) diff --git a/Ship.gd b/Ship.gd index 1c2727c..cdaa542 100644 --- a/Ship.gd +++ b/Ship.gd @@ -115,17 +115,27 @@ func _physics_process(delta: float) -> void: rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) -func _on_area_entered(area: Area2D) -> void: - if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): - return +func take_damage(_amount: int) -> bool: + # Ships have no hit points - any unabsorbed hit is lethal. A shield + # charge absorbs a hit entirely regardless of its amount, matching + # VesselShieldComponent's existing all-or-nothing semantics; `_amount` + # is accepted (not `int`-clamped away) so callers like EnemyProjectile + # have a normal damage parameter to pass even though it isn't graded here. for child in get_children(): if child.has_method("absorb_hit") and child.absorb_hit(): - if area.is_in_group("asteroids") and is_instance_valid(area): - area.queue_free() - return + return true ShipExplosionVFX.spawn(get_tree().root, position) destroyed.emit() queue_free() + return false + + +func _on_area_entered(area: Area2D) -> void: + if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): + return + var absorbed: bool = take_damage(1) + if absorbed and area.is_in_group("asteroids") and is_instance_valid(area): + area.queue_free() func _draw() -> void: diff --git a/tests/_gas_bomb_aim_preview.gd b/tests/_gas_bomb_aim_preview.gd index 8a38362..cb1922c 100644 --- a/tests/_gas_bomb_aim_preview.gd +++ b/tests/_gas_bomb_aim_preview.gd @@ -39,5 +39,5 @@ func _ready() -> void: for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]: var enemy: Node = load("res://Enemy.tscn").instantiate() enemy.position = skill.aim_target + offset - enemy.speed = 0.0 main.add_child(enemy) + enemy.speed = 0.0 # after add_child - _ready() sets it from the definition diff --git a/tests/test_enemy_composition.gd b/tests/test_enemy_composition.gd new file mode 100644 index 0000000..a7660d3 --- /dev/null +++ b/tests/test_enemy_composition.gd @@ -0,0 +1,78 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_composition.gd +# Verifies: +# 1. an Enemy resolves its stats/hull/color from EnemyDefinitions, not +# hardcoded fields +# 2. an enemy instantiated under a node that is NOT a sibling of Fleet +# still finds the fleet and pursues it (the old `../Fleet` path would +# break here - this is exactly the planet-surface hazard #44 exists +# to close before #45/#46 land) + +var main_scene: Node +var fleet: Node +var nested_host: Node2D +var enemy: Node +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + var chaser_def: Resource = EnemyDefinitions.get_definition("chaser") + if chaser_def.enemy_id != "chaser" or chaser_def.max_hp != 3: + return _fail("EnemyDefinitions did not resolve the chaser definition") + + main_scene = load("res://Main.tscn").instantiate() + root.add_child(main_scene) + fleet = main_scene.get_node("Fleet") + main_scene.get_node("EnemySpawner").set_process(false) + + # Deliberately NOT a sibling of Fleet - two levels deep under an + # unrelated node, the way a planet surface's SurfaceFeatures parents + # things. + nested_host = Node2D.new() + nested_host.name = "NestedHost" + main_scene.add_child(nested_host) + var inner: Node2D = Node2D.new() + inner.name = "Inner" + nested_host.add_child(inner) + + enemy = load("res://Enemy.tscn").instantiate() + enemy.position = fleet.get_centroid() + Vector2(600, 0) + inner.add_child(enemy) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + if enemy.definition == null or enemy.definition.enemy_id != "chaser": + return _fail("enemy did not resolve its definition on _ready") + if enemy.hp != enemy.definition.max_hp: + return _fail("enemy hp did not come from the definition") + if not enemy.has_capability("pursuit"): + return _fail("chaser did not compose a pursuit component") + elapsed = 0.0 + stage = 1 + return false + 1: + # Give the pursuit component a few physics ticks to close distance. + if elapsed < 1.0: + return false + var distance: float = enemy.position.distance_to(fleet.get_centroid()) + print("distance_after_1s=%.1f (started at 600)" % distance) + if distance >= 550.0: + return _fail("enemy under a non-sibling host never approached the fleet") + print("TEST_ENEMY_COMPOSITION: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_COMPOSITION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_composition.gd.uid b/tests/test_enemy_composition.gd.uid new file mode 100644 index 0000000..002eb3d --- /dev/null +++ b/tests/test_enemy_composition.gd.uid @@ -0,0 +1 @@ +uid://dltu3a0n318lo diff --git a/tests/test_enemy_projectile_damage.gd b/tests/test_enemy_projectile_damage.gd new file mode 100644 index 0000000..a72fb36 --- /dev/null +++ b/tests/test_enemy_projectile_damage.gd @@ -0,0 +1,91 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_projectile_damage.gd +# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an +# unshielded ship on contact, and is absorbed by a shielded ship, removing +# exactly one charge rather than destroying it. + +var game_state: Node +var scene: Node +var fleet: Node +var interceptor: Node +var shield_ship: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 + game_state.reset_progression() + game_state.rocks = 2000 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + + +func _fire_at(ship: Node) -> void: + var projectile: Area2D = load("res://EnemyProjectile.tscn").instantiate() + projectile.global_position = ship.global_position + projectile.launch(Vector2.ZERO) # already on top of the target + scene.add_child(projectile) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.spawn_ships(1, Vector2.ZERO) + interceptor = fleet.ships[0] + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[1] + shield_ship.position = Vector2(2000.0, 0.0) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + if not shield_ship.has_shield(): + return _fail("a fresh shield vessel has no shield") + _fire_at(interceptor) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + if is_instance_valid(interceptor): + return _fail("an unshielded ship survived an enemy projectile") + print("unshielded interceptor destroyed by an enemy projectile") + + _fire_at(shield_ship) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + if not is_instance_valid(shield_ship): + return _fail("a shielded ship was destroyed by an enemy projectile") + if shield_ship.shield_charges != 0: + return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" % + shield_ship.shield_charges) + print("shielded ship absorbed the hit and lost exactly one charge") + print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_projectile_damage.gd.uid b/tests/test_enemy_projectile_damage.gd.uid new file mode 100644 index 0000000..592713a --- /dev/null +++ b/tests/test_enemy_projectile_damage.gd.uid @@ -0,0 +1 @@ +uid://cx7f3e82jkumb diff --git a/tests/test_gas_bomber.gd b/tests/test_gas_bomber.gd index 8bd1210..cda2a2f 100644 --- a/tests/test_gas_bomber.gd +++ b/tests/test_gas_bomber.gd @@ -107,16 +107,19 @@ func _process(delta: float) -> bool: if main.get_node("Camera2D").zoom.x >= original_zoom: return _fail_now("long aim did not zoom out") + # speed is set AFTER add_child(): _ready() copies it from the + # enemy's definition, which would otherwise clobber a + # pre-add_child override the instant the enemy entered the tree. for offset in [Vector2(-60, 0), Vector2(70, 35)]: var enemy: Node = load("res://Enemy.tscn").instantiate() enemy.position = skill.aim_target + offset - enemy.speed = 0.0 main.add_child(enemy) + enemy.speed = 0.0 inside_enemies.append(enemy) outside_enemy = load("res://Enemy.tscn").instantiate() outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0) - outside_enemy.speed = 0.0 main.add_child(outside_enemy) + outside_enemy.speed = 0.0 var release := InputEventScreenTouch.new() release.index = 7 From a1fb239a50f3ca44d71f3a0eae47b7d99f2e68ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:43:46 +0200 Subject: [PATCH 4/5] Add a main menu scene (#47) MainMenu.tscn is now project.godot's entry point: Play / Map Editor (disabled, explains itself - #48 wires it up) / Quit (hidden in web builds), styled like the rest of the game's menus and reusing the starfield shader for its background. Play calls GameState.reset_new_game(), so a second playthrough in the same process starts fresh instead of inheriting whatever the previous run left in the autoload - existing tests still load Main.tscn directly and are unaffected. Part of the #40 conquest-mechanic breakdown. --- GameState.gd | 27 +++++++ MainMenu.gd | 33 +++++++++ MainMenu.gd.uid | 1 + MainMenu.tscn | 139 ++++++++++++++++++++++++++++++++++++ project.godot | 2 +- tests/test_main_menu.gd | 95 ++++++++++++++++++++++++ tests/test_main_menu.gd.uid | 1 + 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 MainMenu.gd create mode 100644 MainMenu.gd.uid create mode 100644 MainMenu.tscn create mode 100644 tests/test_main_menu.gd create mode 100644 tests/test_main_menu.gd.uid diff --git a/GameState.gd b/GameState.gd index 0f2a3db..5487299 100644 --- a/GameState.gd +++ b/GameState.gd @@ -344,6 +344,33 @@ func reset_progression() -> void: vessel_levels.clear() +# Called from the main menu's Play button (#47): the autoload survives +# change_scene_to_file(), so without this a second playthrough in the same +# process would start from wherever the last one left off instead of a +# fresh game. +func reset_new_game() -> void: + has_return_position = false + surviving_ship_count = BASE_FLEET_SIZE + surviving_vessel_types = PackedStringArray() + return_world_position = Vector2.ZERO + return_camera_zoom = 1.0 + planet_name = "" + planet_id = "" + planet_definition_id = "rocky" + explored_chunks.clear() + bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) + fleet_inventory = INVENTORY.new() + buildings.clear() + next_building_id = 1 + stored_vessels.clear() + vessel_deliveries.clear() + extractors.clear() + transporters.clear() + planet_capture.clear() + reset_progression() + reset_formation() + + # --- building stock and production queue (#16) ----------------------------- # Since #12, a building cannot just deduct from the bank at the moment of diff --git a/MainMenu.gd b/MainMenu.gd new file mode 100644 index 0000000..e5afbcb --- /dev/null +++ b/MainMenu.gd @@ -0,0 +1,33 @@ +extends Node2D + +@onready var play_button: IconButton = $UI/PlayButton +@onready var editor_button: IconButton = $UI/EditorButton +@onready var quit_button: IconButton = $UI/QuitButton +@onready var status_label: Label = $UI/StatusLabel + + +func _ready() -> void: + play_button.pressed.connect(_on_play_pressed) + editor_button.pressed.connect(_on_editor_pressed) + # #48 wires the real map editor scene up to this button; until then it + # stays visible but explains itself instead of silently doing nothing + # or pointing at a scene that doesn't exist yet. + editor_button.enabled = false + quit_button.pressed.connect(_on_quit_pressed) + quit_button.visible = not OS.has_feature("web") + + +func _on_play_pressed() -> void: + GameState.reset_new_game() + get_tree().change_scene_to_file("res://Main.tscn") + + +func _on_editor_pressed() -> void: + if editor_button.enabled: + get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") + return + status_label.text = "Map editor coming soon" + + +func _on_quit_pressed() -> void: + get_tree().quit() diff --git a/MainMenu.gd.uid b/MainMenu.gd.uid new file mode 100644 index 0000000..165914d --- /dev/null +++ b/MainMenu.gd.uid @@ -0,0 +1 @@ +uid://p7yl0hc43yai diff --git a/MainMenu.tscn b/MainMenu.tscn new file mode 100644 index 0000000..7cf4450 --- /dev/null +++ b/MainMenu.tscn @@ -0,0 +1,139 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://MainMenu.gd" id="1"] +[ext_resource type="Script" path="res://IconButton.gd" id="2"] +[ext_resource type="Shader" path="res://starfield.gdshader" id="3"] + +[sub_resource type="ShaderMaterial" id="1"] +shader = ExtResource("3") +shader_parameter/camera_cell = Vector2(0, 0) +shader_parameter/camera_frac = Vector2(0, 0) +shader_parameter/camera_zoom = 1.0 +shader_parameter/viewport_size = Vector2(720, 1280) +shader_parameter/cell_size = 60.0 +shader_parameter/bg_color = Color(0.02, 0.02, 0.08, 1) + +[node name="MainMenu" type="Node2D"] +script = ExtResource("1") + +[node name="BackgroundLayer" type="CanvasLayer" parent="."] +layer = -10 + +[node name="Starfield" type="ColorRect" parent="BackgroundLayer"] +material = SubResource("1") +mouse_filter = 2 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="TitleLabel" type="Label" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -300 +offset_right = 300 +offset_top = -560 +offset_bottom = -460 +theme_override_font_sizes/font_size = 60 +horizontal_alignment = 1 +text = "SPACESHIP TEST" + +[node name="PlayButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = -300 +offset_bottom = -172 +script = ExtResource("2") +icon = 2 +glow_color = Color(0.35, 0.95, 0.55, 1) + +[node name="PlayLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -160 +offset_right = 160 +offset_top = -160 +offset_bottom = -94 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Play" + +[node name="EditorButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = -20 +offset_bottom = 108 +script = ExtResource("2") +icon = 0 +glow_color = Color(0.5, 0.75, 1, 1) + +[node name="EditorLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -200 +offset_right = 200 +offset_top = 120 +offset_bottom = 186 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Map Editor" + +[node name="StatusLabel" type="Label" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -260 +offset_right = 260 +offset_top = 200 +offset_bottom = 260 +theme_override_font_sizes/font_size = 26 +horizontal_alignment = 1 +autowrap_mode = 2 +text = "" + +[node name="QuitButton" type="Control" parent="UI"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 320 +offset_bottom = 448 +script = ExtResource("2") +icon = 1 +glow_color = Color(0.9, 0.35, 0.35, 1) + +[node name="QuitLabel" type="Label" parent="UI"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -160 +offset_right = 160 +offset_top = 460 +offset_bottom = 526 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Quit" diff --git a/project.godot b/project.godot index 05d44a8..0dcc344 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="Spaceship Test" -run/main_scene="res://Main.tscn" +run/main_scene="res://MainMenu.tscn" config/features=PackedStringArray("4.7") config/icon="res://icon.svg" diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd new file mode 100644 index 0000000..5193f33 --- /dev/null +++ b/tests/test_main_menu.gd @@ -0,0 +1,95 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_main_menu.gd +# Verifies: +# 1. the menu loads with Play/Map Editor/Quit, the editor button starts +# disabled and explains itself instead of silently doing nothing +# 2. Quit's visibility follows OS.has_feature("web") (this platform is +# not a web export, so this run exercises the "visible" branch; the +# condition itself is what's under test, not the concrete boolean) +# 3. Play resets GameState to a fresh game AND reaches Main.tscn + +var menu: Node +var game_state: Node +var elapsed: float = 0.0 +var stage: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + menu = load("res://MainMenu.tscn").instantiate() + root.add_child(menu) + current_scene = menu + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # A scene added to the tree inside _initialize() has not had its + # _ready() flushed yet - see test_planet_setups.gd for the same + # hazard - so the menu's @onready buttons aren't valid until a + # frame has actually processed. + if elapsed < 0.1: + return false + if menu.editor_button.enabled: + return _fail("map editor button is not disabled ahead of #48") + if menu.quit_button.visible != (not OS.has_feature("web")): + return _fail("quit button visibility did not follow OS.has_feature('web')") + + _press(menu.editor_button) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.1: + return false + if "coming soon" not in menu.status_label.text.to_lower(): + return _fail("disabled editor button press did not explain itself") + if current_scene != menu: + return _fail("pressing the disabled editor button changed scenes") + + # Dirty the game state exactly like an in-progress run would, + # so a genuine reset is distinguishable from a coincidental match. + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 999) + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + game_state.surviving_ship_count = 1 + game_state.planet_capture["chunk:1:1"] = {"captured": [true], "conquered": true} + + _press(menu.play_button) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + var on_main: bool = current_scene != null and current_scene.name == "Main" + var reset_ok: bool = game_state.rocks == 500 and game_state.buildings.is_empty() \ + and game_state.surviving_ship_count == GameState.BASE_FLEET_SIZE \ + and game_state.planet_capture.is_empty() + print("on_main=%s reset_ok=%s rocks=%d buildings=%d ship_count=%d" % [ + on_main, reset_ok, game_state.rocks, game_state.buildings.size(), + game_state.surviving_ship_count + ]) + if not on_main or not reset_ok: + return _fail("Play did not reset to a fresh game before reaching Main.tscn") + print("TEST_MAIN_MENU: PASS") + quit(0) + return true + return false + + +func _press(button: IconButton) -> void: + var down: InputEventScreenTouch = InputEventScreenTouch.new() + down.pressed = true + button._gui_input(down) + var up: InputEventScreenTouch = InputEventScreenTouch.new() + up.pressed = false + button._gui_input(up) + + +func _fail(message: String) -> bool: + print("TEST_MAIN_MENU: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_main_menu.gd.uid b/tests/test_main_menu.gd.uid new file mode 100644 index 0000000..984875e --- /dev/null +++ b/tests/test_main_menu.gd.uid @@ -0,0 +1 @@ +uid://dgf2ichdb7d4j From 65fe19aeb643765d5a805d98769e036988727e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 14:44:02 +0200 Subject: [PATCH 5/5] Backfill missing script UID sidecar files Unrelated pre-existing gap: 11 scripts never got a committed .uid, exposed by running a full --headless --import pass while working on #40's sub-tickets. Godot generates these deterministically from the script path, so this is a no-op for behavior. --- GasCargoCounter.gd.uid | 1 + GasCounter.gd.uid | 1 + GasVentField.gd.uid | 1 + ResourceGlyph.gd.uid | 1 + ResourceTransferList.gd.uid | 1 + VesselDelivery.gd.uid | 1 + VesselDeliveryField.gd.uid | 1 + tests/_gas_surface_preview.gd.uid | 1 + tests/test_gas_system.gd.uid | 1 + tests/test_lab_resources.gd.uid | 1 + tests/test_vessel_delivery.gd.uid | 1 + 11 files changed, 11 insertions(+) create mode 100644 GasCargoCounter.gd.uid create mode 100644 GasCounter.gd.uid create mode 100644 GasVentField.gd.uid create mode 100644 ResourceGlyph.gd.uid create mode 100644 ResourceTransferList.gd.uid create mode 100644 VesselDelivery.gd.uid create mode 100644 VesselDeliveryField.gd.uid create mode 100644 tests/_gas_surface_preview.gd.uid create mode 100644 tests/test_gas_system.gd.uid create mode 100644 tests/test_lab_resources.gd.uid create mode 100644 tests/test_vessel_delivery.gd.uid diff --git a/GasCargoCounter.gd.uid b/GasCargoCounter.gd.uid new file mode 100644 index 0000000..a5a329c --- /dev/null +++ b/GasCargoCounter.gd.uid @@ -0,0 +1 @@ +uid://dn5wt1hyaui81 diff --git a/GasCounter.gd.uid b/GasCounter.gd.uid new file mode 100644 index 0000000..4f2dca2 --- /dev/null +++ b/GasCounter.gd.uid @@ -0,0 +1 @@ +uid://wykjjb3qc01h diff --git a/GasVentField.gd.uid b/GasVentField.gd.uid new file mode 100644 index 0000000..4e2907b --- /dev/null +++ b/GasVentField.gd.uid @@ -0,0 +1 @@ +uid://di1kvjt0dwvqc diff --git a/ResourceGlyph.gd.uid b/ResourceGlyph.gd.uid new file mode 100644 index 0000000..52c084d --- /dev/null +++ b/ResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://beff2kjshg4vf diff --git a/ResourceTransferList.gd.uid b/ResourceTransferList.gd.uid new file mode 100644 index 0000000..53f117f --- /dev/null +++ b/ResourceTransferList.gd.uid @@ -0,0 +1 @@ +uid://bv6u4t3oabews diff --git a/VesselDelivery.gd.uid b/VesselDelivery.gd.uid new file mode 100644 index 0000000..1f4b96e --- /dev/null +++ b/VesselDelivery.gd.uid @@ -0,0 +1 @@ +uid://ft0puvels7lg diff --git a/VesselDeliveryField.gd.uid b/VesselDeliveryField.gd.uid new file mode 100644 index 0000000..2c5afe0 --- /dev/null +++ b/VesselDeliveryField.gd.uid @@ -0,0 +1 @@ +uid://clgmyishafevr diff --git a/tests/_gas_surface_preview.gd.uid b/tests/_gas_surface_preview.gd.uid new file mode 100644 index 0000000..0bb3aef --- /dev/null +++ b/tests/_gas_surface_preview.gd.uid @@ -0,0 +1 @@ +uid://x0rvhwfnneqp diff --git a/tests/test_gas_system.gd.uid b/tests/test_gas_system.gd.uid new file mode 100644 index 0000000..b71cec5 --- /dev/null +++ b/tests/test_gas_system.gd.uid @@ -0,0 +1 @@ +uid://ce1d4f81i7iqp diff --git a/tests/test_lab_resources.gd.uid b/tests/test_lab_resources.gd.uid new file mode 100644 index 0000000..6bc8efd --- /dev/null +++ b/tests/test_lab_resources.gd.uid @@ -0,0 +1 @@ +uid://dbp0236qseh1x diff --git a/tests/test_vessel_delivery.gd.uid b/tests/test_vessel_delivery.gd.uid new file mode 100644 index 0000000..57266e2 --- /dev/null +++ b/tests/test_vessel_delivery.gd.uid @@ -0,0 +1 @@ +uid://bbqmyl26uqrbj