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

220 lines
11 KiB
GDScript

extends SceneTree
const LegacyService = preload("res://p9_3/core_loop_service.gd")
const LegacyState = preload("res://p9_3/core_loop_state.gd")
const P97MarketService = preload("res://p9_7/market_service.gd")
const P97BattleService = preload("res://p9_7/battle_service.gd")
const State = preload("res://p9_8/contract_state.gd")
const Service = preload("res://p9_8/contract_service.gd")
const SaveService = preload("res://p9_8/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_8_SAVE_TEST_ROOT")
if save_root.is_empty():
save_root = "/private/tmp/abg-p98-persistence-default"
DirAccess.make_dir_recursive_absolute(save_root)
var legacy := complete_p96()
var market8 := prepare_market8(legacy)
verify_v3_round_trip(market8)
verify_v2_market8_migration(market8)
verify_v1_and_v0_chain(legacy)
verify_interruption(market8)
verify_time_corruption_and_backup(market8)
if failures.is_empty():
print("ABG_P9_8_PERSISTENCE_OK version=3 migration=0,1,2_to_3 p97_market8=exact artifact_promotion=preserved interruption=pre_battle_market corruption=true backup=true rollback=true offline_cap=86400 offline_rewards=0 duplicate_after_reload=0 temp_clean=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func verify_v3_round_trip(market8: Dictionary) -> void:
var eligible := market8.duplicate(true)
eligible.recruit_records.Hearthguard.level = 4
eligible.recruit_records.Hearthguard.survival_battles = 7
var service = Service.new(eligible)
service.choose_artifact("persist-artifact", "AR-002")
service.open_promotion_draft("Hearthguard")
service.confirm_promotion("persist-promotion", "Hearthguard", "PF-A02")
var saves = fresh_saves("v3")
var original: Dictionary = service.state.snapshot()
var saved: Dictionary = saves.save_snapshot(original, 1000)
expect(saved.ok and int(saved.version) == 3, "v3 state saves")
expect(not FileAccess.file_exists(saves.save_path + ".tmp"), "atomic write leaves no temp file")
var loaded: Dictionary = saves.load_snapshot(1060)
expect(loaded.ok and not loaded.migrated and loaded.snapshot == original, "v3 round trip is byte-stable")
expect(loaded.snapshot.selected_artifact_id == "AR-002" and loaded.snapshot.recruit_records.Hearthguard.profession_id == "PF-A02", "Artifact and promotion persist")
var restored = Service.new()
expect(restored.state.restore_snapshot(loaded.snapshot), "loaded v3 restores")
var before: Dictionary = restored.state.snapshot()
var duplicate: Dictionary = restored.choose_artifact("persist-artifact", "AR-002")
expect(duplicate.ok and duplicate.duplicate and restored.state.snapshot() == before, "reloaded operation remains idempotent")
expect(int(loaded.elapsed_seconds) == 60 and int(loaded.offline_coin) == 0 and int(loaded.offline_xp) == 0, "elapsed time grants no progress")
func verify_v2_market8_migration(market8: Dictionary) -> void:
var saves = fresh_saves("v2")
write_json(saves.save_path, saves.envelope_for_snapshot(market8, 2, 2000))
var loaded: Dictionary = saves.load_snapshot(2010)
expect(loaded.ok and loaded.migrated and int(loaded.source_version) == 2 and loaded.p98_activated, "v2 Market 8 activates P9.8")
expect(loaded.snapshot.company == market8.company and loaded.snapshot.inventory == market8.inventory and loaded.snapshot.member_condition == market8.member_condition, "v2 migration preserves Company, inventory and HP")
expect(loaded.snapshot.route_history == market8.route_history and loaded.snapshot.operation_receipts == market8.operation_receipts, "v2 migration preserves exact receipts")
expect(loaded.snapshot.market_board.recruit_offer_ids.size() == 3 and loaded.snapshot.artifact_draft.offer_ids == ["AR-001", "AR-002"], "v2 migration opens deterministic Market 8")
func verify_v1_and_v0_chain(legacy: Dictionary) -> void:
var v1 = fresh_saves("v1")
write_json(v1.save_path, v1.envelope_for_snapshot(legacy, 1, 2100))
var loaded_v1: Dictionary = v1.load_snapshot(2110)
expect(loaded_v1.ok and loaded_v1.migrated and not loaded_v1.p98_activated, "v1 follows adjacent migration into preserved Market 5")
expect(int(loaded_v1.snapshot.current_market) == 5 and loaded_v1.snapshot.content_revision == "p9.7-content-r1", "pre-P9.8 migration retains P9.7 authority")
var legacy_v0 := legacy.duplicate(true)
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_v0.erase(key)
var v0 = fresh_saves("v0")
write_json(v0.save_path, v0.envelope_for_snapshot(legacy_v0, 0, 2200))
var loaded_v0: Dictionary = v0.load_snapshot(2210)
expect(loaded_v0.ok and loaded_v0.migrated and int(loaded_v0.source_version) == 0, "v0 follows adjacent migration chain")
expect(loaded_v0.snapshot.member_condition == {"Hearthguard": 130, "Farstring": 85, "Kindhand": 90}, "v0 migration supplies safe condition")
func verify_interruption(market8: Dictionary) -> void:
var state = State.new()
expect(state.initialize_from_p97(market8), "interruption fixture activates")
var checkpoint: Dictionary = state.snapshot()
var encounter := checkpoint.duplicate(true)
encounter.phase = State.Phase.ENCOUNTER
encounter.contract_phase = State.ContractPhase.ENCOUNTER
encounter.selected_route = "P98-R8-Q0"
encounter.route_history.append("P98-R8-Q0")
encounter.battle_checkpoint = checkpoint
var encounter_state = State.new()
expect(encounter_state.restore_snapshot(encounter), "active P9.8 encounter fixture restores")
var saves = fresh_saves("encounter")
expect(saves.save_snapshot(encounter_state.snapshot(), 2500).ok, "active encounter saves")
var loaded: Dictionary = saves.load_snapshot(2510)
expect(loaded.ok and loaded.battle_interrupted and loaded.snapshot == checkpoint, "interruption restores exact pre-battle Market")
func verify_time_corruption_and_backup(market8: Dictionary) -> void:
var service = Service.new(market8)
var rollback = fresh_saves("rollback")
expect(rollback.save_snapshot(service.state.snapshot(), 3000).ok, "rollback fixture writes")
var rolled: Dictionary = rollback.load_snapshot(2900)
expect(rolled.ok and rolled.clock_rollback and int(rolled.elapsed_seconds) == 0, "clock rollback is inert")
var capped: Dictionary = rollback.load_snapshot(999999)
expect(capped.ok and int(capped.elapsed_seconds) == SaveService.OFFLINE_CAP_SECONDS, "offline duration is capped")
var malformed = fresh_saves("malformed")
write_text(malformed.save_path, "{")
expect(not malformed.load_snapshot(4000).ok and malformed.load_snapshot(4000).code == "malformed_json", "malformed JSON is rejected")
var checksum = fresh_saves("checksum")
var bad_checksum: Dictionary = checksum.envelope_for_snapshot(service.state.snapshot(), 3, 4000)
bad_checksum.checksum = "0".repeat(64)
write_json(checksum.save_path, bad_checksum)
expect(not checksum.load_snapshot(4001).ok and checksum.load_snapshot(4001).code == "checksum_mismatch", "checksum drift is rejected")
var unknown = fresh_saves("unknown")
write_json(unknown.save_path, unknown.envelope_for_snapshot(service.state.snapshot(), 99, 4000))
expect(not unknown.load_snapshot(4001).ok and unknown.load_snapshot(4001).code == "unsupported_version", "unknown version is rejected")
var invalid = fresh_saves("invalid")
var invalid_state: Dictionary = service.state.snapshot()
invalid_state.coin = -1
write_json(invalid.save_path, invalid.envelope_for_snapshot(invalid_state, 3, 4000))
expect(not invalid.load_snapshot(4001).ok and invalid.load_snapshot(4001).code == "invalid_state", "valid-checksum invalid state is rejected")
var backup = fresh_saves("backup")
var first: Dictionary = service.state.snapshot()
expect(backup.save_snapshot(first, 5000).ok, "backup first save writes")
service.choose_artifact("backup-artifact", "AR-001")
var second: Dictionary = service.state.snapshot()
expect(backup.save_snapshot(second, 5010).ok and FileAccess.file_exists(backup.save_path + ".bak"), "second save retains backup")
write_text(backup.save_path, "broken")
var recovered: Dictionary = backup.load_snapshot(5020)
expect(recovered.ok and recovered.code == "backup_recovered" and recovered.primary_error == "malformed_json", "corrupt primary falls back explicitly")
expect(recovered.snapshot == first and recovered.snapshot != second, "backup restores previous complete state")
func prepare_market8(legacy: Dictionary) -> Dictionary:
var market = P97MarketService.new(legacy)
for step in [
{"market": 5, "recruit": "P97-M5-R2", "route": "P97-R5-Q0"},
{"market": 6, "recruit": "P97-M6-R2", "route": "P97-R6-Q0"},
{"market": 7, "recruit": "P97-M7-R3", "route": "P97-R7-Q0"},
]:
market.focus_recruit_offer(String(step.recruit))
market.hire("persist-hire-%d" % int(step.market), String(step.recruit))
market.commit_deployment("persist-deploy-%d" % int(step.market), market.auto_order_candidate())
market.focus_route(String(step.route))
market.commit_route("persist-route-%d" % int(step.market), String(step.route))
var battle = P97BattleService.new()
battle.start_from_market(market.state)
var guard := 0
while not battle.state.terminal and guard < 2000:
battle.advance_ticks(1)
guard += 1
market.settle_battle("persist-settle-%d" % int(step.market), battle.state.snapshot())
if market.state.recovery_required:
market.recover_injured_company("persist-recover-%d" % int(step.market))
return market.state.snapshot()
func fresh_saves(name: String):
var saves = SaveService.new()
saves.save_path = save_root.path_join(name + ".json")
saves.clear()
return saves
func complete_p96() -> Dictionary:
var service = LegacyService.new()
service.enter_hall()
service.focus_offer("Farstring")
service.hire("Farstring")
complete_legacy_battle(service, "open_road")
service.focus_offer("Kindhand")
service.hire("Kindhand")
complete_legacy_battle(service, "crosswind")
service.focus_equipment("EQ-002")
service.equip_focused("Hearthguard")
complete_legacy_battle(service, "bell_breach")
service.choose_rift_tide_plan("press_on")
complete_legacy_battle(service, "rift_tide")
return service.state.snapshot()
func complete_legacy_battle(service, encounter_id: String) -> void:
service.select_encounter(encounter_id)
service.start_battle()
var guard := 0
while service.state.phase == LegacyState.Phase.BATTLE and guard < 4000:
service.advance_battle_ticks(1)
guard += 1
service.claim_and_return()
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, "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.8 persistence: %s" % message)