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_readability_contract(failures) _check_dialogue_localization_and_side(failures) _check_event_dialogue_side_passthrough(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_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_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/009_xiapi_siege.json"): failures.append("could not load large 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 and board_size.y <= view_rect.size.y: failures.append("chapter one should include at least one map large enough to exercise edge scrolling") 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 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(0, 0): failures.append("screen-to-cell mapping should stay stable after scrolling") _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_next_offset" ]: 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 outside_velocity: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(4.0, 4.0), view_rect) if outside_velocity != Vector2.ZERO: failures.append("edge scroll should ignore mouse positions outside the map view: %s" % str(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_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 != "닫기": failures.append("briefing objective button should start with a clear close label") _check_text_fit_contract(scene, failures) 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" ]: if not scene.has_method(method_name): failures.append("missing text fit helper: %s" % method_name) var dialogue_area := Vector2(666.0, 144.0) var short_dialogue_size := int(scene._fit_font_size_for_text("군령을 전하라.", 17, 14, dialogue_area)) if short_dialogue_size != 17: 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, 17, 14, dialogue_area)) if fitted_dialogue_size >= 17: failures.append("long dialogue text should reduce font size to avoid clipping") if fitted_dialogue_size < 14: failures.append("long dialogue text should not shrink below its readable minimum") 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(500.0, 104.0) var long_objective := "승리: 서영의 후군을 꺾었다. 동쪽 길목을 장악하고 뒤따르는 봉쇄병까지 정리하라.\n패배: 조조가 퇴각하거나 제17턴이 시작되면 패한다.\n보상: 군자금 700냥, 콩, 술, 철검" var fitted_objective_size := int(scene._fit_font_size_for_text(long_objective, 15, 12, objective_area)) if fitted_objective_size < 12 or fitted_objective_size > 15: 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") 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_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", "turn_2_warning", "turn_4_eastern_reserve", "turn_7_southern_raiders", "turn_9_western_reserve", "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 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") 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_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) if dialogue_batches.size() < 4: 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 9 event should update the victory objective after the final reserve arrives") if not state._condition_gate_open({"after_event": "turn_9_western_reserve"}): failures.append("opening battle final objective gate should open after the turn 9 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 _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_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_bob", "_unit_idle_step", "_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" ]: if not scene.has_method(method_name): failures.append("missing pixel-map unit helper: %s" % method_name) _check_pixel_unit_profiles(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")