diff --git a/core/GameState.gd b/core/GameState.gd index b8a8e5a..f804ecf 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -20,6 +20,10 @@ const CATALOG_CHECK = preload("res://core/CatalogCheck.gd") # survive the trip has to live here instead. var has_return_position: bool = false +# Set by the menu when a save has just been loaded, cleared by whoever acts +# on it - the same one-shot handoff as has_return_position above, and +# deliberately NOT saved: it describes this launch, not the run. +var resumed_from_save: bool = false var surviving_ship_count: int = STARTING_SHIP_COUNT var surviving_vessel_types: PackedStringArray = PackedStringArray() # #106: a mirror of what is flying, pushed by Fleet._sync_composition_state() diff --git a/planets/PlanetAtmosphereController.gd b/planets/PlanetAtmosphereController.gd index 75a93fb..7eb23e1 100644 --- a/planets/PlanetAtmosphereController.gd +++ b/planets/PlanetAtmosphereController.gd @@ -39,6 +39,23 @@ func _ready() -> void: camera.position = GameState.return_world_position camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false + elif GameState.resumed_from_save: + # #227: a resumed run used to fall through to the brand-new-game + # branch below, which put the STARTING fleet at the world origin - + # so loading a save stranded the player in empty space with the + # wrong ships while their base sat wherever they had left it. + # + # The mothership is where a wiped-out fleet is already rebuilt (see + # BuildController._check_fleet_wipe), so it is the run's home whether + # or not this is a resume. Falls back to the position the save was + # written at, and only then to the origin, because a run can have no + # mothership standing. + var home: Variant = GameState.get_mothership_position() + var at: Vector2 = home if home != null else GameState.fleet_position + fleet.spawn_ships(GameState.fleet_vessel_types.size(), at, + GameState.fleet_vessel_types) + camera.position = at + GameState.resumed_from_save = false elif GameState.intro_active: # #108: the prologue opens with a convoy, and crews it itself. This # node still runs first, so spawning the ordinary opening fleet here diff --git a/tests/test_resume_spawn.gd b/tests/test_resume_spawn.gd new file mode 100644 index 0000000..f32a09b --- /dev/null +++ b/tests/test_resume_spawn.gd @@ -0,0 +1,150 @@ +extends SceneTree + +# Forgejo #227: a resumed run comes back at its own mothership. +# +# Loading a save used to fall through to the brand-new-game branch in +# PlanetAtmosphereController, which spawns STARTING_SHIP_COUNT interceptors +# at the world origin. So a resume stranded the player in empty space with +# the wrong ships, while their base sat wherever they had left it - which on +# a charted map is thousands of units away. +# +# Covers: +# A. resuming puts the fleet at the mothership, not at the origin +# B. and crews it with the ships the save actually had +# C. with no mothership standing, it falls back to where the save was made +# D. a NEW game is untouched - still the starting fleet at the origin + +const HOME: Vector2 = Vector2(7400.0, -3100.0) +const AWAY: Vector2 = Vector2(-5200.0, 900.0) + +var game_state: Node +var scene: Node +var elapsed: float = 0.0 +var stage: int = 0 +var expected_crew: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + SaveGame.clear() + + +func _fresh_state() -> void: + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_new_game() + game_state.reset_quests() + game_state.intro_active = false + + +func _load_scene() -> void: + if scene != null and is_instance_valid(scene): + scene.free() + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + # A run with a base at HOME and the fleet parked far from it. + _fresh_state() + game_state.add_building(GameState.BUILDING_MOTHERSHIP, HOME) + # Capacity for the crew below. Since #219 the fleet holds + # BASE_FLEET_SIZE (1) plus one berth per supplied fleet office, + # so a save with three ships flying is a save that had two + # offices standing - without them spawn_ships clamps to 1 and + # this would assert nothing. + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, HOME + Vector2(400.0, 0.0)) + game_state.add_building(GameState.BUILDING_FLEET_OFFICE, HOME + Vector2(800.0, 0.0)) + game_state.fleet_vessel_types = PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_INTERCEPTOR]) + game_state.fleet_position = AWAY + expected_crew = game_state.fleet_vessel_types.size() + if game_state.max_fleet_size() < expected_crew: + return _fail("the fleet cannot hold %d, so a resumed crew would prove nothing (cap %d)" \ + % [expected_crew, game_state.max_fleet_size()]) + if not SaveGame.save(game_state): + return _fail("could not write the save this test resumes from") + _fresh_state() + var result: Dictionary = SaveGame.load_into(game_state) + if not bool(result.get("ok", false)): + return _fail("the save would not load: %s" % result.get("reason", "")) + game_state.resumed_from_save = true + _load_scene() + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.6: + return false + var fleet: Node2D = scene.get_node("Fleet") + # --- A. at the mothership, not the origin ------------------- + var at: Vector2 = fleet.get_centroid() + if at.distance_to(HOME) > 400.0: + return _fail("resumed at %s, but the mothership is at %s" % [at, HOME]) + if at.distance_to(Vector2.ZERO) < 400.0: + return _fail("resumed at the world origin, which is the new-game behaviour") + print("resumed at the mothership (%.0f units from it)" % at.distance_to(HOME)) + + # --- B. with the crew the save had -------------------------- + if fleet.ships.size() != expected_crew: + return _fail("resumed with %d ships, the save had %d" \ + % [fleet.ships.size(), expected_crew]) + print("resumed with the saved crew of %d" % fleet.ships.size()) + if game_state.resumed_from_save: + return _fail("the resume flag was left raised, so the next scene would re-use it") + + # --- C. no mothership: fall back to where the save was made -- + _fresh_state() + game_state.fleet_vessel_types = PackedStringArray([GameState.VESSEL_INTERCEPTOR]) + game_state.fleet_position = AWAY + game_state.resumed_from_save = true + _load_scene() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.6: + return false + var fleet2: Node2D = scene.get_node("Fleet") + var at2: Vector2 = fleet2.get_centroid() + if at2.distance_to(AWAY) > 400.0: + return _fail("with no mothership, resumed at %s rather than the saved %s" % [at2, AWAY]) + print("with no mothership, resumed where the save was written") + + # --- D. a new game is untouched ----------------------------- + _fresh_state() + game_state.resumed_from_save = false + _load_scene() + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.6: + return false + var fleet3: Node2D = scene.get_node("Fleet") + if fleet3.get_centroid().distance_to(Vector2.ZERO) > 400.0: + return _fail("a new game no longer starts at the origin (%s)" % fleet3.get_centroid()) + if fleet3.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("a new game starts with %d ships, expected %d" \ + % [fleet3.ships.size(), GameState.STARTING_SHIP_COUNT]) + print("a new game still starts with %d ships at the origin" % fleet3.ships.size()) + + SaveGame.clear() + print("TEST_RESUME_SPAWN: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_RESUME_SPAWN: FAIL (%s)" % message) + quit(1) + return true diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 3d639c5..214d83f 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -140,6 +140,7 @@ func _on_continue_pressed() -> void: # A resumed run never replays the prologue, whatever it was doing when # it was written. GameState.intro_active = false + GameState.resumed_from_save = true _start("res://world/Main.tscn")