#225: save the run, and offer to resume it

JSON at user://savegame.json rather than a binary resource: a save that can
be opened in a text editor is diagnosable from a bug report, and
ResourceSaver would tie the file's shape to GDScript class layout, which is
the thing that keeps changing.

The run is not plain data, which is the actual work here. A tagged codec
carries Vector2, Vector2i, PackedStringArray and the Inventory objects
nested inside `buildings`. explored_chunks is keyed by Vector2i and JSON has
no such key, so every key carries its own type prefix ("v2i:3,-4") - without
that a planet id could come back as a chunk coordinate.

WHAT is saved is an explicit list of 30 fields, not a dump of GameState's
58 vars. A dump would carry transient junk - which pointer last touched the
screen, whether a dialog holds the camera - and would break silently on
every rename. The cost is that new progression must be added to the list;
the benefit is that forgetting is a missing feature, not a corrupt save.

Versioning, as the ticket asked. MIGRATIONS walks a save up one version at a
time; MIN_SUPPORTED_VERSION is how a break too messy to migrate gets
DECLARED rather than bodged. A save that cannot be walked forward is refused
with a player-readable reason, because a save that loads subtly WRONG is
worse than one that refuses and says so. Adding a field needs no bump: it
arrives missing from old saves and keeps its new-game value (case F).

The menu offers Continue only when there is something to offer. A save this
build CANNOT open is still shown, disabled, with the reason underneath -
silently hiding someone's run reads as lost rather than incompatible.

Autosave every 30s, plus on close and on Android's app-pause, since the
phone kills backgrounded apps with no warning. Skipped during the prologue.

That autosave writes to the same user:// path every test that loads
Main.tscn shares, so it could make Continue appear and shift the menu stack
under test_main_menu depending on what ran first. That test clears the save
before building the menu.

Verified by breaking it: neutering the key decoder fails with "chunk keys
came back as [v2i:-11,0, v2i:3,-4]".

Still absent, deliberately: no save-on-demand button, and no route back to
the menu from in-game, so a resume currently needs a relaunch.

175/175.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Claude 2026-09-09 21:32:23 +02:00
commit fb3587173d
7 changed files with 634 additions and 1 deletions

243
core/SaveGame.gd Normal file
View file

@ -0,0 +1,243 @@
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", "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