extends SceneTree const BattleStateScript := preload("res://scripts/core/battle_state.gd") const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") func _init() -> void: var failures: Array[String] = [] _check_deferred_move_events(failures) _check_scene_post_move_menu_flow(failures) _check_scene_post_move_edge_positioning(failures) _check_scene_post_move_scrolled_view_positioning(failures) _check_scene_post_move_menu_tracks_map_scroll(failures) _check_scene_post_move_text_fit(failures) _check_scene_post_move_blocked_command_labels(failures) _check_scene_post_move_tactic_picker_flow(failures) _check_scene_post_move_item_picker_flow(failures) _check_scene_auto_attack_safe_origin_and_counter_preview(failures) _check_scene_enemy_click_auto_attack(failures) if failures.is_empty(): print("post move action flow smoke ok") quit(0) return for failure in failures: push_error(failure) quit(1) func _check_deferred_move_events(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for pending move state") return var from_cell := Vector2i(1, 3) var to_cell := Vector2i(2, 3) state.get_unit("cao_cao")["pos"] = from_cell state.battle_events.append({ "id": "pending_move_gold", "once": true, "when": { "type": "unit_reaches_tile", "unit_ids": ["cao_cao"], "pos": [to_cell.x, to_cell.y] }, "actions": [{"type": "grant_gold", "amount": 77}] }) if not state.select_unit("cao_cao"): failures.append("could not select Cao Cao for pending move state") return if not state.try_move_selected(to_cell, true): failures.append("tentative move should succeed") return var cao_cao: Dictionary = state.get_unit("cao_cao") if cao_cao.get("pos", Vector2i.ZERO) != to_cell: failures.append("tentative move should update position") if not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("tentative move should mark moved but not acted") if state.get_battle_gold_reward() != 0: failures.append("tentative move should defer unit_reaches_tile rewards") if not state.cancel_pending_move("cao_cao", from_cell, to_cell): failures.append("pending move cancel should succeed") cao_cao = state.get_unit("cao_cao") if cao_cao.get("pos", Vector2i.ZERO) != from_cell or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("pending move cancel should restore original action state") if state.get_battle_gold_reward() != 0: failures.append("canceled pending move should not fire movement event") if not state.try_move_selected(to_cell, true): failures.append("second tentative move should succeed") if not state.commit_pending_move_events("cao_cao"): failures.append("pending move commit should keep battle active") if state.get_battle_gold_reward() != 77: failures.append("pending move commit should fire deferred movement event") func _check_scene_post_move_menu_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for scene flow") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() scene.state.get_unit("cao_cao")["pos"] = Vector2i(1, 3) if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for scene flow") scene.free() return scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") if not scene._has_pending_move(): failures.append("scene should keep pending move metadata after move click") if scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("post-move action menu should be visible after moving") else: _check_local_panel_position(failures, scene, scene.post_move_menu, Vector2i(2, 3), "post-move action menu") _check_local_command_button_tooltips_suppressed(failures, [ scene.post_move_attack_button, scene.post_move_tactic_button, scene.post_move_item_button, scene.post_move_wait_button, scene.post_move_cancel_button ], "post-move action menu") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("scene move should be pending with action still available") if scene.state.get_selected_unit().is_empty(): failures.append("scene should keep moved unit selected for follow-up action") if not scene.attack_cells.is_empty(): failures.append("attack cells should wait until Attack is chosen from the post-move menu") var right_click := InputEventMouseButton.new() right_click.button_index = MOUSE_BUTTON_RIGHT right_click.pressed = true right_click.position = _screen_for_cell(Vector2i(2, 3)) scene._unhandled_input(right_click) cao_cao = scene.state.get_unit("cao_cao") if scene._has_pending_move() or (scene.post_move_menu != null and scene.post_move_menu.visible): failures.append("right-click cancel handler should clear pending move menu state") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(1, 3) or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("right-click cancel handler should restore the starting cell") scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) scene._on_post_move_wait_pressed() cao_cao = scene.state.get_unit("cao_cao") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)): failures.append("post-move Wait should commit movement and end the unit action") if not scene.state.get_selected_unit().is_empty() or scene._has_pending_move(): failures.append("post-move Wait should clear selection and pending metadata") _check_scene_post_move_attack_targeting(failures) scene.free() func _check_scene_post_move_edge_positioning(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for edge menu positioning") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for edge menu positioning") scene.free() return var edge_cell := Vector2i(scene.state.map_size.x - 1, 3) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = edge_cell cao_cao["moved"] = true cao_cao["acted"] = false scene.pending_move_unit_id = "cao_cao" scene.pending_move_from_cell = Vector2i(edge_cell.x - 1, edge_cell.y) scene.pending_move_to_cell = edge_cell scene._show_post_move_menu() if scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("edge post-move action menu should be visible") else: _check_local_panel_position(failures, scene, scene.post_move_menu, edge_cell, "edge post-move action menu") scene.free() func _check_scene_post_move_scrolled_view_positioning(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for scrolled menu positioning") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() scene.board_scroll_offset = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var edge_cell := Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = edge_cell cao_cao["moved"] = true cao_cao["acted"] = false scene.state.select_unit("cao_cao") scene.pending_move_unit_id = "cao_cao" scene.pending_move_from_cell = Vector2i(edge_cell.x - 1, edge_cell.y) scene.pending_move_to_cell = edge_cell scene._show_post_move_menu() if scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("scrolled post-move action menu should be visible") else: _check_local_panel_position(failures, scene, scene.post_move_menu, edge_cell, "scrolled post-move action menu") scene.free() func _check_scene_post_move_menu_tracks_map_scroll(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for scroll-tracking menu") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() var pending_cell := Vector2i(10, 6) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = pending_cell cao_cao["moved"] = true cao_cao["acted"] = false scene.state.select_unit("cao_cao") scene.pending_move_unit_id = "cao_cao" scene.pending_move_from_cell = Vector2i(9, 6) scene.pending_move_to_cell = pending_cell scene._show_post_move_menu() if scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("scroll-tracking post-move menu should be visible") scene.free() return scene.post_move_menu.position = Vector2(1.0, 1.0) var map_rect: Rect2 = scene._minimap_map_rect() if not scene._scroll_board_to_minimap_position(map_rect.end): failures.append("minimap scroll should move the opening large map") var expected_position: Vector2 = scene._local_command_panel_position_for_cell(pending_cell, scene.POST_MOVE_MENU_SIZE) if scene.post_move_menu.position.distance_squared_to(expected_position) > 0.01: failures.append("post-move menu should track board scroll: %s vs %s" % [str(scene.post_move_menu.position), str(expected_position)]) _check_local_panel_position(failures, scene, scene.post_move_menu, pending_cell, "scroll-tracking post-move action menu") scene.free() func _check_scene_post_move_text_fit(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for post-move text fit") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for post-move text fit") scene.free() return var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["name"] = "매우 긴 이름의 임시 선봉장" cao_cao["pos"] = Vector2i(2, 3) cao_cao["moved"] = true cao_cao["acted"] = false scene.pending_move_unit_id = "cao_cao" scene.pending_move_from_cell = Vector2i(1, 3) scene.pending_move_to_cell = Vector2i(2, 3) scene._show_post_move_menu() if scene.post_move_title_label == null: failures.append("post-move menu should expose a fitted title label") else: _check_fitted_label_font(failures, scene.post_move_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "post-move long unit title") scene._show_post_move_picker("tactic") if scene.post_move_picker_title_label == null: failures.append("post-move picker should expose a fitted title label") else: _check_fitted_label_font(failures, scene.post_move_picker_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "post-move picker long unit title") if scene.post_move_picker_detail_label == null: failures.append("post-move picker should expose a fitted detail label") else: _check_fitted_label_font(failures, scene.post_move_picker_detail_label, BattleSceneScript.LOCAL_COMMAND_DETAIL_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE, "post-move picker detail") _check_local_command_button_tooltips_suppressed(failures, [ scene.post_move_picker_back_button, scene.post_move_picker_cancel_move_button, scene.targeting_hint_back_button, scene.targeting_hint_cancel_move_button ], "local command utility buttons") scene.selected_item_id = "very_long_supply_order_marker_name" scene.selected_skill_id = "" scene.basic_attack_targeting = false scene._update_targeting_hint_panel() if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible: failures.append("targeting guidance should stay on the board instead of opening a hint panel") if scene.targeting_hint_panel != null and scene.targeting_hint_panel.mouse_filter != Control.MOUSE_FILTER_IGNORE: failures.append("retired targeting hint panel should never block board clicks") if scene.targeting_hint_title_label == null: failures.append("targeting hint should expose a fitted title label") else: _check_fitted_label_font(failures, scene.targeting_hint_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "targeting long item title") if scene.targeting_hint_detail_label == null: failures.append("targeting hint should expose a fitted detail label") else: _check_fitted_label_font(failures, scene.targeting_hint_detail_label, BattleSceneScript.LOCAL_COMMAND_DETAIL_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE, "targeting detail") scene.free() func _check_scene_post_move_blocked_command_labels(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for blocked command labels") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() scene.state.set_inventory_snapshot({}) var far_cell := Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2) for unit in scene.state.units: if unit.get("team", "") == BattleStateScript.TEAM_ENEMY: unit["pos"] = far_cell var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(1, 3) cao_cao["mp"] = 0 if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for blocked command labels") scene.free() return scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) if scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("blocked command label setup should show the post-move menu") else: if scene.post_move_attack_button == null or not scene.post_move_attack_button.disabled or scene.post_move_attack_button.text != "타격 없음": failures.append("blocked attack command should explain itself through button text") if scene.post_move_tactic_button == null or not scene.post_move_tactic_button.disabled or scene.post_move_tactic_button.text != "책략 없음": failures.append("blocked tactic command should explain itself through button text") if scene.post_move_item_button == null or not scene.post_move_item_button.disabled or scene.post_move_item_button.text != "보급 없음": failures.append("blocked item command should explain itself through button text") _check_local_command_button_tooltips_suppressed(failures, [ scene.post_move_attack_button, scene.post_move_tactic_button, scene.post_move_item_button ], "blocked post-move commands") scene.free() func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for attack targeting flow") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") enemy["pos"] = Vector2i(3, 3) enemy["hp"] = 24 enemy["def"] = 1 var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(1, 3) cao_cao["agi"] = 999 cao_cao["atk"] = 60 scene.state.rng.seed = 1 scene.state.battle_events.append({ "id": "pending_attack_gold", "once": true, "when": { "type": "unit_reaches_tile", "unit_ids": ["cao_cao"], "pos": [2, 3] }, "actions": [{"type": "grant_gold", "amount": 33}] }) if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for attack targeting flow") scene.free() return scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("attack targeting setup should leave the post-move menu visible") if scene.post_move_attack_button == null or scene.post_move_attack_button.disabled or scene.post_move_attack_button.text != "타격령": failures.append("available attack command should stay compact and enabled") if scene.basic_attack_targeting: failures.append("attack targeting should be off before pressing Attack") if not scene.attack_cells.is_empty(): failures.append("attack range should stay hidden before pressing Attack") if scene.state.get_battle_gold_reward() != 0: failures.append("attack setup should not commit pending move events early") scene._on_post_move_attack_pressed() if not scene.basic_attack_targeting: failures.append("Attack should enter target selection mode") if scene.post_move_menu != null and scene.post_move_menu.visible: failures.append("post-move menu should hide while selecting an attack target") if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible: failures.append("attack targeting should not open a separate hint panel") if not scene.attack_cells.has(Vector2i(3, 3)): failures.append("attack targeting should highlight the adjacent enemy cell") var markers := scene._attack_target_marker_entries() if markers.size() != 1 or markers[0].get("cell", Vector2i.ZERO) != Vector2i(3, 3): failures.append("attack target markers should include only the reachable enemy: %s" % str(markers)) scene._on_targeting_back_pressed() cao_cao = scene.state.get_unit("cao_cao") if scene.basic_attack_targeting or not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("Back should return to the post-move action menu") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("Back should preserve the pending moved unit without acting") scene._on_post_move_attack_pressed() var enemy_hp_before := int(enemy.get("hp", 0)) scene._handle_board_click(_screen_for_cell(Vector2i(3, 3))) cao_cao = scene.state.get_unit("cao_cao") enemy = scene.state.get_unit("yellow_turban_1") if scene._has_pending_move() or scene.basic_attack_targeting: failures.append("attack click should clear pending move and targeting state") if not scene.state.get_selected_unit().is_empty(): failures.append("attack click should clear selected unit after acting") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)): failures.append("attack click should commit the moved attacker action") if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)): failures.append("attack click should damage or defeat the target") if scene.state.get_battle_gold_reward() != 33: failures.append("attack click should commit deferred movement event before resolving attack") scene.free() func _check_scene_enemy_click_auto_attack(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for enemy click auto attack") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") cao_cao["pos"] = Vector2i(1, 3) cao_cao["atk"] = 60 cao_cao["agi"] = 999 cao_cao["moved"] = false cao_cao["acted"] = false enemy["pos"] = Vector2i(4, 3) enemy["hp"] = 24 enemy["max_hp"] = 24 enemy["def"] = 1 enemy["alive"] = true scene.state.rng.seed = 1 if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for enemy click auto attack") scene.free() return scene._refresh_ranges() if scene._is_targeting_mode(): failures.append("move+attack marker setup should not enter formal targeting mode") if scene.attack_cells.has(Vector2i(4, 3)): failures.append("auto attack setup enemy should start outside current attack range") var auto_markers := scene._target_selection_marker_entries() var found_auto_marker := false for marker in auto_markers: if marker.get("cell", Vector2i.ZERO) != Vector2i(4, 3): continue found_auto_marker = true if marker.get("target_id", "") != "yellow_turban_1": failures.append("move+attack marker should keep the clicked target id: %s" % str(marker)) if marker.get("text", "") != "행공": failures.append("move+attack marker should use compact battlefield text: %s" % str(marker)) if marker.get("origin", Vector2i.ZERO) != Vector2i(3, 3): failures.append("move+attack marker should expose the chosen attack origin: %s" % str(marker)) if marker.get("origin", Vector2i.ZERO) == Vector2i(1, 3): failures.append("move+attack marker origin should differ from the current cell: %s" % str(marker)) if not bool(marker.get("requires_move", false)): failures.append("move+attack marker should flag that movement is required: %s" % str(marker)) if not found_auto_marker: failures.append("selected unit should mark enemies reachable by move+attack: %s" % str(auto_markers)) scene.hover_cell = Vector2i(4, 3) var auto_badge := scene._target_preview_badge() if not str(auto_badge.get("text", "")).contains("행공"): failures.append("hovering a move+attack target should explain the combined action: %s" % str(auto_badge)) if not ["advance", "counter", "danger"].has(str(auto_badge.get("kind", ""))): failures.append("move+attack hover badge should use an attack-risk color kind: %s" % str(auto_badge)) cao_cao["status_effects"] = [{ "type": "action_lock", "status": "disarm", "action": "attack", "remaining_phases": 2 }] scene._refresh_ranges() for marker in scene._target_selection_marker_entries(): if marker.get("target_id", "") == "yellow_turban_1": failures.append("attack-locked units should not mark move+attack targets: %s" % str(marker)) scene.hover_cell = Vector2i(4, 3) var locked_badge := scene._target_preview_badge() if str(locked_badge.get("text", "")).contains("행공"): failures.append("attack-locked hover badge should not offer move+attack: %s" % str(locked_badge)) scene._handle_board_click(_screen_for_cell(Vector2i(4, 3))) if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(1, 3) or int(enemy.get("hp", 0)) != 24: failures.append("attack-locked enemy click should not auto-move or attack") cao_cao["status_effects"] = [] scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(4, 3))) cao_cao = scene.state.get_unit("cao_cao") enemy = scene.state.get_unit("yellow_turban_1") var auto_origin: Vector2i = cao_cao.get("pos", Vector2i.ZERO) if absi(auto_origin.x - Vector2i(4, 3).x) + absi(auto_origin.y - Vector2i(4, 3).y) != 1: failures.append("enemy click should auto-move to an attack origin, got %s" % str(auto_origin)) if not bool(cao_cao.get("acted", false)) or not bool(cao_cao.get("moved", false)): failures.append("enemy click auto attack should spend the unit action") if int(enemy.get("hp", 0)) >= 24 and bool(enemy.get("alive", true)): failures.append("enemy click auto attack should damage or defeat the target") if scene._has_pending_move() or scene.basic_attack_targeting: failures.append("enemy click auto attack should not leave pending or targeting UI state") scene.free() func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for safe auto attack origin") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() for unit in scene.state.units: if unit.get("team", "") == BattleStateScript.TEAM_ENEMY: unit["pos"] = Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2) var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") cao_cao["pos"] = Vector2i(1, 3) cao_cao["move"] = 4 cao_cao["range"] = 2 cao_cao["min_range"] = 1 cao_cao["atk"] = 30 cao_cao["agi"] = 999 cao_cao["hp"] = 80 cao_cao["max_hp"] = 80 cao_cao["moved"] = false cao_cao["acted"] = false enemy["pos"] = Vector2i(5, 3) enemy["range"] = 1 enemy["min_range"] = 1 enemy["atk"] = 70 enemy["agi"] = 0 enemy["hp"] = 80 enemy["max_hp"] = 80 enemy["def"] = 1 enemy["alive"] = true enemy["controllable"] = true scene.state.rng.seed = 1 if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for safe auto attack origin") scene.free() return scene._refresh_ranges() var auto_markers := scene._target_selection_marker_entries() var safe_marker := {} for marker in auto_markers: if marker.get("target_id", "") == "yellow_turban_1": safe_marker = marker break if safe_marker.is_empty(): failures.append("safe auto attack setup should mark the reachable enemy: %s" % str(auto_markers)) else: if safe_marker.get("origin", Vector2i.ZERO) != Vector2i(3, 3): failures.append("auto attack should prefer the non-counter origin: %s" % str(safe_marker)) if bool(safe_marker.get("counter_in_range", true)): failures.append("safe auto attack marker should expose no counter risk: %s" % str(safe_marker)) if str(safe_marker.get("kind", "")) != "advance": failures.append("safe auto attack marker should use advance color: %s" % str(safe_marker)) scene.hover_cell = Vector2i(5, 3) var safe_badge := scene._target_preview_badge() if str(safe_badge.get("kind", "")) != "advance" or str(safe_badge.get("text", "")).contains("반"): failures.append("safe auto attack hover should not show counter risk: %s" % str(safe_badge)) scene._handle_board_click(_screen_for_cell(Vector2i(5, 3))) cao_cao = scene.state.get_unit("cao_cao") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(3, 3): failures.append("auto attack click should use the same safe origin as marker and hover: %s" % str(cao_cao.get("pos", Vector2i.ZERO))) scene.free() var risky_scene = BattleSceneScript.new() risky_scene._create_hud() if not risky_scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for risky auto attack preview") risky_scene.free() return risky_scene.battle_started = true risky_scene.campaign_complete_screen = false risky_scene.briefing_panel.visible = false risky_scene.result_panel.visible = false risky_scene._clear_pending_move_state() var risky_attacker: Dictionary = risky_scene.state.get_unit("cao_cao") var risky_enemy: Dictionary = risky_scene.state.get_unit("yellow_turban_1") risky_attacker["pos"] = Vector2i(1, 3) risky_attacker["range"] = 1 risky_attacker["min_range"] = 1 risky_attacker["atk"] = 80 risky_attacker["agi"] = 0 risky_attacker["hp"] = 40 risky_attacker["max_hp"] = 40 risky_enemy["pos"] = Vector2i(5, 3) risky_enemy["range"] = 1 risky_enemy["min_range"] = 1 risky_enemy["atk"] = 70 risky_enemy["agi"] = 50 risky_enemy["hp"] = 20 risky_enemy["max_hp"] = 20 risky_enemy["def"] = 1 risky_enemy["alive"] = true risky_enemy["controllable"] = true var risky_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if risky_preview.is_empty() or not bool(risky_preview.get("would_defeat", false)) or not bool(risky_preview.get("counter_in_range", false)): failures.append("risky auto attack preview should expose miss-counter risk on a potential KO: %s" % str(risky_preview)) var risky_badge := risky_scene._auto_attack_target_preview_badge_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if not str(risky_badge.get("text", "")).contains("반") or not ["counter", "danger"].has(str(risky_badge.get("kind", ""))): failures.append("risky auto attack badge should show counter risk: %s" % str(risky_badge)) risky_enemy["controllable"] = false var escort_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3)) if bool(escort_preview.get("counter_in_range", false)): failures.append("non-controllable targets should not show counter risk: %s" % str(escort_preview)) risky_scene.free() func _check_scene_post_move_tactic_picker_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for tactic picker flow") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() var enemy: Dictionary = scene.state.get_unit("yellow_turban_1") enemy["pos"] = Vector2i(3, 3) enemy["hp"] = 28 enemy["def"] = 1 var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(1, 3) cao_cao["int"] = 80 cao_cao["mp"] = 10 scene.state.battle_events.append({ "id": "pending_tactic_gold", "once": true, "when": { "type": "unit_reaches_tile", "unit_ids": ["cao_cao"], "pos": [2, 3] }, "actions": [{"type": "grant_gold", "amount": 44}] }) if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for tactic picker flow") scene.free() return scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) if scene.post_move_tactic_button == null or scene.post_move_tactic_button.disabled: failures.append("post-move Tactic should be enabled when Spark has a valid target") scene._on_tactic_pressed() if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "tactic": failures.append("post-move Tactic shortcut should open the local tactic picker") if scene.post_move_menu != null and scene.post_move_menu.visible: failures.append("post-move action menu should hide while the tactic picker is open") if scene.tactic_menu != null and scene.tactic_menu.visible: failures.append("post-move tactic shortcut should not use the side tactic menu") _check_post_move_picker_option_tooltips_suppressed(failures, scene, "post-move tactic picker") var escape := InputEventKey.new() escape.keycode = KEY_ESCAPE scene._handle_key(escape) cao_cao = scene.state.get_unit("cao_cao") if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible: failures.append("Escape should close the local tactic picker") if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("Escape from the tactic picker should return to the post-move action menu") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("Escape from the tactic picker should preserve the pending move") scene._on_post_move_tactic_pressed() scene._on_post_move_tactic_option_pressed("spark") if scene.selected_skill_id != "spark": failures.append("choosing Spark should enter tactic targeting mode") if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible: failures.append("tactic picker should hide after choosing a tactic") if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible: failures.append("tactic targeting should not open a separate hint panel") if not scene.skill_cells.has(Vector2i(3, 3)): failures.append("Spark targeting should highlight the adjacent enemy cell") var tactic_markers := scene._skill_target_marker_entries() if tactic_markers.size() != 1 or tactic_markers[0].get("cell", Vector2i.ZERO) != Vector2i(3, 3): failures.append("tactic target markers should include only the Spark target: %s" % str(tactic_markers)) else: if str(tactic_markers[0].get("text", "")) != "퇴각": failures.append("tactic target marker should preview the deterministic KO: %s" % str(tactic_markers[0])) if str(tactic_markers[0].get("kind", "")) != "damage": failures.append("tactic target marker should use damage color: %s" % str(tactic_markers[0])) scene._on_targeting_back_pressed() cao_cao = scene.state.get_unit("cao_cao") if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("tactic Back should return to the post-move action menu") if scene.selected_skill_id != "": failures.append("tactic Back should clear the selected tactic") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("tactic Back should preserve the pending moved unit without acting") scene._on_post_move_tactic_pressed() scene._on_post_move_tactic_option_pressed("spark") var enemy_hp_before := int(enemy.get("hp", 0)) scene._handle_board_click(_screen_for_cell(Vector2i(3, 3))) cao_cao = scene.state.get_unit("cao_cao") enemy = scene.state.get_unit("yellow_turban_1") if scene._has_pending_move() or scene.selected_skill_id != "": failures.append("tactic target click should clear pending move and selected tactic") if not scene.state.get_selected_unit().is_empty(): failures.append("tactic target click should clear selected unit after acting") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)): failures.append("tactic target click should commit the moved caster action") if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)): failures.append("tactic target click should damage or defeat the target") if scene.state.get_battle_gold_reward() != 44: failures.append("tactic target click should commit deferred movement event before resolving tactic") scene.free() func _check_scene_post_move_item_picker_flow(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for item picker flow") scene.free() return scene.battle_started = true scene.campaign_complete_screen = false scene.briefing_panel.visible = false scene.result_panel.visible = false scene._clear_pending_move_state() scene.state.set_inventory_snapshot({"bean": 1}) var ally: Dictionary = scene.state.get_unit("xiahou_dun") ally["pos"] = Vector2i(2, 4) ally["hp"] = 12 var cao_cao: Dictionary = scene.state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(1, 3) cao_cao["mp"] = 10 scene.state.battle_events.append({ "id": "pending_item_gold", "once": true, "when": { "type": "unit_reaches_tile", "unit_ids": ["cao_cao"], "pos": [2, 3] }, "actions": [{"type": "grant_gold", "amount": 55}] }) if not scene.state.select_unit("cao_cao"): failures.append("could not select Cao Cao for item picker flow") scene.free() return scene._refresh_ranges() scene._handle_board_click(_screen_for_cell(Vector2i(2, 3))) if scene.post_move_item_button == null or scene.post_move_item_button.disabled: failures.append("post-move Item should be enabled when Bean can heal an adjacent ally") scene._on_item_pressed() if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "item": failures.append("post-move Item shortcut should open the local item picker") if scene.post_move_menu != null and scene.post_move_menu.visible: failures.append("post-move action menu should hide while the item picker is open") if scene.item_menu != null and scene.item_menu.visible: failures.append("post-move item shortcut should not use the side item menu") _check_post_move_picker_option_tooltips_suppressed(failures, scene, "post-move item picker") scene._on_post_move_item_option_pressed("bean") if scene.selected_item_id != "bean": failures.append("choosing Bean should enter item targeting mode") if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible: failures.append("item picker should hide after choosing an item") if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible: failures.append("item targeting should not open a separate hint panel") if not scene.item_cells.has(Vector2i(2, 4)): failures.append("Bean targeting should highlight the adjacent injured ally") var item_markers := scene._item_target_marker_entries() if item_markers.size() != 1 or item_markers[0].get("cell", Vector2i.ZERO) != Vector2i(2, 4): failures.append("item target markers should include only the Bean target: %s" % str(item_markers)) else: if str(item_markers[0].get("text", "")) != "+20 병력": failures.append("item target marker should preview healing: %s" % str(item_markers[0])) if str(item_markers[0].get("kind", "")) != "heal": failures.append("item target marker should use heal color: %s" % str(item_markers[0])) scene._on_targeting_back_pressed() cao_cao = scene.state.get_unit("cao_cao") if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible: failures.append("item Back should return to the post-move action menu") if scene.selected_item_id != "": failures.append("item Back should clear the selected item") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)): failures.append("item Back should preserve the pending moved unit without acting") scene._on_post_move_item_pressed() scene._on_post_move_item_option_pressed("bean") var ally_hp_before := int(ally.get("hp", 0)) scene._handle_board_click(_screen_for_cell(Vector2i(2, 4))) cao_cao = scene.state.get_unit("cao_cao") ally = scene.state.get_unit("xiahou_dun") if scene._has_pending_move() or scene.selected_item_id != "": failures.append("item target click should clear pending move and selected item") if not scene.state.get_selected_unit().is_empty(): failures.append("item target click should clear selected unit after acting") if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)): failures.append("item target click should commit the moved user action") if int(ally.get("hp", 0)) <= ally_hp_before: failures.append("item target click should heal the selected ally") if int(scene.state.get_inventory_snapshot().get("bean", 0)) != 0: failures.append("item target click should consume the Bean") if scene.state.get_battle_gold_reward() != 55: failures.append("item target click should commit deferred movement event before resolving item") scene.free() func _check_local_panel_position(failures: Array[String], scene, panel: Control, cell: Vector2i, label: String) -> void: var padding := BattleSceneScript.LOCAL_COMMAND_PANEL_BOARD_PADDING var board_rect: Rect2 = scene._board_rect().intersection(scene._map_view_rect()) if board_rect.size.x <= 0.0 or board_rect.size.y <= 0.0: board_rect = scene._board_rect() board_rect = board_rect.grow(-padding) var panel_rect := Rect2(panel.position, panel.size) var cell_rect: Rect2 = scene._rect_for_cell(cell).grow(2.0) if panel_rect.size.x <= 0.0 or panel_rect.size.y <= 0.0: failures.append("%s should have a stable visible size" % label) if panel_rect.position.x < board_rect.position.x or panel_rect.position.y < board_rect.position.y: failures.append("%s should stay inside the visible board minimum: %s" % [label, str(panel_rect)]) if panel_rect.end.x > board_rect.end.x or panel_rect.end.y > board_rect.end.y: failures.append("%s should stay inside the visible board maximum: %s" % [label, str(panel_rect)]) if panel_rect.intersects(cell_rect): failures.append("%s should not cover the moved unit cell: %s over %s" % [label, str(panel_rect), str(cell_rect)]) func _check_local_command_button_tooltips_suppressed(failures: Array[String], buttons: Array, label: String) -> void: for button in buttons: if button == null: failures.append("%s should expose local command buttons for tooltip checks" % label) continue if not str(button.tooltip_text).is_empty(): failures.append("%s should keep guidance on the map instead of native tooltips: %s" % [label, str(button.tooltip_text)]) func _check_post_move_picker_option_tooltips_suppressed(failures: Array[String], scene, label: String) -> void: if scene.post_move_picker_list == null: failures.append("%s should expose a picker option list" % label) return for child in scene.post_move_picker_list.get_children(): if child is Button and not str(child.tooltip_text).is_empty(): failures.append("%s option should not rely on native tooltip popups: %s" % [label, str(child.tooltip_text)]) func _check_fitted_label_font(failures: Array[String], label: Label, base_size: int, min_size: int, label_name: String) -> void: var font_size := label.get_theme_font_size("font_size") if font_size < min_size or font_size > base_size: failures.append("%s font size should stay inside fitted bounds %d..%d, got %d" % [label_name, min_size, base_size, font_size]) if label.custom_minimum_size.x <= 0.0 or label.custom_minimum_size.y <= 0.0: failures.append("%s should reserve stable label space" % label_name) func _screen_for_cell(cell: Vector2i) -> Vector2: return BattleSceneScript.BOARD_OFFSET + Vector2(cell.x, cell.y) * BattleSceneScript.TILE_SIZE + Vector2.ONE * (BattleSceneScript.TILE_SIZE * 0.5)