From df4d5e35f947b9c2059dc60842cf66bddbebc819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Mon, 7 Sep 2026 13:55:41 +0200 Subject: [PATCH] Find the planet setups in an exported build The registry scanned res://PlanetSetups for names ending ".tres". An export does not ship those: text resources are converted to binary and the pack carries ".tres.remap", which ResourceLoader follows when asked for the original path. So the scan found nothing in the web build, every planet drew no setup, and since #211 the setup IS the garrison - every world in the deployed game was deserted, with nothing logged to say why. Confirmed by reading index.pck, which holds rocky_default.tres.remap and no .tres. The suite runs from source, where the bug cannot occur, so the name resolution is its own function and tested against the exact strings the pack contains. Also: the planning centre listed transporters and repair drones but set_vessel_target() still refused anything not fleet-eligible, so both rows had buttons that did nothing - the worst shape a refusal can take. Listing and setting read the same rule now, and a standing order is only offered to a crafter that can actually take it. And two quests close the chain: the tentacle world, which the scanner will not offer until the story hands it over, and the Warden itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- core/GameState.gd | 30 ++++++++++- core/Quests.gd | 37 +++++++++++++- surface/PlanetSetups.gd | 29 ++++++++++- tests/test_planet_scanner.gd | 12 ++++- tests/test_planet_setup_export_names.gd | 67 +++++++++++++++++++++++++ tests/test_planning_center.gd | 12 ++++- 6 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 tests/test_planet_setup_export_names.gd diff --git a/core/GameState.gd b/core/GameState.gd index 8ce6917..fcab54e 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -1005,13 +1005,29 @@ func reset_new_game() -> void: reset_formation() +# The chain has to have SENT the player after it. The tentacle world is a +# destination the story hands over, not something to find while scanning for +# ore - so the scanner carries its signature without offering it until the +# quest that names it is live. +# +# The four survey signatures are still required. They are implied by the +# chain reaching that quest, but the rule is about what the SCANNER has +# learned, and it should read that way whatever order the two arrive in. func scanner_can_find_boss() -> bool: + if not quest_reached("tentacles"): + return false for definition_id in SCANNER_SURVEY_TYPES: if not visited_planet_kinds.has(definition_id): return false return true +# Has the chain arrived at this quest - handed it out, or gone past it? +func quest_reached(quest_id: String) -> bool: + var at: int = QUESTS.index_of(quest_id) + return at >= 0 and quest_index >= at + + func scanner_available_planet_types() -> PackedStringArray: var available: PackedStringArray = SCANNER_SURVEY_TYPES.duplicate() if scanner_can_find_boss(): @@ -2217,7 +2233,11 @@ func vessel_target(vessel_type: String) -> int: func set_vessel_target(vessel_type: String, count: int) -> int: - if not vessel_is_fleet_eligible(vessel_type): + # Plannable means CRAFTABLE, not fleet-eligible. #221 listed the support + # craft in the planning centre and left this guard reading the old rule, + # so those two rows appeared with buttons that silently did nothing - + # the worst shape a refusal can take. + if not planned_vessel_types().has(vessel_type): return 0 var final_count: int = maxi(count, 0) if final_count == 0: @@ -2360,7 +2380,10 @@ func _advance_planning() -> void: return for vessel_type in vessel_targets: var type_id: String = String(vessel_type) - if not vessel_is_unlocked(type_id): + # Not merely unlocked: orderable. A standing order for a transporter + # with no hub standing would be re-offered to a crafter every pass + # and refused every time. + if not vessel_can_be_crafted(type_id): continue var missing: int = vessel_shortfall(type_id) if missing <= 0: @@ -3986,6 +4009,9 @@ func quest_progress(quest: Dictionary) -> Vector2i: QUESTS.GOAL_PLANET_VISITS: var visits: int = int(goal.get("count", 1)) return Vector2i(mini(visited_planets.size(), visits), visits) + QUESTS.GOAL_WORLD_VISITED: + var kind: String = String(goal.get("definition", "")) + return Vector2i(1 if visited_planet_kinds.has(kind) else 0, 1) QUESTS.GOAL_PLANET_KINDS: var kinds: int = int(goal.get("count", 1)) return Vector2i(mini(visited_planet_kinds.size(), kinds), kinds) diff --git a/core/Quests.gd b/core/Quests.gd index ad8c449..d28e8d7 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -61,6 +61,10 @@ const GOAL_STATION_CREW: String = "station_crew" # expensive way. This goal is "there are resources sitting in the thing", # which is the step to teach before the berth is ever crewed. const GOAL_BUILDING_STOCK: String = "building_stock" +# One NAMED world, rather than "four different kinds". The tentacle world is +# a place the story sends the player to, so the chain has to be able to say +# "go there" and know when they have. +const GOAL_WORLD_VISITED: String = "world_visited" # #167: which quests carry the STORY, marked `"important": true` below. # @@ -368,7 +372,28 @@ const CHAIN: Array = [ "objective": "Chart 150 sectors", "goal": {"kind": GOAL_EXPLORE, "sectors": 150}, "brief": "Then we search wider. A hundred and fifty sectors, and every one of them looked at properly. They are out there somewhere.", - "done": "A hundred and fifty sectors charted, and still no trail. The search does not stop just because the list ran out.", + "done": "A hundred and fifty sectors charted, and still no trail - but the scanner has been carrying a signature it could not place, and now there is enough of the map behind us to place it.", + "important": true, + }, + { + # The scanner will not offer this world until this quest is live: it + # is a destination the story hands over, not something to stumble + # into while looking for ore. See GameState.scanner_can_find_boss(). + "id": "tentacles", + "title": "THE SIGNATURE", + "objective": "Land on the tentacle world", + "goal": {"kind": GOAL_WORLD_VISITED, "definition": "boss"}, + "brief": "One signature on the scanner matches nothing we have charted, and everything near it is dead. Scan for it and go down there. Whatever is holding that world is what has been out here with us the whole time.", + "done": "We are on the surface, and it is awake. It has been waiting a long time for someone to come down.", + "important": true, + }, + { + "id": "warden", + "title": "THE WARDEN", + "objective": "Destroy the Warden", + "goal": {"kind": GOAL_CONQUERED, "definition": "boss", "count": 1}, + "brief": "It is between us and everything past this place. Bring what the fleet has - the beam reaches furthest, the bomb hits hardest - and take its world off it.", + "done": "The Warden is dead and its world is ours. Whatever it was keeping out there, nothing is keeping it any more.", "important": true, }, ] @@ -378,6 +403,16 @@ static func count() -> int: return CHAIN.size() +# Where a quest sits in the chain, or -1 for an id that is not in it. Used to +# gate world state on the story having reached a point (#: the tentacle world +# is hidden from the scanner until the chain sends the player after it). +static func index_of(quest_id: String) -> int: + for i in range(CHAIN.size()): + if String(CHAIN[i].id) == quest_id: + return i + return -1 + + static func at(index: int) -> Dictionary: if index < 0 or index >= CHAIN.size(): return {} diff --git a/surface/PlanetSetups.gd b/surface/PlanetSetups.gd index e1d35dd..acf078d 100644 --- a/surface/PlanetSetups.gd +++ b/surface/PlanetSetups.gd @@ -14,6 +14,28 @@ const SETUPS_DIR: String = "res://PlanetSetups" # registered it, while a direct script preload never depends on that cache. const PLANET_SETUP_SCRIPT: Script = preload("res://surface/PlanetSetup.gd") + +# The name to LOAD for a directory entry, or "" for an entry that is not a +# setup at all. +# +# An export does not ship the .tres. Text resources are converted to binary +# and the pack carries ".tres.remap" beside them - the remap points at +# the converted file, and ResourceLoader follows it when asked for the +# ORIGINAL path. So a scan that matches on ".tres" finds every setup when run +# from source and none at all in an exported build, which is what emptied +# every planet of its garrison on the web version: no setups, no enemies, and +# nothing logged anywhere to say so. +# +# The suite runs from source, so nothing here can catch that by running the +# game - this function exists to be tested directly. +static func setup_file_name(entry: String) -> String: + var name: String = entry + if name.ends_with(".remap"): + name = name.get_basename() + if name.ends_with(".tres") or name.ends_with(".res"): + return name + return "" + static var _all: Array = [] static var _scanned: bool = false # Which setup each planet drew, once it has drawn one. See setup_for_planet. @@ -29,8 +51,11 @@ static func _ensure_scanned() -> void: return dir.list_dir_begin() var file_name: String = dir.get_next() + var seen: Dictionary = {} while file_name != "": - if not dir.current_is_dir() and file_name.ends_with(".tres"): + var setup_file: String = setup_file_name(file_name) + if not dir.current_is_dir() and setup_file != "" and not seen.has(setup_file): + seen[setup_file] = true # CACHE_MODE_REPLACE, not a plain load(). Godot caches resources by # path, so after the map editor saved a setup this handed back the # CACHED copy - still holding the values from before the save. The @@ -42,7 +67,7 @@ static func _ensure_scanned() -> void: # existing instance in place instead of minting a new one, so # object identity across a rescan survives - which the per-planet # map assignments depend on (see setup_for_planet). - var res: Resource = ResourceLoader.load(SETUPS_DIR + "/" + file_name, + var res: Resource = ResourceLoader.load(SETUPS_DIR + "/" + setup_file, "", ResourceLoader.CACHE_MODE_REPLACE) if res != null and res.get_script() == PLANET_SETUP_SCRIPT: _all.append(res) diff --git a/tests/test_planet_scanner.gd b/tests/test_planet_scanner.gd index 2ee00b2..f83ab86 100644 --- a/tests/test_planet_scanner.gd +++ b/tests/test_planet_scanner.gd @@ -93,9 +93,17 @@ func _run() -> void: for definition_id in GameState.SCANNER_SURVEY_TYPES: state.note_planet_visited("scanner:test:%s" % definition_id, definition_id) menu.refresh() + # The survey alone is not enough any more: the tentacle world is offered + # only once the chain sends the player after it, so a player who has + # charted four kinds early cannot stumble into the endgame. + if menu.get_node("BossButton").visible: + return _fail("the boss signature appeared on the survey alone, before the quest that names it") + state.quest_index = QUESTS.index_of("tentacles") + menu.refresh() if not menu.get_node("BossButton").visible \ or not menu.get_node("BossLabel").visible: - return _fail("the fifth scanner option did not appear after four planet types") + return _fail("the fifth scanner option did not appear once its quest was live") + print("the boss signature waits for BOTH the four-world survey and its own quest") var boss: Dictionary = state.scan_for_planet(PlanetDefinitions.BOSS_ID, Vector2.ZERO) if boss.is_empty() or Vector2i(boss.chunk) != WorldGen.OCTOPUS_BIOME_CENTER: @@ -107,7 +115,7 @@ func _run() -> void: Vector2(boss.world_position) * map_content.WORLD_TO_LOCAL) > 0.01: return _fail("the active scan has no matching destination point on the map") map_content.free() - print("researched scanner reveals generated destinations; the fifth option waits for 4/4") + print("researched scanner reveals generated destinations") # #192: the scanner is taught immediately before the quest that needs it, # and before every later one that does. diff --git a/tests/test_planet_setup_export_names.gd b/tests/test_planet_setup_export_names.gd new file mode 100644 index 0000000..eae8ee2 --- /dev/null +++ b/tests/test_planet_setup_export_names.gd @@ -0,0 +1,67 @@ +extends SceneTree + +# The setup registry has to find its maps in an EXPORTED build, not only when +# run from source. +# +# An export converts text resources to binary and ships ".tres.remap" +# in place of ".tres" - the remap points at the converted file and +# ResourceLoader follows it when asked for the original path. The scan matched +# on ".tres", so in the exported web build it found nothing: every planet drew +# no setup, and since #211 the setup IS the garrison, so every world was +# empty. Nothing was logged; the planets were simply deserted. +# +# The suite runs from source, where the bug cannot appear, so this tests the +# name resolution directly against what a pack actually contains. +# +# Covers: +# A. the names a source tree lists +# B. the names an exported pack lists - the case that was broken +# C. things that are not setups are still ignored +# D. and the shipped maps really are all found when run from source + +const SETUPS = preload("res://surface/PlanetSetups.gd") + + +func _initialize() -> void: + # --- A. from source ------------------------------------------------ + for name in ["rocky_default.tres", "gas_default.tres"]: + if SETUPS.setup_file_name(name) != name: + return _fail("'%s' is not recognised from a source tree" % name) + + # --- B. from a pack ------------------------------------------------ + # Exactly the entries found inside the built index.pck. + for name in ["rocky_default.tres.remap", "ResearchLabSetup.tres.remap", + "researtch_lab_garrison.tres.remap", "gas_default.tres.remap"]: + var resolved: String = SETUPS.setup_file_name(name) + if resolved != name.get_basename(): + return _fail("exported entry '%s' resolved to '%s', expected '%s'" \ + % [name, resolved, name.get_basename()]) + # A binary resource shipped directly is a setup too. + if SETUPS.setup_file_name("rocky_default.res") != "rocky_default.res": + return _fail("a binary .res setup is not recognised") + print("both spellings resolve: rocky_default.tres and .tres.remap") + + # --- C. and nothing else is dragged in ----------------------------- + for name in ["rocky_default.tres.import", "notes.txt", "icon.png", + "rocky_default.png.remap", ""]: + if SETUPS.setup_file_name(name) != "": + return _fail("'%s' was taken for a setup" % name) + print("non-setup entries are ignored, remapped or not") + + # --- D. the shipped maps are all found ----------------------------- + # Guards the other half: a resolver that is right about names is no use + # if the scan around it stops finding the real files. + var found: Array = [] + for setup in SETUPS.all(): + found.append(String(setup.setup_id)) + if found.size() < 4: + return _fail("the registry found %d shipped setups: %s" % [found.size(), found]) + print("registry finds %d shipped setups: %s" % [found.size(), found]) + + print("TEST_PLANET_SETUP_EXPORT_NAMES: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_PLANET_SETUP_EXPORT_NAMES: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_planning_center.gd b/tests/test_planning_center.gd index 7dd2b99..f3dfa03 100644 --- a/tests/test_planning_center.gd +++ b/tests/test_planning_center.gd @@ -87,7 +87,17 @@ func _state_cases() -> bool: return _fail("a transporter standing at a hub is counted %d times, expected once" \ % game_state.vessel_census(GameState.VESSEL_TRANSPORTER)) game_state.remove_buildings_of_type(GameState.BUILDING_TRANSPORT_HUB) - print("support craft are plannable, and counted where they stand") + # Listed is not enough - the buttons have to move the number. #221 left + # the setter reading the old fleet-eligible rule, so both rows appeared + # with controls that did nothing at all. + for support in [GameState.VESSEL_TRANSPORTER, GameState.REPAIR_DRONE]: + if game_state.adjust_vessel_target(support, 2) != 2: + return _fail("'%s' is listed in the planning centre but its target cannot be raised" \ + % support) + if game_state.vessel_target(support) != 2: + return _fail("'%s' target did not stick at 2" % support) + game_state.set_vessel_target(support, 0) + print("support craft are plannable, settable, and counted where they stand") game_state.fleet_vessel_types = PackedStringArray([interceptor, interceptor]) game_state.stored_vessels.append(interceptor) game_state.set_garrison_desired(outpost_id, interceptor, 1)