diff --git a/GameState.gd b/GameState.gd index c41a18b..5d514bf 100644 --- a/GameState.gd +++ b/GameState.gd @@ -5,6 +5,10 @@ const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +# By path, not by its `WorldGen` global name: an autoload compiles before the +# global class table is necessarily populated, the same hazard PlanetSetups.gd +# works around. +const WORLD_GEN = preload("res://WorldGen.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -14,7 +18,7 @@ const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") # survive the trip has to live here instead. var has_return_position: bool = false -var surviving_ship_count: int = 5 +var surviving_ship_count: int = STARTING_SHIP_COUNT var surviving_vessel_types: PackedStringArray = PackedStringArray() var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -52,6 +56,20 @@ func set_fleet_speed_multiplier(value: float) -> void: signal research_points_changed(total: int) var research_points: int = 0 +# Set only by start_dev_game(), which the main menu offers only when running +# from Godot. Makes WorldGen plant one of every planet type around the spawn. +# Cleared by reset_new_game(), so starting a normal game after a dev one +# cannot leave a dev world behind. +# +# The setter mirrors it into WorldGen, which cannot read it back: world +# generation is static and dependency-free by design. This stays the single +# place to set it - assigning dev_mode anywhere, tests included, reaches +# world generation. +var dev_mode: bool = false: + set(value): + dev_mode = value + WORLD_GEN.dev_planets_enabled = value + # Explored-chunk history for the world map's fog-of-war reveal. Lives # here (not on ExplorationTracker, which gets destroyed and recreated on # every scene swap) so exploration progress isn't lost every time the @@ -68,7 +86,11 @@ const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER # All resource-bearing locations use the same container. The old public # names below are computed compatibility accessors, not separate storage. -var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500}) +# Starts EMPTY. A new game used to open with 500 ore banked, which quietly +# skipped the opening of the game - the first mining run is what teaches the +# fleet hold, the return trip and the mothership. Dev mode (start_dev_game) +# is where a stocked start belongs. +var bank_inventory: RefCounted = INVENTORY.new() var fleet_inventory: RefCounted = INVENTORY.new() var rocks: int: @@ -324,7 +346,11 @@ const SKILL_TREE: Array = [ "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", }, { - "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", + # Cheaper than it was (30 ore / 1 point): the fleet now starts at + # BASE_FLEET_SIZE 3 rather than 5, so the first two doctrines are no + # longer a mid-game luxury but the ordinary way out of the opening. + # Pricing them as a luxury made the early game feel stuck. + "id": "wing", "name": "Wing Doctrine", "cost": 15, "requires": "coordination", "x": 0.82, "fleet_bonus": 1, "research_points": 1, "desc": "Permanent: +1 vessel in the fleet.", }, @@ -335,18 +361,41 @@ const SKILL_TREE: Array = [ # prerequisite, also x=0.82) instead, so the whole fleet-size # doctrine branch now runs straight rather than zigzagging into # someone else's lane. - "id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing", + # Also cut (70 ore / 2 points), for the same reason as wing. + "id": "squadron", "name": "Squadron Doctrine", "cost": 35, "requires": "wing", + "x": 0.82, "fleet_bonus": 1, "research_points": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, + { + "id": "armada", "name": "Armada Doctrine", "cost": 90, "requires": "squadron", "x": 0.82, "fleet_bonus": 1, "research_points": 2, "desc": "Permanent: +1 vessel in the fleet.", }, { - "id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron", - "x": 0.82, "fleet_bonus": 1, "research_points": 3, - "desc": "Permanent: +1 vessel in the fleet.", + # The last two tiers grant +2 rather than +1. Seven single-vessel + # nodes in one lane would be a long, monotonous chain for a branch + # that only ever does one thing; two larger, costlier steps carry the + # ceiling from 6 to 10 while keeping the branch a readable length. + "id": "battlegroup", "name": "Battlegroup Doctrine", "cost": 180, "requires": "armada", + "x": 0.82, "fleet_bonus": 2, "research_points": 4, + "desc": "Permanent: +2 vessels in the fleet.", + }, + { + "id": "grand_fleet", "name": "Grand Fleet Doctrine", "cost": 320, "requires": "battlegroup", + "x": 0.82, "fleet_bonus": 2, "research_points": 6, + "desc": "Permanent: +2 vessels in the fleet.", }, ] -const BASE_FLEET_SIZE: int = 5 +# How many SLOTS the fleet has before any doctrine is researched. Distinct +# from STARTING_SHIP_COUNT below: this is capacity, that is how many vessels +# are actually flying. The two used to be one constant, which forced a new +# game to open with a full fleet and left the first crafter with nothing to +# do - there was no room to put a vessel until a doctrine was researched. +const BASE_FLEET_SIZE: int = 3 +# A new game opens with a SINGLE vessel. Building the fleet up is the early +# game, so the two empty slots are the first thing a ship crafter is for. +const STARTING_SHIP_COUNT: int = 1 var unlocked: Array = [] @@ -448,7 +497,7 @@ func reset_progression() -> void: # fresh game. func reset_new_game() -> void: has_return_position = false - surviving_ship_count = BASE_FLEET_SIZE + surviving_ship_count = STARTING_SHIP_COUNT surviving_vessel_types = PackedStringArray() return_world_position = Vector2.ZERO return_camera_zoom = 1.0 @@ -459,8 +508,9 @@ func reset_new_game() -> void: _distorder_drain_progress = 0.0 boost_depleted_notice = false research_points = 0 + dev_mode = false explored_chunks.clear() - bank_inventory = INVENTORY.new({RESOURCE_ORE: 500}) + bank_inventory = INVENTORY.new() fleet_inventory = INVENTORY.new() buildings.clear() next_building_id = 1 @@ -473,6 +523,29 @@ func reset_new_game() -> void: reset_formation() +# Amounts a dev game opens with: enough of everything to reach any part of +# the game immediately, without being so large that costs stop registering. +const DEV_RESOURCE_AMOUNT: int = 2000 +const DEV_RESEARCH_POINTS: int = 500 + + +# A new game stocked for play-testing: every resource banked, research points +# to spend, and one of each planet type next to the spawn (WorldGen reads +# dev_mode for that). Offered by the main menu only when running from Godot - +# see MainMenu.gd - so it cannot be reached in a shipped build. +# +# Deliberately built ON TOP of reset_new_game() rather than beside it, so a +# dev game is a normal game plus a stock rather than a second, separately +# maintained definition of what a fresh game is. +func start_dev_game() -> void: + reset_new_game() + dev_mode = true + for resource_type in resource_types(): + bank_inventory.set_amount(resource_type, DEV_RESOURCE_AMOUNT) + research_points = DEV_RESEARCH_POINTS + research_points_changed.emit(research_points) + + # --- building stock and production queue (#16) ----------------------------- # Since #12, a building cannot just deduct from the bank at the moment of @@ -1101,9 +1174,13 @@ func get_mothership_position() -> Variant: # capacity upgrade is researched, but they are defined up front so the wedge # keeps a deliberate shape as it grows rather than having positions invented # at unlock time. +# Ten slots, because max_fleet_size() caps itself on this array's length - +# so the wedge has to be long enough for every doctrine to be worth buying. +# The last pair continues the V rather than starting a new shape. const DEFAULT_FORMATION: Array = [ Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32), Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56), + Vector2(-120, 112), Vector2(120, 112), ] var formation: Array = DEFAULT_FORMATION.duplicate() diff --git a/MainMenu.gd b/MainMenu.gd index 7ea9255..bb44be4 100644 --- a/MainMenu.gd +++ b/MainMenu.gd @@ -1,8 +1,35 @@ extends Node2D +# The menu stacks whatever entries are actually available, rather than +# trusting the positions authored in the scene. +# +# Two of the four entries only exist when the game is run from Godot, so a +# shipped build shows a two-entry menu and an editor run shows four. Fixed +# offsets would leave holes where the hidden entries used to be - the same +# failure the HUD's button columns had, where a literal y meant "below the +# thing above me" and stopped being true when that thing moved. Here the +# stack is measured instead, and centred on whatever it ends up containing. + +const ROW_BUTTON_HEIGHT: float = 128.0 +const ROW_LABEL_OFFSET: float = 134.0 +const ROW_LABEL_HEIGHT: float = 56.0 +const ROW_HEIGHT: float = ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT +const ROW_GAP: float = 20.0 +const ROW_PITCH: float = ROW_HEIGHT + ROW_GAP +# The stack is centred on this rather than on the screen, to leave the title +# above it room to breathe. +const STACK_CENTER_Y: float = 40.0 +const STATUS_GAP: float = 20.0 +const STATUS_HEIGHT: float = 70.0 + @onready var play_button: IconButton = $UI/PlayButton +@onready var play_label: Label = $UI/PlayLabel @onready var editor_button: IconButton = $UI/EditorButton +@onready var editor_label: Label = $UI/EditorLabel +@onready var dev_button: IconButton = $UI/DevModeButton +@onready var dev_label: Label = $UI/DevModeLabel @onready var quit_button: IconButton = $UI/QuitButton +@onready var quit_label: Label = $UI/QuitLabel @onready var status_label: Label = $UI/StatusLabel @@ -10,16 +37,78 @@ func _ready() -> void: play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) editor_button.enabled = true + dev_button.pressed.connect(_on_dev_mode_pressed) + dev_button.enabled = true quit_button.pressed.connect(_on_quit_pressed) quit_button.visible = not OS.has_feature("web") + # Dev mode and the map editor are development tools, not features of the + # game: they are for building and play-testing it, and a player who + # installed a build has no use for either. `editor` is the feature tag + # Godot sets when running from the editor and clears in an export, which + # is exactly the "started from Godot" distinction wanted here. + var developing: bool = is_developer_build() + editor_button.visible = developing + editor_label.visible = developing + dev_button.visible = developing + dev_label.visible = developing + + _layout_stack() + + +# Wrapped so tests can state the rule they are checking rather than +# duplicating the feature-tag string. +func is_developer_build() -> bool: + return OS.has_feature("editor") + + +func _layout_stack() -> void: + var rows: Array = [] + for row in [[play_button, play_label], [editor_button, editor_label], + [dev_button, dev_label], [quit_button, quit_label]]: + if row[0].visible: + rows.append(row) + if rows.is_empty(): + return + + # The last row carries no trailing gap, so the block is one pitch per row + # minus that gap. + var block_height: float = float(rows.size()) * ROW_PITCH - ROW_GAP + var top: float = STACK_CENTER_Y - block_height * 0.5 + for row in rows: + var button: Control = row[0] + var label: Label = row[1] + button.offset_top = top + button.offset_bottom = top + ROW_BUTTON_HEIGHT + label.offset_top = top + ROW_LABEL_OFFSET + label.offset_bottom = top + ROW_LABEL_OFFSET + ROW_LABEL_HEIGHT + top += ROW_PITCH + + # Status sits under the whole stack, where a message about any entry can + # be read without it shifting the entries themselves. + var status_top: float = top - ROW_GAP + STATUS_GAP + status_label.offset_top = status_top + status_label.offset_bottom = status_top + STATUS_HEIGHT + func _on_play_pressed() -> void: GameState.reset_new_game() get_tree().change_scene_to_file("res://Main.tscn") +func _on_dev_mode_pressed() -> void: + # Guarded as well as hidden. Visibility is presentation; this is the rule. + if not is_developer_build(): + status_label.text = "Dev mode only runs from Godot" + return + GameState.start_dev_game() + get_tree().change_scene_to_file("res://Main.tscn") + + func _on_editor_pressed() -> void: + if not is_developer_build(): + status_label.text = "Map editor only runs from Godot" + return if editor_button.enabled: get_tree().change_scene_to_file("res://PlanetSetupEditor.tscn") return diff --git a/MainMenu.tscn b/MainMenu.tscn index 7cf4450..20899a8 100644 --- a/MainMenu.tscn +++ b/MainMenu.tscn @@ -97,6 +97,33 @@ theme_override_font_sizes/font_size = 32 horizontal_alignment = 1 text = "Map Editor" +[node name="DevModeButton" 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 = 190 +offset_bottom = 318 +script = ExtResource("2") +icon = 29 +glow_color = Color(1, 0.7, 0.25, 1) + +[node name="DevModeLabel" 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 = 324 +offset_bottom = 380 +theme_override_font_sizes/font_size = 32 +horizontal_alignment = 1 +text = "Dev Mode" + [node name="StatusLabel" type="Label" parent="UI"] anchor_left = 0.5 anchor_right = 0.5 diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index 36ac960..8faca25 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -40,7 +40,12 @@ func _ready() -> void: camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false else: - fleet.spawn_ships(5, Vector2.ZERO) + # A brand new game. STARTING_SHIP_COUNT rather than a literal: this + # said 5, which was the old BASE_FLEET_SIZE written out by hand, and + # it silently became "however many slots exist" once max_fleet_size() + # clamped it. The opening fleet size is one decision and it lives in + # GameState. + fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, Vector2.ZERO) # AsteroidField/PlanetField normally only populate nearby chunks once # their own check_interval timer elapses (~1s) - fine during normal diff --git a/WorldGen.gd b/WorldGen.gd index 1fb1c58..d051788 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -29,12 +29,42 @@ const BLACK_HOLE_CHANCE: float = 0.05 # research points had become to progression. const RESEARCH_LAB_CHANCE: float = 0.16 -# A predictable early gas source for play-testing. The origin chunk is -# deterministically normal space. Its explicit in-chunk position puts it -# roughly 1,080 world units from a fresh fleet: easy to reach, but not -# sitting on the spawn. -const NEARBY_GAS_GIANT_CHUNK: Vector2i = Vector2i.ZERO -const NEARBY_GAS_GIANT_LOCAL_POS: Vector2 = Vector2(900.0, 600.0) +# DEV MODE ONLY: one of every planet type, parked in the chunks immediately +# around the spawn so all four can be reached in seconds. +# +# This replaces the guaranteed gas giant that used to sit in the origin +# chunk for every game. That was a play-testing convenience which had become +# part of the real world's shape - every player got the same landmark in the +# same place, and the surrounding space was generated as if it were normal. +# Play-testing wants ALL the planet types to hand, not just gas, and a +# normal game wants none of them handed over; those are different needs and +# they now have different mechanisms. +# +# The local positions keep each planet off the spawn point itself: a fresh +# fleet starts at world origin, which is the top-left corner of chunk (0,0). +const DEV_PLANET_CHUNKS: Dictionary = { + Vector2i(0, 0): PLANET_DEFINITIONS.GAS_GIANT_ID, + Vector2i(1, 0): PLANET_DEFINITIONS.ROCKY_ID, + Vector2i(0, 1): PLANET_DEFINITIONS.RESEARCH_LAB_ID, + Vector2i(1, 1): PLANET_DEFINITIONS.BLACK_HOLE_ID, +} +const DEV_PLANET_LOCAL_POS: Vector2 = Vector2(600.0, 600.0) + + +# Dev mode plants a fixed set of planets; a normal game must not see them. +# +# PUSHED here by GameState.dev_mode's setter rather than read back out of the +# autoload. World generation is deliberately dependency-free - it is static, +# seeded purely by chunk coordinates, and callable from anywhere - and an +# autoload cannot be named from here anyway: `GameState` is not resolvable as +# a global identifier this early, the same load-order hazard PlanetSetups.gd +# and PlanetSurfaceController.gd already work around. GameState.dev_mode +# stays the single source of truth; this is its mirror. +static var dev_planets_enabled: bool = false + + +static func _dev_planets_active() -> bool: + return dev_planets_enabled static var _noise: FastNoiseLite static var _planet_noise: FastNoiseLite @@ -76,7 +106,7 @@ static func biome_at_chunk(chunk: Vector2i) -> int: static func has_planet_at_chunk(chunk: Vector2i) -> bool: - if chunk == NEARBY_GAS_GIANT_CHUNK: + if _dev_planets_active() and DEV_PLANET_CHUNKS.has(chunk): return true var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y)) return (biome_at_chunk(chunk) == Biome.ASTEROID_FIELD and n > PLANET_THRESHOLD) or (biome_at_chunk(chunk) == Biome.SPACE and n > GAS_GIANT_THRESHOLD) @@ -92,8 +122,9 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: # can never drift out of sync with each other. var rng: RandomNumberGenerator = RandomNumberGenerator.new() rng.seed = hash(chunk) - var guaranteed_nearby_giant: bool = chunk == NEARBY_GAS_GIANT_CHUNK - var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \ + var forced_definition_id: String = String(DEV_PLANET_CHUNKS.get(chunk, "")) \ + if _dev_planets_active() else "" + var local_pos: Vector2 = DEV_PLANET_LOCAL_POS if not forced_definition_id.is_empty() \ else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE) var shape_seed: int = rng.randi() # Drawn unconditionally, in the same fixed position every call, @@ -105,15 +136,20 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var research_lab_roll: float = rng.randf() # A stable per-chunk roll makes gas giants persistent landmarks rather # than a different resource whenever the scene reloads. - var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE + var gas_giant: bool = biome_at_chunk(chunk) == Biome.SPACE var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID # A rare third outcome, seeded the same deterministic way as everything - # else here. Never overrides the guaranteed early gas giant landmark - - # that chunk's whole purpose is to always be the same reachable source. - if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE: + # else here. + if black_hole_roll < BLACK_HOLE_CHANCE: definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID - elif not guaranteed_nearby_giant and research_lab_roll < RESEARCH_LAB_CHANCE: + elif research_lab_roll < RESEARCH_LAB_CHANCE: definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID + # Dev mode's fixed planting wins over every roll above. Applied last, and + # AFTER all the rng draws, so switching it on cannot shift which random + # values any other chunk consumes - a dev world and a normal one differ + # only in these four chunks. + if not forced_definition_id.is_empty(): + definition_id = forced_definition_id var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) var names: PackedStringArray = definition.names var planet_name: String = names[rng.randi() % names.size()] diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd new file mode 100644 index 0000000..38b8a2f --- /dev/null +++ b/tests/test_dev_mode.gd @@ -0,0 +1,152 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_dev_mode.gd +# A "Dev Mode" start for play-testing: one of every planet type next to the +# spawn, every resource banked, and research points to spend. Offered by the +# main menu only when the game is run from Godot, alongside the map editor. +# +# The same change took the training wheels off a NORMAL game: no 500 free +# ore, and no gas giant guaranteed in the origin chunk. Both of those were +# play-testing conveniences that had become part of the real game, so this +# test covers both sides - what dev mode hands you, and what a normal game +# no longer does. +# +# Covers: +# A. a dev game banks every resource type and research points +# B. a dev game is a normal game PLUS that, not a separate definition +# C. one of each planet type sits within a chunk or two of the spawn +# D. none of that leaks into a normal game, and starting one clears it +# E. a normal game starts with an empty bank +# F. the menu gates dev mode and the map editor on the same rule + +var game_state: Node +var menu: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- E. a normal game hands over nothing ---------------------- + game_state.reset_new_game() + if game_state.rocks != 0: + return _fail("a normal game started with %d ore banked" % game_state.rocks) + for resource_type in game_state.resource_types(): + if game_state.banked_resource(resource_type) != 0: + return _fail("a normal game started with %s banked" % resource_type) + if game_state.research_points != 0: + return _fail("a normal game started with research points") + if game_state.dev_mode: + return _fail("a normal game started in dev mode") + print("normal game starts with an empty bank and no research points") + + # --- D. no dev planets in a normal game ----------------------- + # The dev arrangement must not survive into a normal world. Noise + # could coincidentally place a planet in one of these chunks, so + # what is asserted is that the ARRANGEMENT is not reproduced, not + # that each chunk is empty. + var reproduced: bool = true + for chunk in WorldGen.DEV_PLANET_CHUNKS: + var expected: String = String(WorldGen.DEV_PLANET_CHUNKS[chunk]) + var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) + if not WorldGen.has_planet_at_chunk(chunk) or String(data.get("definition_id", "")) != expected: + reproduced = false + if Vector2(data.get("local_pos", Vector2.ZERO)) == WorldGen.DEV_PLANET_LOCAL_POS: + return _fail("a normal game placed a planet at dev mode's fixed in-chunk position, in chunk %s" % chunk) + if reproduced: + return _fail("a normal game reproduced dev mode's whole planet arrangement") + print("normal game does not carry dev mode's planets") + + # --- A + B. what a dev game hands over ------------------------ + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + game_state.planet_capture["chunk:9:9"] = {"captured": [true], "conquered": true} + game_state.start_dev_game() + if not game_state.dev_mode: + return _fail("start_dev_game() did not set dev_mode") + for resource_type in game_state.resource_types(): + var banked: int = game_state.banked_resource(resource_type) + if banked != GameState.DEV_RESOURCE_AMOUNT: + return _fail("dev game banked %d of %s, expected %d" \ + % [banked, resource_type, GameState.DEV_RESOURCE_AMOUNT]) + if game_state.research_points != GameState.DEV_RESEARCH_POINTS: + return _fail("dev game granted %d research points, expected %d" \ + % [game_state.research_points, GameState.DEV_RESEARCH_POINTS]) + # B: it still went through reset_new_game, so a dirty run is gone. + if not game_state.buildings.is_empty() or not game_state.planet_capture.is_empty(): + return _fail("a dev game did not reset the previous run - it must be a fresh game plus a stock") + print("dev game banks %d of each resource and %d research points, from a clean slate" \ + % [GameState.DEV_RESOURCE_AMOUNT, GameState.DEV_RESEARCH_POINTS]) + + # --- C. one of each planet type, close by --------------------- + var found: Dictionary = {} + for chunk in WorldGen.DEV_PLANET_CHUNKS: + if not WorldGen.has_planet_at_chunk(chunk): + return _fail("dev mode chunk %s holds no planet" % chunk) + var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) + var definition_id: String = String(data.get("definition_id", "")) + if definition_id != String(WorldGen.DEV_PLANET_CHUNKS[chunk]): + return _fail("dev mode chunk %s holds a %s, expected a %s" \ + % [chunk, definition_id, WorldGen.DEV_PLANET_CHUNKS[chunk]]) + found[definition_id] = true + # "Near the start" is the whole point - a play-tester should + # reach any of them in seconds, not go exploring. + var world_pos: Vector2 = WorldGen.chunk_origin(chunk) + Vector2(data.local_pos) + if world_pos.length() > WorldGen.CHUNK_SIZE * 2.5: + return _fail("dev mode's %s spawned %.0f units away, too far to be 'near the start'" \ + % [definition_id, world_pos.length()]) + for definition_id in [PlanetDefinitions.ROCKY_ID, PlanetDefinitions.GAS_GIANT_ID, + PlanetDefinitions.BLACK_HOLE_ID, PlanetDefinitions.RESEARCH_LAB_ID]: + if not found.has(definition_id): + return _fail("dev mode is missing a %s - it must plant one of EVERY planet type" % definition_id) + print("dev mode plants all %d planet types within %.0f units of the spawn" \ + % [found.size(), WorldGen.CHUNK_SIZE * 2.5]) + + # --- D. starting a normal game clears it ---------------------- + game_state.reset_new_game() + if game_state.dev_mode: + return _fail("starting a normal game after a dev one left dev_mode set") + if game_state.rocks != 0 or game_state.research_points != 0: + return _fail("starting a normal game after a dev one kept the dev stock") + print("starting a normal game afterwards clears dev mode entirely") + + menu = load("res://MainMenu.tscn").instantiate() + root.add_child(menu) + current_scene = menu + elapsed = 0.0 + stage = 1 + return false + 1: + # --- F. the menu gates both on the same rule ------------------ + if elapsed < 0.1: + return false + var developing: bool = menu.is_developer_build() + if menu.dev_button.visible != developing: + return _fail("the dev mode button's visibility did not follow is_developer_build()") + if menu.editor_button.visible != developing: + return _fail("the map editor button's visibility did not follow is_developer_build()") + if menu.play_button.visible != true or menu.quit_button.visible != (not OS.has_feature("web")): + return _fail("gating the development entries also hid an entry a player needs") + # Hiding is presentation; pressing it anyway must still be refused. + if not developing: + menu._on_dev_mode_pressed() + if game_state.dev_mode: + return _fail("dev mode started outside a developer build") + print("dev mode and the map editor are both gated on is_developer_build() (%s here)" % developing) + + print("TEST_DEV_MODE: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_DEV_MODE: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_enemy_collision.gd b/tests/test_enemy_collision.gd index 923fe41..8ea1d12 100644 --- a/tests/test_enemy_collision.gd +++ b/tests/test_enemy_collision.gd @@ -32,6 +32,13 @@ func _physics_process(delta: float) -> bool: # happens strictly after _ready(). Here, at the point _ready() is # already confirmed complete, it's safe. main_scene.get_node("EnemySpawner").set_process(false) + # Crew the fleet HERE, not in _initialize(), for the same reason the + # set_process(false) call above lives here: work done in _initialize() + # can be undone by the engine's deferred tree-entry finalization. A + # new game flies one vessel into three slots now, and this test needs + # a full one. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) target_ship = fleet.ships[0] var enemy_scene: PackedScene = load("res://Enemy.tscn") enemy = enemy_scene.instantiate() diff --git a/tests/test_enemy_combat.gd b/tests/test_enemy_combat.gd index 18e1ad3..aa1951e 100644 --- a/tests/test_enemy_combat.gd +++ b/tests/test_enemy_combat.gd @@ -32,6 +32,13 @@ func _physics_process(delta: float) -> bool: # happens strictly after _ready(). Here, at the point _ready() is # already confirmed complete, it's safe. main_scene.get_node("EnemySpawner").set_process(false) + # Crew the fleet HERE, not in _initialize(), for the same reason the + # set_process(false) call above lives here: work done in _initialize() + # can be undone by the engine's deferred tree-entry finalization. A + # new game flies one vessel into three slots now, and this test needs + # a full one. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) var enemy_scene: PackedScene = load("res://Enemy.tscn") enemy = enemy_scene.instantiate() enemy.position = fleet.get_centroid() + SPAWN_OFFSET diff --git a/tests/test_enemy_mothership.gd b/tests/test_enemy_mothership.gd index e55f778..4424988 100644 --- a/tests/test_enemy_mothership.gd +++ b/tests/test_enemy_mothership.gd @@ -54,6 +54,9 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") beam_script = load("res://EnemyBigLaserBeam.gd") + # Four vessels are crewed below, past BASE_FLEET_SIZE's 3 slots, so the + # capacity doctrines are granted rather than assumed. + game_state.unlocked.append_array(["wing", "squadron", "armada"]) main = load("res://Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd new file mode 100644 index 0000000..dbbcabc --- /dev/null +++ b/tests/test_fleet_growth.gd @@ -0,0 +1,139 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fleet_growth.gd +# A new game opens with a SINGLE vessel and three slots, and the fleet grows +# from there through the doctrine branch of the skill tree. +# +# BASE_FLEET_SIZE used to be both "how many slots exist" and "how many ships +# you start with", which forced a new game to open with a full fleet - there +# was nowhere to put a vessel until a doctrine was researched, so the first +# ship crafter had nothing to do. The two are now separate constants, and +# this test pins the distinction. +# +# Covers: +# A. a new game starts with one vessel and three slots +# B. the fleet really spawns one ship, and can be grown into its slots +# C. every doctrine raises the ceiling by its own stated bonus +# D. the doctrine branch is a chain, in increasing cost order +# E. the tree cannot promise a slot the formation has no room for - the +# invariant that made extra doctrines silently worthless + +const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", "grand_fleet"] + +var game_state: Node +var scene: Node +var fleet: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_new_game() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. the opening position --------------------------------- + if GameState.STARTING_SHIP_COUNT != 1: + return _fail("a new game starts with %d vessels, expected 1" % GameState.STARTING_SHIP_COUNT) + if GameState.BASE_FLEET_SIZE != 3: + return _fail("the fleet has %d slots by default, expected 3" % GameState.BASE_FLEET_SIZE) + if game_state.surviving_ship_count != GameState.STARTING_SHIP_COUNT: + return _fail("reset_new_game left %d surviving ships" % game_state.surviving_ship_count) + if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("an unresearched fleet caps at %d, expected %d" \ + % [game_state.max_fleet_size(), GameState.BASE_FLEET_SIZE]) + print("new game: %d vessel, %d slots" % [GameState.STARTING_SHIP_COUNT, GameState.BASE_FLEET_SIZE]) + + # --- C + D. the doctrine chain ------------------------------- + var expected: int = GameState.BASE_FLEET_SIZE + var previous_cost: int = -1 + var previous_points: int = -1 + var previous_id: String = "coordination" + for id in DOCTRINE_CHAIN: + var node: Dictionary = game_state.skill_node(id) + if node.is_empty(): + return _fail("no skill node '%s' - the doctrine chain is broken" % id) + var bonus: int = int(node.get("fleet_bonus", 0)) + if bonus <= 0: + return _fail("'%s' is in the doctrine chain but grants no fleet_bonus" % id) + if String(node.get("requires", "")) != previous_id: + return _fail("'%s' requires '%s', expected it to follow '%s' - the branch is not one chain" \ + % [id, node.get("requires", ""), previous_id]) + # Later tiers must not be cheaper than earlier ones, or the + # order the player meets them stops matching what they cost. + var cost: int = int(node.get("cost", 0)) + var points: int = int(node.get("research_points", 0)) + if cost <= previous_cost: + return _fail("'%s' costs %d ore, not more than the tier before it (%d)" % [id, cost, previous_cost]) + if points < previous_points: + return _fail("'%s' costs %d research points, fewer than the tier before it (%d)" \ + % [id, points, previous_points]) + previous_cost = cost + previous_points = points + previous_id = id + + game_state.unlocked.append(id) + expected += bonus + if game_state.max_fleet_size() != expected: + return _fail("after researching '%s' the fleet caps at %d, expected %d" \ + % [id, game_state.max_fleet_size(), expected]) + print(" %-12s +%d -> %2d slots (%d ore, %d research)" % [id, bonus, expected, cost, points]) + + # --- E. no doctrine may be swallowed by the formation --------- + # max_fleet_size() clamps to the formation's length, so a doctrine + # added without extending DEFAULT_FORMATION would cost the player + # real resources and grant nothing. + if expected > GameState.DEFAULT_FORMATION.size(): + return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \ + % [expected, GameState.DEFAULT_FORMATION.size()]) + print("full doctrine chain reaches %d slots, with %d formation positions defined" \ + % [expected, GameState.DEFAULT_FORMATION.size()]) + + # --- B. what actually flies ---------------------------------- + game_state.reset_new_game() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("a fresh scene spawned %d vessels, expected %d" \ + % [fleet.ships.size(), GameState.STARTING_SHIP_COUNT]) + print("a fresh game really flies %d vessel" % fleet.ships.size()) + + # The two empty slots are what the opening is for, so they have to + # be fillable without researching anything. + for i in range(GameState.BASE_FLEET_SIZE - GameState.STARTING_SHIP_COUNT): + if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR): + return _fail("could not crew slot %d of the starting fleet" % (i + 2)) + if fleet.ships.size() != GameState.BASE_FLEET_SIZE: + return _fail("the fleet holds %d after filling its slots, expected %d" \ + % [fleet.ships.size(), GameState.BASE_FLEET_SIZE]) + # And the cap has to actually bite past that. + if fleet.add_ship(GameState.VESSEL_INTERCEPTOR): + return _fail("a %dth vessel joined an unresearched fleet of %d slots" \ + % [fleet.ships.size(), GameState.BASE_FLEET_SIZE]) + print("the starting slots fill to %d and refuse a %dth" \ + % [GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 1]) + + print("TEST_FLEET_GROWTH: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_FLEET_GROWTH: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index ef8234f..7f98ee6 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -20,15 +20,21 @@ func _initialize() -> void: func _process(delta: float) -> bool: elapsed += delta if stage == 0: - var nearby_chunk: Vector2i = WorldGen.NEARBY_GAS_GIANT_CHUNK + # A guaranteed gas source near the spawn is now a DEV MODE guarantee + # rather than something every game gets - a normal game has to go and + # find one. Dev mode is therefore where this premise still holds, and + # where a play-tester reaching gas quickly still matters. + game_state.dev_mode = true + var nearby_chunk: Vector2i = Vector2i.ZERO + for chunk in WorldGen.DEV_PLANET_CHUNKS: + if String(WorldGen.DEV_PLANET_CHUNKS[chunk]) == "gas_giant": + nearby_chunk = chunk var nearby_data: Dictionary = WorldGen.planet_data_at_chunk(nearby_chunk) var nearby_position: Vector2 = WorldGen.chunk_origin(nearby_chunk) + nearby_data.local_pos if not WorldGen.has_planet_at_chunk(nearby_chunk): - return _fail("nearby gas giant chunk does not contain a planet") - if WorldGen.biome_at_chunk(nearby_chunk) != WorldGen.Biome.SPACE: - return _fail("nearby gas giant is not in the normal space biome") + return _fail("dev mode's gas giant chunk does not contain a planet") if String(nearby_data.get("definition_id", "")) != "gas_giant": - return _fail("nearby guaranteed planet is not a gas source") + return _fail("dev mode's guaranteed planet is not a gas source") if nearby_position.length() > WorldGen.CHUNK_SIZE * 2.0: return _fail("nearby gas giant spawned too far away: %s" % nearby_position) diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index 8d2982f..938ca89 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -35,6 +35,14 @@ func _process(delta: float) -> bool: return false if not menu.editor_button.enabled: return _fail("map editor button is not enabled (#48 shipped)") + # Both development entries follow the same rule, and the rule is + # what is under test rather than the concrete boolean - this run + # is from Godot, so it exercises the "shown" branch. + var developing: bool = menu.is_developer_build() + if menu.editor_button.visible != developing or menu.editor_label.visible != developing: + return _fail("map editor entry visibility did not follow is_developer_build()") + if menu.dev_button.visible != developing or menu.dev_label.visible != developing: + return _fail("dev mode entry visibility did not follow is_developer_build()") if menu.quit_button.visible != (not OS.has_feature("web")): return _fail("quit button visibility did not follow OS.has_feature('web')") @@ -74,8 +82,10 @@ func _process(delta: float) -> bool: 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 \ + # A normal game now starts with NOTHING banked - the opening + # mining run is the tutorial, and 500 free ore skipped it. + var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \ + and game_state.surviving_ship_count == GameState.STARTING_SHIP_COUNT \ 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(), diff --git a/tests/test_pivot_controls.gd b/tests/test_pivot_controls.gd index 1d60512..8396c82 100644 --- a/tests/test_pivot_controls.gd +++ b/tests/test_pivot_controls.gd @@ -19,6 +19,7 @@ var cw: IconButton var stage: int = 0 var elapsed: float = 0.0 var angle_at_hold_start: float = 0.0 +var hold_amount_at_start: float = 0.0 var angle_after_hold: float = 0.0 # GameState (an autoload) is not available as a bare identifier from a # script that extends SceneTree - fetch it via the tree root instead. @@ -45,6 +46,13 @@ func _process(delta: float) -> bool: 0: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) + # Crew the fleet HERE, not in _initialize(): work done there can + # be undone by the engine's deferred tree-entry finalization, the + # same reason the set_process(false) calls above sit at this + # point. A new game flies one vessel now, and the checks below + # index ships[1]. + game_state.unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) # Hold the button down without releasing it. angle_at_hold_start = _angle() cw.pressed_down = true @@ -127,6 +135,7 @@ func _process(delta: float) -> bool: return true angle_at_hold_start = _angle() + hold_amount_at_start = controller.pivot_hold_amount elapsed = 0.0 stage = 2 return false @@ -135,7 +144,12 @@ func _process(delta: float) -> bool: return false # Both fingers still down: the formation must have kept turning # while the stick kept re-issuing move orders every frame. - var turned_while_steering: float = absf(angle_difference(angle_at_hold_start, _angle())) + # The ACCUMULATED turn, not the angle difference. Comparing + # formation angles wraps at +/-pi, so a hold that happened to + # rotate a full turn read as no rotation at all - and how far it + # gets in 0.4s depends on frame times, so this failed only under + # load. pivot_hold_amount is the same quantity without the wrap. + var turned_while_steering: float = controller.pivot_hold_amount - hold_amount_at_start print("turned %.3f rad while steering" % turned_while_steering) if turned_while_steering < 0.05: print("TEST_PIVOT_CONTROLS: FAIL (no rotation while the stick was held)") diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index 109bfa2..8a78fce 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -64,6 +64,12 @@ func _initialize() -> void: current_scene = main_scene fleet = main_scene.get_node("Fleet") + # A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE + # (3) slots. This test needs a crewed fleet, so it asks for one outright + # rather than inheriting whatever a fresh game happens to open with - + # which is exactly what changed underneath it. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) planet_field = main_scene.get_node("PlanetField") landing_controller = main_scene.get_node("LandingController") asteroid_field_ref = main_scene.get_node("AsteroidField") diff --git a/tests/test_planet_landing_proximity.gd b/tests/test_planet_landing_proximity.gd index aa42319..bdc98c6 100644 --- a/tests/test_planet_landing_proximity.gd +++ b/tests/test_planet_landing_proximity.gd @@ -20,7 +20,13 @@ func _initialize() -> void: 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): + # Not a black hole: it destroys ships on contact, and this + # scan takes the FIRST planet it finds. That used to be the gas + # giant guaranteed in the origin chunk; with that guarantee gone + # the scan can land the fleet on something that kills it, which + # is not what either of these tests is about. + if WorldGen.has_planet_at_chunk(chunk) \ + and String(WorldGen.planet_data_at_chunk(chunk).get("definition_id", "")) != "black_hole": target_chunk = chunk found = true break @@ -57,6 +63,18 @@ func _process(delta: float) -> bool: # complete (fleet.ships is reliably populated by now), it's safe. main_scene.get_node("AsteroidField").set_process(false) main_scene.get_node("EnemySpawner").set_process(false) + # Crew the fleet HERE rather than in _initialize(): work done + # there can be undone by the engine's deferred tree-entry + # finalization, the same reason the set_process(false) calls + # above live at this point. A new game flies one vessel now. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) + # Silencing the field does not remove what it already placed, and + # those keep drifting - a rock taking the fleet out mid-test reads + # here as a landing failure. + for rock in get_nodes_in_group("asteroids"): + if is_instance_valid(rock): + rock.queue_free() # PlanetField only scans chunks near the fleet's CURRENT # position, so it never even looks at target_chunk unless the diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd index 72adc94..0a2cf69 100644 --- a/tests/test_research_points.gd +++ b/tests/test_research_points.gd @@ -61,6 +61,10 @@ func _initialize() -> void: # --- F. a fresh game still reaches a crafter and an extractor ------------- game_state.reset_new_game() + # can_unlock() also checks the ore price, and a new game now banks + # nothing - the opening mining run is the point. Stock the ore so this + # checks what it says it checks: reachability at ZERO RESEARCH POINTS. + game_state.rocks = 500 if not game_state.can_unlock("shipyards"): return _fail("shipyards is not reachable with 0 research points") game_state.unlock("shipyards") diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index 8854082..90299f7 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -55,6 +55,12 @@ func _initialize() -> void: scene = load("res://Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") + # A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE + # (3) slots. This test needs a crewed fleet, so it asks for one outright + # rather than inheriting whatever a fresh game happens to open with - + # which is exactly what changed underneath it. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) controller = scene.get_node("BuildController") diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 17c6780..a954b8c 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -104,6 +104,10 @@ func _process(delta: float) -> bool: if elapsed < 0.3: return false # --- crafter adds a ship -------------------------------------- + # Crafting adds a FOURTH vessel below, one past the 3 slots a + # new game has, so make room first - the subject here is the + # crafter, not the fleet cap. + game_state.unlocked.append_array(["wing", "squadron", "armada"]) fleet.spawn_ships(3, Vector2.ZERO) var before_ships: int = fleet.ships.size() var before_rocks: int = game_state.rocks diff --git a/tests/test_return_position_matches_planet.gd b/tests/test_return_position_matches_planet.gd index c7550c3..99b4beb 100644 --- a/tests/test_return_position_matches_planet.gd +++ b/tests/test_return_position_matches_planet.gd @@ -25,7 +25,13 @@ func _initialize() -> void: 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): + # Not a black hole: it destroys ships on contact, and this + # scan takes the FIRST planet it finds. That used to be the gas + # giant guaranteed in the origin chunk; with that guarantee gone + # the scan can land the fleet on something that kills it, which + # is not what either of these tests is about. + if WorldGen.has_planet_at_chunk(chunk) \ + and String(WorldGen.planet_data_at_chunk(chunk).get("definition_id", "")) != "black_hole": target_chunk = chunk found = true break @@ -53,6 +59,18 @@ func _process(delta: float) -> bool: 0: main_scene.get_node("AsteroidField").set_process(false) main_scene.get_node("EnemySpawner").set_process(false) + # Crew the fleet HERE rather than in _initialize(): work done + # there can be undone by the engine's deferred tree-entry + # finalization, the same reason the set_process(false) calls + # above live at this point. A new game flies one vessel now. + root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"]) + fleet.spawn_ships(5, Vector2.ZERO) + # Silencing the field does not remove what it already placed, and + # those keep drifting - a rock taking the fleet out mid-test reads + # here as a landing failure. + for rock in get_nodes_in_group("asteroids"): + if is_instance_valid(rock): + rock.queue_free() var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5 for ship in fleet.ships: ship.position = vicinity_pos