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