212 lines
10 KiB
GDScript
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)
|