spaceship/surface/PlanetSetups.gd
Loïc Wisniewski df4d5e35f9 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 "<name>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-07 13:55:41 +02:00

168 lines
6.9 KiB
GDScript

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://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 "<name>.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.
static var _assigned: Dictionary = {}
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()
var seen: Dictionary = {}
while file_name != "":
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
# editor called rescan() and the pool dutifully re-served the stale
# data, which is what "I changed the research points and it was not
# saved" actually was: the file on disk was correct all along.
#
# REPLACE rather than IGNORE deliberately: it refreshes the
# 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 + "/" + setup_file,
"", ResourceLoader.CACHE_MODE_REPLACE)
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()
# Assignments are PRUNED, not cleared. Clearing them would undo the whole
# point of remembering: saving one map in the editor would silently re-deal
# every planet the player had already visited. What has to go is only the
# entries pointing at a setup that no longer exists - a deleted map must
# not stay pinned to a planet.
var live: Dictionary = {}
for setup in _all:
live[setup] = true
for key in _assigned.keys():
if not live.has(_assigned[key]):
_assigned.erase(key)
static func all() -> Array:
_ensure_scanned()
return _all
static func find_by_id(setup_id: String) -> Resource:
for setup in all():
if String(setup.setup_id) == setup_id:
return setup
return null
static func path_for_id(setup_id: String) -> String:
# The file a setup actually LIVES in, which is not reliably
# "PlanetSetups/<setup_id>.tres". Authored resources carry whatever
# filename they were created with, and at least one shipped setup has an
# id that differs from its filename outright (ResearchLabSetup.tres holds
# setup_id "research_lab_1"). Deriving the path from the id instead of
# asking the resource broke two things at once: deleting such a setup
# looked for a file that does not exist and silently refused, and saving
# one wrote a SECOND file under the derived name, leaving the original
# behind with the old contents - which reads exactly like "my change was
# not saved", because the stale twin is still in the pool.
var setup: Resource = find_by_id(setup_id)
if setup != null and String(setup.resource_path) != "":
return String(setup.resource_path)
return "%s/%s.tres" % [SETUPS_DIR, setup_id]
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)
# Sorted by id, because the order they arrive in is whatever order the
# filesystem listed them in - which is not something to seed a choice
# with. The pick below indexes into this array, so an unstable order
# means the same planet can land on a different map from one run to the
# next, and the "this is MY planet's layout" illusion goes with it.
result.sort_custom(func(a, b): return String(a.setup_id) < String(b.setup_id))
return result
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
# Every planet gets a map of its own type, drawn at random, and keeps it.
#
# The draw is seeded purely from the planet's own stable id - same pattern
# as WorldGen.planet_data_at_chunk - so leaving a planet and coming back
# lands on the same layout without anything having to be saved.
#
# The result is then remembered anyway, because the seed alone is not
# quite enough: the pool it indexes into changes the moment the map editor
# saves a new setup, which would silently re-deal every planet in the
# middle of a session. Once a planet has been visited its map is pinned
# for as long as the process lives.
#
# Keyed on the profile too, not the planet alone: the memo must never be
# able to hand back a gas map for a rocky query just because that planet
# id was seen first.
var key: String = "%s|%s" % [planet_id, profile_id]
var remembered: Resource = _assigned.get(key)
if remembered != null:
return remembered
var candidates: Array = setups_for_profile(profile_id)
if candidates.is_empty():
return null
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = hash(planet_id)
var chosen: Resource = candidates[rng.randi() % candidates.size()]
_assigned[key] = chosen
return chosen