class_name SaveGame extends RefCounted # Forgejo #225. Writing the run to disk and reading it back. # # Saves are JSON rather than a binary resource: a save that can be opened in # a text editor can be diagnosed from a bug report, and ResourceSaver would # tie the file's shape to GDScript class layout, which is exactly the thing # that keeps changing. # # WHAT IS SAVED is an explicit list (FIELDS), not every var on GameState. # A blind dump would carry transient junk - which pointer last touched the # screen, whether a dialog is currently holding the camera - and would break # silently every time a field was renamed. The cost is that a new piece of # progression has to be added to the list; the benefit is that forgetting to # is a missing feature rather than a corrupt save. const SAVE_PATH: String = "user://savegame.json" # Bumped whenever the SHAPE of the saved data changes - a field renamed, a # structure reorganised. Adding a brand new field does not need a bump: it # simply arrives missing from old saves and keeps whatever GameState starts # it at. const SAVE_VERSION: int = 1 # The oldest version this build can still open. Raising it is how a format # change too messy to migrate is DECLARED rather than bodged: #225 is # explicit that breaking compatibility is the right call when the transition # is too complex, and a save that loads subtly WRONG is far worse than one # that refuses to load and says so. const MIN_SUPPORTED_VERSION: int = 1 # version N -> N+1, keyed by the version being migrated FROM. A save is # walked up one step at a time until it reaches SAVE_VERSION. A gap here is # not an oversight - it is the declaration that saves below it are # incompatible, and load() refuses them by name rather than guessing. const MIGRATIONS: Dictionary = {} # The run, field by field. Grouped by what they mean rather than by type, so # it is obvious what a new piece of progression sits next to. const FIELDS: Array = [ # The purse and the hold. "bank_inventory", "fleet_inventory", # What has been built, and what has been taken. "buildings", "next_building_id", "extractors", "planet_capture", # What has been learned. "unlocked", "research_points", "vessel_levels", # The fleet itself. "fleet_position", "fleet_vessel_types", "surviving_ship_count", "surviving_vessel_types", "formation", "formation_rotation", "stored_vessels", "vessel_targets", "vessel_deliveries", "repair_drones", "max_fleet_speed_multiplier", "fleet_speed_multiplier", "build_time_scale", # The world as the player has seen it. "explored_chunks", "scanner_target", # The siege. "wave_number", "wave_phase", "wave_timer", # The campaign. "quests_armed", "quest_index", "quest_announced", "quest_messages", "visited_planets", "visited_planet_kinds", ] static func has_save(path: String = SAVE_PATH) -> bool: return FileAccess.file_exists(path) # What the "load your last run?" prompt needs, without committing to loading # it. Returns an empty dictionary when there is nothing to offer. static func peek(path: String = SAVE_PATH) -> Dictionary: if not FileAccess.file_exists(path): return {} var raw: Dictionary = _read(path) if raw.is_empty(): return {} return { "version": int(raw.get("version", 0)), "saved_at": String(raw.get("saved_at", "")), "loadable": _refusal(int(raw.get("version", 0))) == "", "refusal": _refusal(int(raw.get("version", 0))), } static func save(state: Node, path: String = SAVE_PATH) -> bool: if state == null: return false var fields: Dictionary = {} for name in FIELDS: fields[name] = _encode(state.get(name)) var payload: Dictionary = { "version": SAVE_VERSION, "saved_at": Time.get_datetime_string_from_system(true), "fields": fields, } var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) if file == null: return false file.store_string(JSON.stringify(payload, "\t")) file.close() return true # Applies a save to GameState. Returns {ok, reason} - reason is empty on # success and player-readable on failure, because "it did not load" with no # explanation is the thing that makes people delete their save folder. static func load_into(state: Node, path: String = SAVE_PATH) -> Dictionary: if state == null: return {"ok": false, "reason": "No game to load into."} if not FileAccess.file_exists(path): return {"ok": false, "reason": "There is no saved run."} var raw: Dictionary = _read(path) if raw.is_empty(): return {"ok": false, "reason": "The saved run could not be read - the file is damaged."} var version: int = int(raw.get("version", 0)) var refusal: String = _refusal(version) if refusal != "": return {"ok": false, "reason": refusal} var fields: Dictionary = raw.get("fields", {}) if not (fields is Dictionary) or fields.is_empty(): return {"ok": false, "reason": "The saved run has no contents."} while version < SAVE_VERSION: fields = (MIGRATIONS[version] as Callable).call(fields) version += 1 for name in FIELDS: # A field the save predates keeps whatever a new game starts it at, # which is what makes ADDING progression a non-breaking change. if fields.has(name): state.set(name, _decode(fields[name])) return {"ok": true, "reason": ""} static func clear(path: String = SAVE_PATH) -> void: if FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) # Why this version cannot be opened, or "" if it can. static func _refusal(version: int) -> String: if version <= 0: return "The saved run does not say what version it is." if version > SAVE_VERSION: return "That run was saved by a NEWER version of the game (save %d, this build reads %d)." \ % [version, SAVE_VERSION] if version < MIN_SUPPORTED_VERSION: return "That run was saved by an older version of the game (save %d) and cannot be opened by this one." \ % version var at: int = version while at < SAVE_VERSION: if not MIGRATIONS.has(at): return "That run was saved by an older version of the game (save %d) and there is no way to bring it forward." \ % version at += 1 return "" static func _read(path: String) -> Dictionary: var file: FileAccess = FileAccess.open(path, FileAccess.READ) if file == null: return {} var text: String = file.get_as_text() file.close() var parsed: Variant = JSON.parse_string(text) return parsed if parsed is Dictionary else {} # --- the type-tagged codec --------------------------------------------- # JSON has numbers, strings, arrays and string-keyed objects. The run has # Vector2s, chunk coordinates used AS dictionary keys, and Inventory # objects, so each of those is written with a tag saying what to rebuild. static func _encode(value: Variant) -> Variant: if value is Vector2: return {"__t": "v2", "x": value.x, "y": value.y} if value is Vector2i: return {"__t": "v2i", "x": value.x, "y": value.y} if value is PackedStringArray: return {"__t": "psa", "v": Array(value)} if value is Inventory: return {"__t": "inv", "a": value.snapshot(), "cap": value.total_capacity, "caps": value.resource_capacities.duplicate()} if value is Array: var out: Array = [] for item in value: out.append(_encode(item)) return out if value is Dictionary: var out_d: Dictionary = {} for key in value: out_d[_encode_key(key)] = _encode(value[key]) return out_d return value static func _decode(value: Variant) -> Variant: if value is Array: var out: Array = [] for item in value: out.append(_decode(item)) return out if value is Dictionary: match String(value.get("__t", "")): "v2": return Vector2(float(value.get("x", 0.0)), float(value.get("y", 0.0))) "v2i": return Vector2i(int(value.get("x", 0)), int(value.get("y", 0))) "psa": return PackedStringArray(value.get("v", [])) "inv": var inv: Inventory = Inventory.new() inv.total_capacity = int(value.get("cap", -1)) for id in Dictionary(value.get("caps", {})): inv.resource_capacities[String(id)] = int(value["caps"][id]) for id in Dictionary(value.get("a", {})): inv.set_amount(String(id), int(value["a"][id])) return inv var out_d: Dictionary = {} for key in value: out_d[_decode_key(String(key))] = _decode(value[key]) return out_d return value # Every key carries its type, so a planet id that happens to look like a # chunk coordinate cannot be mistaken for one on the way back in. static func _encode_key(key: Variant) -> String: if key is Vector2i: return "v2i:%d,%d" % [key.x, key.y] if key is int: return "i:%d" % key return "s:%s" % String(key) static func _decode_key(key: String) -> Variant: if key.begins_with("v2i:"): var parts: PackedStringArray = key.substr(4).split(",") if parts.size() == 2: return Vector2i(int(parts[0]), int(parts[1])) return key if key.begins_with("i:"): return int(key.substr(2)) if key.begins_with("s:"): return key.substr(2) return key