Files
aetherbound-guild/runtime/p9_7/save_service.gd
T

195 lines
7.0 KiB
GDScript

class_name P97SaveService
extends RefCounted
const MarketState = preload("res://p9_7/market_state.gd")
const LegacyState = preload("res://p9_3/core_loop_state.gd")
const Rules = preload("res://domain/p9_2/battle_rules.gd")
const SAVE_VERSION := 2
const OFFLINE_CAP_SECONDS := 24 * 60 * 60
const DEFAULT_PATH := "user://aetherbound_p9_6_save.json"
var save_path := DEFAULT_PATH
func save_snapshot(snapshot: Dictionary, now_unix := -1) -> Dictionary:
var candidate = MarketState.new()
if not candidate.restore_snapshot(snapshot):
return {"ok": false, "code": "invalid_state"}
if candidate.phase not in [MarketState.Phase.MARKET, MarketState.Phase.ENCOUNTER]:
return {"ok": false, "code": "unstable_phase"}
var saved_at := _now(now_unix)
var envelope := envelope_for_snapshot(candidate.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
if not envelope.has("checksum") or not envelope.has("version") or not envelope.has("saved_at_unix") or not envelope.has("state"):
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 candidate = MarketState.new()
var migrated := false
if version == SAVE_VERSION:
if not candidate.restore_snapshot(Dictionary(envelope.state)):
return {"ok": false, "code": "invalid_state"}
elif version in [0, 1]:
var legacy_raw: Dictionary = Dictionary(envelope.state).duplicate(true)
if version == 0:
legacy_raw = _migrate_v0_to_v1(legacy_raw)
var legacy = LegacyState.new()
if not legacy.restore_snapshot(legacy_raw) or not candidate.initialize_from_p96(legacy.snapshot()):
return {"ok": false, "code": "invalid_state"}
migrated = true
else:
return {"ok": false, "code": "unsupported_version", "version": version}
var battle_interrupted := false
if candidate.phase == MarketState.Phase.ENCOUNTER:
var checkpoint = MarketState.new()
if candidate.battle_checkpoint.is_empty() or not checkpoint.restore_snapshot(candidate.battle_checkpoint):
return {"ok": false, "code": "invalid_battle_checkpoint"}
candidate = checkpoint
battle_interrupted = true
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,
"battle_interrupted": battle_interrupted,
"snapshot": candidate.snapshot(),
"saved_at_unix": saved_at,
"elapsed_seconds": elapsed,
"clock_rollback": rollback,
"offline_coin": 0,
"offline_xp": 0,
"offline_healing": 0,
"offline_battle_ticks": 0,
}
func _migrate_v0_to_v1(raw: Dictionary) -> Dictionary:
var migrated := raw.duplicate(true)
var company: Array = migrated.get("company", ["Hearthguard"])
var condition := {}
for raw_member_id in company:
var member_id := String(raw_member_id)
condition[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0))
migrated["member_condition"] = condition
migrated["rift_tide_plan"] = ""
migrated["rift_tide_wave"] = 0
migrated["rift_tide_wave_receipts"] = []
migrated["rift_tide_result_receipts"] = []
migrated["recovery_required"] = false
migrated["injured_members"] = []
migrated["recovery_receipts"] = []
return migrated
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())