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
179 lines
7.2 KiB
GDScript
179 lines
7.2 KiB
GDScript
extends SceneTree
|
|
|
|
# Forgejo #225: the run survives being written to disk and read back.
|
|
#
|
|
# The interesting part is not "does it write a file" but whether the things
|
|
# JSON cannot hold survive: Vector2 positions, chunk coordinates used as
|
|
# dictionary KEYS, and Inventory objects nested inside the buildings array.
|
|
# Those are what a naive save silently flattens.
|
|
#
|
|
# Covers:
|
|
# A. a rich run round-trips field for field
|
|
# B. Vector2i dictionary keys come back as Vector2i, not as strings
|
|
# C. inventories come back with their contents AND their capacity
|
|
# D. a save from a newer build is refused, with a reason
|
|
# E. a damaged file is refused rather than half-applied
|
|
# F. a field the save predates keeps its new-game value
|
|
|
|
# Mirrored rather than read off SaveGame: a bare ClassName.CONST in a
|
|
# SceneTree script has broken method resolution for a whole file here before.
|
|
const TEST_PATH: String = "user://test_savegame.json"
|
|
|
|
var game_state: Node
|
|
|
|
|
|
func _initialize() -> void:
|
|
game_state = root.get_node("GameState")
|
|
|
|
|
|
func _process(_delta: float) -> bool:
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.reset_progression()
|
|
game_state.reset_new_game()
|
|
game_state.reset_quests()
|
|
|
|
# --- a run with something in every awkward corner -------------------
|
|
game_state.research_points = 42
|
|
game_state.unlocked = ["shipyards", "coordination"]
|
|
game_state.rocks = 1234
|
|
game_state.fleet_position = Vector2(987.5, -321.25)
|
|
game_state.wave_number = 7
|
|
game_state.quest_index = 5
|
|
game_state.explored_chunks[Vector2i(3, -4)] = true
|
|
game_state.explored_chunks[Vector2i(-11, 0)] = true
|
|
game_state.visited_planets["some-planet-id"] = true
|
|
var b_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(600.0, -150.0))
|
|
var record: Dictionary = game_state.find_building(b_id)
|
|
record.inventory.total_capacity = 88
|
|
record.inventory.set_amount(GameState.RESOURCE_ORE, 17)
|
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 9)
|
|
|
|
var before: Dictionary = _snapshot()
|
|
|
|
if not SaveGame.save(game_state, TEST_PATH):
|
|
return _fail("the run would not save")
|
|
if not SaveGame.has_save(TEST_PATH):
|
|
return _fail("saved, but has_save() cannot see the file")
|
|
|
|
# Wipe it back to a fresh game, so a load that does nothing cannot pass.
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.reset_progression()
|
|
game_state.reset_new_game()
|
|
game_state.reset_quests()
|
|
game_state.explored_chunks.clear()
|
|
if _snapshot() == before:
|
|
return _fail("wiping the run changed nothing, so a load would prove nothing")
|
|
|
|
var result: Dictionary = SaveGame.load_into(game_state, TEST_PATH)
|
|
if not bool(result.get("ok", false)):
|
|
return _fail("the run would not load back: %s" % result.get("reason", ""))
|
|
|
|
# --- A. field for field ---------------------------------------------
|
|
var after: Dictionary = _snapshot()
|
|
for key in before:
|
|
if after.get(key) != before[key]:
|
|
return _fail("'%s' did not survive the round trip: %s -> %s" \
|
|
% [key, before[key], after.get(key)])
|
|
print("a run with %d fields round-tripped intact" % before.size())
|
|
|
|
# --- B. chunk coordinates are still coordinates ----------------------
|
|
if not game_state.explored_chunks.has(Vector2i(3, -4)):
|
|
return _fail("chunk keys came back as something other than Vector2i: %s" \
|
|
% str(game_state.explored_chunks.keys()))
|
|
print("explored chunks came back keyed by Vector2i")
|
|
|
|
# --- C. inventories keep contents and capacity -----------------------
|
|
var loaded: Dictionary = game_state.find_building(b_id)
|
|
if loaded.is_empty():
|
|
return _fail("the transport hub did not come back")
|
|
if int(loaded.inventory.amount(GameState.RESOURCE_ORE)) != 17:
|
|
return _fail("the hub's ore did not survive: %d" % loaded.inventory.amount(GameState.RESOURCE_ORE))
|
|
if int(loaded.inventory.total_capacity) != 88:
|
|
return _fail("the hub's capacity did not survive: %d" % loaded.inventory.total_capacity)
|
|
if int(game_state.fleet_inventory.amount(GameState.RESOURCE_GAS)) != 9:
|
|
return _fail("the fleet hold's gas did not survive")
|
|
print("inventories came back with their contents and their capacity")
|
|
|
|
# --- D. a save from a newer build is refused --------------------------
|
|
_rewrite_version(999)
|
|
var newer: Dictionary = SaveGame.load_into(game_state, TEST_PATH)
|
|
if bool(newer.get("ok", false)):
|
|
return _fail("a save from a newer build loaded anyway")
|
|
if not String(newer.get("reason", "")).to_lower().contains("newer"):
|
|
return _fail("a newer save was refused without saying why: '%s'" % newer.get("reason", ""))
|
|
print("a newer save is refused: %s" % newer.get("reason", ""))
|
|
|
|
# --- E. a damaged file is refused, not half-applied -------------------
|
|
var f: FileAccess = FileAccess.open(TEST_PATH, FileAccess.WRITE)
|
|
f.store_string("{ this is not json")
|
|
f.close()
|
|
var marker: int = game_state.research_points
|
|
var damaged: Dictionary = SaveGame.load_into(game_state, TEST_PATH)
|
|
if bool(damaged.get("ok", false)):
|
|
return _fail("a damaged save reported success")
|
|
if game_state.research_points != marker:
|
|
return _fail("a damaged save was partly applied before it failed")
|
|
print("a damaged save is refused: %s" % damaged.get("reason", ""))
|
|
|
|
# --- F. a field the save predates keeps its new-game value ------------
|
|
# Adding progression must not invalidate every existing save, so a
|
|
# missing field is left alone rather than cleared.
|
|
SaveGame.save(game_state, TEST_PATH)
|
|
_drop_field("research_points")
|
|
game_state.research_points = 77
|
|
var partial: Dictionary = SaveGame.load_into(game_state, TEST_PATH)
|
|
if not bool(partial.get("ok", false)):
|
|
return _fail("a save missing one field was refused entirely: %s" % partial.get("reason", ""))
|
|
if game_state.research_points != 77:
|
|
return _fail("a missing field was cleared instead of left alone (%d)" % game_state.research_points)
|
|
print("a field the save predates keeps its current value")
|
|
|
|
SaveGame.clear(TEST_PATH)
|
|
print("TEST_SAVE_GAME: PASS")
|
|
quit(0)
|
|
return true
|
|
|
|
|
|
# The fields this test compares. Deliberately a hand-picked spread rather
|
|
# than SaveGame.FIELDS: comparing the list against itself would pass even if
|
|
# the codec dropped everything.
|
|
func _snapshot() -> Dictionary:
|
|
return {
|
|
"research_points": game_state.research_points,
|
|
"unlocked": game_state.unlocked.duplicate(),
|
|
"rocks": game_state.rocks,
|
|
"fleet_position": game_state.fleet_position,
|
|
"wave_number": game_state.wave_number,
|
|
"quest_index": game_state.quest_index,
|
|
"building_count": game_state.buildings.size(),
|
|
"chunk_count": game_state.explored_chunks.size(),
|
|
"visited": game_state.visited_planets.size(),
|
|
}
|
|
|
|
|
|
func _rewrite_version(to: int) -> void:
|
|
var file: FileAccess = FileAccess.open(TEST_PATH, FileAccess.READ)
|
|
var data: Dictionary = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
data["version"] = to
|
|
file = FileAccess.open(TEST_PATH, FileAccess.WRITE)
|
|
file.store_string(JSON.stringify(data))
|
|
file.close()
|
|
|
|
|
|
func _drop_field(name: String) -> void:
|
|
var file: FileAccess = FileAccess.open(TEST_PATH, FileAccess.READ)
|
|
var data: Dictionary = JSON.parse_string(file.get_as_text())
|
|
file.close()
|
|
data["fields"].erase(name)
|
|
file = FileAccess.open(TEST_PATH, FileAccess.WRITE)
|
|
file.store_string(JSON.stringify(data))
|
|
file.close()
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
print("TEST_SAVE_GAME: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|