Files
Kirill c4e7302567 Add new tests and implement frame budget management
- Introduced `civilization_benchmark.gd` and `legacy_quantity_math.gd` for benchmarking and legacy quantity calculations.
- Created `test_dynamic_math.gd`, `test_frame_evolution.gd`, and `test_sparse_batches.gd` to validate mathematical operations and frame evolution logic.
- Implemented `frame_budget.gd` to manage cooperative work on the main thread, ensuring no partially calculated state is published.
- Added UID files for all new scripts to maintain unique identifiers.
2026-10-01 23:43:34 +03:00

358 lines
18 KiB
GDScript

# SaveManager.gd (добавить в AutoLoad)
extends Node
const E = preload("res://world/owniverse/math/exact.gd")
# ~/Library/Application Support/Godot/app_userdata/Owniverse Adventure/savegame.cfg
const SETTING_SECTION = "Settings"
const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION : int = 5
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const STATE_VERSION : String = SimulationTime.STATE_VERSION
var last_error: String = ""
var blocked_saves: Dictionary = {}
var save_directory: String = "user://"
signal save_completed ( filename : String , success : bool , error : String )
signal saves_drained
# Queue ownership stays on the main thread. The worker only sees its snapshot.
var _save_thread: Thread
var _active_filename: String = ""
var _pending_saves: Dictionary = {}
func _process(_delta: Variant) -> void:
if (E.ne(_save_thread, null) and (not _save_thread.is_alive())):
_finish_save()
func is_saving() -> bool:
return (E.ne(_save_thread, null) or (not _pending_saves.is_empty()))
func _snapshot(filename: String, password: String, data: Dictionary) -> Dictionary:
var payload: Dictionary = E.encode(data)
payload.format_version = FORMAT_VERSION
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
return {"filename": filename, "path": save_directory.path_join(E.add(filename, ".adventure")), "password": password, "payload": payload, "plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT, "stages": Global.CivilizationRules.keys(), "entities": Global.OwniverseEntities.keys()}
## True means queued, not written. Completion is reported by save_completed.
func request_save(filename: String, password: String, data: Dictionary) -> bool:
if blocked_saves.has(filename):
return _fail(E.add("Save blocked to preserve an unreadable save: ", filename))
_pending_saves[filename] = _snapshot(filename, password, data)
if E.eq(_save_thread, null):
_start_save()
return true
func _start_save() -> void:
if _pending_saves.is_empty():
return
_active_filename = _pending_saves.keys()[0]
var job: Dictionary = _pending_saves[_active_filename]
_pending_saves.erase(_active_filename)
_save_thread = Thread.new()
var error: Error = _save_thread.start(_write_snapshot.bind(job))
if E.ne(error, OK):
_save_thread = null
_fail(E.add("Cannot start save worker: ", error_string(error)))
save_completed.emit(_active_filename, false, last_error)
_start_save()
if (not is_saving()): saves_drained.emit()
func _finish_save() -> void:
var error: String = _save_thread.wait_to_finish()
var filename: String = _active_filename
_save_thread = null
last_error = error
if E.ne(error, ""): push_error(error)
save_completed.emit(filename, E.eq(error, ""), error)
if E.eq(_save_thread, null): _start_save()
if (not is_saving()): saves_drained.emit()
## Used for application suspension and teardown, when frames may stop arriving.
func flush_saves() -> void:
while is_saving():
if E.eq(_save_thread, null): _start_save()
if E.ne(_save_thread, null): _finish_save()
func _exit_tree() -> void:
flush_saves()
#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:
# Synchronous compatibility API must never race an asynchronous writer.
flush_saves()
last_error = ""
if blocked_saves.has(filename):
return _fail(E.add("Save blocked to preserve an unreadable save: ", filename))
var error: String = _write_snapshot(_snapshot(filename, password, data))
return (_fail(error) if E.ne(error, "") else true)
static func _write_snapshot(job: Dictionary) -> String:
var payload: Dictionary = job.payload
var problem: String = _validate_payload(payload, job.speed_limit, job.stages, job.entities)
if E.ne(problem, ""):
return E.add("Invalid save: ", problem)
var path: String = job.path
var temporary: String = E.add(path, ".tmp")
var file = FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password)
if E.eq(file, null):
return E.add("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, job.password)
if E.eq(verification, null):
return E.add("Cannot verify encrypted save: ", temporary)
var restored: Variant = verification.get_var(false)
verification.close()
if ((not (restored is Dictionary)) or E.ne(restored, payload)):
return E.add("Encrypted save verification failed: ", temporary)
if E.ne(DirAccess.rename_absolute(temporary, path), OK):
return E.add("Cannot replace encrypted save: ", path)
if job.plaintext:
var plain_path: String = E.add(path, ".json")
file = FileAccess.open(E.add(plain_path, ".tmp"), FileAccess.WRITE)
if E.eq(file, null):
return E.add("Encrypted save written, but cannot write diagnostic copy: ", plain_path)
file.store_string(E.add(JSON.stringify(payload, " ", true, true), "\n"))
file.flush()
var write_error: Error = file.get_error()
file.close()
if (E.ne(write_error, OK) or E.ne(DirAccess.rename_absolute(E.add(plain_path, ".tmp"), plain_path), OK)):
return E.add("Encrypted save written, but diagnostic copy failed: ", plain_path)
return ""
func load_data(filename: String, password: String) -> Dictionary:
flush_saves()
last_error = ""
# Plaintext is diagnostic only: never use it as a fallback.
var path: String = save_directory.path_join(E.add(filename, ".adventure"))
if (not FileAccess.file_exists(path)):
return {}
var file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
if E.eq(file, null):
blocked_saves[filename] = true
_fail(E.add("Cannot decrypt save: ", path))
return {}
var data: Variant = file.get_var(false)
file.close()
# Unsupported formats are archived, never converted or overwritten.
# Returning no data lets the stage start a new, saveable session.
if ((data is Dictionary) and E.ne(data.get("format_version"), FORMAT_VERSION)):
var archive: String = E.add(path, ".incompatible.bak")
var index: int = 0
while FileAccess.file_exists(archive):
index = E.add(index, 1)
archive = E.add(E.add(path, ".incompatible.bak."), str(index))
var error: Error = DirAccess.rename_absolute(path, archive)
if E.ne(error, OK):
blocked_saves[filename] = true
_fail(E.add("Cannot archive incompatible save: ", error_string(error)))
return {}
blocked_saves.erase(filename)
push_warning(E.add(E.add("Incompatible save preserved at ", archive), "; starting a new game."))
return {}
var problem: String = validate_data(data)
if E.ne(problem, ""):
blocked_saves[filename] = true
_fail(E.add(E.add(E.add("Invalid save ", path), ": "), problem))
return {}
blocked_saves.erase(filename)
return E.decode(data)
static func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
return E.numeric(value) and E.finite(value) and (not value is float or absf(value) < 1e28) and (not whole or E.eq(value, E.floor_value(value))) and (not nonnegative or E.ge(value, 0))
static 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
static 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 ((((E.eq(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:
return _validate_payload(data, Global.SPEED_LIMIT, Global.CivilizationRules.keys(), Global.OwniverseEntities.keys())
static func _validate_payload(data: Variant, speed_limit: int, stages: Array, entities: Array) -> String:
if not E.valid_encoded(data): return "invalid exact number encoding"
data = E.decode(data)
if ((not (data is Dictionary)) or (not _plain_tree(data))):
return "save must contain plain finite values"
if (E.ne(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 E.numeric(data.ui.get("current_page")))) or E.lt(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"]
# Optional additions to the current format. Saves predating indicators keep
# their quantities and receive neutral defaults from Owniverse on load.
for field in saved:
if (not E.contains(required, field) and not E.contains(["world_parameters", "trends"], field)):
return "unsupported state structure"
if saved.has("world_parameters"):
if ((not _number_map(saved.world_parameters)) or E.ne(saved.world_parameters.size(), WorldParameters.defaults().size())):
return "invalid state.world_parameters"
for key in WorldParameters.defaults():
if ((not saved.world_parameters.has(key)) or E.lt(saved.world_parameters[key], 1)):
return E.add("invalid world parameter ", key)
if (E.contains(["Bur", "Exp", "Agg"], key) and E.gt(saved.world_parameters[key], 19)):
return E.add("invalid world scale ", key)
if saved.has("trends"):
if ((not _number_map(saved.trends)) or E.ne(saved.trends.size(), WorldParameters.TRENDS.size())):
return "invalid state.trends"
for key in WorldParameters.TRENDS:
if ((not saved.trends.has(key)) or E.ge(saved.trends[key], 1)):
return E.add("invalid trend ", key)
for field in required:
if (not saved.has(field)):
return E.add("missing state.", field)
if E.ne(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 E.ne(saved.time.year, SimulationTime.processed_year(saved.time.datetime)):
return "invalid processed time"
if saved.time.has("tick_remainder"):
if not E.finite(saved.time.tick_remainder) or E.ge(E.absolute(saved.time.tick_remainder), 1):
return "invalid fractional tick"
if (((not _number(saved.speed, true)) or E.lt(saved.speed, 1)) or E.gt(saved.speed, speed_limit)):
return "invalid state.speed"
if ((not _number(saved.speed_in_year)) or E.ne(saved.speed_in_year, E.power(10, E.subtract(saved.speed, 1)))):
return "invalid state.speed_in_year"
if data.has("simulation"):
var checkpoint: Variant = data.simulation
if not checkpoint is Dictionary or not checkpoint.get("productions") is Array:
return "invalid simulation checkpoint"
if not _number(checkpoint.get("speed"), true) or E.lt(checkpoint.speed, 1) or E.gt(checkpoint.speed, speed_limit):
return "invalid simulation speed"
for name in ["requested_clock", "active_clock"]:
var clock: Variant = checkpoint.get(name)
if not clock is Dictionary: return "invalid simulation clock"
if clock.is_empty(): continue
if not _number(clock.get("datetime")) or not _number(clock.get("year")):
return "invalid simulation clock"
if E.lt(clock.datetime, saved.time.datetime) or E.ne(clock.year, SimulationTime.processed_year(clock.datetime)):
return "invalid simulation clock"
if not E.finite(clock.get("tick_remainder", 0)) or E.ge(E.absolute(clock.get("tick_remainder", 0)), 1):
return "invalid simulation tick"
if not checkpoint.active_clock.is_empty():
if checkpoint.requested_clock.is_empty() or E.gt(checkpoint.active_clock.datetime, checkpoint.requested_clock.datetime):
return "invalid simulation clock order"
for command in checkpoint.productions:
if not command is Dictionary or not entities.has(command.get("id")) or not command.get("resource") is bool or not _number(command.get("amount")):
return "invalid queued production"
if command.resource and command.id not in ["S", "H"]: return "invalid queued resource production"
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 E.ne(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 E.add("invalid state.binds.", owner_id)
for id in saved.binds[owner_id]:
if (not saved.amount.has(id)):
return E.add("unknown bound resource ", id)
for id in saved.batches:
if ((not saved.amount.has(id)) or (not (saved.batches[id] is Array))):
return E.add("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 E.add("invalid batch in ", id)
if E.lt(batch.end_year, batch.start_year):
return E.add("reversed batch interval in ", id)
if E.ne(batch.has("created_from"), batch.has("created_to")):
return E.add("incomplete batch creation interval in ", id)
if (batch.has("created_from") and (((((not _number(batch.created_from)) or (not _number(batch.created_to))) or E.le(batch.created_to, batch.created_from)) or E.lt(batch.start_year, batch.created_from)))):
return E.add("invalid batch creation interval in ", id)
if (batch.has("processed_until") and ((((not _number(batch.processed_until)) or E.lt(batch.processed_until, batch.start_year)) or E.gt(batch.processed_until, batch.end_year)))):
return E.add("invalid batch.processed_until in ", id)
if (batch.has("withdraw_remainder") and (((not _number(batch.withdraw_remainder)) or E.gt(batch.withdraw_remainder, batch.amount)))):
return E.add("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 E.add("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 E.ne(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 E.add("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 E.contains(stages, id) or (not _number_map(saved.ethics[id], true))):
return E.add("invalid state.ethics.", str(id))
for ethic in saved.ethics[id]:
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic):
return E.add("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 E.ne(err, OK):
return false
for item in Global.Settings:
Global.Settings[item] = config.get_value(SETTING_SECTION, item, Global.Settings[item])
return true
#endregion