Refactor save management; enhance save_data function with verification and plaintext copy options, and update load_data logic for improved error handling
This commit is contained in:
+4
-1
@@ -2,6 +2,9 @@ extends Node
|
||||
|
||||
const SPEED_LIMIT: int = 5
|
||||
|
||||
# Write a readable diagnostic copy next to the encrypted save. Never used for loading.
|
||||
var save_plaintext_copy: bool = true
|
||||
|
||||
var Settings: Dictionary = {
|
||||
"filename": "save-0001",
|
||||
"sound_volume": 1.0,
|
||||
@@ -39,7 +42,7 @@ func get_description(item_id: String, items: Array = []) -> String:
|
||||
func format_number(value: float, digits: int = 0) -> String:
|
||||
if value == 0: return ""
|
||||
if value < 10_000:
|
||||
return "%.*f" % [digits, value]
|
||||
return "%.*f" % [digits, value]
|
||||
|
||||
var exp_value = int(floor(log(abs(value)) / log(10.0)))
|
||||
var mantissa = value / pow(10.0, exp_value)
|
||||
|
||||
+166
-16
@@ -5,34 +5,184 @@ extends Node
|
||||
const SETTING_SECTION := "Settings"
|
||||
const SAVE_PATH = "user://settings.cfg"
|
||||
|
||||
const FORMAT_VERSION: int = 1
|
||||
const STATE_VERSION: String = "0.1.0"
|
||||
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) -> void:
|
||||
filename = "user://" + filename + ".adventure"
|
||||
var file := FileAccess.open_encrypted_with_pass(filename, FileAccess.WRITE, password)
|
||||
|
||||
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:
|
||||
push_error("Can't write to the file: ", filename)
|
||||
return
|
||||
|
||||
file.store_var(data) # бинарно + быстрее + сериализация
|
||||
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:
|
||||
filename = "user://" + filename + ".adventure"
|
||||
if not FileAccess.file_exists(filename):
|
||||
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(filename, FileAccess.READ, password)
|
||||
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
|
||||
if file == null:
|
||||
push_error(filename, " couldn't be decrypted.")
|
||||
blocked_saves[filename] = true
|
||||
_fail("Cannot decrypt save: " + path)
|
||||
return {}
|
||||
|
||||
var data: Variant = file.get_var()
|
||||
var data: Variant = file.get_var(false)
|
||||
file.close()
|
||||
|
||||
return data if data is Dictionary else {}
|
||||
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 data.get("state") is Dictionary:
|
||||
return "state must be a Dictionary"
|
||||
if not _plain_tree(data):
|
||||
return "unsupported value or non-finite number"
|
||||
# Legacy files contained only {state}; accept their known state schema.
|
||||
if data.has("format_version") and (not data.format_version is int or data.format_version != FORMAT_VERSION):
|
||||
return "unsupported format_version"
|
||||
if data.has("ui"):
|
||||
if not data.ui is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
|
||||
return "invalid ui.current_page"
|
||||
var saved: Dictionary = data.state
|
||||
if saved.get("version") != STATE_VERSION:
|
||||
return "unsupported state.version"
|
||||
if not saved.get("time") is Dictionary:
|
||||
return "state.time must be a Dictionary"
|
||||
if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year"), true):
|
||||
return "invalid state.time"
|
||||
if saved.time.year != floorf(saved.time.datetime):
|
||||
return "state.time.year must equal floor(datetime)"
|
||||
if not _number(saved.get("speed"), true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
||||
return "invalid state.speed"
|
||||
if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
||||
return "invalid state.speed_in_year"
|
||||
if not _number(saved.get("energy"), true):
|
||||
return "invalid state.energy"
|
||||
if not _number_map(saved.get("amount"), true):
|
||||
return "invalid state.amount"
|
||||
if not saved.get("stats") is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
|
||||
return "invalid state.stats.total_amount"
|
||||
for id in saved.amount:
|
||||
if not saved.stats.total_amount.has(id):
|
||||
return "missing total_amount for " + id
|
||||
if not saved.get("binds") is Dictionary or not saved.get("batches") is Dictionary:
|
||||
return "state.binds and state.batches must be Dictionaries"
|
||||
for owner in saved.binds:
|
||||
if not saved.amount.has(owner) or not _number_map(saved.binds[owner], true):
|
||||
return "invalid state.binds." + owner
|
||||
for id in saved.binds[owner]:
|
||||
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:
|
||||
return "batch must be a Dictionary"
|
||||
if 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") or batch.has("created_to"):
|
||||
if not _number(batch.get("created_from")) or not _number(batch.get("created_to")):
|
||||
return "invalid batch creation interval in " + id
|
||||
if batch.created_to <= batch.created_from or batch.start_year < batch.created_from:
|
||||
return "reversed batch creation interval in " + id
|
||||
if batch.has("processed_until"):
|
||||
if 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", "auto_resources", "production_remainders", "auto_star_deficits", "evolution_remainders"]:
|
||||
if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"):
|
||||
return "invalid state." + field
|
||||
if saved.has("resource_remainders"):
|
||||
if not saved.resource_remainders is Dictionary:
|
||||
return "invalid state.resource_remainders"
|
||||
for channel in ["basic", "automatic"]:
|
||||
# Signed allocation errors are valid, even though resource balances are whole.
|
||||
if not _number_map(saved.resource_remainders.get(channel), false, false):
|
||||
return "invalid state.resource_remainders." + channel
|
||||
return ""
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
Reference in New Issue
Block a user