extends SceneTree const MainScene = preload("res://main.tscn") const State = preload("res://p9_3/core_loop_state.gd") const VIEWPORT_SIZE := Vector2i(844, 390) const PATHS := { "Wayrunner": {"tick": 99, "hearthguard_hp": 22, "heals": 0, "coin": 22}, "Kindhand": {"tick": 133, "hearthguard_hp": 84, "heals": 11, "coin": 21}, } var failures: Array[String] = [] var game: Control var experience: Control var touch_count := 0 var capture_root := "" func _initialize() -> void: call_deferred("run_test") func run_test() -> void: capture_root = OS.get_environment("ABG_P9_4_CAPTURE_ROOT") root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND root.content_scale_size = VIEWPORT_SIZE root.size = VIEWPORT_SIZE for recruit_id in ["Wayrunner", "Kindhand"]: await run_path(recruit_id) await destroy_game() if failures.is_empty(): print("ABG_P9_4_SECOND_CYCLE_RUNTIME_OK paths=2 touch=%d fallback=0 direct=0 ticks=99,133 hearthguard_hp=22,84 wayrunner=true healing=11 coin=22,21 company=3/10 cards=0" % touch_count) quit(0) else: for failure in failures: push_error(failure) quit(1) func run_path(recruit_id: String) -> void: var expected: Dictionary = PATHS[recruit_id] game = MainScene.instantiate() game.auto_boot = false game.audio_enabled = false root.add_child(game) await settle(8) game.start_p93_experience() await settle(8) experience = game.p93_experience prepare_returned_hall() experience.returned_from_battle = true experience._render_current() await settle(8) expect(find_control(experience, "P94IncomingThreat") != null, "%s sees Crosswind before input" % recruit_id) expect(find_control(experience, "P93OfferTarget_%s" % recruit_id) != null, "%s body is a direct target" % recruit_id) await capture(recruit_id, "01-returned-threat") await tap("P93OfferTarget_%s" % recruit_id) expect(experience.service.state.focused_offer == recruit_id, "%s first touch focuses" % recruit_id) expect(find_control(experience, "P93OfferDetail") != null, "%s exposes role detail" % recruit_id) await capture(recruit_id, "02-focused") await tap("P93OfferTarget_%s" % recruit_id) expect(experience.service.state.phase == State.Phase.ENCOUNTER, "%s second touch hires" % recruit_id) expect(experience.service.state.company == ["Hearthguard", "Farstring", recruit_id], "%s joins the 3/10 line" % recruit_id) await tap("P93EncounterTarget_crosswind") expect(experience.service.state.selected_encounter == "crosswind", "%s selects Crosswind" % recruit_id) await tap("P93EncounterTarget_crosswind") expect(experience.service.state.phase == State.Phase.BATTLE, "%s starts the second battle" % recruit_id) await tap("P93SpeedButton") var role_seen := false var role_captured := false var role_cue_seen := false var guard := 0 while experience.service.state.phase == State.Phase.BATTLE and guard < 1600: for event in experience.service.battle.state.event_log: if String(event.get("kind", "")) != "member_action" or String(event.get("source_member_id", "")) != recruit_id: continue if recruit_id == "Kindhand" and String(event.get("qualifier_en", "")) != "healing": continue role_seen = true role_cue_seen = role_cue_seen or find_control(experience, "P94RoleCue_%s" % recruit_id) != null if role_seen and not role_captured: await settle(10) role_cue_seen = role_cue_seen or find_control(experience, "P94RoleCue_%s" % recruit_id) != null await capture(recruit_id, "03-role-action") role_captured = true await process_frame guard += 1 expect(role_seen, "%s performs the required role action" % recruit_id) expect(role_cue_seen, "%s exposes a visible role cue" % recruit_id) expect(experience.service.state.phase == State.Phase.RESULT and experience.service.state.last_outcome == "victory", "%s reaches victory" % recruit_id) expect(experience.service.battle.state.tick == int(expected.tick), "%s preserves the exact terminal tick" % recruit_id) expect(int(experience.service.battle.state.member_hp.Hearthguard) == int(expected.hearthguard_hp), "%s preserves the exact Hearthguard consequence" % recruit_id) expect(count_heals(recruit_id) == int(expected.heals), "%s preserves the exact heal count" % recruit_id) experience._process(0.8) await settle(55) expect(find_control(experience, "P93BattleResult") != null, "%s result holds on the field" % recruit_id) await capture(recruit_id, "04-result") await tap("P93ReturnHallButton") expect(experience.service.state.phase == State.Phase.HALL, "%s returns to Hall" % recruit_id) expect(experience.service.state.coin == int(expected.coin), "%s preserves exact Coin" % recruit_id) expect(experience.service.state.company == ["Hearthguard", "Farstring", recruit_id], "%s preserves the selected Company" % recruit_id) expect(find_control(experience, "P93Company_%s" % recruit_id) != null, "%s is visible in the returned line" % recruit_id) expect(count_named_fragment(experience, "Card") == 0, "%s path adds no cards" % recruit_id) verify_control_bounds(experience, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE))) await capture(recruit_id, "05-returned-company") func prepare_returned_hall() -> void: var service = experience.service service.enter_hall() service.focus_offer("Farstring") service.hire("Farstring") service.select_encounter("open_road") service.start_battle() while service.state.phase == State.Phase.BATTLE: service.advance_battle_ticks(1) service.claim_and_return() func count_heals(recruit_id: String) -> int: var result := 0 for event in experience.service.battle.state.event_log: if String(event.get("kind", "")) == "member_action" and String(event.get("source_member_id", "")) == recruit_id and String(event.get("qualifier_en", "")) == "healing": result += 1 return result func tap(node_name: String) -> void: var control := find_control(experience, node_name) if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled): failures.append("unavailable parsed-touch target %s" % node_name) return var position := root.get_final_transform() * control.get_global_rect().get_center() var down := InputEventScreenTouch.new() down.index = 0 down.position = position down.pressed = true Input.parse_input_event(down) await process_frame var up := InputEventScreenTouch.new() up.index = 0 up.position = position up.pressed = false Input.parse_input_event(up) touch_count += 1 await settle(6) func capture(path_id: String, name: String) -> void: if capture_root.is_empty(): return var path := capture_root.path_join(path_id.to_lower()) DirAccess.make_dir_recursive_absolute(path) await settle(3) await RenderingServer.frame_post_draw var error := root.get_texture().get_image().save_png(path.path_join(name + ".png")) expect(error == OK, "capture %s/%s writes" % [path_id, name]) func verify_control_bounds(node: Node, bounds: Rect2) -> void: if node is BaseButton and node.is_visible_in_tree(): var rect: Rect2 = node.get_global_rect() if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5: failures.append("control %s outside viewport: %s" % [node.name, rect]) if rect.size.x < 44.0 or rect.size.y < 44.0: failures.append("control %s below 44px: %s" % [node.name, rect.size]) for child in node.get_children(): verify_control_bounds(child, bounds) func count_named_fragment(node: Node, fragment: String) -> int: var result := 1 if fragment in String(node.name) else 0 for child in node.get_children(): result += count_named_fragment(child, fragment) return result func find_control(node: Node, node_name: String) -> Control: if node.name == node_name and node is Control and not node.is_queued_for_deletion(): return node for child in node.get_children(): var found := find_control(child, node_name) if found != null: return found return null func destroy_game() -> void: if is_instance_valid(game): game.queue_free() await settle(90) game = null experience = null func settle(frames: int) -> void: for _frame in frames: await process_frame func expect(condition: bool, message: String) -> void: if not condition: failures.append("P9.4 second-cycle runtime: %s" % message)