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 ".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/.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