Files
aetherbound-guild/runtime/tests/p9_6/persistence_test.gd
T

212 lines
10 KiB
GDScript

extends SceneTree
const Service = preload("res://p9_3/core_loop_service.gd")
const State = preload("res://p9_3/core_loop_state.gd")
const SaveService = preload("res://p9_6/save_service.gd")
var failures: Array[String] = []
var save_root := ""
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
save_root = OS.get_environment("ABG_P9_6_SAVE_TEST_ROOT")
if save_root.is_empty():
save_root = "/private/tmp/abg-p96-persistence-default"
DirAccess.make_dir_recursive_absolute(save_root)
verify_no_save()
verify_v1_and_time_boundaries()
verify_battle_and_result_checkpoint()
verify_duplicate_claim_after_reload()
verify_duplicate_recovery_after_reload()
verify_v0_migration()
verify_corruption_boundaries()
verify_backup_fallback()
if failures.is_empty():
print("ABG_P9_6_PERSISTENCE_OK version=1 migration=0_to_1 cases=14 checksum=true backup=true corruption=true rollback=true offline_cap=86400 offline_rewards=0 checkpoint=pre_battle duplicate_claim=0 duplicate_recovery=0 temp_clean=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func verify_no_save() -> void:
var saves: Variant = fresh_saves("no-save")
expect(not saves.load_snapshot(1000).ok and saves.load_snapshot(1000).code == "no_save", "missing save is explicit")
func verify_v1_and_time_boundaries() -> void:
var service = complete_bell("Kindhand", "EQ-002")
expect(service.choose_rift_tide_plan("press_on").ok, "v1 fixture commits a stable plan")
var saves: Variant = fresh_saves("v1")
var original: Dictionary = service.state.snapshot()
var saved: Dictionary = saves.save_snapshot(original, 1000)
expect(saved.ok and int(saved.version) == 1 and FileAccess.file_exists(saves.save_path), "v1 save writes")
expect(not FileAccess.file_exists(saves.save_path + ".tmp"), "atomic save leaves no temp")
var loaded: Dictionary = saves.load_snapshot(1060)
expect(loaded.ok and loaded.snapshot == original and int(loaded.elapsed_seconds) == 60, "v1 load restores exact state")
expect(int(loaded.offline_coin) == 0 and int(loaded.offline_xp) == 0 and int(loaded.offline_healing) == 0 and int(loaded.offline_battle_ticks) == 0, "elapsed time grants no offline progress")
var rollback: Dictionary = saves.load_snapshot(900)
expect(rollback.ok and rollback.clock_rollback and int(rollback.elapsed_seconds) == 0 and rollback.snapshot == original, "clock rollback is inert")
var capped: Dictionary = saves.load_snapshot(999999)
expect(capped.ok and not capped.clock_rollback and int(capped.elapsed_seconds) == SaveService.OFFLINE_CAP_SECONDS and capped.snapshot == original, "offline elapsed is capped without state mutation")
var restored = Service.new()
expect(restored.state.restore_snapshot(loaded.snapshot), "loaded snapshot restores into domain state")
var before_duplicate: Dictionary = restored.state.snapshot()
expect(not restored.choose_rift_tide_plan("press_on").ok and restored.state.snapshot() == before_duplicate, "loaded plan cannot duplicate")
func verify_battle_and_result_checkpoint() -> void:
var service = complete_bell("Wayrunner", "EQ-002")
service.choose_rift_tide_plan("recover")
service.select_encounter("rift_tide")
var checkpoint: Dictionary = service.state.snapshot()
var saves: Variant = fresh_saves("checkpoint")
expect(saves.save_snapshot(checkpoint, 2000).ok, "pre-battle checkpoint saves")
service.start_battle()
service.advance_battle_ticks(55)
var during: Dictionary = saves.load_snapshot(2010)
expect(during.ok and int(during.snapshot.phase) == State.Phase.ENCOUNTER and during.snapshot.selected_encounter == "rift_tide", "battle interruption restores pre-battle encounter")
run_to_result(service)
var at_result: Dictionary = saves.load_snapshot(2020)
expect(at_result.ok and at_result.snapshot == checkpoint and at_result.snapshot.rift_tide_result_receipts.is_empty(), "result interruption grants no pending result")
func verify_duplicate_claim_after_reload() -> void:
var service = complete_bell("Kindhand", "EQ-002")
service.choose_rift_tide_plan("press_on")
service.select_encounter("rift_tide")
service.start_battle()
run_to_result(service)
service.claim_and_return()
var saves: Variant = fresh_saves("claimed")
expect(saves.save_snapshot(service.state.snapshot(), 3000).ok, "claimed stable Hall saves")
var loaded: Dictionary = saves.load_snapshot(3010)
var restored = Service.new()
expect(restored.state.restore_snapshot(loaded.snapshot), "claimed state restores")
var before: Dictionary = restored.state.snapshot()
expect(not restored.claim_and_return().ok and restored.state.snapshot() == before, "reloaded reward and XP cannot duplicate")
expect(restored.state.reward_receipts.size() == 4 and restored.state.xp_receipts.size() == 4, "reloaded receipts remain exactly once")
func verify_duplicate_recovery_after_reload() -> void:
var service = complete_bell("Wayrunner", "EQ-002")
service.choose_rift_tide_plan("press_on")
service.select_encounter("rift_tide")
service.start_battle()
run_to_result(service)
service.claim_and_return()
expect(service.recover_injured_company().ok, "failure recovery fixture commits once")
var saves: Variant = fresh_saves("recovered")
expect(saves.save_snapshot(service.state.snapshot(), 3500).ok, "recovered Encounter saves")
var loaded: Dictionary = saves.load_snapshot(3510)
var restored = Service.new()
expect(restored.state.restore_snapshot(loaded.snapshot), "recovered state restores")
var before: Dictionary = restored.state.snapshot()
expect(not restored.recover_injured_company().ok and restored.state.snapshot() == before, "reloaded recovery cannot duplicate")
expect(restored.state.recovery_receipts.size() == 1 and int(restored.state.recovery_receipts[0].cost) == 8, "reloaded recovery receipt remains exactly once")
func verify_v0_migration() -> void:
var service = complete_bell("Kindhand", "EQ-012")
var legacy: Dictionary = service.state.snapshot()
for key in ["member_condition", "rift_tide_plan", "rift_tide_wave", "rift_tide_wave_receipts", "rift_tide_result_receipts", "recovery_required", "injured_members", "recovery_receipts"]:
legacy.erase(key)
var saves: Variant = fresh_saves("migration")
write_json(saves.save_path, saves.envelope_for_snapshot(legacy, 0, 4000))
var loaded: Dictionary = saves.load_snapshot(4010)
expect(loaded.ok and loaded.migrated and int(loaded.source_version) == 0 and int(loaded.version) == 1, "v0 migrates to v1")
expect(loaded.snapshot.member_condition == {"Hearthguard": 130, "Farstring": 85, "Kindhand": 90}, "migration supplies safe full condition")
expect(loaded.snapshot.rift_tide_plan.is_empty() and loaded.snapshot.recovery_receipts.is_empty(), "migration supplies P9.6 defaults")
func verify_corruption_boundaries() -> void:
var service = complete_bell("Kindhand", "EQ-002")
var saves: Variant = fresh_saves("corrupt")
write_text(saves.save_path, "{")
expect(not saves.load_snapshot(5000).ok and saves.load_snapshot(5000).code == "malformed_json", "malformed JSON is rejected")
var envelope: Dictionary = saves.envelope_for_snapshot(service.state.snapshot(), 1, 5000)
envelope.checksum = "0".repeat(64)
write_json(saves.save_path, envelope)
expect(not saves.load_snapshot(5001).ok and saves.load_snapshot(5001).code == "checksum_mismatch", "checksum drift is rejected")
write_json(saves.save_path, saves.envelope_for_snapshot(service.state.snapshot(), 99, 5000))
expect(not saves.load_snapshot(5001).ok and saves.load_snapshot(5001).code == "unsupported_version", "unknown version is rejected")
var invalid: Dictionary = service.state.snapshot()
invalid.coin = -1
write_json(saves.save_path, saves.envelope_for_snapshot(invalid, 1, 5000))
expect(not saves.load_snapshot(5001).ok and saves.load_snapshot(5001).code == "invalid_state", "valid-checksum invalid state is rejected")
func verify_backup_fallback() -> void:
var service = complete_bell("Kindhand", "EQ-002")
var saves: Variant = fresh_saves("backup")
var first: Dictionary = service.state.snapshot()
expect(saves.save_snapshot(first, 6000).ok, "backup fixture first save writes")
service.choose_rift_tide_plan("press_on")
var second: Dictionary = service.state.snapshot()
expect(saves.save_snapshot(second, 6010).ok and FileAccess.file_exists(saves.save_path + ".bak"), "second save retains valid backup")
write_text(saves.save_path, "broken")
var loaded: Dictionary = saves.load_snapshot(6020)
expect(loaded.ok and loaded.code == "backup_recovered" and loaded.primary_error == "malformed_json", "corrupt primary falls back explicitly")
expect(loaded.snapshot == first and loaded.snapshot != second, "backup fallback restores the previous atomic state")
func fresh_saves(name: String) -> Variant:
var saves = SaveService.new()
saves.save_path = save_root.path_join(name + ".json")
saves.clear()
return saves
func complete_bell(third_member: String, item_id: String):
var service = Service.new()
service.enter_hall()
service.focus_offer("Farstring")
service.hire("Farstring")
service.select_encounter("open_road")
service.start_battle()
run_to_result(service)
service.claim_and_return()
service.focus_offer(third_member)
service.hire(third_member)
service.select_encounter("crosswind")
service.start_battle()
run_to_result(service)
service.claim_and_return()
service.focus_equipment(item_id)
service.equip_focused("Hearthguard" if item_id == "EQ-002" else "Farstring")
service.select_encounter("bell_breach")
service.start_battle()
run_to_result(service)
service.claim_and_return()
return service
func run_to_result(service) -> void:
var guard := 0
while service.state.phase == State.Phase.BATTLE and guard < 3000:
service.advance_battle_ticks(1)
guard += 1
expect(guard < 3000 and service.state.phase == State.Phase.RESULT, "fixture reaches bounded result")
func write_json(path: String, value: Dictionary) -> void:
write_text(path, JSON.stringify(value))
func write_text(path: String, value: String) -> void:
var file := FileAccess.open(path, FileAccess.WRITE)
expect(file != null, "test fixture opens %s" % path)
if file != null:
file.store_string(value)
file.flush()
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("P9.6 persistence: %s" % message)