Files
owniverse/autoload/save_manager.gd
T
Kirill a09e3df220 Refactor save manager validation and update state structure
- Updated the save manager to enforce a new format version (2) and improved validation checks for the save data structure.
- Enhanced the `validate_data` function to ensure all required fields are present and valid.
- Adjusted the handling of legacy saves and introduced new checks for various state fields.
- Improved number formatting in the global script for better readability of large numbers.
- Updated tests to reflect changes in the save manager and validation logic, ensuring compatibility with the new state structure.
- Added new tests for group indexing and production transactions to ensure correctness of entity management and resource production.
2026-09-20 20:56:15 +03:00

221 lines
10 KiB
GDScript

# SaveManager.gd (добавить в AutoLoad)
extends Node
# ~/Library/Application Support/Godot/app_userdata/Owniverse Adventure/savegame.cfg
const SETTING_SECTION := "Settings"
const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION: int = 2
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const STATE_VERSION: String = SimulationTime.STATE_VERSION
var last_error: String = ""
var blocked_saves: Dictionary = {}
#region Save Load routines
func _fail(message: String) -> bool:
last_error = message
push_error(message)
return false
func save_data(filename: String, password: String, data: Dictionary) -> bool:
last_error = ""
if blocked_saves.has(filename):
return _fail("Save blocked to preserve an unreadable save: " + filename)
var payload: Dictionary = data.duplicate(true)
payload.format_version = FORMAT_VERSION
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
var problem: String = validate_data(payload)
if problem != "":
return _fail("Invalid save: " + problem)
var path: String = "user://" + filename + ".adventure"
var temporary: String = path + ".tmp"
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, password)
if file == null:
return _fail("Cannot write encrypted save: " + temporary)
file.store_var(payload, false)
file.close()
# Check the actual encrypted file, including finalization, before replacing the old save.
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, password)
if verification == null:
return _fail("Cannot verify encrypted save: " + temporary)
var restored: Variant = verification.get_var(false)
verification.close()
if not restored is Dictionary or restored != payload:
return _fail("Encrypted save verification failed: " + temporary)
if DirAccess.rename_absolute(temporary, path) != OK:
return _fail("Cannot replace encrypted save: " + path)
if Global.save_plaintext_copy:
var plain_path: String = path + ".json"
file = FileAccess.open(plain_path + ".tmp", FileAccess.WRITE)
if file == null:
return _fail("Encrypted save written, but cannot write diagnostic copy: " + plain_path)
file.store_string(JSON.stringify(payload, " ", true, true) + "\n")
file.flush()
var write_error: Error = file.get_error()
file.close()
if write_error != OK or DirAccess.rename_absolute(plain_path + ".tmp", plain_path) != OK:
return _fail("Encrypted save written, but diagnostic copy failed: " + plain_path)
return true
func load_data(filename: String, password: String) -> Dictionary:
last_error = ""
# Plaintext is diagnostic only: never use it as a fallback.
var path: String = "user://" + filename + ".adventure"
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
if file == null:
blocked_saves[filename] = true
_fail("Cannot decrypt save: " + path)
return {}
var data: Variant = file.get_var(false)
file.close()
var problem: String = validate_data(data)
if problem != "":
blocked_saves[filename] = true
_fail("Invalid save " + path + ": " + problem)
return {}
blocked_saves.erase(filename)
return data
func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
if not (value is int or value is float) or not is_finite(float(value)):
return false
return (not whole or float(value) == floorf(float(value))) and (not nonnegative or value >= 0)
func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
if not value is Dictionary:
return false
for key in value:
if not (key is String or key is StringName) or not _number(value[key], whole, nonnegative):
return false
return true
func _plain_tree(value: Variant) -> bool:
if value is Dictionary:
for key in value:
if not (key is String or key is StringName) or not _plain_tree(value[key]):
return false
return true
if value is Array:
for item in value:
if not _plain_tree(item):
return false
return true
return value == null or value is String or value is StringName or value is bool or _number(value, false, false)
## Return a diagnostic path on failure; an empty string means the structure is valid.
func validate_data(data: Variant) -> String:
if not data is Dictionary or not _plain_tree(data):
return "save must contain plain finite values"
if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
return "unsupported save format"
if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
return "invalid ui.current_page"
if not data.get("state") is Dictionary:
return "state must be a Dictionary"
var saved: Dictionary = data.state
var required := [
"version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins",
"binds", "amount", "distribution", "auto_resources", "auto_star_deficits",
"production_remainders", "resource_remainders", "evolution_remainders", "life_remainders",
"planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders",
"civilization_stats", "civilization_homes", "ethics", "stats",
]
if saved.size() != required.size():
return "unsupported state structure"
for field in required:
if not saved.has(field):
return "missing state." + field
if saved.version != STATE_VERSION:
return "unsupported state.version"
if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
return "invalid state.time"
if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
return "invalid processed time"
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
return "invalid state.speed"
if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
return "invalid state.speed_in_year"
if not _number(saved.energy, true) or not _number_map(saved.amount, true):
return "invalid state resources"
if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
return "invalid state.stats.total_amount"
if saved.amount.keys() != saved.stats.total_amount.keys():
return "amount and total_amount keys differ"
if not saved.binds is Dictionary or not saved.batches is Dictionary:
return "state.binds and state.batches must be Dictionaries"
for owner_id in saved.binds:
if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true):
return "invalid state.binds." + owner_id
for id in saved.binds[owner_id]:
if not saved.amount.has(id):
return "unknown bound resource " + id
for id in saved.batches:
if not saved.amount.has(id) or not saved.batches[id] is Array:
return "invalid state.batches." + id
for batch in saved.batches[id]:
if not batch is Dictionary or not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
return "invalid batch in " + id
if batch.end_year < batch.start_year:
return "reversed batch interval in " + id
if batch.has("created_from") != batch.has("created_to"):
return "incomplete batch creation interval in " + id
if batch.has("created_from") and (not _number(batch.created_from) or not _number(batch.created_to) or batch.created_to <= batch.created_from or batch.start_year < batch.created_from):
return "invalid batch creation interval in " + id
if batch.has("processed_until") and (not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year):
return "invalid batch.processed_until in " + id
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
return "invalid batch.withdraw_remainder in " + id
for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]:
if not _number_map(saved[field], false, false):
return "invalid state." + field
if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false):
return "invalid automatic production state"
if not saved.resource_remainders is Dictionary or saved.resource_remainders.keys().size() != 2 or not saved.resource_remainders.has("basic") or not saved.resource_remainders.has("automatic"):
return "invalid state.resource_remainders"
for channel in ["basic", "automatic"]:
if not _number_map(saved.resource_remainders[channel], false, false):
return "invalid state.resource_remainders." + channel
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
return "invalid civilization state"
for id in saved.ethics:
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
return "invalid state.ethics." + str(id)
for ethic in saved.ethics[id]:
if ethic not in ["m", "l", "w", "d", "c", "b"]:
return "invalid ethic " + str(ethic)
for id in saved.civilization_homes:
if not _number_map(saved.civilization_homes[id], true):
return "invalid civilization homes"
return ""
#endregion
#region Settings
func save_settings():
var config = ConfigFile.new()
for item in Global.Settings:
config.set_value(SETTING_SECTION, item, Global.Settings[item])
config.save(SAVE_PATH)
func load_settings():
var config = ConfigFile.new()
var err = config.load(SAVE_PATH)
if err != OK:
return false
for item in Global.Settings:
Global.Settings[item] = config.get_value(SETTING_SECTION, item, Global.Settings[item])
return true
#endregion