Files
aetherbound-guild/runtime/fg3/save_v10_service.gd
T

249 lines
7.8 KiB
GDScript

class_name FG3SaveV10Service
extends RefCounted
const P103Save = preload("res://p10_3/save_v9_service.gd")
const MarketGenerator = preload("res://fg3/contract1_market_generator.gd")
const State = preload("res://fg3/run_state.gd")
const SAVE_VERSION := 10
const SAVE_KIND := "aetherbound_guild_fg3"
const OFFLINE_ZERO := {
"coin": 0,
"credit": 0,
"xp": 0,
"renown": 0,
"healing": 0,
"battle_ticks": 0,
}
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": "",
"validation": probe.invariant_report(),
}
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": probe.snapshot(),
}
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 or not parser.data is Dictionary:
return {"ok": false, "code": "save_malformed"}
var envelope: Dictionary = parser.data
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_input := envelope.duplicate(true)
var checksum := String(checksum_input.checksum)
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 probe := State.new()
if not envelope.state is Dictionary or not 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": probe.snapshot(),
"offline_delta": OFFLINE_ZERO.duplicate(true),
}
func migrate_v9_text(text: String, minimum_sequence: int = -1) -> Dictionary:
var decoded := P103Save.new().decode_text(text, minimum_sequence)
if not decoded.ok:
return {"ok": false, "code": "v9_%s" % String(decoded.code)}
var prior: Dictionary = decoded.snapshot.current_run
if String(prior.phase) != "market_8" or int(prior.market_index) != 8:
return {"ok": false, "code": "v9_saved_market_8_required"}
var generated := MarketGenerator.new().generate(String(prior.run_seed), 8, 0)
if not generated.ok:
return {"ok": false, "code": "market_8_generation_failed"}
var state := State.new()
if not state.initialize_from_p103(decoded.snapshot, generated.board):
return {"ok": false, "code": "v9_state_rejected"}
var encoded := encode_snapshot(
state.snapshot(),
int(decoded.sequence) + 1,
String(decoded.commit_hash)
)
if not encoded.ok:
return {
"ok": false,
"code": "v10_encode_failed",
"validation": encoded.get("validation", {}),
}
return {
"ok": true,
"code": "v9_migrated_to_v10",
"text": String(encoded.text),
"envelope": encoded.envelope,
"snapshot": state.snapshot(),
"source_sequence": int(decoded.sequence),
"sequence": int(decoded.sequence) + 1,
"previous_commit_hash": String(decoded.commit_hash),
"offline_delta": OFFLINE_ZERO.duplicate(true),
}
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 verified := decode_text(FileAccess.get_file_as_string(candidate_path), 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 _is_hex(value: String, length: int) -> bool:
if value.length() != length:
return false
for character in value:
if character not in "0123456789abcdef":
return false
return true
func _hash(value) -> String:
return JSON.stringify(_canonical(value)).sha256_text()
func _canonical(value):
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