249 lines
7.8 KiB
GDScript
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
|