Files
aetherbound-guild/runtime/tests/fg4/candidate_persistence_matrix_test.gd
T

335 lines
13 KiB
GDScript

extends SceneTree
const Adapter = preload("res://campaign/fg4_adapter.gd")
const Fixture = preload("res://tests/fg4/contract1_fixture.gd")
const SaveV11 = preload("res://fg4/save_v11_service.gd")
const SLOT_PATH := "user://abg_fg4_candidate_persistence.json"
const EXPECTED_PHASES := {
"market_1": "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",
"v10_migrated_market_1": "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_FG4_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=v10_to_v11 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_v11(300)
var states: Array[Dictionary] = [
row("market_1", fixture.market_1_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(
"v10_migrated_market_1",
Dictionary(migrated.get("snapshot", {})).duplicate(true)
),
]
expect(bool(migrated.get("ok", false)), "Save-v10 RG-03 Market 1 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-v10 commit")
expect(migrated.offline_delta == SaveV11.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 := SaveV11.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 == SaveV11.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 := SaveV11.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 = 12
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 := SaveV11.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 fg4 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().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 contract1_recovery() -> Dictionary:
var adapter = configured_adapter(Fixture.new().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("vent_and_brace")
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("vent_and_brace")
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("vent_and_brace")
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("focus_foreman")
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("vary_tags")
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("vary_tags")
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-4 candidate persistence: %s" % message)