Files
aetherbound-guild/runtime/tests/dev5/battle_live_timeline_test.gd
T

341 lines
15 KiB
GDScript

extends SceneTree
const EntryShell = preload("res://entry/entry_shell.gd")
const SettingsStore = preload("res://entry/settings_store.gd")
const DraftService = preload("res://entry/initial_company_draft_service.gd")
const ContractService = preload("res://entry/guild_contract_service.gd")
const FormationService = preload("res://entry/formation_risk_service.gd")
const BattleService = preload("res://entry/battle_service.gd")
const DRAFT_PATH := "user://dev5_battle_live_timeline_draft.json"
const COMPANY_PATH := "user://dev5_battle_live_timeline_company.json"
const SEED := "13579bdf02468ace13579bdf02468ace"
const CONTRACT_OP := "0102030405060708090a0b0c0d0e0f00"
const HIRE_OP_1 := "11112222333344445555666677778888"
const HIRE_OP_2 := "88887777666655554444333322221111"
const ORDER_OP := "abcdefabcdefabcdefabcdefabcdefab"
const READY_OP := "fedcbafedcbafedcbafedcbafedcbafe"
const BATTLE_OP := "1234567890abcdef1234567890abcdef"
var failures: Array[String] = []
var parsed_touches := 0
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.size = Vector2i(844, 390)
var at_1x := await _run_timeline(1.0, true)
var at_4x := await _run_timeline(4.0, false)
_expect(not at_1x.is_empty() and not at_4x.is_empty(), "both speed fixtures reach an immutable outcome")
if not at_1x.is_empty() and not at_4x.is_empty():
_expect(at_1x.event_ids == at_4x.event_ids, "1x and 4x preserve event order and identity")
_expect(at_1x.event_hashes == at_4x.event_hashes, "1x and 4x preserve event hashes")
_expect(at_1x.journal_hash == at_4x.journal_hash, "1x and 4x preserve the journal hash")
_expect(at_1x.outcome_hash == at_4x.outcome_hash, "1x and 4x preserve the outcome hash")
_expect(float(at_4x.timer_wait) < float(at_1x.timer_wait) * 0.3, "4x shortens presentation timing without changing simulation")
_cleanup()
if failures.is_empty():
print("ABG_DEV5_BATTLE_TIMELINE_OK states=waiting,forecast,committed,resolving,resolved,paused,inspect_handoff,pause_handoff,invalid,retrying,loading,interrupted,outcome speed=1x,2x,4x invariant=event_order,event_hash,journal_hash,outcome_hash pause=frozen settings=paused_return retry=same_snapshot safe_exit=title handoffs=BAT-003,BAT-004,BAT-005,BAT-006,OUT-001 settlement=false reward=false recovery=false focus=keyboard_controller touch=%d" % parsed_touches)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _run_timeline(speed: float, exercise_diagnostics: bool) -> Dictionary:
_cleanup()
var company := _create_prepared_company()
if company.is_empty():
_expect(false, "fixture prepares the four-member Company")
return {}
var option_id := String(company.active_contract.formation.risk_options[1].risk_option_id)
var started: Dictionary = _battle_service().begin_battle(BATTLE_OP, option_id)
_expect(started.ok, "fixture creates one battle receipt")
if not started.ok:
return {}
var baseline := _non_settlement_snapshot(started.company)
var shell := await _new_shell()
shell.show_battle(started.company, false)
await _frames(4)
_expect(shell.battle_live_state == "waiting_for_next_event" and shell.battle_paused, "manual fixture opens at the safe waiting boundary")
while shell.battle_speed < speed:
shell.cycle_battle_speed()
_expect(is_equal_approx(shell.battle_speed, speed), "fixture selects legal %.0fx speed" % speed)
if exercise_diagnostics:
await _verify_input_and_diagnostic_boundaries(shell)
var loaded_after_exit: Dictionary = _battle_service().load_company()
_expect(loaded_after_exit.ok, "Safe Exit preserves a loadable Company")
shell.queue_free()
await _frames(8)
shell = await _new_shell()
shell._open_company_destination(loaded_after_exit.company)
await _frames(4)
_expect(shell.battle_live_state == "interrupted_background" and shell.battle_paused, "Continue routes an active observation through BAT-006")
_expect(shell.find_child("BattleLiveNextBoundary", true, false) != null, "BAT-006 handoff entry remains visible")
var before_bat006 := _company_hash()
shell._battle_live_open_bat006()
_expect(shell.battle_live_state == "interrupted_background" and _company_hash() == before_bat006, "BAT-006 entry does not resume or mutate the battle")
shell.show_battle(loaded_after_exit.company, false)
await _frames(3)
while shell.battle_speed < speed:
shell.cycle_battle_speed()
shell.toggle_battle_pause()
_expect(not shell.battle_paused and shell.battle_playback_timer != null, "Resume schedules automatic presentation")
var timer_wait: float = shell.battle_playback_timer.wait_time if shell.battle_playback_timer != null else 99.0
shell.toggle_battle_pause()
_expect(shell.battle_paused and shell.battle_live_state == "paused" and shell.battle_playback_timer == null, "Pause freezes the current presentation stage")
_expect(int(_battle_snapshot().phase_index) == 0, "pause before forecast commits no event")
shell.toggle_battle_pause()
var seen := {}
var guard := 0
while shell.battle_live_state != "outcome_locked" and guard < 80:
seen[shell.battle_live_state] = true
if exercise_diagnostics and shell.battle_live_state == "event_resolving" and int(_battle_snapshot().phase_index) == 0 and not seen.has("invalid_event_data"):
shell.battle_live_event.event_hash = "0".repeat(64)
shell.advance_battle_phase()
seen[shell.battle_live_state] = true
_expect(shell.battle_live_state == "invalid_event_data" and shell.battle_paused, "invalid event hash pauses before commit")
_expect(int(_battle_snapshot().phase_index) == 0, "invalid event commits no hidden phase")
_expect(shell.find_child("BattleLiveRetrySnapshot", true, false) != null and shell.find_child("BattleLiveSafeExit", true, false) != null, "invalid state exposes Retry Snapshot and Safe Exit")
shell._battle_live_retry_snapshot()
seen[shell.battle_live_state] = true
await _frames(1)
seen[shell.battle_live_state] = true
await _frames(3)
seen[shell.battle_live_state] = true
_expect(shell.battle_live_state == "waiting_for_next_event" and shell.battle_paused, "retry returns to the same safe snapshot without implicit resume")
_expect(int(_battle_snapshot().phase_index) == 0, "retry does not skip or duplicate an event")
shell.toggle_battle_pause()
else:
shell.advance_battle_phase()
guard += 1
seen[shell.battle_live_state] = true
_expect(guard < 80 and shell.battle_live_state == "outcome_locked", "timeline reaches the immutable outcome lock")
for required_state in ["waiting_for_next_event", "event_forecast", "action_committed", "event_resolving", "event_resolved", "outcome_locked"]:
_expect(seen.has(required_state), "timeline exposes %s" % required_state)
if exercise_diagnostics:
for required_state in ["invalid_event_data", "snapshot_retrying", "loading_resume"]:
_expect(seen.has(required_state), "diagnostic flow exposes %s" % required_state)
var final_loaded: Dictionary = _battle_service().load_company()
_expect(final_loaded.ok, "outcome lock remains loadable")
if not final_loaded.ok:
shell.queue_free()
return {}
var battle: Dictionary = final_loaded.company.active_contract.battle_state
var observation: Dictionary = battle.observation
_expect(String(battle.status) == "outcome_locked" and int(battle.phase_index) == 9, "nine events stop at outcome_locked")
_expect(final_loaded.company.active_contract.get("battle_receipts", []).size() == 1, "timeline keeps exactly one original receipt")
_expect(final_loaded.company.active_contract.get("battle_settlement_receipts", []).is_empty(), "timeline creates no settlement receipt")
_expect(_non_settlement_snapshot(final_loaded.company) == baseline, "timeline applies no Coin, XP, history, reward or recovery mutation")
var before_outcome_entry := String(final_loaded.company.company_hash)
shell._battle_live_outcome_handoff()
_expect(shell.battle_live_state == "outcome_locked" and shell.current_screen == "battle", "OUT-001 entry keeps BAT-002 at the immutable handoff")
_expect(_company_hash() == before_outcome_entry, "OUT-001 entry applies no result operation")
var events: Array = battle.phase_events
var event_ids: Array[String] = []
var event_hashes: Array[String] = []
for event in events:
event_ids.append(String(event.event_id))
event_hashes.append(String(event.event_hash))
var result := {
"event_ids": event_ids,
"event_hashes": event_hashes,
"journal_hash": String(observation.event_journal_hash),
"outcome_hash": String(observation.outcome_lock.outcome_hash),
"timer_wait": timer_wait,
}
shell.queue_free()
await _frames(12)
return result
func _verify_input_and_diagnostic_boundaries(shell: Control) -> void:
var before := _company_hash()
var settings_button := shell.find_child("BattleLiveSettings", true, false) as Button
_expect(settings_button != null and settings_button.has_focus(), "Settings owns initial keyboard/controller focus")
await _focus_next()
var inspect := shell.find_child("BattleLiveInspect", true, false) as Button
_expect(inspect != null and inspect.has_focus(), "focus navigation reaches Inspect")
await _touch(inspect)
_expect(shell.battle_live_state == "inspect_handoff" and shell.battle_paused, "Inspect freezes and names BAT-003 ownership")
_expect(_company_hash() == before, "Inspect handoff changes no authoritative state")
shell._battle_live_cancel_handoff()
shell._battle_live_open_retreat_entry()
_expect(shell.battle_live_state == "pause_controls_handoff", "Retreat entry names BAT-004/BAT-005 ownership")
_expect(_company_hash() == before and not shell.guild_company.active_contract.has("retreat_receipts"), "retreat entry creates no receipt")
shell._battle_live_cancel_handoff()
shell._battle_live_open_settings()
await _frames(3)
_expect(shell.current_screen == "settings", "Settings opens from the paused live field")
shell.request_settings_back()
await _frames(4)
_expect(shell.current_screen == "battle" and shell.battle_live_state == "paused" and shell.battle_paused, "Settings returns without implicit resume")
_expect(_company_hash() == before, "Settings round-trip changes no battle event")
shell._battle_live_interrupt("test_background")
_expect(shell.battle_live_state == "interrupted_background" and shell.battle_paused, "background interruption freezes and names BAT-006")
_expect(_company_hash() == before, "interruption changes no authoritative state")
shell._battle_live_safe_exit()
await _frames(4)
_expect(shell.current_screen == "title" and _company_hash() == before, "Safe Exit returns to Title without mutating the receipt")
func _new_shell() -> Control:
var shell := EntryShell.new()
shell.auto_start = false
shell.initial_company_service.path = DRAFT_PATH
shell.initial_company_service.company_path = COMPANY_PATH
root.add_child(shell)
await _frames(3)
var settings := SettingsStore.new().defaults()
settings.setup_completed = true
settings.locale = "en"
settings.text_scale = 1.0
shell.settings = settings
shell.setup_draft = settings.duplicate(true)
return shell
func _battle_snapshot() -> Dictionary:
var loaded: Dictionary = _battle_service().load_company()
if not loaded.ok:
return {}
var battle: Dictionary = loaded.company.active_contract.battle_state
return {
"phase_index": int(battle.get("phase_index", -1)),
"journal_hash": String(Dictionary(battle.get("observation", {})).get("event_journal_hash", "")),
}
func _non_settlement_snapshot(company: Dictionary) -> Dictionary:
var contract: Dictionary = company.active_contract
var recruit_state: Array = []
for recruit in company.owned_recruits:
recruit_state.append({
"id": String(recruit.recruit_id),
"level": int(recruit.level),
"xp": int(recruit.xp),
"history": Dictionary(recruit.battle_history).duplicate(true),
})
return {
"coin": int(contract.coin),
"recruits": recruit_state,
"settlements": Array(contract.get("battle_settlement_receipts", [])).duplicate(true),
"recoveries": Array(contract.get("recovery_receipts", [])).duplicate(true),
"contract_status": String(contract.status),
}
func _company_hash() -> String:
var loaded: Dictionary = _battle_service().load_company()
return String(loaded.company.company_hash) if loaded.ok else ""
func _create_prepared_company() -> Dictionary:
var draft: Dictionary = _draft_service().begin_or_load(SEED)
if not draft.ok:
return {}
_draft_service().set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[2].offer_id)])
if not _draft_service().commit_company().ok:
return {}
var created: Dictionary = _contract_service().create_contract(CONTRACT_OP, "CTR-R1-01", "standard")
if not created.ok:
return {}
var offers: Array = created.company.active_contract.market_board.recruit_offers
_contract_service().hire_recruit(HIRE_OP_1, String(offers[0].source_offer_id), 14)
var hired: Dictionary = _contract_service().hire_recruit(HIRE_OP_2, String(offers[1].source_offer_id), 14)
if not hired.ok:
return {}
var begun: Dictionary = _formation_service().begin_formation()
var saved: Dictionary = _formation_service().save_order(ORDER_OP, begun.formation.base_front_to_rear_ids)
var ready: Dictionary = _formation_service().accept_readiness(READY_OP, String(saved.receipt.order_hash))
return ready.company if ready.ok else {}
func _draft_service() -> RefCounted:
var service := DraftService.new()
service.path = DRAFT_PATH
service.company_path = COMPANY_PATH
return service
func _contract_service() -> RefCounted:
var service := ContractService.new()
service.company_path = COMPANY_PATH
return service
func _formation_service() -> RefCounted:
var service := FormationService.new()
service.company_path = COMPANY_PATH
return service
func _battle_service() -> RefCounted:
var service := BattleService.new()
service.company_path = COMPANY_PATH
return service
func _touch(control: Control) -> void:
if control == null:
_expect(false, "touch target exists")
return
var center := control.get_global_rect().get_center()
var press := InputEventScreenTouch.new()
press.position = center
press.pressed = true
press.index = 0
Input.parse_input_event(press)
await _frames(2)
var release := InputEventScreenTouch.new()
release.position = center
release.pressed = false
release.index = 0
Input.parse_input_event(release)
parsed_touches += 1
await _frames(5)
func _focus_next() -> void:
var press := InputEventAction.new()
press.action = "ui_focus_next"
press.pressed = true
Input.parse_input_event(press)
await _frames(2)
var release := InputEventAction.new()
release.action = "ui_focus_next"
release.pressed = false
Input.parse_input_event(release)
await _frames(2)
func _frames(count: int) -> void:
for _index in range(count):
await process_frame
func _cleanup() -> void:
for path in [
DRAFT_PATH, "%s.bak" % DRAFT_PATH, "%s.candidate" % DRAFT_PATH,
"%s.bak.candidate" % DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH,
"%s.candidate" % COMPANY_PATH, "%s.bak.candidate" % COMPANY_PATH,
"%s.corrupt" % COMPANY_PATH,
]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append("BAT2-4C timeline: %s" % message)