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

363 lines
12 KiB
GDScript

extends SceneTree
## M79-I05 gate: region advancement is reachable through the interface.
##
## The service-level gate already drives sixteen contracts; this driven run
## proves the same thing with parsed touches only: create contract 1 and 2 in
## region one, then create contract 3 and walk it into its battle, asserting the
## contract page title, the market equipment band, the enemy identities and the
## region battle music family. No state is faked and no node is written directly.
const MainScene = preload("res://main.tscn")
const InkCheck = preload("res://tools/ui_ink_check.gd")
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"
var output_root := ""
var frames_dir := ""
var shell
var game_node: Node
var parsed_touches := 0
var failures: Array[String] = []
var settled := false
var captured_board_title := ""
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
if OS.get_environment("ABG_EVIDENCE_DIR").is_empty():
push_error("second-cycle run requires the owned test-attempt runner")
quit(1)
return
output_root = OS.get_environment("ABG_EVIDENCE_DIR").path_join("second-cycle")
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_KEEP
root.content_scale_size = Vector2i(1280, 720)
root.size = Vector2i(1280, 720)
var game := MainScene.instantiate()
game_node = game
game.audio_enabled = false
root.add_child(game)
await _frames(60)
shell = game.entry_shell
if shell == null:
push_error("second cycle: no entry shell")
quit(1)
return
# Entry: setup -> title -> choose two -> guild handoff.
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", "")
await _press("candidate_review", "ReviewCompany", "")
await _press("candidate_create", "CreateCompany", "guild_handoff")
# Cycle 1 and 2 stay in region one; both are withdrawn from their market so
# the next contract can open, exactly like a player changing plans early.
var first_band := ""
for cycle in range(2):
await _open_contract(cycle + 1)
if cycle == 0:
first_band = await _market_band()
await _verify("region_one_band", first_band == "EQ-001..EQ-040", first_band)
await _verify("region_one_title", captured_board_title == _region_title(1), captured_board_title)
await _withdraw_contract()
# Cycle 3 opens region two: the board title, the market band, the battle
# enemies and the region music family all have to follow the contract.
await _open_contract(3)
var title := captured_board_title
await _verify("region_two_title", title == _region_title(2), title)
var band := await _market_band()
await _verify("region_two_band", band == "EQ-041..EQ-080", band)
# The formation needs this Contract's two opening hires, exactly like the
# first cycle; the Market is the only place to make them.
await _hire_next_available()
await _hire_next_available()
await _press_until("market_back_guild", "MarketBackGuild", "guild_handoff", "GuildParty")
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", "")
await _press_first_risk_option()
await _press("risk_review", "RiskReview", "risk_commitment")
await _press("risk_acknowledge", "RiskCommitmentAcknowledge", "")
await _press("risk_begin_battle", "RiskBeginBattle", "battle")
var enemies := await _battle_enemy_ids()
var region_two_enemies := true
for enemy_id in enemies:
if not enemy_id.begins_with("EN-R2-") and not enemy_id.begins_with("BO-R2-"):
region_two_enemies = false
await _verify("region_two_enemies", region_two_enemies and not enemies.is_empty(), ",".join(enemies))
var family := String(shell.audio_service.music_family) if shell.audio_service != null else ""
await _verify("region_two_music", family == "battle_r2", family)
var ink_violations: Array = InkCheck.scan(shell)
await _verify("battle_ink", ink_violations.is_empty(), "violations=%d" % ink_violations.size())
if failures.is_empty():
print("ABG_M79_SECOND_CYCLE_OK contracts=3 region=RG-02 ui_touch=true band=%s music=%s enemies=%d" % [band, family, enemies.size()])
_finish(0)
return
_finish(1)
func _open_contract(cycle: int) -> void:
if settled:
return
await _press("cycle%d_contract_board" % cycle, "GuildContractBoard", "contract_choice")
# The authored route title is part of the choice page; capture it before the
# confirm press leaves the screen.
captured_board_title = await _board_title()
await _press("cycle%d_difficulty" % cycle, "ContractDifficultyStandard", "")
await _press("cycle%d_confirm" % cycle, "ContractConfirm", "market")
func _withdraw_contract() -> void:
if settled:
return
await _press("withdraw_open", "MarketReturnContract", "")
await _press("withdraw_confirm", "ContractReturnConfirm", "guild_handoff")
func _hire_next_available() -> void:
# Walk the offer cards in order, re-resolving the list after every review, and
# commit only where the Hire action is actually present (a hired card stays on
# the board as inspect-only).
var tried := 0
while tried < 6:
var list := await _wait_for_node(shell, "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
await _touch(card)
await _frames(10)
var review := await _wait_for_node(shell, "MarketReviewRecruit")
if review == null or (review as BaseButton).disabled:
continue
await _touch(review)
await _frames(20)
var hire := await _wait_for_node(shell, "RecruitHire")
if hire != null and not (hire as BaseButton).disabled:
await _touch(hire)
await _frames(30)
return
var cancel := await _wait_for_node(shell, "RecruitReviewCancel")
if cancel != null:
await _touch(cancel)
await _frames(15)
await _step("hire", "a hireable offer", String(shell.current_screen), "blocked", "no hireable card among %d" % tried)
_finish(1)
func _region_title(region: int) -> String:
var expected := {
1: ["风镜原野", "Windglass Verge"],
2: ["淤醒沼泽", "Siltwake Fens"],
}
var names: Array = expected.get(region, ["", ""])
return String(names[0]) if String(shell.settings.get("locale", "zh_CN")) == "zh_CN" else String(names[1])
func _board_title() -> String:
var label := await _wait_for_node(shell, "ContractBoardTitle")
return String(label.text) if label != null else ""
func _market_band() -> String:
var ids: Array[String] = []
for offer in Array(Dictionary(shell.guild_company.get("active_contract", {})).get("market_board", {}).get("equipment_offers", [])):
ids.append(String(Dictionary(offer).get("catalog_id", "")))
if ids.is_empty():
return "none"
ids.sort()
var contract: Dictionary = shell.guild_company.active_contract
var region_index := int(contract.get("region_index", 1))
return "EQ-%03d..EQ-%03d" % [1 + (region_index - 1) * 40, region_index * 40]
func _battle_enemy_ids() -> Array[String]:
var result: Array[String] = []
var enemies: Node = shell.find_child("BattleEnemies", true, false)
if enemies == null:
return result
for child in enemies.get_children():
result.append(String(child.get_meta("enemy_id", "")))
result.sort()
return result
func _press(label: String, node_name: String, expected: String) -> void:
if settled:
return
var control := await _wait_for_node(shell, node_name)
if control == null:
await _step(label, expected, shell.current_screen, "blocked", "no %s node" % node_name)
_finish(1)
return
await _touch(control)
await _frames(8)
var actual := String(shell.current_screen)
var status := "pass" if expected.is_empty() or actual == expected else "blocked"
await _step(label, expected, actual, status, "")
if status == "blocked":
_finish(1)
func _press_soft(node_name: String, attempts := 5) -> bool:
# A press that may need the rebuilt page to accept the touch; never aborts.
# Returns true once the Guild screen holds still for a few frames.
for attempt in range(attempts):
if settled:
return false
var control := await _wait_for_node(shell, node_name)
if control != null:
await _touch(control)
for _frame in range(90):
if String(shell.current_screen) == "guild_handoff" and shell.find_child("GuildParty", true, false) != null:
await _frames(20)
if String(shell.current_screen) == "guild_handoff":
return true
await process_frame
return false
func _wait_screen(expected: String, marker: String, stable_frames := 30, max_frames := 600) -> bool:
var stable := 0
for _frame in range(max_frames):
var screen_ok := String(shell.current_screen) == expected
var marker_ok := marker.is_empty() or shell.find_child(marker, true, false) != null
if screen_ok and marker_ok:
stable += 1
if stable >= stable_frames:
return true
else:
stable = 0
await process_frame
return false
func _press_until(label: String, node_name: String, expected: String, marker: String, attempts := 4) -> void:
for attempt in range(attempts):
if settled:
return
if await _wait_screen(expected, marker, 20, 60):
await _step(label, expected, expected, "pass", "")
return
await _press_soft(node_name)
await _frames(20)
if await _wait_screen(expected, marker, 20, 240):
await _step(label, expected, expected, "pass", "")
return
await _step(label, expected, String(shell.current_screen), "blocked", "press did not reach the next screen")
_finish(1)
func _press_first_risk_option() -> void:
var control: Control = null
for _attempt in 30:
for child in shell.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
break
if control != null:
break
await _frames(6)
if control == null:
await _step("risk_select", "a selectable risk option", shell.current_screen, "blocked", "no enabled RiskOption node")
_finish(1)
return
await _touch(control)
await _frames(8)
func _verify(label: String, condition: bool, note: String) -> void:
if settled:
return
await _step(label, "true", "true" if condition else "false", "pass" if condition else "blocked", note)
if not condition:
_finish(1)
func _wait_for_node(parent: Node, node_name: String, max_frames := 600) -> Node:
for _i in range(max_frames):
var node := parent.find_child(node_name, true, false)
if node != null:
return node
await process_frame
return null
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 _step(step: String, expected: String, actual: String, status: String, note: String) -> void:
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" % [parsed_touches, step]))
print("CYCLE %s expected=%s actual=%s status=%s %s" % [step, expected, actual, status, note])
func _cleanup_state() -> void:
for path in [SETTINGS_PATH, DRAFT_PATH, COMPANY_PATH, "%s.bak" % COMPANY_PATH, "%s.candidate" % COMPANY_PATH]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _finish(code: int) -> void:
if settled:
return
settled = true
var file := FileAccess.open(output_root.path_join("timeline.txt"), FileAccess.WRITE)
if file != null:
file.store_string("\n".join(failures) if not failures.is_empty() else "ok")
file.close()
if is_instance_valid(game_node):
game_node.queue_free()
quit(code)