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
168 lines
6.9 KiB
GDScript
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
|