363 lines
12 KiB
GDScript
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)
|