Files
aetherbound-guild/runtime/tools/m60_full_cycle_playthrough.gd
T

494 lines
19 KiB
GDScript

extends SceneTree
## M60-B driven full-cycle playthrough (test infrastructure, W1).
##
## Walks the current EntryShell from a clean boot through one complete first
## cycle with parsed touch input only: boot -> first-launch setup -> Title ->
## initial Company (choose two) -> Guild -> Contract -> Market (recruit hire) ->
## Guild -> Party Line -> Readiness -> Risk -> Battle Opening -> Battle Live ->
## Result -> Knockout -> Reward -> Recovery -> back to Guild/Market.
##
## Every step writes a PNG and a timeline entry (step, expected screen, actual
## screen, status, note). A step whose control is missing or whose expected
## screen does not arrive is recorded as blocked; the run then stops there so
## the earliest failed boundary is the reported result. No state is faked.
const MainScene = preload("res://main.tscn")
const SETTINGS_PATH := "user://aetherbound_guild_settings.json"
const DRAFT_PATH := "user://aetherbound_initial_company_draft.json"
const COMPANY_PATH := "user://aetherbound_expedition_company.json"
const LEGACY_SAVE_PATH := "user://aetherbound_guild_p10_3.json"
var output_root := ""
var frames_dir := ""
var timeline: Array = []
var parsed_touches := 0
var shell
var game_node: Node
var settled := false
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
if OS.get_environment("ABG_EVIDENCE_DIR").is_empty():
push_error("M60 playthrough requires the owned test-attempt runner")
quit(1)
return
output_root = OS.get_environment("ABG_EVIDENCE_DIR").path_join("m60-playthrough")
frames_dir = output_root.path_join("frames")
DirAccess.make_dir_recursive_absolute(frames_dir)
_cleanup_state()
root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND
root.content_scale_size = Vector2i(1280, 720)
root.size = Vector2i(1280, 720)
var game := MainScene.instantiate()
game_node = game
root.add_child(game)
await _frames(60)
shell = game.entry_shell
if shell == null:
await _step("boot", "boot", "no_entry_shell", "blocked", "MainScene created no EntryShell")
_finish(1)
return
await _step("boot", "boot", shell.current_screen, "", "")
if shell.current_screen != "boot":
_finish(1)
return
# Entry: first-launch setup -> Title -> initial Company.
await _press("setup_apply", "SetupApply", "title")
await _press("title_new_game", "TitleNewGame", "initial_company")
await _press("candidate_select_0", "CandidateSelect_0", "")
await _press("candidate_select_2", "CandidateSelect_2", "")
# The initial-company page owns choice/compare/inspect/review modes; the
# shell keeps current_screen == "initial_company" until Create Company.
await _press("candidate_review", "ReviewCompany", "")
await _verify("candidate_review_mode", String(shell.initial_company_page.mode) == "review", "mode=%s" % String(shell.initial_company_page.mode))
await _press("candidate_create", "CreateCompany", "guild_handoff")
# Guild -> Contract -> Market.
await _press("guild_contract_board", "GuildContractBoard", "contract_choice")
await _press("contract_difficulty_standard", "ContractDifficultyStandard", "")
await _press("contract_confirm", "ContractConfirm", "market")
# Market: the opening requires exactly two independent hires before the
# formation can begin (FORMATION_OPENING_HIRES_REQUIRED), so the cycle
# hires twice and confirms the Company reaches 4/4, then returns to Guild.
await _hire_next_available("hire_1")
await _hire_next_available("hire_2")
await _verify("company_line_4_4", await _company_is_full(), "owned=%d capacity=%s" % [await _owned_count(), await _line_capacity()])
await _press("market_back_guild", "MarketBackGuild", "guild_handoff")
await _sample_screen("guild_settle", 60)
# Guild -> Party Line -> Readiness -> Risk.
await _press("guild_party", "GuildParty", "party_line")
await _press("party_position_3", "PartyPosition3", "")
await _press("party_save_review", "PartySaveReview", "readiness_review")
await _press("readiness_accept", "ReadinessAccept", "readiness_review")
await _press("readiness_risk", "ReadinessRisk", "")
# Risk: the board starts unselected, so pick an option, review it and then
# acknowledge the commitment before Battle Opening becomes available.
await _press_first_risk_option("risk_select")
await _press("risk_review", "RiskReview", "risk_commitment")
await _press("risk_acknowledge", "RiskCommitmentAcknowledge", "")
await _press("risk_begin_battle", "RiskBeginBattle", "battle")
# A driven first cycle should not sit through the full 1x timeline. Speed is
# presentation-only (the UI ships 1x/2x/4x); the driver raises the playback
# divisor so the same deterministic timeline completes inside the run.
if "battle_speed" in shell:
shell.battle_speed = 60.0
await _press("battle_start_observation", "BattleStartObservation", "")
await _wait_for_outcome()
# Outcome chain: commit the locked outcome (OUT-001) -> Result -> Knockout
# -> Reward -> Recovery.
await _press_any("result_open", ["BattleLiveNextBoundary", "BattleResultContinue"], "battle_result")
await _press_any("result_continue", ["BattleResultContinue"], "")
await _press_optional("knockout_acknowledge", ["BattleKnockoutAcknowledge"], "", "no knockout in this battle")
# Reward choice needs one option selected before Claim becomes the commit.
await _press_first_child("reward_select", "BattleRewardOptions", "BattleRewardOption_")
await _press_any("reward_claim", ["BattleRewardClaim"], "")
await _press_any("recovery_commit", ["BattleRecoveryCommit"], "")
# The cycle closes where the next decision starts: Recovery commits back to
# the current Contract's Market.
await _step("final", "market", String(shell.current_screen), "", "")
_finish(0 if not _has_blocked() else 1)
func _press(label: String, node_name: String, expected: String) -> void:
var control := await _resolve(node_name)
if control == null:
_dump_controls(label)
await _step(label, expected, shell.current_screen, "blocked", "%s missing, hidden or disabled" % node_name)
_finish(1)
return
var before := String(shell.current_screen)
await _touch(control)
var after := String(shell.current_screen)
var status := "pass"
var note := ""
if not expected.is_empty() and after != expected:
status = "blocked"
note = "expected %s after %s; before=%s after=%s; rect=%s" % [expected, node_name, before, after, control.get_global_rect()]
_dump_controls(label)
await _step(label, expected, after, status, note if not note.is_empty() else "before=%s after=%s" % [before, after])
if status == "blocked":
_finish(1)
func _press_first_risk_option(label: String) -> void:
var list := shell.find_child("RiskOptionList", true, false) as Control
var control: Control = null
var used := ""
var parents: Array = [list] if list != null else [shell]
for parent in parents:
if parent == null:
continue
for child in parent.find_children("RiskOption*", "BaseButton", true, false):
var button := child as BaseButton
if button != null and button.is_visible_in_tree() and not button.disabled:
control = button
used = String(button.name)
break
if control != null:
break
if control == null:
await _step(label, "a selectable risk option", shell.current_screen, "blocked", "no enabled RiskOption node")
_finish(1)
return
await _touch(control)
await _step(label, "", String(shell.current_screen), "pass", "selected=%s" % used)
func _hire_next_available(label: String) -> void:
# A hired offer stays on the board as inspect-only, so the cycle walks the
# cards in order, re-resolving the list after every review, and commits
# only where the Hire action is actually present.
var tried := 0
while tried < 6:
var list := await _resolve("MarketOfferList")
if list == null:
break
var cards: Array = []
for child in list.get_children():
var button := child as BaseButton
if button != null and button.is_visible_in_tree():
cards.append(button)
if tried >= cards.size():
break
var card: Control = cards[tried]
tried += 1
var card_name := String(card.name)
await _touch(card)
var review := await _resolve("MarketReviewRecruit")
if review == null:
continue
await _touch(review)
var hire := await _resolve("RecruitHire")
if hire == null:
var cancel := await _resolve("RecruitReviewCancel")
if cancel != null:
await _touch(cancel)
await _frames(10)
continue
await _touch(hire)
var after := String(shell.current_screen)
await _step(label, "market", after, "pass" if after == "market" else "blocked", "hired via %s (card %d)" % [card_name, tried])
if after != "market":
_finish(1)
return
await _step(label, "a hireable offer", String(shell.current_screen), "blocked", "no hireable card among %d" % tried)
_finish(1)
func _resolve(node_name: String) -> Control:
# Screens rebuild their controls on every transition, so a node found at
# one frame can be freed the next. Re-resolve until a live, visible,
# enabled control is available, then hand it over for the touch.
for _attempt in 12:
var control := shell.find_child(node_name, true, false) as Control
if control != null and is_instance_valid(control) and control.is_visible_in_tree():
if not (control is BaseButton) or not control.disabled:
return control
await _frames(6)
return null
func _owned_count() -> int:
var result: Dictionary = shell.initial_company_service.load_company()
return Array(result.company.owned_recruits).size() if result.ok else -1
func _line_capacity() -> float:
var result: Dictionary = shell.initial_company_service.load_company()
return float(result.company.active_contract.get("line_capacity", 0)) if result.ok else -1.0
func _company_is_full() -> bool:
return await _owned_count() == 4 and await _line_capacity() == 4.0
func _sample_screen(label: String, frames: int) -> void:
var samples: Array[String] = []
var last := ""
for _index in frames:
var current := String(shell.current_screen)
if current != last:
samples.append("%d:%s" % [_index, current])
last = current
await process_frame
await _step(label, "", String(shell.current_screen), "pass" if samples.size() <= 1 else "blocked", "samples=%s" % [samples])
func _press_first_offer(label: String) -> void:
var list := shell.find_child("MarketOfferList", true, false) as Control
var control: Control = null
var used := ""
if list != null:
for child in list.get_children():
var button := child as BaseButton
if button != null and button.is_visible_in_tree() and not button.disabled:
control = button
used = String(button.name)
break
if control == null:
await _step(label, "a visible offer card", shell.current_screen, "blocked", "MarketOfferList has no enabled card")
_finish(1)
return
await _touch(control)
await _step(label, "", shell.current_screen, "pass", "selected=%s" % used)
func _press_first_child(label: String, parent_name: String, child_prefix: String) -> void:
var parent := shell.find_child(parent_name, true, false) as Control
var control: Control = null
var used := ""
if parent != null:
for node in parent.find_children("%s*" % child_prefix, "BaseButton", true, false):
var button := node as BaseButton
if button != null and button.is_visible_in_tree() and not button.disabled:
control = button
used = String(button.name)
break
if control == null:
await _step(label, "a selectable %s" % child_prefix, shell.current_screen, "blocked", "none under %s" % parent_name)
_finish(1)
return
await _touch(control)
await _step(label, "", String(shell.current_screen), "pass", "selected=%s" % used)
func _press_optional(label: String, node_names: Array, expected: String, skip_note: String) -> void:
var present := false
for node_name in node_names:
var candidate := shell.find_child(String(node_name), true, false) as Control
if candidate != null and candidate.is_visible_in_tree():
present = true
break
if not present:
await _step(label, "", String(shell.current_screen), "skip", skip_note)
return
await _press_any(label, node_names, expected)
func _press_any(label: String, node_names: Array, expected: String) -> void:
var control: Control = null
var used := ""
for node_name in node_names:
var candidate := shell.find_child(String(node_name), true, false) as Control
if candidate != null and candidate.is_visible_in_tree():
control = candidate
used = String(node_name)
break
if control == null:
await _step(label, expected, shell.current_screen, "blocked", "none of %s present" % [node_names])
_finish(1)
return
var before := String(shell.current_screen)
await _touch(control)
var after := String(shell.current_screen)
var status := "pass"
var note := "via=%s before=%s after=%s" % [used, before, after]
if not expected.is_empty() and after != expected:
status = "blocked"
note += " expected=%s" % expected
await _step(label, expected, after, status, note)
if status == "blocked":
_finish(1)
func _verify(label: String, condition: bool, note: String) -> void:
await _step(label, "true", "true" if condition else "false", "pass" if condition else "blocked", note)
if not condition:
_finish(1)
func _wait_for_outcome() -> void:
var deadline := 3600
var elapsed := 0
while elapsed < deadline:
var live := String(shell.battle_live_state) if "battle_live_state" in shell else ""
if live == "outcome_locked":
break
if shell.find_child("BattleResultContinue", true, false) != null:
break
if shell.find_child("BattleRecoveryCommit", true, false) != null:
break
await _frames(10)
elapsed += 1
var status := "pass" if elapsed < deadline else "blocked"
var live_state := String(shell.battle_live_state) if "battle_live_state" in shell else "-"
var note := "waited=%d frames, screen=%s live=%s" % [elapsed * 10, shell.current_screen, live_state]
await _step("battle_outcome", "result or recovery", String(shell.current_screen), status, note)
if status == "blocked":
_finish(1)
func _touch(control: Control) -> void:
if not is_instance_valid(control):
return
var center := root.get_final_transform() * 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(12)
func _frames(count: int) -> void:
for _index in count:
await process_frame
func _dump_controls(label: String) -> void:
var lines: Array[String] = ["screen=%s" % String(shell.current_screen)]
for node in shell.find_children("*", "BaseButton", true, false):
var button := node as BaseButton
if button == null or not button.is_visible_in_tree():
continue
lines.append("%s disabled=%s rect=%s" % [button.name, button.disabled, button.get_global_rect()])
var file := FileAccess.open(output_root.path_join("controls-%s.txt" % label), FileAccess.WRITE)
if file != null:
file.store_string("\n".join(lines))
file.close()
func _step(step: String, expected: String, actual: String, status: String, note: String) -> void:
var entry := {
"step": step,
"expected": expected,
"actual": actual,
"status": status if not status.is_empty() else ("pass" if expected.is_empty() or expected == actual else "blocked"),
"note": note,
}
timeline.append(entry)
await RenderingServer.frame_post_draw
var image := root.get_texture().get_image()
if image != null and not image.is_empty():
image.save_png(frames_dir.path_join("%02d-%s.png" % [timeline.size(), step]))
func _has_blocked() -> bool:
for entry in timeline:
if String(entry.status) == "blocked":
return true
return false
func _finish(code: int) -> void:
if settled:
return
settled = true
var file := FileAccess.open(output_root.path_join("timeline.json"), FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(timeline, " "))
file.close()
var blocked := 0
for entry in timeline:
if String(entry.status) == "blocked":
blocked += 1
print("ABG_M60_PLAYTHROUGH steps=%d touches=%d blocked=%d last=%s" % [
timeline.size(), parsed_touches, blocked,
String(timeline[timeline.size() - 1].step) if not timeline.is_empty() else "none",
])
if code == 0 and blocked == 0:
print("ABG_M60_PLAYTHROUGH_OK boot_to_recovery=true steps=%d touches=%d" % [timeline.size(), parsed_touches])
var audio_ok := _check_audio()
if code == 0 and blocked == 0 and not audio_ok:
code = 1
# Free the driven scene before quitting so the strict run sees no leaked
# ObjectDB instances.
if is_instance_valid(game_node):
game_node.queue_free()
await _frames(5)
quit(code)
func _cleanup_state() -> void:
# A leftover .bak/.candidate is a recoverable Company for the boot, which
# disables New Game. A driven fresh run must clear every variant so the
# playthrough always starts at first launch.
var paths: Array[String] = [SETTINGS_PATH, DRAFT_PATH, COMPANY_PATH, LEGACY_SAVE_PATH]
for base in [DRAFT_PATH, COMPANY_PATH]:
paths.append("%s.bak" % base)
paths.append("%s.candidate" % base)
paths.append("%s.corrupt" % base)
for path in paths:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _check_audio() -> bool:
# M76-C gate: the driven cycle must switch music families and dispatch the
# semantic cues for its own committed actions.
if shell == null or not is_instance_valid(shell) or shell.audio_service == null:
push_error("M76-C audio: shell has no audio service")
return false
var service = shell.audio_service
if int(service.events.size()) != 144:
push_error("M76-C audio: expected 144 mapped events, got %d" % int(service.events.size()))
return false
var families: Array = []
var page_opens := 0
var committed: Array = []
for record in service.journal:
if String(record.get("kind", "")) == "music":
families.append(String(record.get("name", "")))
if String(record.get("kind", "")) == "sfx":
if String(record.get("name", "")) == "page_open":
page_opens += 1
if String(record.get("suppressed", "")) == "":
committed.append(String(record.get("name", "")))
var required_families := ["title", "guild", "market", "prep", "run"]
var missing_families: Array = []
for family in required_families:
if not families.has(family):
missing_families.append(family)
var required_events := ["recruit_hire_commit", "risk_commit", "battle_start_commit", "reward_accept"]
var missing_events: Array = []
for event_name in required_events:
if not committed.has(event_name):
missing_events.append(event_name)
if not missing_families.is_empty() or not missing_events.is_empty() or page_opens < 8:
push_error("M76-C audio: missing families=%s events=%s page_opens=%d" % [str(missing_families), str(missing_events), page_opens])
return false
print("ABG_M76_AUDIO_CYCLE_OK families=%d page_open=%d events=%d switches=%d" % [families.size(), page_opens, committed.size(), int(service.music_switches)])
return true