184 lines
7.0 KiB
GDScript
184 lines
7.0 KiB
GDScript
class_name P99SaveService
|
|
extends RefCounted
|
|
|
|
const ProfileState = preload("res://p9_9/profile_state.gd")
|
|
const RunState = preload("res://p9_9/run_state.gd")
|
|
const P98State = preload("res://p9_8/contract_state.gd")
|
|
|
|
const SAVE_VERSION := 4
|
|
const OFFLINE_CAP_SECONDS := 24 * 60 * 60
|
|
const DEFAULT_PATH := "user://aetherbound_p9_9_save.json"
|
|
|
|
var save_path := DEFAULT_PATH
|
|
|
|
|
|
func save_snapshot(snapshot: Dictionary, now_unix := -1) -> Dictionary:
|
|
var validated := _validate_v4_snapshot(snapshot)
|
|
if not validated.ok:
|
|
return validated
|
|
var saved_at := _now(now_unix)
|
|
var envelope := envelope_for_snapshot(validated.snapshot, SAVE_VERSION, saved_at)
|
|
var write_result := _atomic_write(JSON.stringify(envelope))
|
|
if not write_result.ok:
|
|
return write_result
|
|
return {"ok": true, "code": "saved", "version": SAVE_VERSION, "saved_at_unix": saved_at, "checksum": envelope.checksum}
|
|
|
|
|
|
func load_snapshot(now_unix := -1) -> Dictionary:
|
|
var primary := _load_path(save_path, now_unix)
|
|
if primary.ok:
|
|
return primary
|
|
var backup_path := save_path + ".bak"
|
|
if FileAccess.file_exists(backup_path):
|
|
var backup := _load_path(backup_path, now_unix)
|
|
if backup.ok:
|
|
backup.code = "backup_recovered"
|
|
backup.primary_error = primary.code
|
|
return backup
|
|
if primary.code == "missing":
|
|
return {"ok": false, "code": "no_save"}
|
|
return primary
|
|
|
|
|
|
func clear() -> Dictionary:
|
|
for path in [save_path, save_path + ".tmp", save_path + ".bak"]:
|
|
if FileAccess.file_exists(path):
|
|
var error := DirAccess.remove_absolute(_absolute(path))
|
|
if error != OK:
|
|
return {"ok": false, "code": "clear_failed", "error": error}
|
|
return {"ok": true, "code": "cleared"}
|
|
|
|
|
|
func envelope_for_snapshot(snapshot: Dictionary, version := SAVE_VERSION, saved_at_unix := -1) -> Dictionary:
|
|
var core := {"version": version, "saved_at_unix": _now(saved_at_unix), "state": snapshot.duplicate(true)}
|
|
var normalized = JSON.parse_string(JSON.stringify(core))
|
|
assert(normalized is Dictionary)
|
|
var envelope: Dictionary = Dictionary(normalized).duplicate(true)
|
|
envelope.checksum = _checksum(envelope)
|
|
return envelope
|
|
|
|
|
|
func _load_path(path: String, now_unix: int) -> Dictionary:
|
|
if not FileAccess.file_exists(path):
|
|
return {"ok": false, "code": "missing"}
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return {"ok": false, "code": "read_failed"}
|
|
var parser := JSON.new()
|
|
if parser.parse(file.get_as_text()) != OK or not parser.data is Dictionary:
|
|
return {"ok": false, "code": "malformed_json"}
|
|
var envelope: Dictionary = parser.data
|
|
for key in ["checksum", "version", "saved_at_unix", "state"]:
|
|
if not envelope.has(key):
|
|
return {"ok": false, "code": "malformed_envelope"}
|
|
var expected_checksum := String(envelope.checksum)
|
|
var core := envelope.duplicate(true)
|
|
core.erase("checksum")
|
|
if expected_checksum.is_empty() or expected_checksum.to_lower() != _checksum(core):
|
|
return {"ok": false, "code": "checksum_mismatch"}
|
|
if not envelope.state is Dictionary:
|
|
return {"ok": false, "code": "invalid_state"}
|
|
var version := int(envelope.version)
|
|
var migrated := version != SAVE_VERSION
|
|
var snapshot: Dictionary = {}
|
|
var p99_activated := false
|
|
var battle_interrupted := false
|
|
if version == SAVE_VERSION:
|
|
var validated := _validate_v4_snapshot(Dictionary(envelope.state))
|
|
if not validated.ok:
|
|
return validated
|
|
snapshot = Dictionary(validated.snapshot).duplicate(true)
|
|
p99_activated = bool(validated.p99_activated)
|
|
if String(validated.state_kind) == "p99_run" and int(snapshot.phase) == RunState.Phase.ENCOUNTER:
|
|
var interrupted = RunState.new()
|
|
if not interrupted.restore_snapshot(Dictionary(snapshot.battle_checkpoint)):
|
|
return {"ok": false, "code": "invalid_battle_checkpoint"}
|
|
snapshot = interrupted.snapshot()
|
|
battle_interrupted = true
|
|
elif version == 3:
|
|
var prior = P98State.new()
|
|
var candidate = ProfileState.new()
|
|
if not prior.restore_snapshot(Dictionary(envelope.state)) or not candidate.initialize_from_p98(prior.snapshot()):
|
|
return {"ok": false, "code": "invalid_p98_closed_state"}
|
|
snapshot = candidate.snapshot()
|
|
p99_activated = candidate.p99_activated
|
|
else:
|
|
return {"ok": false, "code": "unsupported_version", "version": version}
|
|
var now := _now(now_unix)
|
|
var saved_at := int(envelope.saved_at_unix)
|
|
var rollback := now < saved_at
|
|
var elapsed := 0 if rollback else mini(OFFLINE_CAP_SECONDS, maxi(0, now - saved_at))
|
|
return {
|
|
"ok": true,
|
|
"code": "loaded",
|
|
"version": SAVE_VERSION,
|
|
"source_version": version,
|
|
"migrated": migrated,
|
|
"p99_activated": p99_activated,
|
|
"state_kind": String(snapshot.get("state_kind", "p99_profile")),
|
|
"battle_interrupted": battle_interrupted,
|
|
"snapshot": snapshot,
|
|
"saved_at_unix": saved_at,
|
|
"elapsed_seconds": elapsed,
|
|
"clock_rollback": rollback,
|
|
"offline_coin": 0,
|
|
"offline_xp": 0,
|
|
"offline_renown": 0,
|
|
"offline_healing": 0,
|
|
"offline_battle_ticks": 0,
|
|
}
|
|
|
|
|
|
func _validate_v4_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
if String(snapshot.get("state_kind", "")) == "p99_run":
|
|
var run = RunState.new()
|
|
if not run.restore_snapshot(snapshot):
|
|
return {"ok": false, "code": "invalid_state"}
|
|
return {"ok": true, "snapshot": run.snapshot(), "state_kind": "p99_run", "p99_activated": true}
|
|
var profile = ProfileState.new()
|
|
if not profile.restore_snapshot(snapshot):
|
|
return {"ok": false, "code": "invalid_state"}
|
|
return {"ok": true, "snapshot": profile.snapshot(), "state_kind": "p99_profile", "p99_activated": profile.p99_activated}
|
|
|
|
|
|
func _atomic_write(contents: String) -> Dictionary:
|
|
var absolute := _absolute(save_path)
|
|
var temporary := _absolute(save_path + ".tmp")
|
|
var backup := _absolute(save_path + ".bak")
|
|
var directory_error := DirAccess.make_dir_recursive_absolute(absolute.get_base_dir())
|
|
if directory_error != OK:
|
|
return {"ok": false, "code": "directory_failed", "error": directory_error}
|
|
if FileAccess.file_exists(save_path + ".tmp"):
|
|
DirAccess.remove_absolute(temporary)
|
|
var file := FileAccess.open(temporary, FileAccess.WRITE)
|
|
if file == null:
|
|
return {"ok": false, "code": "write_failed"}
|
|
file.store_string(contents)
|
|
file.flush()
|
|
file = null
|
|
if FileAccess.file_exists(save_path):
|
|
if FileAccess.file_exists(save_path + ".bak"):
|
|
DirAccess.remove_absolute(backup)
|
|
var backup_error := DirAccess.rename_absolute(absolute, backup)
|
|
if backup_error != OK:
|
|
DirAccess.remove_absolute(temporary)
|
|
return {"ok": false, "code": "backup_failed", "error": backup_error}
|
|
var promote_error := DirAccess.rename_absolute(temporary, absolute)
|
|
if promote_error != OK:
|
|
if FileAccess.file_exists(save_path + ".bak") and not FileAccess.file_exists(save_path):
|
|
DirAccess.rename_absolute(backup, absolute)
|
|
return {"ok": false, "code": "promote_failed", "error": promote_error}
|
|
return {"ok": true, "code": "written"}
|
|
|
|
|
|
func _checksum(core: Dictionary) -> String:
|
|
return JSON.stringify(core).sha256_text()
|
|
|
|
|
|
func _absolute(path: String) -> String:
|
|
return path if path.is_absolute_path() else ProjectSettings.globalize_path(path)
|
|
|
|
|
|
func _now(override: int) -> int:
|
|
return override if override >= 0 else int(Time.get_unix_time_from_system())
|