195 lines
7.0 KiB
GDScript
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())
|