1477 lines
77 KiB
GDScript
1477 lines
77 KiB
GDScript
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_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_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/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 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"
|
|
]:
|
|
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 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/009_xiapi_siege.json"):
|
|
failures.append("could not load large 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"
|
|
]:
|
|
if not scene.has_method(method_name):
|
|
failures.append("missing minimap helper: %s" % method_name)
|
|
|
|
var view_rect := scene._map_view_rect()
|
|
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 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.free()
|
|
|
|
|
|
func _check_map_focus_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 map focus")
|
|
scene.free()
|
|
return
|
|
scene.battle_started = true
|
|
for method_name in [
|
|
"_board_scroll_offset_for_cell_focus",
|
|
"_focus_board_on_cell"
|
|
]:
|
|
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)])
|
|
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_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 = 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("군령을 전하라.", 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 < 14 or fitted_dialogue_size > 17:
|
|
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(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",
|
|
"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 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_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",
|
|
"_draw_unit_class_emblem",
|
|
"_draw_unit_class_badge",
|
|
"_draw_unit_identity_marks",
|
|
"_unit_class_abbrev"
|
|
]:
|
|
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_class_labels(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_class_labels(scene, failures: Array[String]) -> void:
|
|
var expected_labels := {
|
|
"infantry": "보",
|
|
"archer": "궁",
|
|
"cavalry": "기",
|
|
"strategist": "책",
|
|
"bandit": "무"
|
|
}
|
|
for class_id in expected_labels.keys():
|
|
var label := str(scene._unit_class_abbrev({"class_id": class_id}))
|
|
if label != str(expected_labels[class_id]):
|
|
failures.append("pixel unit class badge should use Hangul label for %s: %s" % [class_id, label])
|