186 lines
8.4 KiB
GDScript
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
|