extends SceneTree const BattleStateScript := preload("res://scripts/core/battle_state.gd") const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") const CHAPTER_ONE_SCENARIOS := [ "res://data/scenarios/001_yellow_turbans.json", "res://data/scenarios/002_sishui_gate.json", "res://data/scenarios/003_xingyang_ambush.json", "res://data/scenarios/004_qingzhou_campaign.json", "res://data/scenarios/005_puyang_raid.json", "res://data/scenarios/006_dingtao_counterattack.json", "res://data/scenarios/007_xian_emperor_escort.json", "res://data/scenarios/008_wan_castle_escape.json", "res://data/scenarios/009_xiapi_siege.json" ] func _init() -> void: var failures: Array[String] = [] _check_chapter_one_scenarios_have_visual_grounding(failures) _check_edge_scroll_layout_contract(failures) _check_minimap_navigation_contract(failures) _check_map_focus_contract(failures) _check_battle_presentation_sequence_contract(failures) _check_battle_start_notice_sequence(failures) _check_readability_contract(failures) _check_first_battle_rendered_text_fit(failures) _check_command_notice_readable_preview(failures) _check_korean_fallback_labels(failures) _check_dialogue_localization_and_side(failures) _check_event_dialogue_side_passthrough(failures) _check_opening_battle_camp_context(failures) _check_opening_battle_tactical_spacing(failures) _check_opening_battle_log_location_labels(failures) _check_opening_battle_marker_hints(failures) _check_opening_battle_briefing_marker_focus(failures) _check_opening_battle_lure_line_ai_reaction(failures) _check_opening_battle_post_battle_bridge(failures) _check_sishui_gate_camp_context(failures) _check_opening_battle_event_dialogue_structure(failures) _check_sishui_gate_event_dialogue_structure(failures) _check_xingyang_ambush_event_dialogue_structure(failures) _check_qingzhou_campaign_event_dialogue_structure(failures) _check_puyang_raid_event_dialogue_structure(failures) _check_dingtao_counterattack_event_dialogue_structure(failures) _check_xian_emperor_escort_event_dialogue_structure(failures) _check_wan_castle_escape_event_dialogue_structure(failures) _check_xiapi_siege_event_dialogue_structure(failures) _check_opening_battle_story_beats(failures) _check_sishui_gate_story_beats(failures) _check_xingyang_ambush_story_beats(failures) _check_qingzhou_campaign_story_beats(failures) _check_puyang_raid_story_beats(failures) _check_dingtao_counterattack_story_beats(failures) _check_xian_emperor_escort_story_beats(failures) _check_wan_castle_escape_story_beats(failures) _check_xiapi_siege_story_beats(failures) _check_pixel_unit_helpers(failures) if failures.is_empty(): print("chapter one polish smoke ok") quit(0) return for failure in failures: push_error(failure) quit(1) func _check_chapter_one_scenarios_have_visual_grounding(failures: Array[String]) -> void: var scene = BattleSceneScript.new() for scenario_path in CHAPTER_ONE_SCENARIOS: if not scene.state.load_battle(scenario_path): failures.append("could not load chapter one scenario: %s" % scenario_path) continue var background_path: String = scene.state.get_map_background_path() if background_path.is_empty(): failures.append("scenario is missing a map background: %s" % scenario_path) continue if not FileAccess.file_exists(background_path): failures.append("scenario background does not exist: %s -> %s" % [scenario_path, background_path]) scene.free() func _check_edge_scroll_layout_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening chapter one map for edge scrolling") scene.free() return var view_rect := scene._map_view_rect() var board_size := scene._board_size() if view_rect.size.x < 160.0 or view_rect.size.y < 160.0: failures.append("map view rect should keep a usable minimum size") if board_size.x <= view_rect.size.x or board_size.y <= view_rect.size.y: failures.append("opening chapter one map should be larger than the visible map window: board=%s view=%s" % [str(board_size), str(view_rect.size)]) scene._reset_board_scroll() if scene.board_scroll_offset.x > 0.0 or scene.board_scroll_offset.y > 0.0: failures.append("reset board scroll should never push the map past the top-left origin") var far_offset := scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var min_x := minf(0.0, view_rect.size.x - board_size.x) var min_y := minf(0.0, view_rect.size.y - board_size.y) if far_offset.x < min_x - 0.01 or far_offset.y < min_y - 0.01: failures.append("edge scroll clamp should stay inside the visible map bounds") if far_offset.x > 0.01 or far_offset.y > 0.01: failures.append("edge scroll clamp should not create positive board offsets") scene.board_scroll_offset = far_offset var far_visible_cell := Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1) var far_visible_screen := scene._rect_for_cell(far_visible_cell).position + Vector2(4.0, 4.0) if scene._cell_from_screen(far_visible_screen) != far_visible_cell: failures.append("screen-to-cell mapping should stay stable for the visible far edge after scrolling") var first_cell_screen := scene._rect_for_cell(Vector2i(0, 0)).position + Vector2(4.0, 4.0) if scene._cell_from_screen(first_cell_screen) != Vector2i(-1, -1): failures.append("hidden map cells should not accept board clicks after scrolling") var side_panel_screen := Vector2(view_rect.end.x + 8.0, view_rect.position.y + 24.0) if scene._cell_from_screen(side_panel_screen) != Vector2i(-1, -1): failures.append("screen-to-cell mapping should ignore clicks outside the visible map view") _check_edge_scroll_velocity_curve(scene, view_rect, failures) scene.free() func _check_edge_scroll_velocity_curve(scene, view_rect: Rect2, failures: Array[String]) -> void: for method_name in [ "_edge_scroll_velocity_for_position", "_capped_edge_scroll_velocity", "_edge_scroll_axis_velocity", "_edge_scroll_pressure", "_edge_scroll_speed_for_pressure", "_edge_scroll_activation_rect", "_edge_scroll_next_offset", "_edge_scroll_active_sides", "_edge_scroll_cue_strip_width", "_edge_scroll_cue_fill_color", "_edge_scroll_cue_line_color" ]: if not scene.has_method(method_name): failures.append("missing edge-scroll helper: %s" % method_name) var center_velocity: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.get_center(), view_rect) if center_velocity != Vector2.ZERO: failures.append("edge scroll should be idle away from map edges: %s" % str(center_velocity)) var near_right: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.end.x - 36.0, view_rect.get_center().y), view_rect) var hard_right: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.end.x - 2.0, view_rect.get_center().y), view_rect) if near_right.x >= 0.0 or hard_right.x >= 0.0: failures.append("right-edge scroll should move the board left: near=%s hard=%s" % [str(near_right), str(hard_right)]) if absf(hard_right.x) <= absf(near_right.x): failures.append("edge scroll should accelerate as the mouse reaches the outer edge") var near_left: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.position.x + 36.0, view_rect.get_center().y), view_rect) var hard_left: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.position.x + 2.0, view_rect.get_center().y), view_rect) if near_left.x <= 0.0 or hard_left.x <= 0.0: failures.append("left-edge scroll should move the board right: near=%s hard=%s" % [str(near_left), str(hard_left)]) if hard_left.x <= near_left.x: failures.append("left-edge scroll should accelerate as the mouse reaches the outer edge") var top_left: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position + Vector2(2.0, 2.0), view_rect) if top_left.x <= 0.0 or top_left.y <= 0.0: failures.append("corner edge scroll should support diagonal movement: %s" % str(top_left)) var max_edge_speed := float(scene._edge_scroll_speed_for_pressure(1.0)) if top_left.length() > max_edge_speed + 0.01: failures.append("corner edge scroll should cap diagonal speed: %.2f > %.2f" % [top_left.length(), max_edge_speed]) var idle_sides: Dictionary = scene._edge_scroll_active_sides(view_rect.get_center(), view_rect) if bool(idle_sides.get("left", false)) or bool(idle_sides.get("right", false)) or bool(idle_sides.get("up", false)) or bool(idle_sides.get("down", false)): failures.append("edge cue sides should stay idle away from the frame: %s" % str(idle_sides)) var left_active_sides: Dictionary = scene._edge_scroll_active_sides(Vector2(view_rect.position.x + 2.0, view_rect.get_center().y), view_rect) if not bool(left_active_sides.get("left", false)) or bool(left_active_sides.get("right", false)): failures.append("left edge cue should activate only the left side: %s" % str(left_active_sides)) var right_active_sides: Dictionary = scene._edge_scroll_active_sides(Vector2(view_rect.end.x - 2.0, view_rect.get_center().y), view_rect) if not bool(right_active_sides.get("right", false)) or bool(right_active_sides.get("left", false)): failures.append("right edge cue should activate only the right side: %s" % str(right_active_sides)) if float(scene._edge_scroll_cue_strip_width(true)) <= float(scene._edge_scroll_cue_strip_width(false)): failures.append("active edge cue should draw a wider response strip") var active_fill: Color = scene._edge_scroll_cue_fill_color(true) var idle_fill: Color = scene._edge_scroll_cue_fill_color(false) if active_fill.a <= idle_fill.a: failures.append("active edge cue fill should be brighter than idle fill") var active_line: Color = scene._edge_scroll_cue_line_color(true) var idle_line: Color = scene._edge_scroll_cue_line_color(false) if active_line.a <= idle_line.a: failures.append("active edge cue line should be brighter than idle line") var near_outside_left: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(4.0, 0.0), view_rect) if near_outside_left.x <= 0.0: failures.append("edge scroll should continue when the mouse slightly crosses the left map edge: %s" % str(near_outside_left)) var near_outside_right: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.end.x + 4.0, view_rect.get_center().y), view_rect) if near_outside_right.x >= 0.0: failures.append("edge scroll should continue when the mouse slightly crosses the right map edge: %s" % str(near_outside_right)) var near_outside_corner: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(4.0, 4.0), view_rect) if near_outside_corner.x <= 0.0 or near_outside_corner.y <= 0.0: failures.append("edge scroll should keep diagonal movement just outside the map corner: %s" % str(near_outside_corner)) if near_outside_corner.length() > max_edge_speed + 0.01: failures.append("near-outside corner edge scroll should cap diagonal speed: %.2f > %.2f" % [near_outside_corner.length(), max_edge_speed]) var far_outside_velocity: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(scene.EDGE_SCROLL_OUTER_MARGIN + 8.0, scene.EDGE_SCROLL_OUTER_MARGIN + 8.0), view_rect) if far_outside_velocity != Vector2.ZERO: failures.append("edge scroll should ignore mouse positions far outside the map view: %s" % str(far_outside_velocity)) var hard_right_position := Vector2(view_rect.end.x - 2.0, view_rect.get_center().y) var moved_right: Vector2 = scene._edge_scroll_next_offset(Vector2.ZERO, hard_right_position, view_rect, 0.25) if moved_right.x >= 0.0: failures.append("edge scroll next offset should apply delta movement toward the right edge: %s" % str(moved_right)) var hard_left_position := Vector2(view_rect.position.x + 2.0, view_rect.get_center().y) var min_offset: Vector2 = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var moved_left: Vector2 = scene._edge_scroll_next_offset(min_offset, hard_left_position, view_rect, 0.25) if moved_left.x <= min_offset.x: failures.append("edge scroll next offset should recover from the left clamp toward the map origin: %s -> %s" % [str(min_offset), str(moved_left)]) var clamped_at_origin: Vector2 = scene._edge_scroll_next_offset(Vector2.ZERO, hard_left_position, view_rect, 0.25) if clamped_at_origin != Vector2.ZERO: failures.append("edge scroll next offset should not exceed the origin clamp: %s" % str(clamped_at_origin)) var clamped_at_far_edge: Vector2 = scene._edge_scroll_next_offset(min_offset, hard_right_position, view_rect, 0.25) if clamped_at_far_edge.x < min_offset.x - 0.01: failures.append("edge scroll next offset should not exceed the far clamp: %s below %s" % [str(clamped_at_far_edge), str(min_offset)]) func _check_minimap_navigation_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening chapter one map for minimap navigation") scene.free() return scene.battle_started = true for method_name in [ "_minimap_rect", "_minimap_map_rect", "_minimap_cell_rect", "_minimap_view_rect", "_board_scroll_offset_for_minimap_position", "_scroll_board_to_minimap_position", "_handle_minimap_mouse_button", "_handle_minimap_mouse_motion", "_is_minimap_visible", "_map_has_scrollable_area", "_minimap_cell_for_screen_position", "_minimap_hover_badge", "_minimap_hover_badge_rect", "_minimap_hover_badge_lines", "_draw_minimap_hover_badge", "_minimap_marker_rect", "_minimap_objective_marker_fill_color", "_minimap_event_marker_fill_color", "_minimap_event_marker_shape", "_draw_minimap_objective_markers", "_draw_minimap_event_markers" ]: if not scene.has_method(method_name): failures.append("missing minimap helper: %s" % method_name) var view_rect := scene._map_view_rect() if not scene._map_has_scrollable_area(): failures.append("opening map should require a scrollable minimap viewport") if not scene._is_minimap_visible(): failures.append("minimap should be visible on the opening large map") var minimap_rect: Rect2 = scene._minimap_rect() if minimap_rect.size.x < 150.0 or minimap_rect.size.y < 110.0: failures.append("minimap should reserve a usable tactical map plate: %s" % str(minimap_rect)) if minimap_rect.position.x < view_rect.position.x or minimap_rect.position.y < view_rect.position.y or minimap_rect.end.x > view_rect.end.x or minimap_rect.end.y > view_rect.end.y: failures.append("minimap should stay inside the visible map view: %s within %s" % [str(minimap_rect), str(view_rect)]) var map_rect: Rect2 = scene._minimap_map_rect() if map_rect.size.x >= minimap_rect.size.x or map_rect.size.y >= minimap_rect.size.y: failures.append("minimap map body should leave room for its frame/title: %s vs %s" % [str(map_rect), str(minimap_rect)]) var first_cell_rect: Rect2 = scene._minimap_cell_rect(Vector2i(0, 0)) if first_cell_rect.size.x <= 0.0 or first_cell_rect.size.y <= 0.0: failures.append("minimap cells should have visible area: %s" % str(first_cell_rect)) var first_hover_position := first_cell_rect.get_center() if scene._minimap_cell_for_screen_position(first_hover_position) != Vector2i(0, 0): failures.append("minimap hover position should resolve to the matching map cell") var first_hover_badge: Dictionary = scene._minimap_hover_badge(first_hover_position) if first_hover_badge.is_empty(): failures.append("minimap hover should expose a compact battlefield badge") else: var first_hover_lines: Array = first_hover_badge.get("lines", []) var first_hover_text := "\n".join(first_hover_lines) if not first_hover_text.contains("클릭/드래그"): failures.append("minimap hover should explain click-drag navigation: %s" % first_hover_text) if first_hover_text.contains("1,1"): failures.append("minimap hover should not expose raw grid coordinates in visible text: %s" % first_hover_text) if str(first_hover_badge.get("icon", "")).strip_edges().is_empty(): failures.append("minimap hover should carry generated icon metadata: %s" % str(first_hover_badge)) if str(first_hover_badge.get("panel", "")) != "hud_jade": failures.append("minimap hover should use generated jade panel metadata: %s" % str(first_hover_badge)) var first_hover_rect: Rect2 = first_hover_badge.get("rect", Rect2()) if first_hover_rect.size.x <= 0.0 or first_hover_rect.size.y <= 0.0 or not view_rect.encloses(first_hover_rect): failures.append("minimap hover badge should stay inside the visible map view: %s within %s" % [str(first_hover_rect), str(view_rect)]) if not scene._minimap_hover_badge(map_rect.position - Vector2(4.0, 4.0)).is_empty(): failures.append("minimap hover should stay hidden outside the map body") var objective_cells: Array[Vector2i] = scene.state.get_objective_cells() if objective_cells.is_empty(): failures.append("opening map should expose minimap objective markers") else: for objective_cell in objective_cells: var objective_marker_rect: Rect2 = scene._minimap_marker_rect(objective_cell, 1.36, 5.8, 9.0) if not map_rect.encloses(objective_marker_rect): failures.append("minimap objective marker should stay inside map body: %s within %s" % [str(objective_marker_rect), str(map_rect)]) if scene._minimap_objective_marker_fill_color().a < 0.85: failures.append("minimap objective marker should be prominent") var objective_hover: Dictionary = scene._minimap_hover_badge(scene._minimap_cell_rect(objective_cells[0]).get_center()) var objective_hover_text := "\n".join(Array(objective_hover.get("lines", []))) if objective_hover.is_empty() or not objective_hover_text.contains("목표"): failures.append("minimap objective hover should identify objective cells: %s" % objective_hover_text) var has_event_marker := false for marker in scene.state.get_event_markers(): var cells = marker.get("cells", []) if typeof(cells) != TYPE_ARRAY or cells.is_empty(): continue has_event_marker = true for event_cell in cells: var event_marker_rect: Rect2 = scene._minimap_marker_rect(event_cell, 1.05, 4.8, 7.0) if not map_rect.encloses(event_marker_rect): failures.append("minimap event marker should stay inside map body: %s within %s" % [str(event_marker_rect), str(map_rect)]) if scene._minimap_event_marker_fill_color(str(marker.get("kind", "event"))).a < 0.85: failures.append("minimap event marker should be visible") if not has_event_marker: failures.append("opening map should expose minimap side-event markers") if scene._minimap_event_marker_shape("supply") != "circle": failures.append("minimap supply markers should use a distinct circular shape") if scene._minimap_event_marker_shape("tactic") != "triangle": failures.append("minimap tactic markers should use a distinct triangular shape") if scene._minimap_event_marker_shape("gold") != "diamond": failures.append("minimap gold markers should use a distinct diamond shape") if scene._minimap_event_marker_shape("event") != "square": failures.append("minimap generic event markers should fall back to a square shape") var min_offset: Vector2 = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var top_left_offset: Vector2 = scene._board_scroll_offset_for_minimap_position(map_rect.position) if top_left_offset.distance_squared_to(Vector2.ZERO) > 0.01: failures.append("minimap top-left click should scroll to map origin: %s" % str(top_left_offset)) var bottom_right_offset: Vector2 = scene._board_scroll_offset_for_minimap_position(map_rect.end) if bottom_right_offset.distance_squared_to(min_offset) > 0.01: failures.append("minimap bottom-right click should scroll to far clamp: %s vs %s" % [str(bottom_right_offset), str(min_offset)]) var center_offset: Vector2 = scene._board_scroll_offset_for_minimap_position(map_rect.get_center()) var board_size: Vector2 = scene._board_size() var center_local: Vector2 = view_rect.get_center() - (scene.BOARD_OFFSET + center_offset) if board_size.x > view_rect.size.x and absf(center_local.x - board_size.x * 0.5) > 1.0: failures.append("minimap center click should center the board midpoint on scrollable X: %s vs %.2f" % [str(center_local), board_size.x * 0.5]) if board_size.y > view_rect.size.y and absf(center_local.y - board_size.y * 0.5) > 1.0: failures.append("minimap center click should center the board midpoint on scrollable Y: %s vs %.2f" % [str(center_local), board_size.y * 0.5]) if board_size.y <= view_rect.size.y and absf(center_offset.y) > 0.01: failures.append("minimap center click should keep non-scrollable Y at origin: %s" % str(center_offset)) scene.board_scroll_offset = center_offset var minimap_view: Rect2 = scene._minimap_view_rect() if minimap_view.position.x < map_rect.position.x - 0.01 or minimap_view.position.y < map_rect.position.y - 0.01 or minimap_view.end.x > map_rect.end.x + 0.01 or minimap_view.end.y > map_rect.end.y + 0.01: failures.append("minimap current-view frame should stay inside the map body: %s within %s" % [str(minimap_view), str(map_rect)]) scene.board_scroll_offset = Vector2.ZERO scene._scroll_board_to_minimap_position(map_rect.get_center()) if scene.board_scroll_offset.distance_squared_to(center_offset) > 0.01: failures.append("minimap scroll action should apply the calculated center offset: %s vs %s" % [str(scene.board_scroll_offset), str(center_offset)]) scene.board_scroll_offset = Vector2.ZERO var drag_start := map_rect.position + Vector2(map_rect.size.x * 0.35, map_rect.size.y * 0.35) var drag_end := map_rect.end var press := InputEventMouseButton.new() press.button_index = MOUSE_BUTTON_LEFT press.pressed = true press.position = drag_start if not scene._handle_minimap_mouse_button(press): failures.append("minimap press inside the map body should be consumed") if not scene.minimap_dragging: failures.append("minimap press inside the map body should start dragging") var drag_motion := InputEventMouseMotion.new() drag_motion.position = drag_end var expected_drag_offset: Vector2 = scene._board_scroll_offset_for_minimap_position(drag_end) if not scene._handle_minimap_mouse_motion(drag_motion): failures.append("minimap drag motion should be consumed while dragging") if scene.board_scroll_offset.distance_squared_to(expected_drag_offset) > 0.01: failures.append("minimap drag should keep recentering the tactical viewport: %s vs %s" % [str(scene.board_scroll_offset), str(expected_drag_offset)]) var release := InputEventMouseButton.new() release.button_index = MOUSE_BUTTON_LEFT release.pressed = false release.position = drag_end if not scene._handle_minimap_mouse_button(release): failures.append("minimap release should be consumed after dragging") if scene.minimap_dragging: failures.append("minimap release should end dragging") scene.free() var small_scene = BattleSceneScript.new() if not small_scene.state.load_battle("res://data/scenarios/002_sishui_gate.json"): failures.append("could not load compact chapter one map for minimap hiding") small_scene.free() return small_scene.battle_started = true if small_scene._map_has_scrollable_area(): failures.append("compact maps should not require minimap scrolling") if small_scene._is_minimap_visible(): failures.append("minimap should stay hidden on compact maps") var compact_press := InputEventMouseButton.new() compact_press.button_index = MOUSE_BUTTON_LEFT compact_press.pressed = true compact_press.position = small_scene._map_view_rect().end - Vector2(18.0, 18.0) if small_scene._handle_minimap_mouse_button(compact_press): failures.append("hidden minimap should not consume lower-right board clicks on compact maps") small_scene.free() func _check_map_focus_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening chapter one map for map focus") scene.free() return scene.battle_started = true for method_name in [ "_board_scroll_offset_for_cell_focus", "_focus_board_on_cell", "_player_deployment_focus_cell", "_focus_board_on_player_deployment", "_focus_board_on_formation_unit" ]: if not scene.has_method(method_name): failures.append("missing map focus helper: %s" % method_name) var view_rect: Rect2 = scene._map_view_rect() var far_cell := Vector2i(scene.state.map_size.x - 1, 4) scene.board_scroll_offset = Vector2.ZERO var far_offset: Vector2 = scene._board_scroll_offset_for_cell_focus(far_cell) if far_offset.x >= -0.01: failures.append("far map focus should scroll horizontally on a large map: %s" % str(far_offset)) var tile_size := float(scene.TILE_SIZE) var far_center: Vector2 = scene.BOARD_OFFSET + far_offset + Vector2(far_cell.x + 0.5, far_cell.y + 0.5) * tile_size if far_center.x > view_rect.end.x - 12.0 or far_center.x < view_rect.position.x + 12.0: failures.append("far map focus should keep the target cell inside the visible view: %s in %s" % [str(far_center), str(view_rect)]) var center_cell := Vector2i(scene.state.map_size.x / 2, 4) var center_offset: Vector2 = scene._board_scroll_offset_for_cell_focus(center_cell, true) var centered_local: Vector2 = view_rect.get_center() - (scene.BOARD_OFFSET + center_offset) var expected_local: Vector2 = Vector2(center_cell.x + 0.5, center_cell.y + 0.5) * tile_size if scene._board_size().x > view_rect.size.x and absf(centered_local.x - expected_local.x) > 1.0: failures.append("forced map focus should center the target X for action feedback: %s vs %s" % [str(centered_local), str(expected_local)]) scene.board_scroll_offset = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var origin_offset: Vector2 = scene._board_scroll_offset_for_cell_focus(Vector2i(0, 0)) if origin_offset.x > 0.01 or origin_offset.y > 0.01: failures.append("origin map focus should never scroll past the map origin: %s" % str(origin_offset)) scene.board_scroll_offset = Vector2.ZERO var near_offset: Vector2 = scene._board_scroll_offset_for_cell_focus(Vector2i(2, 4)) if near_offset.distance_squared_to(Vector2.ZERO) > 0.01: failures.append("map focus should not move when the target is already comfortably visible: %s" % str(near_offset)) scene.board_scroll_offset = Vector2.ZERO if not scene._focus_board_on_cell(far_cell): failures.append("map focus action should report movement for an off-screen cell") if scene.board_scroll_offset.distance_squared_to(far_offset) > 0.01: failures.append("map focus action should apply the calculated offset: %s vs %s" % [str(scene.board_scroll_offset), str(far_offset)]) for unit in scene.state.get_controllable_player_units(): var unit_id := str(unit.get("id", "")) if unit_id == "cao_cao": unit["pos"] = Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2) elif unit_id == "xiahou_dun": unit["pos"] = Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1) else: unit["pos"] = Vector2i(scene.state.map_size.x - 3, scene.state.map_size.y - 2) scene.board_scroll_offset = Vector2.ZERO var deployment_focus_cell: Vector2i = scene._player_deployment_focus_cell() if not scene.state.is_inside(deployment_focus_cell): failures.append("player deployment focus should produce an in-map cell: %s" % str(deployment_focus_cell)) if not scene._focus_board_on_player_deployment(true): failures.append("battle start deployment focus should move to far deployed units") if scene.board_scroll_offset.x >= -0.01 or scene.board_scroll_offset.y >= -0.01: failures.append("far deployment focus should scroll both axes on the opening large map: %s" % str(scene.board_scroll_offset)) scene.battle_started = false scene.board_scroll_offset = Vector2.ZERO if scene._focus_board_on_cell(far_cell, true): failures.append("normal map focus should stay locked before battle start") if not scene._focus_board_on_cell(far_cell, true, true): failures.append("explicit prebattle map focus should support formation navigation") scene.formation_unit_id = "cao_cao" scene.board_scroll_offset = Vector2.ZERO if not scene._focus_board_on_formation_unit(true): failures.append("formation unit focus should work before battle start") if scene.board_scroll_offset.x >= -0.01 or scene.board_scroll_offset.y >= -0.01: failures.append("formation unit focus should scroll to a far selected unit: %s" % str(scene.board_scroll_offset)) scene.free() func _check_battle_presentation_sequence_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening map for battle presentation sequencing") scene.free() return scene.battle_started = true scene.state.battle_status = BattleStateScript.STATUS_ACTIVE scene.state.current_team = BattleStateScript.TEAM_PLAYER for method_name in [ "_begin_battle_presentation_sequence", "_end_battle_presentation_sequence", "_presentation_delay_for_step", "_has_active_battle_presentation_sequence" ]: if not scene.has_method(method_name): failures.append("missing battle presentation helper: %s" % method_name) scene._begin_battle_presentation_sequence() scene._on_unit_motion_requested("yellow_turban_1", Vector2i(20, 1), Vector2i(19, 1)) scene._on_unit_action_motion_requested("yellow_turban_2", Vector2i(12, 5), Vector2i(11, 5), "attack") scene._on_combat_feedback_requested("yellow_turban_2", "-10", "damage") scene._end_battle_presentation_sequence() var first_motion: Dictionary = scene.unit_motion_by_unit.get("yellow_turban_1", {}) if first_motion.is_empty() or float(first_motion.get("age", 0.0)) < -0.01: failures.append("first queued enemy movement should start immediately: %s" % str(first_motion)) if not bool(first_motion.get("sequence", false)): failures.append("enemy movement should be marked as part of the presentation sequence") var action_motion: Dictionary = scene.unit_action_motion_by_unit.get("yellow_turban_2", {}) if action_motion.is_empty() or float(action_motion.get("age", 0.0)) >= 0.0: failures.append("later enemy action should wait for earlier movement: %s" % str(action_motion)) if not bool(action_motion.get("sequence", false)): failures.append("enemy action should be marked as part of the presentation sequence") if bool(action_motion.get("sfx_played", false)): failures.append("delayed enemy action SFX should wait for the action start: %s" % str(action_motion)) var enemy_before_facing: Dictionary = scene.state.get_unit("yellow_turban_2") if scene._unit_pixel_facing(enemy_before_facing) != scene.state.unit_facing_x(enemy_before_facing): failures.append("delayed enemy action should not flip unit facing before it starts") if scene.floating_texts.is_empty(): failures.append("enemy damage feedback should be queued as floating text") else: var popup: Dictionary = scene.floating_texts[0] if float(popup.get("age", 0.0)) >= 0.0: failures.append("enemy damage feedback should wait for the action impact: %s" % str(popup)) if not bool(popup.get("sequence", false)): failures.append("enemy damage feedback should keep input locked while it is presented") if not scene._has_active_battle_presentation_sequence(): failures.append("queued enemy presentation should stay active after enemy turn logic returns") if not scene._is_input_locked(): failures.append("queued enemy presentation should lock input until the sequence has played") for index in range(40): scene._process(0.20) if not scene._has_active_battle_presentation_sequence(): break if scene._has_active_battle_presentation_sequence(): failures.append("queued enemy presentation should finish after enough process time") scene.free() func _check_battle_start_notice_sequence(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._ready() scene._load_scenario("001_yellow_turbans") scene._show_briefing() scene._start_battle_from_briefing() if not scene._is_dialogue_visible(): failures.append("opening battle should begin with contextual dialogue before command notices") if scene.objective_notice_label != null and scene.objective_notice_label.text.contains("전투 개시"): failures.append("battle start notice should wait until opening dialogue is closed") var guard := 0 while scene._is_dialogue_visible() and guard < 12: scene._advance_dialogue() guard += 1 if scene._is_dialogue_visible(): failures.append("opening battle dialogue should be advanceable during start notice test") var start_notice_text: String = "" if scene.objective_notice_label == null else scene.objective_notice_label.text if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible: failures.append("battle start should show a compact tactical command notice after dialogue") elif not start_notice_text.contains("전투 개시"): failures.append("battle start notice should use a clear Korean title: %s" % start_notice_text) elif not start_notice_text.contains("목표:") or not start_notice_text.contains("주의:"): failures.append("battle start notice should summarize objective and risk: %s" % start_notice_text) elif not start_notice_text.contains("표식:") or not start_notice_text.contains("유인선") or not start_notice_text.contains("북숲 보급고") or not start_notice_text.contains("마을 보급"): failures.append("battle start notice should summarize tactical event markers without making the player hunt for them: %s" % start_notice_text) elif not start_notice_text.contains("앞줄 유인") or not start_notice_text.contains("숨은 콩") or not start_notice_text.contains("회복 보급"): failures.append("battle start notice should carry compact tactical marker hints: %s" % start_notice_text) elif start_notice_text.contains("(20") or start_notice_text.contains("(21") or start_notice_text.contains(",1"): failures.append("battle start notice should not expose raw grid coordinates: %s" % start_notice_text) if scene.objective_notice_icon_strip == null or scene.objective_notice_icon_strip.get_child_count() < 3: failures.append("battle start notice should use generated objective, warning, and marker icons") else: var notice_icon_tooltips := _collect_tooltips(scene.objective_notice_icon_strip) if not notice_icon_tooltips.contains("목표:") or not notice_icon_tooltips.contains("주의:") or not notice_icon_tooltips.contains("유인선") or not notice_icon_tooltips.contains("북숲 보급고") or not notice_icon_tooltips.contains("마을 보급"): failures.append("battle start notice icon hover should explain objective, risk, and tactical markers: %s" % notice_icon_tooltips) for expected_hint in ["끌어낼", "콩 보급", "치료소"]: if not notice_icon_tooltips.contains(expected_hint): failures.append("battle start notice icon hover should preserve marker hint `%s`: %s" % [expected_hint, notice_icon_tooltips]) if _count_descendants_by_name(scene.objective_notice_icon_strip, "CommandNoticeTileMarkerImage") < 3: failures.append("battle start marker notice chips should layer generated map marker art behind objective/tactic/supply icons") if scene.objective_notice_panel != null and scene.objective_notice_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE: failures.append("battle start notice panel should allow marker icon hover and click guidance") for expected_key in ["objective", "tactic", "supply", "recover"]: var chip := _find_command_notice_chip_by_key(scene.objective_notice_icon_strip, expected_key) if chip == null: failures.append("battle start notice should expose a clickable `%s` guide chip" % expected_key) continue var focus_cell: Vector2i = chip.get_meta("focus_cell", Vector2i(-1, -1)) if not scene.state.is_inside(focus_cell): failures.append("battle start `%s` chip should carry a map focus cell: %s" % [expected_key, str(focus_cell)]) if not chip.tooltip_text.contains("클릭하면"): failures.append("battle start `%s` chip tooltip should mention map focus: %s" % [expected_key, chip.tooltip_text]) if chip.mouse_filter == Control.MOUSE_FILTER_IGNORE: failures.append("battle start `%s` chip should receive pointer input" % expected_key) var objective_chip := _find_command_notice_chip_by_key(scene.objective_notice_icon_strip, "objective") if objective_chip != null: var objective_focus_cell: Vector2i = objective_chip.get_meta("focus_cell", Vector2i(-1, -1)) var previous_offset: Vector2 = scene.board_scroll_offset if not scene._focus_board_on_command_notice_chip(objective_chip): failures.append("clicking the objective notice chip should focus the battlefield objective") elif not scene._cell_is_in_visible_bounds(objective_focus_cell, 0): failures.append("objective notice chip focus should bring the castle cells into view") elif scene.board_scroll_offset.distance_squared_to(previous_offset) <= 0.01: failures.append("objective notice chip should scroll the large opening map toward the eastern castle") elif scene.hover_cell != objective_focus_cell: failures.append("objective notice chip should update the hover focus cell") scene.free() func _check_readability_contract(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() if scene.dialogue_text_panel.custom_minimum_size.y < 160.0: failures.append("dialogue text panel should be tall enough for multi-line story text") if scene.dialogue_text_label.custom_minimum_size.y < 130.0: failures.append("dialogue label should reserve enough height to avoid clipping") if scene.dialogue_text_label.autowrap_mode != TextServer.AUTOWRAP_WORD_SMART: failures.append("dialogue text should use smart Korean-friendly wrapping") if scene.briefing_objective_panel.custom_minimum_size.y < 120.0: failures.append("briefing objective panel should reserve enough height for victory and defeat text") if scene.briefing_objective_toggle_button == null: failures.append("briefing objective section should have a close/open button") elif scene.briefing_objective_toggle_button.text != "" or scene.briefing_objective_toggle_button.icon == null or not bool(scene.briefing_objective_toggle_button.get_meta("icon_only", false)): failures.append("briefing objective button should start as an icon-only control") elif not scene.briefing_objective_toggle_button.tooltip_text.contains("접기"): failures.append("briefing objective icon tooltip should explain closing the conditions") _check_text_fit_contract(scene, failures) scene.free() func _check_korean_fallback_labels(failures: Array[String]) -> void: var state = BattleStateScript.new() var scene = BattleSceneScript.new() var prepared := state._prepare_unit({ "id": "fallback_unit", "class_id": "mystery_unit", "pos": [0, 0] }) var labels := [ str(prepared.get("class", "")), state._format_move_type("hover_cart"), state._status_display_name("mystery_status"), state._team_status_text("neutral_force"), scene._team_hud_short_text("neutral_force"), scene._unit_team_text("neutral_force"), scene._item_kind_text("mystery"), scene._skill_kind_text("mystery"), scene._format_move_type("hover_cart"), scene._format_identifier_label("mystery_value") ] var forbidden := ["Unit", "Unknown", "Player", "Enemy", "Hover Cart", "Mystery"] for label in labels: if label.strip_edges().is_empty(): failures.append("fallback labels should not be blank") for word in forbidden: if label.contains(word): failures.append("fallback label should stay Korean-facing, got %s" % label) if prepared.get("class", "") != "부대": failures.append("missing unit class should fall back to 부대: %s" % str(prepared.get("class", ""))) if state._format_move_type("hover_cart") != "행군": failures.append("unknown battle-state move type should fall back to 행군") if state._status_display_name("mystery_status") != "상태": failures.append("unknown battle-state status should fall back to 상태") if state._team_status_text("neutral_force") != "전장": failures.append("unknown battle-state team should fall back to 전장") if scene._team_hud_short_text("neutral_force") != "전장": failures.append("unknown HUD team should fall back to 전장") if scene._unit_team_text("neutral_force") != "소속 미상": failures.append("unknown unit team should fall back to 소속 미상") if scene._item_kind_text("mystery") != "물자": failures.append("unknown item kind should fall back to 물자") if scene._skill_kind_text("mystery") != "책략": failures.append("unknown skill kind should fall back to 책략") if scene._format_move_type("hover_cart") != "행군": failures.append("unknown scene move type should fall back to 행군") scene.free() func _check_text_fit_contract(scene, failures: Array[String]) -> void: for method_name in [ "_fit_label_font_size_to_text", "_fit_font_size_for_text", "_estimated_wrapped_text_height", "_estimated_wrapped_line_count", "_weighted_text_length", "_compact_visible_text", "_set_readable_preview_label_text" ]: if not scene.has_method(method_name): failures.append("missing text fit helper: %s" % method_name) var dialogue_area: Vector2 = scene.dialogue_text_label.custom_minimum_size if dialogue_area.x <= 0.0 or dialogue_area.y <= 0.0: dialogue_area = Vector2(666.0, 144.0) var short_dialogue_size := int(scene._fit_font_size_for_text("군령을 전하라.", scene.DIALOGUE_TEXT_FONT_SIZE, scene.DIALOGUE_TEXT_MIN_FONT_SIZE, dialogue_area)) if short_dialogue_size != scene.DIALOGUE_TEXT_FONT_SIZE: failures.append("short dialogue text should keep the base font size, got %d" % short_dialogue_size) var long_dialogue := "「" \ + "서영의 매복은 아직 끝나지 않았다. 숲 뒤 봉쇄병이 동쪽 길목을 닫기 전에 전열을 유지하고, 부상병을 마을로 물려 회복시킨 뒤 다시 밀어붙여라. " \ + "하후돈은 비탈 궁병을 묶고, 하후연은 길목을 살펴라. 조인은 뒤따르는 병력을 모아 조조의 깃발이 끊기지 않게 하라. " \ + "마지막 봉쇄병이 모습을 드러내면 조급히 흩어지지 말고, 마을과 성채의 회복지를 번갈아 쓰며 전열을 앞으로 밀어라. " \ + "적이 다시 숲을 흔들어도 군령은 하나다. 길목을 열고 남은 병력을 정리한 뒤 추격로를 확보하라. " \ + "북쪽 비탈과 남쪽 수풀을 동시에 살피고, 어느 한 장수가 고립되지 않도록 서로의 이동 거리를 맞추어라.」" var fitted_dialogue_size := int(scene._fit_font_size_for_text(long_dialogue, scene.DIALOGUE_TEXT_FONT_SIZE, scene.DIALOGUE_TEXT_MIN_FONT_SIZE, dialogue_area)) if fitted_dialogue_size < scene.DIALOGUE_TEXT_MIN_FONT_SIZE or fitted_dialogue_size > scene.DIALOGUE_TEXT_FONT_SIZE: failures.append("long dialogue font fit should stay inside its configured bounds") var fitted_dialogue_height := float(scene._estimated_wrapped_text_height(long_dialogue, dialogue_area.x, fitted_dialogue_size)) if fitted_dialogue_height > dialogue_area.y: failures.append("fitted long dialogue should stay within the dialogue text area") var objective_area: Vector2 = scene.briefing_objective_label.custom_minimum_size if objective_area.x <= 0.0 or objective_area.y <= 0.0: objective_area = Vector2(648.0, 120.0) var long_objective := "승리: 서영의 후군을 꺾었다. 동쪽 길목을 장악하고 뒤따르는 봉쇄병까지 정리하라.\n패배: 조조가 퇴각하거나 제17턴이 시작되면 패한다.\n보상: 군자금 700냥, 콩, 술, 철검" var fitted_objective_size := int(scene._fit_font_size_for_text(long_objective, scene.BRIEFING_OBJECTIVE_FONT_SIZE, scene.BRIEFING_OBJECTIVE_MIN_FONT_SIZE, objective_area)) if fitted_objective_size < scene.BRIEFING_OBJECTIVE_MIN_FONT_SIZE or fitted_objective_size > scene.BRIEFING_OBJECTIVE_FONT_SIZE: failures.append("briefing objective font fit should stay inside its configured bounds") var fitted_objective_height := float(scene._estimated_wrapped_text_height(long_objective, objective_area.x, fitted_objective_size)) if fitted_objective_height > objective_area.y: failures.append("fitted briefing objective text should stay within its panel") var compact_lines := str(scene._compact_visible_text( "첫 줄은 그대로 보인다.\n둘째 줄도 보인다.\n셋째 줄은 숨긴다.", 2, 18 )).split("\n") if compact_lines.size() > 2: failures.append("compact visible text should respect the requested line budget: %s" % str(compact_lines)) var compact_single_line := str(scene._compact_visible_text( "영천 들판 너머의 황건 진영이 마을과 성채를 등지고 길게 늘어서 있으니 조급히 들이치지 말고 전열을 유지하라.", 3, 18 )) var compact_single_lines := compact_single_line.split("\n") if compact_single_lines.size() < 2: failures.append("long single-line text should wrap into multiple readable preview lines: %s" % compact_single_line) if compact_single_lines.size() > 3: failures.append("long single-line text should still respect the line budget: %s" % compact_single_line) func _check_command_notice_readable_preview(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() var long_body := "목표: 조조나 하후돈으로 동쪽 성채에 올라 깃발을 붙들고, 제11군령 이후 나타나는 마지막 황건 잔당까지 격파하라.\n주의: 조조가 퇴각하거나 제22턴이 시작되면 패한다.\n보급: 마을과 치료소를 오가며 장기전을 버틴다." scene._show_command_notice("전투 개시", long_body) _check_label_fits_visible_area(scene, failures, scene.objective_notice_label, "long command notice") var notice_lines: Array = [] if scene.objective_notice_label != null: notice_lines = str(scene.objective_notice_label.text).split("\n") if notice_lines.size() > 3: failures.append("long command notice should keep only title plus two readable body lines: %s" % str(notice_lines)) if scene.objective_notice_label != null and not scene.objective_notice_label.text.contains("주의:"): failures.append("long command notice should keep the warning line visible beside the goal: %s" % scene.objective_notice_label.text) if scene.objective_notice_label != null and not scene.objective_notice_label.tooltip_text.contains("마지막 황건 잔당"): failures.append("long command notice should preserve the full text in its tooltip") scene.free() func _check_first_battle_rendered_text_fit(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._create_hud() scene._show_opening_prologue() for index in range(BattleSceneScript.OPENING_PROLOGUE_PAGES.size()): scene.opening_prologue_index = index scene._update_opening_prologue_page() _check_label_fits_visible_area(scene, failures, scene.opening_prologue_title_label, "opening prologue title page %d" % (index + 1)) _check_label_fits_visible_area(scene, failures, scene.opening_prologue_step_label, "opening prologue step page %d" % (index + 1)) _check_label_fits_visible_area(scene, failures, scene.opening_prologue_body_label, "opening prologue body page %d" % (index + 1)) if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for rendered text fit") scene.free() return scene._show_briefing() _check_label_fits_visible_area(scene, failures, scene.briefing_title_label, "opening briefing title") _check_label_fits_visible_area(scene, failures, scene.briefing_location_label, "opening briefing location") _check_label_fits_visible_area(scene, failures, scene.briefing_objective_label, "opening briefing objective") _check_label_fits_visible_area(scene, failures, scene.briefing_camp_overview_label, "opening briefing map overview") _check_label_fits_visible_area(scene, failures, scene.briefing_label, "opening briefing body") if scene.briefing_label != null: var briefing_lines := str(scene.briefing_label.text).split("\n") if briefing_lines.size() > BattleSceneScript.BRIEFING_VISIBLE_BODY_LINES: failures.append("opening briefing body should keep a short visible summary: %s" % str(briefing_lines)) if BattleSceneScript.BRIEFING_VISIBLE_BODY_LINES != 2: failures.append("opening briefing should keep only a two-line first-screen story summary") if not scene.briefing_label.tooltip_text.contains("적은 마을과 성채"): failures.append("opening briefing body should keep the full briefing text in its tooltip") var briefing: Dictionary = scene.state.get_briefing() var dialogue_lines: Array = [] dialogue_lines.append_array(briefing.get("camp_dialogue", [])) for conversation in briefing.get("camp_conversations", []): if typeof(conversation) != TYPE_DICTIONARY: continue dialogue_lines.append_array((conversation as Dictionary).get("lines", [])) for index in range(dialogue_lines.size()): var normalized_lines := scene._normalized_dialogue_lines([dialogue_lines[index]]) if normalized_lines.is_empty(): continue scene.active_dialogue_lines = normalized_lines scene.active_dialogue_index = 0 scene._render_dialogue_line() _check_label_fits_visible_area(scene, failures, scene.dialogue_speaker_label, "opening dialogue speaker %d" % (index + 1)) _check_label_fits_visible_area(scene, failures, scene.dialogue_text_label, "opening dialogue body %d" % (index + 1)) var long_dialogue := "동쪽 성채의 깃발을 먼저 꺾되 북쪽 숲의 보급고를 지나치지 마라. 적이 마을을 등지고 버티면 하후돈으로 앞줄을 묶고 조조는 뒤로 돌아 마지막 황건 잔당을 끊어라." scene.active_dialogue_lines = scene._normalized_dialogue_lines([{"speaker": "Cao Cao", "display_speaker": "조조", "text": long_dialogue}]) scene.active_dialogue_index = 0 scene._render_dialogue_line() _check_label_fits_visible_area(scene, failures, scene.dialogue_text_label, "long tactical dialogue body") if scene.dialogue_text_label != null: var visible_dialogue_lines := str(scene.dialogue_text_label.text).split("\n") if visible_dialogue_lines.size() < 2: failures.append("long tactical dialogue should show more than a single clipped line: %s" % scene.dialogue_text_label.text) if visible_dialogue_lines.size() > BattleSceneScript.DIALOGUE_VISIBLE_BODY_LINES: failures.append("long tactical dialogue should respect visible line budget: %s" % scene.dialogue_text_label.text) if not scene.dialogue_text_label.tooltip_text.contains("마지막 황건 잔당"): failures.append("long tactical dialogue should preserve the full copy in tooltip: %s" % scene.dialogue_text_label.tooltip_text) scene.free() func _check_label_fits_visible_area(scene, failures: Array[String], label: Label, context: String) -> void: if label == null: failures.append("%s missing" % context) return var text := str(label.text).strip_edges() if text.is_empty(): failures.append("%s should render visible text" % context) return var area := label.custom_minimum_size if area.x <= 0.0 or area.y <= 0.0: failures.append("%s should reserve a stable text area, got %s" % [context, str(area)]) return var font_size := label.get_theme_font_size("font_size") var fitted_height := float(scene._estimated_wrapped_text_height(text, area.x, font_size)) if fitted_height > area.y + 2.0: failures.append("%s text should fit its visible area: height %.1f > %.1f at font %d text=`%s`" % [context, fitted_height, area.y, font_size, text]) if font_size < 12: failures.append("%s should not shrink below readable Korean text size: %d" % [context, font_size]) if label.clip_text: failures.append("%s should not hard-clip Korean text" % context) func _check_dialogue_localization_and_side(failures: Array[String]) -> void: var scene = BattleSceneScript.new() var lines := scene._normalized_dialogue_lines([ {"speaker": "Cao Cao", "text": "전열을 세워라."}, {"speaker": "Lu Bu", "text": "조조를 짓밟아라."}, {"speaker": "Qingzhou Chief", "text": "마을 곡식을 내놓아라."}, {"speaker": "Camp Merchant", "text": "군막에 물자가 남았습니다."}, {"speaker": "Hua Xiong", "text": "사수관을 지켜라."}, {"speaker": "Xu Rong", "text": "동쪽 길목을 닫아라."} ]) if lines.size() != 6: failures.append("dialogue normalization should keep all valid chapter one lines") scene.free() return if lines[0].get("speaker", "") != "조조" or lines[0].get("side", "") != "left": failures.append("Cao Cao dialogue should localize to Korean on the left") if lines[1].get("speaker", "") != "여포" or lines[1].get("side", "") != "right": failures.append("enemy officer dialogue should localize to Korean on the right") if lines[2].get("speaker", "") != "청주 두령" or lines[2].get("side", "") != "right": failures.append("chapter one named enemy dialogue should be localized and right-sided") if lines[3].get("speaker", "") != "군막 상인" or lines[3].get("side", "") != "right": failures.append("camp merchant dialogue should be localized and right-sided by default") if lines[4].get("speaker", "") != "화웅" or lines[4].get("side", "") != "right": failures.append("Sishui Gate enemy commander dialogue should localize to Korean on the right") if lines[5].get("speaker", "") != "서영" or lines[5].get("side", "") != "right": failures.append("Xingyang Ambush enemy commander dialogue should localize to Korean on the right") scene.free() func _check_event_dialogue_side_passthrough(failures: Array[String]) -> void: var state = BattleStateScript.new() var scene = BattleSceneScript.new() var raw_lines := state._dialogue_lines_from_action({ "type": "dialogue", "lines": [ {"speaker": "Zhang Mancheng", "text": "성채를 지켜라."}, {"speaker": "Cao Cao", "text": "길을 열어라."} ] }) if raw_lines.size() != 2: failures.append("battle events should preserve valid dialogue lines") scene.free() return if str((raw_lines[0] as Dictionary).get("side", "")) != "": failures.append("battle state should not force event dialogue without an explicit side to the left") var normalized := scene._normalized_dialogue_lines(raw_lines) if normalized.size() != 2: failures.append("scene should normalize battle event dialogue lines") elif normalized[0].get("speaker", "") != "장만성" or normalized[0].get("side", "") != "right": failures.append("event dialogue should localize Zhang Mancheng and infer right-side layout") scene.free() func _check_opening_battle_camp_context(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 camp context") return var conversations: Array = state.get_briefing().get("camp_conversations", []) if conversations.size() < 4: failures.append("opening battle should offer enough camp conversations before the first fight") var villager_report := _camp_conversation_by_id(conversations, "yingchuan_villager_report") if villager_report.is_empty(): failures.append("opening battle should include a villager route report before combat") return if str(villager_report.get("group", "")) != "topic": failures.append("villager route report should be a topic conversation") var report_text := _camp_conversation_text(villager_report) for expected in ["영천 백성", "성채", "마을", "다친 병사"]: if not report_text.contains(expected): failures.append("villager route report should mention %s: %s" % [expected, report_text]) func _check_opening_battle_tactical_spacing(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for tactical spacing") scene.free() return for player_id in ["cao_cao", "xiahou_dun"]: var player: Dictionary = scene.state.get_unit(player_id) if player.is_empty(): failures.append("opening battle missing tactical spacing player: %s" % player_id) continue for enemy in scene.state.units: if typeof(enemy) != TYPE_DICTIONARY: continue if not bool(enemy.get("alive", false)) or enemy.get("team", "") != BattleStateScript.TEAM_ENEMY: continue var origin: Vector2i = scene._best_auto_attack_origin(player, enemy) if origin != Vector2i(-1, -1): failures.append("opening battle should not allow immediate move-attack from %s to %s via %s" % [player_id, str(enemy.get("id", "")), str(origin)]) var front_guard: Dictionary = scene.state.get_unit("yellow_turban_4") if str(front_guard.get("ai_behavior", "")) != "guard" or int(front_guard.get("ai_guard_radius", 0)) < 3: failures.append("opening front guard should react to the lure line without charging from turn one") var central_sentry: Dictionary = scene.state.get_unit("yellow_turban_5") if str(central_sentry.get("ai_behavior", "")) != "sentry" or int(central_sentry.get("ai_sentry_radius", 0)) < 5: failures.append("opening central enemy should use sentry AI for lure-line pacing") if bool(central_sentry.get("ai_awake", false)): failures.append("opening central sentry should start asleep") scene.free() func _check_opening_battle_log_location_labels(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 log location labels") return var sample_label := state._format_cell(Vector2i(1, 6)) if sample_label.contains("(") or sample_label.contains(")") or sample_label.contains(","): failures.append("battle log location labels should not expose raw coordinates: %s" % sample_label) if not sample_label.contains("서측"): failures.append("battle log location labels should include battlefield zones: %s" % sample_label) var logs: Array[String] = [] state.log_added.connect(func(message: String) -> void: logs.append(message) ) if not state.select_unit("cao_cao"): failures.append("could not select Cao Cao for log location check") return var moved := false for cell in state.get_movement_range("cao_cao"): if not state.get_unit_at(cell).is_empty(): continue if state.try_move_selected(cell, true): moved = true break if not moved: failures.append("could not move Cao Cao for log location check") return var log_text := "\n".join(logs) if log_text.contains("?") or log_text.contains("(") or log_text.contains(")"): failures.append("battle movement logs should use polished Korean location text: %s" % log_text) if not (log_text.contains("서측") or log_text.contains("중앙") or log_text.contains("동측")): failures.append("battle movement logs should name battlefield zones: %s" % log_text) func _check_opening_battle_marker_hints(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for marker hints") scene.free() return var castle_cell := Vector2i(20, 0) var castle_hint: String = scene.state.get_objective_cell_hint(castle_cell) if not castle_hint.contains("조조") or not castle_hint.contains("성채"): failures.append("castle objective should explain who must hold the gate: %s" % castle_hint) var castle_hover_text := "\n".join(scene._hover_info_badge_lines(castle_cell)) if not castle_hover_text.contains("목표 성채 장악") or not castle_hover_text.contains("깃발"): failures.append("castle hover should expose objective hint text: %s" % castle_hover_text) var castle_minimap_text := "\n".join(scene._minimap_hover_badge_lines(castle_cell)) if not castle_minimap_text.contains("목표 성채 장악") or not castle_minimap_text.contains("동쪽 성채"): failures.append("castle minimap hover should expose objective hint text: %s" % castle_minimap_text) var castle_cell_detail := scene._format_cell_info_detail_text(castle_cell) if not castle_cell_detail.contains("목표: 성채 장악") or not castle_cell_detail.contains("조조나 하후돈"): failures.append("cell info should carry castle objective detail: %s" % castle_cell_detail) var lure_cell := Vector2i(4, 6) if scene.state.get_event_marker_kind(lure_cell) != "tactic": failures.append("lure line should keep a tactic marker kind") var lure_hint: String = scene.state.get_event_marker_hint(lure_cell) if not lure_hint.contains("유인선") or not lure_hint.contains("끌어낼"): failures.append("lure line should explain the tactical trigger: %s" % lure_hint) var lure_hover_text := "\n".join(scene._hover_info_badge_lines(lure_cell)) if not lure_hover_text.contains("표식 유인선") or not lure_hover_text.contains("앞줄 유인") or not lure_hover_text.contains("끌어낼"): failures.append("lure hover should expose tactical marker hint: %s" % lure_hover_text) var lure_minimap_text := "\n".join(scene._minimap_hover_badge_lines(lure_cell)) if not lure_minimap_text.contains("표식 유인선") or not lure_minimap_text.contains("앞줄 유인"): failures.append("lure minimap hover should expose compact lure hint: %s" % lure_minimap_text) var woods_cell := Vector2i(4, 2) var woods_hint: String = scene.state.get_event_marker_hint(woods_cell) if not woods_hint.contains("콩") or not woods_hint.contains("보급"): failures.append("northern cache should explain its supply value: %s" % woods_hint) var woods_minimap_text := "\n".join(scene._minimap_hover_badge_lines(woods_cell)) if not woods_minimap_text.contains("북숲 보급고") or not woods_minimap_text.contains("숨은 콩") or not woods_minimap_text.contains("콩 보급"): failures.append("northern cache minimap hover should expose compact supply hint: %s" % woods_minimap_text) var village_cell := Vector2i(6, 5) var village_hint: String = scene.state.get_event_marker_hint(village_cell) if not village_hint.contains("치료소") or not village_hint.contains("회복"): failures.append("village supply should explain recovery and supply value: %s" % village_hint) var village_minimap_text := "\n".join(scene._minimap_hover_badge_lines(village_cell)) if not village_minimap_text.contains("마을 보급") or not village_minimap_text.contains("회복 보급") or not village_minimap_text.contains("치료소"): failures.append("village minimap hover should expose compact recovery hint: %s" % village_minimap_text) var village_detail := scene._format_cell_info_detail_text(village_cell) if not village_detail.contains("마을 치료소") or not village_detail.contains("회복 거점"): failures.append("village cell info should explain the recovery/supply marker: %s" % village_detail) scene.free() func _check_opening_battle_briefing_marker_focus(failures: Array[String]) -> void: var scene = BattleSceneScript.new() scene._ready() scene._load_scenario("001_yellow_turbans") scene._show_briefing() if scene.briefing_tactical_marker_list == null or scene.briefing_tactical_marker_list.get_child_count() < 3: failures.append("opening briefing should expose tactical marker chips before battle") scene.free() return for expected_label in ["유인선", "북숲 보급고", "마을 보급"]: var chip := _find_briefing_marker_chip_by_label(scene.briefing_tactical_marker_list, expected_label) if chip == null: failures.append("opening briefing marker chip missing: %s" % expected_label) continue var focus_cell: Vector2i = chip.get_meta("focus_cell", Vector2i(-1, -1)) if not scene.state.is_inside(focus_cell): failures.append("opening briefing marker chip should carry focus cell for %s: %s" % [expected_label, str(focus_cell)]) if not chip.tooltip_text.contains("클릭하면 전장도"): failures.append("opening briefing marker chip should explain clickable map focus for %s: %s" % [expected_label, chip.tooltip_text]) if chip.mouse_filter == Control.MOUSE_FILTER_IGNORE: failures.append("opening briefing marker chip should receive pointer input for %s" % expected_label) var village_chip := _find_briefing_marker_chip_by_label(scene.briefing_tactical_marker_list, "마을 보급") if village_chip != null: var village_focus: Vector2i = village_chip.get_meta("focus_cell", Vector2i(-1, -1)) var previous_offset: Vector2 = scene.board_scroll_offset if not scene._focus_board_on_briefing_marker_chip(village_chip): failures.append("clicking briefing marker chip should focus the battlefield marker") elif not scene._cell_is_in_visible_bounds(village_focus, 0): failures.append("briefing marker focus should bring the marker into the map view") elif scene.board_scroll_offset.distance_squared_to(previous_offset) <= 0.01: failures.append("briefing marker chip should move the large map toward the selected marker") elif scene.hover_cell != village_focus: failures.append("briefing marker chip should update hover focus cell") scene.free() func _check_opening_battle_lure_line_ai_reaction(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 lure line AI reaction") return var cao_cao: Dictionary = state.get_unit("cao_cao") cao_cao["pos"] = Vector2i(4, 6) state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 1, cao_cao) if not state.fired_event_ids.has("first_lure_line"): failures.append("opening lure line should fire when a player reaches the marked forward cells") for unit_id in ["yellow_turban_5", "yellow_turban_9"]: var sentry: Dictionary = state.get_unit(unit_id) if not bool(sentry.get("ai_awake", false)): failures.append("opening lure line should wake front sentry %s without waiting for its activation turn" % unit_id) var rear_archer: Dictionary = state.get_unit("yellow_turban_3") if bool(rear_archer.get("ai_awake", false)): failures.append("opening lure line should not wake the castle-side archer wave") var xiahou_dun: Dictionary = state.get_unit("xiahou_dun") if int(xiahou_dun.get("ai_target_priority", 0)) < 6: failures.append("opening lure line should make Xiahou Dun a visible vanguard lure target") func _check_opening_battle_post_battle_bridge(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 post-battle bridge") return var lines: Array = state.get_post_battle_dialogue() if lines.size() < 6: failures.append("opening battle should have a multi-beat post-battle bridge") var bridge_text := _dialogue_lines_text(lines) for expected in ["사수관", "화웅", "하후연", "조인", "합류", "난세의 길"]: if not bridge_text.contains(expected): failures.append("opening post-battle bridge should mention %s: %s" % [expected, bridge_text]) if bridge_text.find("합류") > bridge_text.find("사수관"): failures.append("opening post-battle bridge should explain officer joins before the Sishui report: %s" % bridge_text) func _check_sishui_gate_camp_context(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/002_sishui_gate.json"): failures.append("could not load Sishui Gate for camp context") return var briefing: Dictionary = state.get_briefing() var camp_dialogue: Array = briefing.get("camp_dialogue", []) if camp_dialogue.size() < 3: failures.append("Sishui Gate should open with camp dialogue before the second battle") var camp_text := _dialogue_lines_text(camp_dialogue) for expected in ["사수관", "화웅", "하후연", "조인", "마을", "성채"]: if not camp_text.contains(expected): failures.append("Sishui Gate camp dialogue should mention %s: %s" % [expected, camp_text]) func _check_opening_battle_event_dialogue_structure(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 event dialogue structure") return for event_id in [ "opening_dialogue", "northern_woods_cache", "village_supply_secured", "castle_gate_secured", "turn_2_warning", "turn_4_eastern_reserve", "turn_7_southern_raiders", "turn_9_western_reserve", "turn_11_last_rally", "zhang_mancheng_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("opening battle missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("opening battle event should include contextual dialogue: %s" % event_id) var opening_event := _event_by_id(state, "opening_dialogue") if not opening_event.is_empty(): var opening_text := _event_dialogue_text(opening_event) if not opening_text.contains("젊은 조조") or not opening_text.contains("하후돈") or not opening_text.contains("첫 군령"): failures.append("opening battle should first explain Cao Cao, Xiahou Dun, and why the first battle begins") for tactical_context in ["마을 우물", "북쪽 숲", "유인선", "성채"]: if not opening_text.contains(tactical_context): failures.append("opening battle dialogue should bridge story into tactical context `%s`: %s" % [tactical_context, opening_text]) if opening_text.find("젊은 조조") > opening_text.find("황건의 깃발"): failures.append("opening battle prologue should come before the tactical battlefield order") var boss_event := _event_by_id(state, "zhang_mancheng_falls") if not boss_event.is_empty(): var when: Dictionary = boss_event.get("when", {}) var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("yellow_turban_1"): failures.append("Zhang Mancheng defeat event should watch yellow_turban_1") for objective_event_id in [ "village_supply_secured", "castle_gate_secured" ]: var objective_event := _event_by_id(state, objective_event_id) if not objective_event.is_empty() and not _event_has_set_objective_action(objective_event): failures.append("opening battle capture event should update the active objective: %s" % objective_event_id) func _check_sishui_gate_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/002_sishui_gate.json"): failures.append("could not load Sishui Gate for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_2_warning", "turn_5_ridge_patrol", "turn_8_gate_reserve", "hua_xiong_vanguard_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Sishui Gate missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Sishui Gate event should include contextual dialogue: %s" % event_id) var vanguard_event := _event_by_id(state, "hua_xiong_vanguard_falls") if not vanguard_event.is_empty(): var when: Dictionary = vanguard_event.get("when", {}) var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("hua_xiong_vanguard"): failures.append("Hua Xiong vanguard defeat event should watch hua_xiong_vanguard") if not _event_has_set_objective_action(vanguard_event): failures.append("Hua Xiong vanguard defeat event should clarify the next objective") func _check_xingyang_ambush_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}): failures.append("could not load Xingyang Ambush for event dialogue structure") return for event_id in [ "opening_dialogue_pursuit", "opening_dialogue_regroup", "turn_3_ambush_pursuit", "turn_3_ambush_regroup", "turn_5_pressure", "turn_5_regroup_pressure", "turn_6_slope_archers", "turn_9_forest_blockade", "xu_rong_rearguard_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Xingyang Ambush missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Xingyang Ambush event should include contextual dialogue: %s" % event_id) var rearguard_event := _event_by_id(state, "xu_rong_rearguard_falls") if not rearguard_event.is_empty(): var when: Dictionary = rearguard_event.get("when", {}) var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("xu_rong_rearguard"): failures.append("Xu Rong rearguard defeat event should watch xu_rong_rearguard") if not _event_has_set_objective_action(rearguard_event): failures.append("Xu Rong rearguard defeat event should clarify the next objective") var blockade_event := _event_by_id(state, "turn_9_forest_blockade") if not blockade_event.is_empty() and not _event_has_set_objective_action(blockade_event): failures.append("Xingyang Ambush blockade event should clarify the final objective") func _check_qingzhou_campaign_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/004_qingzhou_campaign.json"): failures.append("could not load Qingzhou Campaign for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_3_bandit_push", "turn_5_dian_wei_rumor", "turn_6_village_raiders", "turn_9_village_last_stand", "qingzhou_chief_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Qingzhou Campaign missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Qingzhou Campaign event should include contextual dialogue: %s" % event_id) var chief_event := _event_by_id(state, "qingzhou_chief_falls") if not chief_event.is_empty(): var when: Dictionary = chief_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Qingzhou chief defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("qingzhou_chief"): failures.append("Qingzhou chief defeat event should watch qingzhou_chief") if not _event_has_set_objective_action(chief_event): failures.append("Qingzhou chief defeat event should clarify the next objective") var first_push_event := _event_by_id(state, "turn_3_bandit_push") if not first_push_event.is_empty() and not _event_has_set_objective_action(first_push_event): failures.append("Qingzhou Campaign first raider push should clarify the pressure objective") var last_stand_event := _event_by_id(state, "turn_9_village_last_stand") if not last_stand_event.is_empty() and not _event_has_set_objective_action(last_stand_event): failures.append("Qingzhou Campaign last stand event should clarify the final objective") func _check_puyang_raid_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/005_puyang_raid.json"): failures.append("could not load Puyang Raid for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_2_alarm", "turn_3_lu_bu_arrives", "turn_5_pressure", "turn_6_zhang_liao_closes", "turn_9_lu_bu_encirclement", "zhang_liao_vanguard_falls", "lu_bu_banner_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Puyang Raid missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Puyang Raid event should include contextual dialogue: %s" % event_id) var vanguard_event := _event_by_id(state, "zhang_liao_vanguard_falls") if not vanguard_event.is_empty(): var when: Dictionary = vanguard_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Zhang Liao vanguard defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("zhang_liao_vanguard"): failures.append("Zhang Liao vanguard defeat event should watch zhang_liao_vanguard") if not _event_has_set_objective_action(vanguard_event): failures.append("Zhang Liao vanguard defeat event should clarify the next objective") var lu_bu_banner_event := _event_by_id(state, "lu_bu_banner_falls") if not lu_bu_banner_event.is_empty(): var when: Dictionary = lu_bu_banner_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Lu Bu banner defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("lu_bu_banner"): failures.append("Lu Bu banner defeat event should watch lu_bu_banner") if not _event_has_set_objective_action(lu_bu_banner_event): failures.append("Lu Bu banner defeat event should clarify the next objective") var lu_bu_arrival_event := _event_by_id(state, "turn_3_lu_bu_arrives") if not lu_bu_arrival_event.is_empty() and not _event_has_set_objective_action(lu_bu_arrival_event): failures.append("Puyang Raid Lu Bu arrival event should clarify the pressure objective") var encirclement_event := _event_by_id(state, "turn_9_lu_bu_encirclement") if not encirclement_event.is_empty() and not _event_has_set_objective_action(encirclement_event): failures.append("Puyang Raid encirclement event should clarify the final objective") func _check_dingtao_counterattack_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/006_dingtao_counterattack.json"): failures.append("could not load Dingtao Counterattack for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_3_fortified_reserve", "turn_3_pressed_lu_bu_charge", "turn_3_counterattack", "turn_5_hold_center", "turn_6_flank_pressure", "turn_9_lu_bu_second_charge", "gao_shun_line_falls", "lu_bu_counterattack_falls", "lu_bu_second_charge_breaks" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Dingtao Counterattack missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Dingtao Counterattack event should include contextual dialogue: %s" % event_id) var gao_shun_event := _event_by_id(state, "gao_shun_line_falls") if not gao_shun_event.is_empty(): var when: Dictionary = gao_shun_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Gao Shun line defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("gao_shun_line"): failures.append("Gao Shun line defeat event should watch gao_shun_line") if not _event_has_set_objective_action(gao_shun_event): failures.append("Gao Shun line defeat event should clarify the next objective") var lu_bu_event := _event_by_id(state, "lu_bu_counterattack_falls") if not lu_bu_event.is_empty(): var when: Dictionary = lu_bu_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Lu Bu counterattack defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("lu_bu_counterattack"): failures.append("Lu Bu counterattack defeat event should watch lu_bu_counterattack") if not _event_has_set_objective_action(lu_bu_event): failures.append("Lu Bu counterattack defeat event should clarify the next objective") var second_charge_break_event := _event_by_id(state, "lu_bu_second_charge_breaks") if not second_charge_break_event.is_empty(): var when: Dictionary = second_charge_break_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Lu Bu second charge break event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("lu_bu_second_rider"): failures.append("Lu Bu second charge break event should watch lu_bu_second_rider") if not _event_has_set_objective_action(second_charge_break_event): failures.append("Lu Bu second charge break event should clarify the next objective") var counterattack_event := _event_by_id(state, "turn_3_counterattack") if not counterattack_event.is_empty() and not _event_has_set_objective_action(counterattack_event): failures.append("Dingtao Counterattack turn 3 event should clarify the pressure objective") var second_charge_event := _event_by_id(state, "turn_9_lu_bu_second_charge") if not second_charge_event.is_empty() and not _event_has_set_objective_action(second_charge_event): failures.append("Dingtao Counterattack second charge event should clarify the final objective") func _check_xian_emperor_escort_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/007_xian_emperor_escort.json"): failures.append("could not load Xian Emperor Escort for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_3_rear_pressure", "midroad_ambush", "turn_5_road_marker", "turn_6_envoy_pincher", "turn_9_final_escort_pressure", "li_jue_vanguard_falls", "final_escort_rider_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Xian Emperor Escort missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Xian Emperor Escort event should include contextual dialogue: %s" % event_id) var li_jue_event := _event_by_id(state, "li_jue_vanguard_falls") if not li_jue_event.is_empty(): var when: Dictionary = li_jue_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Li Jue vanguard defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("li_jue_vanguard"): failures.append("Li Jue vanguard defeat event should watch li_jue_vanguard") if not _event_has_set_objective_action(li_jue_event): failures.append("Li Jue vanguard defeat event should clarify the escort objective") var final_rider_event := _event_by_id(state, "final_escort_rider_falls") if not final_rider_event.is_empty(): var when: Dictionary = final_rider_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Final escort rider defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("final_escort_rider"): failures.append("Final escort rider defeat event should watch final_escort_rider") if not _event_has_set_objective_action(final_rider_event): failures.append("Final escort rider defeat event should clarify the final escort objective") for objective_event_id in [ "turn_3_rear_pressure", "midroad_ambush", "turn_9_final_escort_pressure" ]: var objective_event := _event_by_id(state, objective_event_id) if not objective_event.is_empty() and not _event_has_set_objective_action(objective_event): failures.append("Xian Emperor Escort event should update the active objective: %s" % objective_event_id) func _check_wan_castle_escape_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): failures.append("could not load Wan Castle Escape for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_2_gate_closes", "court_escape_lane", "remnant_scout_pressure", "midroad_trap", "turn_6_last_push", "turn_6_wan_crossfire", "turn_9_west_gate_hunt", "zhang_xiu_vanguard_falls", "jia_xu_strategist_falls", "west_gate_hunt_rider_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Wan Castle Escape missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Wan Castle Escape event should include contextual dialogue: %s" % event_id) var zhang_xiu_event := _event_by_id(state, "zhang_xiu_vanguard_falls") if not zhang_xiu_event.is_empty(): var when: Dictionary = zhang_xiu_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Zhang Xiu vanguard defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("zhang_xiu_vanguard"): failures.append("Zhang Xiu vanguard defeat event should watch zhang_xiu_vanguard") if not _event_has_set_objective_action(zhang_xiu_event): failures.append("Zhang Xiu vanguard defeat event should clarify the escape objective") var jia_xu_event := _event_by_id(state, "jia_xu_strategist_falls") if not jia_xu_event.is_empty(): var when: Dictionary = jia_xu_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Jia Xu strategist defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("jia_xu_strategist"): failures.append("Jia Xu strategist defeat event should watch jia_xu_strategist") if not _event_has_set_objective_action(jia_xu_event): failures.append("Jia Xu strategist defeat event should clarify the escape objective") var west_gate_event := _event_by_id(state, "west_gate_hunt_rider_falls") if not west_gate_event.is_empty(): var when: Dictionary = west_gate_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("West gate hunt rider defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("west_gate_hunt_rider"): failures.append("West gate hunt rider defeat event should watch west_gate_hunt_rider") if not _event_has_set_objective_action(west_gate_event): failures.append("West gate hunt rider defeat event should clarify the final escape objective") for objective_event_id in [ "turn_2_gate_closes", "midroad_trap", "turn_6_wan_crossfire", "turn_9_west_gate_hunt" ]: var objective_event := _event_by_id(state, objective_event_id) if not objective_event.is_empty() and not _event_has_set_objective_action(objective_event): failures.append("Wan Castle Escape event should update the active objective: %s" % objective_event_id) func _check_xiapi_siege_event_dialogue_structure(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/009_xiapi_siege.json"): failures.append("could not load Xiapi Siege for event dialogue structure") return for event_id in [ "opening_dialogue", "turn_2_lu_bu_charge", "turn_3_flood_gates", "wan_gate_baggage_support", "swift_wan_counterraid", "turn_6_lu_bu_cornered", "turn_6_siege_line_counter", "turn_9_lu_bu_final_breakout", "gao_shun_trap_line_falls", "chen_gong_strategist_falls", "lu_bu_xiapi_falls", "lu_bu_final_breakout_rider_falls" ]: var event := _event_by_id(state, event_id) if event.is_empty(): failures.append("Xiapi Siege missing event: %s" % event_id) continue if not _event_has_dialogue_action(event): failures.append("Xiapi Siege event should include contextual dialogue: %s" % event_id) var gao_shun_event := _event_by_id(state, "gao_shun_trap_line_falls") if not gao_shun_event.is_empty(): var when: Dictionary = gao_shun_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Gao Shun trap line defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("gao_shun_trap_line"): failures.append("Gao Shun trap line defeat event should watch gao_shun_trap_line") if not _event_has_set_objective_action(gao_shun_event): failures.append("Gao Shun trap line defeat event should clarify the siege objective") var chen_gong_event := _event_by_id(state, "chen_gong_strategist_falls") if not chen_gong_event.is_empty(): var when: Dictionary = chen_gong_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Chen Gong defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("chen_gong_strategist"): failures.append("Chen Gong defeat event should watch chen_gong_strategist") if not _event_has_set_objective_action(chen_gong_event): failures.append("Chen Gong defeat event should clarify the siege objective") var lu_bu_event := _event_by_id(state, "lu_bu_xiapi_falls") if not lu_bu_event.is_empty(): var when: Dictionary = lu_bu_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Lu Bu Xiapi defeat event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("lu_bu_xiapi"): failures.append("Lu Bu Xiapi defeat event should watch lu_bu_xiapi") if not _event_has_set_objective_action(lu_bu_event): failures.append("Lu Bu Xiapi defeat event should clarify the siege objective") var final_breakout_event := _event_by_id(state, "lu_bu_final_breakout_rider_falls") if not final_breakout_event.is_empty(): var when: Dictionary = final_breakout_event.get("when", {}) if str(when.get("type", "")) != "unit_defeated": failures.append("Lu Bu final breakout rider event should trigger when the unit is defeated") var unit_ids: Array = when.get("unit_ids", []) if not unit_ids.has("lu_bu_final_breakout_rider"): failures.append("Lu Bu final breakout rider event should watch lu_bu_final_breakout_rider") if not _event_has_set_objective_action(final_breakout_event): failures.append("Lu Bu final breakout rider event should clarify the final siege objective") for objective_event_id in [ "turn_2_lu_bu_charge", "turn_3_flood_gates", "turn_6_siege_line_counter", "turn_9_lu_bu_final_breakout" ]: var objective_event := _event_by_id(state, objective_event_id) if not objective_event.is_empty() and not _event_has_set_objective_action(objective_event): failures.append("Xiapi Siege event should update the active objective: %s" % objective_event_id) func _check_opening_battle_story_beats(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 story beat checks") return var dialogue_batches := [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 7) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 11) if dialogue_batches.size() < 5: failures.append("opening battle should add dialogue beats for each major reinforcement phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("마지막 황건 잔당"): failures.append("opening battle turn 11 event should update the victory objective after the final rally arrives") if not state._condition_gate_open({"after_event": "turn_11_last_rally"}): failures.append("opening battle final objective gate should open after the turn 11 story beat") func _check_sishui_gate_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/002_sishui_gate.json"): failures.append("could not load Sishui Gate for story beat checks") return var dialogue_batches := [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 5) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 8) if dialogue_batches.size() < 3: failures.append("Sishui Gate should add dialogue beats for each major reserve phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("마지막 예비대"): failures.append("Sishui Gate turn 8 event should update the victory objective after the final reserve arrives") if not state._condition_gate_open({"after_event": "turn_8_gate_reserve"}): failures.append("Sishui Gate final objective gate should open after the turn 8 story beat") func _check_xingyang_ambush_story_beats(failures: Array[String]) -> void: var pursuit_state = BattleStateScript.new() if not pursuit_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}): failures.append("could not load Xingyang Ambush pursuit branch for story beat checks") return var pursuit_dialogue_batches := [] pursuit_state.dialogue_requested.connect(func(lines: Array) -> void: pursuit_dialogue_batches.append(lines) ) pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) pursuit_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if pursuit_dialogue_batches.size() < 4: failures.append("Xingyang Ambush pursuit branch should add dialogue beats for each major ambush phase") var pursuit_victory_text := str(pursuit_state.objectives.get("victory", "")) if not pursuit_victory_text.contains("마지막 봉쇄병"): failures.append("Xingyang Ambush turn 9 event should update the victory objective after the blockade arrives") if not pursuit_state._condition_gate_open({"after_event": "turn_9_forest_blockade"}): failures.append("Xingyang Ambush final objective gate should open after the turn 9 blockade") var xu_rong := pursuit_state.get_unit("xu_rong_rearguard") if xu_rong.is_empty(): failures.append("Xingyang Ambush should include Xu Rong rearguard unit for defeat story beat") else: xu_rong["alive"] = false pursuit_state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, xu_rong) if pursuit_dialogue_batches.size() < 5: failures.append("Xingyang Ambush should show dialogue when Xu Rong rearguard falls") var rearguard_objective_text := str(pursuit_state.objectives.get("victory", "")) if not rearguard_objective_text.contains("동쪽 길목") or not rearguard_objective_text.contains("봉쇄병"): failures.append("Xingyang Ambush Xu Rong defeat objective should still point to the road and blockade") var regroup_state = BattleStateScript.new() if not regroup_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"regrouped_after_sishui": true}): failures.append("could not load Xingyang Ambush regroup branch for story beat checks") return var regroup_dialogue_batches := [] regroup_state.dialogue_requested.connect(func(lines: Array) -> void: regroup_dialogue_batches.append(lines) ) regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) regroup_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if regroup_dialogue_batches.size() < 4: failures.append("Xingyang Ambush regroup branch should keep dialogue beats for scouted ambush pressure") func _check_qingzhou_campaign_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/004_qingzhou_campaign.json"): failures.append("could not load Qingzhou Campaign for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 4: failures.append("Qingzhou Campaign should add dialogue beats for each major village pressure phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("마지막 잔당") or not victory_text.contains("촌락 길목"): failures.append("Qingzhou Campaign turn 9 event should update the victory objective for the village last stand") if not state._condition_gate_open({"after_event": "turn_9_village_last_stand"}): failures.append("Qingzhou Campaign final objective gate should open after the turn 9 last stand") var qingzhou_chief: Dictionary = state.get_unit("qingzhou_chief") if qingzhou_chief.is_empty(): failures.append("Qingzhou Campaign should include the Qingzhou chief for the defeat story beat") else: qingzhou_chief["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, qingzhou_chief) if dialogue_batches.size() < 5: failures.append("Qingzhou Campaign should show dialogue when the Qingzhou chief falls") var chief_objective_text := str(state.objectives.get("victory", "")) if not chief_objective_text.contains("촌락 길목") or not chief_objective_text.contains("잔당"): failures.append("Qingzhou chief defeat objective should still point to the village road and remaining enemies") func _check_puyang_raid_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/005_puyang_raid.json"): failures.append("could not load Puyang Raid for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 5: failures.append("Puyang Raid should add dialogue beats for each major night raid pressure phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("포위망") or not victory_text.contains("군영"): failures.append("Puyang Raid turn 9 event should update the victory objective for the encirclement") if not state._condition_gate_open({"after_event": "turn_9_lu_bu_encirclement"}): failures.append("Puyang Raid final objective gate should open after the turn 9 encirclement") var zhang_liao: Dictionary = state.get_unit("zhang_liao_vanguard") if zhang_liao.is_empty(): failures.append("Puyang Raid should include Zhang Liao vanguard for the defeat story beat") else: zhang_liao["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, zhang_liao) if dialogue_batches.size() < 6: failures.append("Puyang Raid should show dialogue when Zhang Liao vanguard falls") var vanguard_objective_text := str(state.objectives.get("victory", "")) if not vanguard_objective_text.contains("군영") or not vanguard_objective_text.contains("포위망"): failures.append("Zhang Liao vanguard defeat objective should still point to camp control and the encirclement") var lu_bu_banner: Dictionary = state.get_unit("lu_bu_banner") if lu_bu_banner.is_empty(): failures.append("Puyang Raid should include Lu Bu banner for the final pressure story beat") else: lu_bu_banner["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, lu_bu_banner) if dialogue_batches.size() < 7: failures.append("Puyang Raid should show dialogue when Lu Bu banner falls") var banner_objective_text := str(state.objectives.get("victory", "")) if not banner_objective_text.contains("군영") or not banner_objective_text.contains("포위망"): failures.append("Lu Bu banner defeat objective should still point to camp control and the encirclement") func _check_dingtao_counterattack_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/006_dingtao_counterattack.json"): failures.append("could not load Dingtao Counterattack for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 4: failures.append("Dingtao Counterattack should add dialogue beats for each major countercharge phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("두 번째 돌격") or not victory_text.contains("중앙 방진"): failures.append("Dingtao Counterattack turn 9 event should update the victory objective for the second charge") if not state._condition_gate_open({"after_event": "turn_9_lu_bu_second_charge"}): failures.append("Dingtao Counterattack final objective gate should open after the turn 9 second charge") var gao_shun: Dictionary = state.get_unit("gao_shun_line") if gao_shun.is_empty(): failures.append("Dingtao Counterattack should include Gao Shun line for the center story beat") else: gao_shun["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, gao_shun) if dialogue_batches.size() < 5: failures.append("Dingtao Counterattack should show dialogue when Gao Shun line falls") var gao_shun_objective_text := str(state.objectives.get("victory", "")) if not gao_shun_objective_text.contains("중앙 방진") or not gao_shun_objective_text.contains("재돌격"): failures.append("Gao Shun line defeat objective should still point to the center and second charge") var lu_bu_counterattack: Dictionary = state.get_unit("lu_bu_counterattack") if lu_bu_counterattack.is_empty(): failures.append("Dingtao Counterattack should include Lu Bu counterattack for the major pressure story beat") else: lu_bu_counterattack["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, lu_bu_counterattack) if dialogue_batches.size() < 6: failures.append("Dingtao Counterattack should show dialogue when Lu Bu counterattack falls") var lu_bu_objective_text := str(state.objectives.get("victory", "")) if not lu_bu_objective_text.contains("중앙 방진") or not lu_bu_objective_text.contains("재돌격"): failures.append("Lu Bu counterattack defeat objective should still point to the center and second charge") var second_charge_rider: Dictionary = state.get_unit("lu_bu_second_rider") if second_charge_rider.is_empty(): failures.append("Dingtao Counterattack should include Lu Bu second charge rider for the final charge story beat") else: second_charge_rider["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, second_charge_rider) if dialogue_batches.size() < 7: failures.append("Dingtao Counterattack should show dialogue when Lu Bu second charge breaks") var second_charge_objective_text := str(state.objectives.get("victory", "")) if not second_charge_objective_text.contains("중앙 방진") or not second_charge_objective_text.contains("남은"): failures.append("Lu Bu second charge break objective should still point to the center and remaining charge") func _check_xian_emperor_escort_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/007_xian_emperor_escort.json"): failures.append("could not load Xian Emperor Escort for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3) var cao_cao: Dictionary = state.get_unit("cao_cao_ch7") cao_cao["pos"] = Vector2i(7, 4) state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 3, cao_cao) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 5: failures.append("Xian Emperor Escort should add dialogue beats for each major escort pressure phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("동도 호송로") or not victory_text.contains("마지막 잔당"): failures.append("Xian Emperor Escort turn 9 event should update the victory objective for final escort pressure") if not state._condition_gate_open({"after_event": "turn_9_final_escort_pressure"}): failures.append("Xian Emperor Escort final objective gate should open after the turn 9 final pressure") if int(state.get_unit("imperial_envoy_ch7").get("ai_target_priority", 0)) != 12: failures.append("Xian Emperor Escort final pressure should keep the imperial envoy under threat") var li_jue: Dictionary = state.get_unit("li_jue_vanguard") if li_jue.is_empty(): failures.append("Xian Emperor Escort should include Li Jue vanguard for the road-opening story beat") else: li_jue["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, li_jue) if dialogue_batches.size() < 6: failures.append("Xian Emperor Escort should show dialogue when Li Jue vanguard falls") var li_jue_objective_text := str(state.objectives.get("victory", "")) if not li_jue_objective_text.contains("천자 사자") or not li_jue_objective_text.contains("동도 호송로"): failures.append("Li Jue vanguard defeat objective should still point to the envoy and east road") var final_rider: Dictionary = state.get_unit("final_escort_rider") if final_rider.is_empty(): failures.append("Xian Emperor Escort should include final escort rider for the last pressure story beat") else: final_rider["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, final_rider) if dialogue_batches.size() < 7: failures.append("Xian Emperor Escort should show dialogue when final escort rider falls") var final_objective_text := str(state.objectives.get("victory", "")) if not final_objective_text.contains("동도 호송로") or not final_objective_text.contains("잔당"): failures.append("Final escort rider defeat objective should still point to the east road and remaining enemies") func _check_wan_castle_escape_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/008_wan_castle_escape.json"): failures.append("could not load Wan Castle Escape for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) var cao_cao: Dictionary = state.get_unit("cao_cao_ch8") cao_cao["pos"] = Vector2i(8, 4) state._run_events("unit_reaches_tile", BattleStateScript.TEAM_PLAYER, 2, cao_cao) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 5: failures.append("Wan Castle Escape should add dialogue beats for each major escape pressure phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("서쪽 탈출로") or not victory_text.contains("마지막 추격대"): failures.append("Wan Castle Escape turn 9 event should update the victory objective for the west gate hunt") if not state._condition_gate_open({"after_event": "turn_9_west_gate_hunt"}): failures.append("Wan Castle Escape final objective gate should open after the turn 9 west gate hunt") if int(state.get_unit("cao_ang_ch8").get("ai_target_priority", 0)) != 14: failures.append("Wan Castle Escape final pressure should keep Cao Ang under threat") var zhang_xiu: Dictionary = state.get_unit("zhang_xiu_vanguard") if zhang_xiu.is_empty(): failures.append("Wan Castle Escape should include Zhang Xiu vanguard for the trap story beat") else: zhang_xiu["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, zhang_xiu) if dialogue_batches.size() < 6: failures.append("Wan Castle Escape should show dialogue when Zhang Xiu vanguard falls") var zhang_xiu_objective_text := str(state.objectives.get("victory", "")) if not zhang_xiu_objective_text.contains("조앙") or not zhang_xiu_objective_text.contains("서쪽 탈출로"): failures.append("Zhang Xiu vanguard defeat objective should still point to Cao Ang and the escape road") var jia_xu: Dictionary = state.get_unit("jia_xu_strategist") if jia_xu.is_empty(): failures.append("Wan Castle Escape should include Jia Xu strategist for the ambush story beat") else: jia_xu["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, jia_xu) if dialogue_batches.size() < 7: failures.append("Wan Castle Escape should show dialogue when Jia Xu strategist falls") var jia_xu_objective_text := str(state.objectives.get("victory", "")) if not jia_xu_objective_text.contains("조앙") or not jia_xu_objective_text.contains("서쪽 탈출로"): failures.append("Jia Xu strategist defeat objective should still point to Cao Ang and the escape road") var west_gate_rider: Dictionary = state.get_unit("west_gate_hunt_rider") if west_gate_rider.is_empty(): failures.append("Wan Castle Escape should include west gate hunt rider for the final escape story beat") else: west_gate_rider["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, west_gate_rider) if dialogue_batches.size() < 8: failures.append("Wan Castle Escape should show dialogue when west gate hunt rider falls") var west_gate_objective_text := str(state.objectives.get("victory", "")) if not west_gate_objective_text.contains("서쪽 탈출로") or not west_gate_objective_text.contains("완성 추격대"): failures.append("West gate hunt rider defeat objective should still point to the escape road and remaining pursuers") func _check_xiapi_siege_story_beats(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/009_xiapi_siege.json"): failures.append("could not load Xiapi Siege for story beat checks") return var dialogue_batches: Array = [] state.dialogue_requested.connect(func(lines: Array) -> void: dialogue_batches.append(lines) ) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 3) state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6) state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9) if dialogue_batches.size() < 5: failures.append("Xiapi Siege should add dialogue beats for each major flood siege phase") var victory_text := str(state.objectives.get("victory", "")) if not victory_text.contains("하비 포위선") or not victory_text.contains("마지막 돌파"): failures.append("Xiapi Siege turn 9 event should update the victory objective for Lu Bu final breakout") if not state._condition_gate_open({"after_event": "turn_3_flood_gates"}): failures.append("Xiapi Siege flood gate story beat should open the flood gate event") if not state._condition_gate_open({"after_event": "turn_9_lu_bu_final_breakout"}): failures.append("Xiapi Siege final objective gate should open after the turn 9 final breakout") if bool(state.get_unit("lu_bu_flank_rider_north").get("deployed", true)): failures.append("Xiapi Siege flood gate story beat should withdraw the north flank rider") var gao_shun: Dictionary = state.get_unit("gao_shun_trap_line") if gao_shun.is_empty(): failures.append("Xiapi Siege should include Gao Shun trap line for the siege line collapse story beat") else: gao_shun["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, gao_shun) if dialogue_batches.size() < 6: failures.append("Xiapi Siege should show dialogue when Gao Shun trap line falls") var gao_shun_objective_text := str(state.objectives.get("victory", "")) if not gao_shun_objective_text.contains("하비 포위선") or not gao_shun_objective_text.contains("진궁"): failures.append("Gao Shun trap line defeat objective should still point to the siege line and Chen Gong") var chen_gong: Dictionary = state.get_unit("chen_gong_strategist") if chen_gong.is_empty(): failures.append("Xiapi Siege should include Chen Gong for the scheme collapse story beat") else: chen_gong["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, chen_gong) if dialogue_batches.size() < 7: failures.append("Xiapi Siege should show dialogue when Chen Gong falls") var chen_gong_objective_text := str(state.objectives.get("victory", "")) if not chen_gong_objective_text.contains("하비 포위선") or not chen_gong_objective_text.contains("최종 돌파"): failures.append("Chen Gong defeat objective should still point to the siege line and final breakout") var lu_bu: Dictionary = state.get_unit("lu_bu_xiapi") if lu_bu.is_empty(): failures.append("Xiapi Siege should include Lu Bu for the chapter climax story beat") else: lu_bu["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, lu_bu) if dialogue_batches.size() < 8: failures.append("Xiapi Siege should show dialogue when Lu Bu falls") var lu_bu_objective_text := str(state.objectives.get("victory", "")) if not lu_bu_objective_text.contains("하비 포위선") or not lu_bu_objective_text.contains("남은"): failures.append("Lu Bu defeat objective should still point to the siege line and remaining defenders") var final_breakout: Dictionary = state.get_unit("lu_bu_final_breakout_rider") if final_breakout.is_empty(): failures.append("Xiapi Siege should include Lu Bu final breakout rider for the final pressure story beat") else: final_breakout["alive"] = false state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, final_breakout) if dialogue_batches.size() < 9: failures.append("Xiapi Siege should show dialogue when Lu Bu final breakout rider falls") var final_objective_text := str(state.objectives.get("victory", "")) if not final_objective_text.contains("하비 포위선") or not final_objective_text.contains("남은"): failures.append("Lu Bu final breakout rider objective should still point to the siege line and remaining enemies") func _event_by_id(state, event_id: String) -> Dictionary: for event in state.battle_events: if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id: return event return {} func _event_has_dialogue_action(event: Dictionary) -> bool: var actions: Array = event.get("actions", []) for action in actions: if typeof(action) == TYPE_DICTIONARY and str(action.get("type", "")) == "dialogue": return true return false func _event_dialogue_text(event: Dictionary) -> String: var parts: Array[String] = [] var actions: Array = event.get("actions", []) for action in actions: if typeof(action) != TYPE_DICTIONARY or str(action.get("type", "")) != "dialogue": continue var lines: Array = (action as Dictionary).get("lines", []) for line in lines: if typeof(line) == TYPE_DICTIONARY: parts.append(str((line as Dictionary).get("text", ""))) elif typeof(line) == TYPE_STRING: parts.append(str(line)) return "\n".join(parts) func _camp_conversation_by_id(conversations: Array, conversation_id: String) -> Dictionary: for conversation in conversations: if typeof(conversation) == TYPE_DICTIONARY and str(conversation.get("id", "")) == conversation_id: return conversation return {} func _camp_conversation_text(conversation: Dictionary) -> String: var parts: Array[String] = [] var lines: Array = conversation.get("lines", []) for line in lines: if typeof(line) != TYPE_DICTIONARY: continue parts.append(str((line as Dictionary).get("display_speaker", (line as Dictionary).get("speaker", "")))) parts.append(str((line as Dictionary).get("text", ""))) return "\n".join(parts) func _dialogue_lines_text(lines: Array) -> String: var parts: Array[String] = [] for line in lines: if typeof(line) != TYPE_DICTIONARY: continue parts.append(str((line as Dictionary).get("display_speaker", (line as Dictionary).get("speaker", "")))) parts.append(str((line as Dictionary).get("text", ""))) return "\n".join(parts) func _event_has_set_objective_action(event: Dictionary) -> bool: var actions: Array = event.get("actions", []) for action in actions: if typeof(action) == TYPE_DICTIONARY and str(action.get("type", "")) == "set_objective": return true return false func _check_pixel_unit_helpers(failures: Array[String]) -> void: var scene = BattleSceneScript.new() for method_name in [ "_draw_pixel_unit_sprite", "_pixel_unit_sprite_profile", "_unit_idle_phase", "_unit_idle_bob", "_unit_idle_step", "_unit_idle_intensity", "_should_animate_unit_idle", "_draw_pixel_shield", "_draw_pixel_spear", "_draw_pixel_bow", "_draw_pixel_banner", "_draw_pixel_staff", "_draw_pixel_heavy_blade", "_draw_pixel_infantry", "_draw_pixel_archer", "_draw_pixel_cavalry", "_draw_pixel_strategist", "_draw_pixel_heavy", "_draw_unit_class_emblem", "_draw_unit_identity_marks" ]: if not scene.has_method(method_name): failures.append("missing pixel-map unit helper: %s" % method_name) _check_pixel_unit_profiles(scene, failures) _check_pixel_unit_idle_state(scene, failures) scene.free() func _check_pixel_unit_profiles(scene, failures: Array[String]) -> void: var profiles := { "infantry": scene._pixel_unit_sprite_profile({"class_id": "infantry"}), "ranged": scene._pixel_unit_sprite_profile({"class_id": "archer"}), "cavalry": scene._pixel_unit_sprite_profile({"class_id": "cavalry"}), "tactic": scene._pixel_unit_sprite_profile({"class_id": "strategist"}), "heavy": scene._pixel_unit_sprite_profile({"class_id": "bandit"}) } for family in profiles.keys(): var profile: Dictionary = profiles[family] if str(profile.get("family", "")) != str(family): failures.append("pixel unit profile should map %s class to %s family: %s" % [family, family, str(profile)]) if absf(float(profile.get("pixel", 0.0)) - 3.0) > 0.001: failures.append("pixel unit profile should use a stable 3px grid for %s" % family) if float(profile.get("min_x", -1.0)) < 0.0 or float(profile.get("max_x", 99.0)) > 16.0: failures.append("pixel unit profile should stay inside tile width for %s: %s" % [family, str(profile)]) var bottom_px := 5.0 + 1.0 + float(profile.get("max_y", 99.0)) * float(profile.get("pixel", 3.0)) if bottom_px >= 56.0: failures.append("pixel unit profile should stay above HP bar for %s: %.1f" % [family, bottom_px]) if float((profiles["cavalry"] as Dictionary).get("max_x", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_x", 0.0)): failures.append("cavalry pixel profile should read wider than infantry") if float((profiles["heavy"] as Dictionary).get("max_x", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_x", 0.0)): failures.append("heavy pixel profile should read wider than infantry") if float((profiles["tactic"] as Dictionary).get("max_y", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_y", 0.0)): failures.append("tactic pixel profile should read taller or more robed than infantry") var seen_weapons := {} var seen_idles := {} for family in profiles.keys(): var profile: Dictionary = profiles[family] var weapon := str(profile.get("weapon", "")) var idle := str(profile.get("idle", "")) if weapon.is_empty() or seen_weapons.has(weapon): failures.append("pixel unit profile should expose a distinct weapon silhouette for %s" % family) seen_weapons[weapon] = true if idle.is_empty() or seen_idles.has(idle): failures.append("pixel unit profile should expose a distinct idle profile for %s" % family) seen_idles[idle] = true if str((profiles["ranged"] as Dictionary).get("weapon", "")) != "bow": failures.append("ranged pixel profile should keep an explicit bow silhouette") if str((profiles["infantry"] as Dictionary).get("weapon", "")) != "shield_spear": failures.append("infantry pixel profile should keep shield and spear silhouette") func _check_pixel_unit_idle_state(scene, failures: Array[String]) -> void: if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle for pixel idle state helpers") return var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var base_phase: float = scene._unit_idle_phase(cao_cao) scene._process(0.35) if absf(scene._unit_idle_phase(cao_cao) - base_phase) > 0.001: failures.append("pixel idle phase should not advance before battle starts") scene.battle_started = true scene.state.battle_status = BattleStateScript.STATUS_ACTIVE scene._process(0.35) if scene.unit_idle_time < 0.34: failures.append("pixel idle clock should advance during active battle: %.3f" % scene.unit_idle_time) var active_phase: float = scene._unit_idle_phase(cao_cao) if active_phase <= base_phase: failures.append("pixel idle phase should advance from scene idle clock") var expected_phase_delta := 0.35 * BattleSceneScript.UNIT_IDLE_PHASE_SPEED if absf((active_phase - base_phase) - expected_phase_delta) > 0.01: failures.append("pixel idle phase should advance by the scene clock speed: %.3f vs %.3f" % [active_phase - base_phase, expected_phase_delta]) if not scene._should_animate_unit_idle(): failures.append("pixel idle animation should be active during player battle control") var base_intensity: float = scene._unit_idle_intensity(cao_cao) scene.state.selected_unit_id = "cao_cao" var selected_intensity: float = scene._unit_idle_intensity(cao_cao) if selected_intensity <= base_intensity: failures.append("selected active unit should idle with a livelier stance") cao_cao["acted"] = true var acted_intensity: float = scene._unit_idle_intensity(cao_cao) if acted_intensity >= base_intensity: failures.append("acted unit idle should settle below active stance") cao_cao["acted"] = false cao_cao["hp"] = maxi(1, int(cao_cao.get("max_hp", 1)) / 4) var wounded_intensity: float = scene._unit_idle_intensity(cao_cao) if wounded_intensity >= base_intensity: failures.append("low-HP unit idle should be subdued") if scene._unit_idle_step("infantry", 0.25, acted_intensity) != 0.0: failures.append("settled acted unit should not keep marching feet") scene.dialogue_panel = PanelContainer.new() scene.dialogue_panel.visible = true if scene._should_animate_unit_idle(): failures.append("pixel idle animation should pause behind dialogue") var paused_idle_time: float = scene.unit_idle_time scene._process(0.35) if absf(scene.unit_idle_time - paused_idle_time) > 0.001: failures.append("pixel idle clock should not advance while dialogue is visible") scene.dialogue_panel.free() scene.dialogue_panel = null func _collect_tooltips(root: Node) -> String: var parts: Array[String] = [] _collect_tooltips_into(root, parts) return "\n".join(parts) func _find_command_notice_chip_by_key(root: Node, key: String) -> Control: if root == null: return null if root is Control and str(root.get_meta("notice_icon_key", "")) == key: return root as Control for child in root.get_children(): var found := _find_command_notice_chip_by_key(child, key) if found != null: return found return null func _find_briefing_marker_chip_by_label(root: Node, label: String) -> Control: if root == null: return null if root is Control and str(root.get_meta("briefing_marker_label", "")) == label: return root as Control for child in root.get_children(): var found := _find_briefing_marker_chip_by_label(child, label) if found != null: return found return null func _collect_tooltips_into(root: Node, parts: Array[String]) -> void: if root == null: return if root is Control: var tooltip := str((root as Control).tooltip_text).strip_edges() if not tooltip.is_empty(): parts.append(tooltip) for child in root.get_children(): _collect_tooltips_into(child, parts) func _count_descendants_by_name(root: Node, node_name: String) -> int: if root == null: return 0 var count := 0 for child in root.get_children(): if child.name == node_name: count += 1 count += _count_descendants_by_name(child, node_name) return count