335 lines
13 KiB
GDScript
335 lines
13 KiB
GDScript
extends SceneTree
|
|
|
|
const Adapter = preload("res://campaign/fg3_adapter.gd")
|
|
const Fixture = preload("res://tests/fg3/contract1_fixture.gd")
|
|
const SaveV10 = preload("res://fg3/save_v10_service.gd")
|
|
|
|
const SLOT_PATH := "user://abg_fg3_candidate_persistence.json"
|
|
const EXPECTED_PHASES := {
|
|
"market_8": "market",
|
|
"contract1_prepared": "prepared",
|
|
"contract1_recovery": "recovery",
|
|
"boss1_muster": "boss_muster",
|
|
"boss1_prepared": "boss_prepared",
|
|
"contract_result": "contract_result",
|
|
"contract2_handoff": "contract2_handoff",
|
|
"contract2_market_1": "contract2_market",
|
|
"contract2_prepared": "contract2_prepared",
|
|
"contract2_recovery": "contract2_recovery",
|
|
"boss2_muster": "contract2_boss_muster",
|
|
"boss2_prepared": "contract2_boss_prepared",
|
|
"region_result": "region_result",
|
|
"region_closed": "region_closed",
|
|
"v9_migrated_market_8": "market",
|
|
}
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run_test")
|
|
|
|
|
|
func run_test() -> void:
|
|
cleanup_files()
|
|
var states := build_states()
|
|
var chain := verify_state_chain(states)
|
|
verify_rejection_boundaries(states, chain)
|
|
verify_atomic_slot_interruption(states)
|
|
cleanup_files()
|
|
if failures.is_empty():
|
|
print("ABG_FG3_CANDIDATE_PERSISTENCE_OK states=15 phases=market,prepared,recovery,boss_muster,boss_prepared,contract_result,contract2_handoff,contract2_market,contract2_prepared,contract2_recovery,contract2_boss_muster,contract2_boss_prepared,region_result,region_closed migration=v9_to_v10 chain=15 roundtrip=15 offline_rewards=0 mutation=true duplication=true rollback=true future_version=true active_backup=true dual_corruption=true interruption=true")
|
|
quit(0)
|
|
else:
|
|
for failure in failures:
|
|
push_error(failure)
|
|
quit(1)
|
|
|
|
|
|
func build_states() -> Array[Dictionary]:
|
|
var fixture := Fixture.new()
|
|
var migrated := fixture.migrated_v10(300)
|
|
var states: Array[Dictionary] = [
|
|
row("market_8", fixture.fg3_market_8_snapshot()),
|
|
row("contract1_prepared", contract1_prepared()),
|
|
row("contract1_recovery", contract1_recovery()),
|
|
row("boss1_muster", fixture.boss_muster_snapshot()),
|
|
row("boss1_prepared", boss1_prepared()),
|
|
row("contract_result", contract_result()),
|
|
row("contract2_handoff", fixture.contract2_handoff_snapshot()),
|
|
row("contract2_market_1", fixture.contract2_market_1_snapshot()),
|
|
row("contract2_prepared", contract2_prepared()),
|
|
row("contract2_recovery", contract2_recovery()),
|
|
row("boss2_muster", fixture.contract2_boss_muster_snapshot()),
|
|
row("boss2_prepared", boss2_prepared()),
|
|
row("region_result", region_result()),
|
|
row("region_closed", region_closed()),
|
|
row(
|
|
"v9_migrated_market_8",
|
|
Dictionary(migrated.get("snapshot", {})).duplicate(true)
|
|
),
|
|
]
|
|
expect(bool(migrated.get("ok", false)), "Save-v9 Market 8 migration succeeds")
|
|
if bool(migrated.get("ok", false)):
|
|
expect(int(migrated.sequence) == 301, "migration advances the source sequence")
|
|
expect(not String(migrated.previous_commit_hash).is_empty(), "migration chains the Save-v9 commit")
|
|
expect(migrated.offline_delta == SaveV10.OFFLINE_ZERO, "migration creates zero offline value")
|
|
return states
|
|
|
|
|
|
func verify_state_chain(states: Array[Dictionary]) -> Dictionary:
|
|
expect(states.size() == 15, "matrix contains exactly fifteen states")
|
|
var saves := SaveV10.new()
|
|
var previous_hash := ""
|
|
var texts: Array[String] = []
|
|
var hashes: Array[String] = []
|
|
for index in range(states.size()):
|
|
var state_row: Dictionary = states[index]
|
|
var state_id := String(state_row.id)
|
|
var snapshot: Dictionary = state_row.snapshot
|
|
expect(not snapshot.is_empty(), "%s fixture is nonempty" % state_id)
|
|
if snapshot.is_empty():
|
|
return {}
|
|
expect(
|
|
String(snapshot.current_run.phase) == String(EXPECTED_PHASES[state_id]),
|
|
"%s has the exact stable phase" % state_id
|
|
)
|
|
var sequence := 400 + index
|
|
var encoded := saves.encode_snapshot(snapshot, sequence, previous_hash)
|
|
expect(bool(encoded.get("ok", false)), "%s encodes" % state_id)
|
|
if not bool(encoded.get("ok", false)):
|
|
return {}
|
|
expect(
|
|
String(encoded.envelope.previous_commit_hash) == previous_hash,
|
|
"%s binds the prior commit hash" % state_id
|
|
)
|
|
var decoded := saves.decode_text(String(encoded.text), sequence)
|
|
expect(
|
|
bool(decoded.get("ok", false)) and decoded.snapshot == snapshot,
|
|
"%s roundtrips byte-equivalent" % state_id
|
|
)
|
|
expect(
|
|
bool(decoded.get("ok", false)) and decoded.offline_delta == SaveV10.OFFLINE_ZERO,
|
|
"%s reload creates zero offline value" % state_id
|
|
)
|
|
var duplicate := saves.encode_snapshot(snapshot, sequence, previous_hash)
|
|
expect(
|
|
bool(duplicate.get("ok", false))
|
|
and String(duplicate.text) == String(encoded.text)
|
|
and String(duplicate.envelope.commit_hash) == String(encoded.envelope.commit_hash),
|
|
"%s deterministic duplicate is byte-identical" % state_id
|
|
)
|
|
texts.append(String(encoded.text))
|
|
hashes.append(String(encoded.envelope.commit_hash))
|
|
previous_hash = String(encoded.envelope.commit_hash)
|
|
return {
|
|
"texts": texts,
|
|
"hashes": hashes,
|
|
"last_sequence": 400 + states.size() - 1,
|
|
}
|
|
|
|
|
|
func verify_rejection_boundaries(states: Array[Dictionary], chain: Dictionary) -> void:
|
|
if states.size() != 15 or chain.is_empty():
|
|
return
|
|
var saves := SaveV10.new()
|
|
var texts: Array = chain.texts
|
|
var last_sequence := int(chain.last_sequence)
|
|
var last_text := String(texts[-1])
|
|
var prior_text := String(texts[-2])
|
|
|
|
var mutated: Dictionary = JSON.parse_string(last_text)
|
|
mutated.state.current_run.coin = int(mutated.state.current_run.coin) + 1
|
|
var mutation_result := saves.decode_text(JSON.stringify(mutated), last_sequence)
|
|
expect(not mutation_result.ok and mutation_result.code == "checksum_mismatch", "state mutation rejects before load")
|
|
|
|
var bad_checksum: Dictionary = JSON.parse_string(last_text)
|
|
bad_checksum.checksum = "0".repeat(64)
|
|
var checksum_result := saves.decode_text(JSON.stringify(bad_checksum), last_sequence)
|
|
expect(not checksum_result.ok and checksum_result.code == "checksum_mismatch", "checksum drift rejects")
|
|
|
|
var future: Dictionary = JSON.parse_string(last_text)
|
|
future.save_version = 11
|
|
var future_result := saves.decode_text(JSON.stringify(future), last_sequence)
|
|
expect(not future_result.ok and future_result.code == "save_version_unknown", "future Save version blocks")
|
|
|
|
var rollback := saves.decode_text(prior_text, last_sequence)
|
|
expect(not rollback.ok and rollback.code == "rollback_rejected", "older chained commit cannot roll back")
|
|
var duplicate_replay := saves.decode_text(last_text, last_sequence + 1)
|
|
expect(not duplicate_replay.ok and duplicate_replay.code == "rollback_rejected", "already-consumed sequence cannot replay")
|
|
|
|
var fallback := saves.load_with_backup(JSON.stringify(mutated), prior_text, last_sequence - 1)
|
|
expect(
|
|
fallback.ok
|
|
and fallback.source == "backup"
|
|
and fallback.active_failure == "checksum_mismatch"
|
|
and int(fallback.sequence) == last_sequence - 1,
|
|
"corrupt active falls back to the exact prior commit"
|
|
)
|
|
var no_valid := saves.load_with_backup("{broken", "also-broken", 0)
|
|
expect(
|
|
not no_valid.ok
|
|
and no_valid.code == "no_valid_save"
|
|
and no_valid.active_failure == "save_malformed"
|
|
and no_valid.backup_failure == "save_malformed",
|
|
"dual corruption rejects without inventing a state"
|
|
)
|
|
|
|
var invalid_snapshot: Dictionary = Dictionary(states[0].snapshot).duplicate(true)
|
|
invalid_snapshot.current_run.coin = -1
|
|
var invalid_state := saves.encode_snapshot(invalid_snapshot, 999)
|
|
expect(not invalid_state.ok and invalid_state.code == "invalid_state", "invalid product state cannot enter an envelope")
|
|
expect(
|
|
not saves.encode_snapshot(states[0].snapshot, 999, "not-a-hash").ok,
|
|
"foreign previous hash rejects"
|
|
)
|
|
|
|
|
|
func verify_atomic_slot_interruption(states: Array[Dictionary]) -> void:
|
|
if states.size() < 2 or states[0].snapshot.is_empty() or states[1].snapshot.is_empty():
|
|
return
|
|
var saves := SaveV10.new()
|
|
var first := saves.write_slot(SLOT_PATH, states[0].snapshot, 900)
|
|
expect(first.ok and FileAccess.file_exists(SLOT_PATH), "first candidate promotes to active")
|
|
if not first.ok:
|
|
return
|
|
var second := saves.write_slot(SLOT_PATH, states[1].snapshot, 901, String(first.commit_hash))
|
|
expect(second.ok and FileAccess.file_exists("%s.bak" % SLOT_PATH), "second candidate rotates active to backup")
|
|
if not second.ok:
|
|
return
|
|
var candidate := FileAccess.open("%s.candidate" % SLOT_PATH, FileAccess.WRITE)
|
|
expect(candidate != null, "interruption candidate opens")
|
|
if candidate != null:
|
|
candidate.store_string("interrupted fg3 candidate bytes")
|
|
candidate.close()
|
|
var resumed := saves.load_slot(SLOT_PATH, 901)
|
|
expect(resumed.ok and resumed.source == "active" and int(resumed.sequence) == 901, "partial candidate never replaces committed active")
|
|
var active := FileAccess.open(SLOT_PATH, FileAccess.WRITE)
|
|
expect(active != null, "active corruption probe opens")
|
|
if active != null:
|
|
active.store_string("corrupt active bytes")
|
|
active.close()
|
|
var backup := saves.load_slot(SLOT_PATH, 900)
|
|
expect(backup.ok and backup.source == "backup" and int(backup.sequence) == 900, "corrupt active recovers committed backup")
|
|
|
|
|
|
func contract1_prepared() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().fg3_market_8_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var route := route_for_risk(adapter.view_state().routes, 0)
|
|
var prepared: Dictionary = adapter.prepare_route(String(route.get("offer_id", "")))
|
|
return adapter.snapshot() if prepared.ok else {}
|
|
|
|
|
|
func contract1_recovery() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().fg3_market_8_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var route := route_for_risk(adapter.view_state().routes, 2)
|
|
var prepared: Dictionary = adapter.prepare_route(String(route.get("offer_id", "")))
|
|
if not prepared.ok:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("hold_line")
|
|
var terminal: Dictionary = adapter.run_battle_to_terminal()
|
|
var settled: Dictionary = adapter.settle_terminal()
|
|
return adapter.snapshot() if started.ok and terminal.ok and settled.ok else {}
|
|
|
|
|
|
func boss1_prepared() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().boss_muster_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("follow_index_clear_eggs")
|
|
return adapter.snapshot() if started.ok else {}
|
|
|
|
|
|
func contract_result() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().boss_muster_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("follow_index_clear_eggs")
|
|
var terminal: Dictionary = adapter.run_battle_to_terminal()
|
|
var settled: Dictionary = adapter.settle_terminal()
|
|
return adapter.snapshot() if started.ok and terminal.ok and settled.ok else {}
|
|
|
|
|
|
func contract2_prepared() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().contract2_market_1_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var route := route_for_risk(adapter.view_state().routes, 0)
|
|
var prepared: Dictionary = adapter.prepare_route(String(route.get("offer_id", "")))
|
|
return adapter.snapshot() if prepared.ok else {}
|
|
|
|
|
|
func contract2_recovery() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().contract2_market_1_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var route := route_for_risk(adapter.view_state().routes, 2)
|
|
var prepared: Dictionary = adapter.prepare_route(String(route.get("offer_id", "")))
|
|
if not prepared.ok:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("interrupt_pulse")
|
|
var terminal: Dictionary = adapter.run_battle_to_terminal()
|
|
var settled: Dictionary = adapter.settle_terminal()
|
|
return adapter.snapshot() if started.ok and terminal.ok and settled.ok else {}
|
|
|
|
|
|
func boss2_prepared() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().contract2_boss_muster_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("interrupt_pulse_family")
|
|
return adapter.snapshot() if started.ok else {}
|
|
|
|
|
|
func region_result() -> Dictionary:
|
|
var adapter = configured_adapter(Fixture.new().contract2_boss_muster_snapshot())
|
|
if adapter == null:
|
|
return {}
|
|
var started: Dictionary = adapter.start_plan("interrupt_pulse_family")
|
|
var terminal: Dictionary = adapter.run_battle_to_terminal()
|
|
var settled: Dictionary = adapter.settle_terminal()
|
|
return adapter.snapshot() if started.ok and terminal.ok and settled.ok else {}
|
|
|
|
|
|
func region_closed() -> Dictionary:
|
|
var adapter = configured_adapter(region_result())
|
|
if adapter == null:
|
|
return {}
|
|
var closed: Dictionary = adapter.continue_result()
|
|
return adapter.snapshot() if closed.ok else {}
|
|
|
|
|
|
func configured_adapter(snapshot: Dictionary):
|
|
if snapshot.is_empty():
|
|
return null
|
|
var adapter := Adapter.new()
|
|
var configured: Dictionary = adapter.configure(snapshot)
|
|
return adapter if configured.ok else null
|
|
|
|
|
|
func route_for_risk(routes: Array, risk: int) -> Dictionary:
|
|
for route_value in routes:
|
|
var route: Dictionary = route_value
|
|
if int(route.get("risk", -1)) == risk:
|
|
return route
|
|
return {}
|
|
|
|
|
|
func row(id: String, snapshot: Dictionary) -> Dictionary:
|
|
return {"id": id, "snapshot": snapshot}
|
|
|
|
|
|
func cleanup_files() -> void:
|
|
for file_path in [SLOT_PATH, "%s.bak" % SLOT_PATH, "%s.candidate" % SLOT_PATH]:
|
|
if FileAccess.file_exists(file_path):
|
|
DirAccess.remove_absolute(file_path)
|
|
|
|
|
|
func expect(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append("FG-3 candidate persistence: %s" % message)
|