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

186 lines
8.4 KiB
GDScript

class_name P100SaveService
extends RefCounted
const MarketService = preload("res://p10_0/market_service.gd")
const State = preload("res://p10_0/run_state.gd")
const SAVE_VERSION := 6
const SAVE_KIND := "aetherbound_guild_p10"
func encode_snapshot(snapshot_value: Dictionary, sequence: int, previous_commit_hash: String = "") -> Dictionary:
var probe := State.new()
if not probe.restore_snapshot(snapshot_value):
return {"ok": false, "code": "invalid_state", "text": ""}
if sequence < 0:
return {"ok": false, "code": "invalid_sequence", "text": ""}
if not previous_commit_hash.is_empty() and not _is_hex(previous_commit_hash, 64):
return {"ok": false, "code": "invalid_previous_hash", "text": ""}
var envelope := {
"save_kind": SAVE_KIND,
"save_version": SAVE_VERSION,
"sequence": sequence,
"previous_commit_hash": previous_commit_hash,
"state": snapshot_value.duplicate(true),
}
envelope.state_hash = _hash(envelope.state)
envelope.commit_hash = _hash({"sequence": sequence, "previous_commit_hash": previous_commit_hash, "state_hash": envelope.state_hash})
envelope.checksum = _hash(envelope)
return {"ok": true, "code": "encoded", "text": JSON.stringify(_canonical(envelope)), "envelope": envelope}
func decode_text(text: String, minimum_sequence: int = -1) -> Dictionary:
if text.is_empty():
return {"ok": false, "code": "save_missing"}
var parser := JSON.new()
if parser.parse(text) != OK:
return {"ok": false, "code": "save_malformed"}
var parsed = parser.data
if not parsed is Dictionary:
return {"ok": false, "code": "save_malformed"}
var envelope: Dictionary = parsed
for key in ["save_kind", "save_version", "sequence", "previous_commit_hash", "state", "state_hash", "commit_hash", "checksum"]:
if not envelope.has(key):
return {"ok": false, "code": "save_field_missing:%s" % key}
if String(envelope.save_kind) != SAVE_KIND:
return {"ok": false, "code": "save_kind_unknown"}
if int(envelope.save_version) != SAVE_VERSION:
return {"ok": false, "code": "save_version_unknown"}
if int(envelope.sequence) < minimum_sequence:
return {"ok": false, "code": "rollback_rejected", "sequence": int(envelope.sequence)}
var checksum := String(envelope.checksum)
var checksum_input := envelope.duplicate(true)
checksum_input.erase("checksum")
if not _is_hex(checksum, 64) or _hash(checksum_input) != checksum:
return {"ok": false, "code": "checksum_mismatch"}
if String(envelope.state_hash) != _hash(envelope.state):
return {"ok": false, "code": "state_hash_mismatch"}
var expected_commit := _hash({"sequence": int(envelope.sequence), "previous_commit_hash": String(envelope.previous_commit_hash), "state_hash": String(envelope.state_hash)})
if String(envelope.commit_hash) != expected_commit:
return {"ok": false, "code": "commit_hash_mismatch"}
var state_probe := State.new()
if not envelope.state is Dictionary or not state_probe.restore_snapshot(envelope.state):
return {"ok": false, "code": "state_invalid"}
return {"ok": true, "code": "loaded", "sequence": int(envelope.sequence), "commit_hash": String(envelope.commit_hash), "snapshot": state_probe.snapshot(), "offline_delta": {"coin": 0, "credit": 0, "xp": 0, "renown": 0, "healing": 0, "battle_ticks": 0}}
func load_with_backup(active_text: String, backup_text: String, minimum_sequence: int = -1) -> Dictionary:
var active := decode_text(active_text, minimum_sequence)
if active.ok:
active.source = "active"
return active
var backup := decode_text(backup_text, minimum_sequence)
if backup.ok:
backup.source = "backup"
backup.active_failure = String(active.code)
return backup
return {"ok": false, "code": "no_valid_save", "active_failure": String(active.code), "backup_failure": String(backup.code)}
func write_slot(active_path: String, snapshot_value: Dictionary, sequence: int, previous_commit_hash: String = "") -> Dictionary:
var encoded := encode_snapshot(snapshot_value, sequence, previous_commit_hash)
if not encoded.ok:
return encoded
var candidate_path := "%s.candidate" % active_path
var backup_path := "%s.bak" % active_path
var candidate := FileAccess.open(candidate_path, FileAccess.WRITE)
if candidate == null:
return {"ok": false, "code": "candidate_open_failed"}
candidate.store_string(String(encoded.text))
candidate.flush()
candidate.close()
var read_back := FileAccess.get_file_as_string(candidate_path)
var verified := decode_text(read_back, sequence)
if not verified.ok or String(verified.commit_hash) != String(encoded.envelope.commit_hash):
return {"ok": false, "code": "candidate_verify_failed"}
if FileAccess.file_exists(backup_path):
DirAccess.remove_absolute(backup_path)
if FileAccess.file_exists(active_path):
var backup_error := DirAccess.rename_absolute(active_path, backup_path)
if backup_error != OK:
return {"ok": false, "code": "backup_rotate_failed"}
var promote_error := DirAccess.rename_absolute(candidate_path, active_path)
if promote_error != OK:
if FileAccess.file_exists(backup_path) and not FileAccess.file_exists(active_path):
DirAccess.rename_absolute(backup_path, active_path)
return {"ok": false, "code": "candidate_promote_failed"}
return {"ok": true, "code": "saved", "sequence": sequence, "commit_hash": String(encoded.envelope.commit_hash), "active_path": active_path, "backup_path": backup_path}
func load_slot(active_path: String, minimum_sequence: int = -1) -> Dictionary:
var active_text := FileAccess.get_file_as_string(active_path) if FileAccess.file_exists(active_path) else ""
var backup_path := "%s.bak" % active_path
var backup_text := FileAccess.get_file_as_string(backup_path) if FileAccess.file_exists(backup_path) else ""
return load_with_backup(active_text, backup_text, minimum_sequence)
func migrate_p9_guild_profile(p9_snapshot: Dictionary, root_seed: String = MarketService.DEFAULT_ROOT_SEED, lineage_id: String = MarketService.DEFAULT_LINEAGE_ID) -> Dictionary:
var persistent = p9_snapshot.get("guild_profile", p9_snapshot)
if not persistent is Dictionary:
return {"ok": false, "code": "p9_profile_invalid"}
var service := MarketService.new({}, root_seed, lineage_id)
var raw := service.state.snapshot()
raw.guild_profile.persistent_renown = clampi(int(persistent.get("persistent_renown", 0)), 0, 9999)
raw.guild_profile.contract_clear_ids = _unique_strings(persistent.get("contract_clear_ids", []))
raw.guild_profile.discovery_ids = _unique_strings(persistent.get("discovery_ids", []))
raw.guild_profile.unlocked_region_ids = ["RG-01"]
if "CLEAR-RG01-C01" in raw.guild_profile.contract_clear_ids and "CLEAR-RG01-C02" in raw.guild_profile.contract_clear_ids:
raw.guild_profile.unlocked_region_ids.append("RG-02")
var records: Array = []
for source_record in persistent.get("company_records", []):
if source_record is Dictionary and not String(source_record.get("record_id", source_record.get("id", ""))).is_empty() and not String(source_record.get("clear_id", "")).is_empty():
var record := Dictionary(source_record).duplicate(true)
record.record_id = String(record.get("record_id", record.get("id", "")))
records.append(record)
raw.guild_profile.company_records = records
raw.guild_profile.clear_receipts = {}
raw.run_creation_draft = {}
raw.current_run = {}
raw.operation_receipts = {}
raw.run_creation_commits = {}
if not service.restore_snapshot(raw):
return {"ok": false, "code": "p9_profile_rejected"}
return {"ok": true, "code": "p9_guild_profile_migrated", "snapshot": service.state.snapshot(), "imported_run_state": false, "imported_recruit_ids": 0}
func _unique_strings(value) -> Array[String]:
var result: Array[String] = []
if not value is Array:
return result
for item in value:
var text := String(item)
if not text.is_empty() and text not in result:
result.append(text)
return result
func _is_hex(value: String, length: int) -> bool:
if value.length() != length:
return false
for index in range(value.length()):
if value.substr(index, 1) not in "0123456789abcdef":
return false
return true
func _hash(value: Variant) -> String:
return JSON.stringify(_canonical(value)).sha256_text()
func _canonical(value: Variant) -> Variant:
if value is float and is_equal_approx(value, round(value)):
return int(round(value))
if value is Dictionary:
var result := {}
var keys: Array = value.keys()
keys.sort_custom(func(left, right): return String(left) < String(right))
for key in keys:
result[key] = _canonical(value[key])
return result
if value is Array:
var result: Array = []
for item in value:
result.append(_canonical(item))
return result
return value