341 lines
15 KiB
GDScript
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)
|