Files
heros/tools/smoke_post_move_action_flow.gd

1182 lines
57 KiB
GDScript

extends SceneTree
const BattleStateScript := preload("res://scripts/core/battle_state.gd")
const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd")
func _init() -> void:
var failures: Array[String] = []
_check_deferred_move_events(failures)
_check_auto_end_turn_prompt_flow(failures)
_check_scene_post_move_menu_flow(failures)
_check_scene_post_move_edge_positioning(failures)
_check_scene_post_move_scrolled_view_positioning(failures)
_check_scene_post_move_menu_tracks_map_scroll(failures)
_check_scene_input_audio_cues(failures)
_check_scene_turn_notice_audio_cues(failures)
_check_scene_post_move_text_fit(failures)
_check_scene_post_move_blocked_command_labels(failures)
_check_scene_post_move_tactic_picker_flow(failures)
_check_scene_post_move_item_picker_flow(failures)
_check_scene_auto_attack_safe_origin_and_counter_preview(failures)
_check_scene_enemy_click_auto_attack(failures)
if failures.is_empty():
print("post move action flow smoke ok")
quit(0)
return
for failure in failures:
push_error(failure)
quit(1)
func _check_deferred_move_events(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for pending move state")
return
var from_cell := Vector2i(1, 3)
var to_cell := Vector2i(2, 3)
state.get_unit("cao_cao")["pos"] = from_cell
state.battle_events.append({
"id": "pending_move_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [to_cell.x, to_cell.y]
},
"actions": [{"type": "grant_gold", "amount": 77}]
})
if not state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for pending move state")
return
if not state.try_move_selected(to_cell, true):
failures.append("tentative move should succeed")
return
var cao_cao: Dictionary = state.get_unit("cao_cao")
if cao_cao.get("pos", Vector2i.ZERO) != to_cell:
failures.append("tentative move should update position")
if not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("tentative move should mark moved but not acted")
if state.get_battle_gold_reward() != 0:
failures.append("tentative move should defer unit_reaches_tile rewards")
if not state.cancel_pending_move("cao_cao", from_cell, to_cell):
failures.append("pending move cancel should succeed")
cao_cao = state.get_unit("cao_cao")
if cao_cao.get("pos", Vector2i.ZERO) != from_cell or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("pending move cancel should restore original action state")
if state.get_battle_gold_reward() != 0:
failures.append("canceled pending move should not fire movement event")
if not state.try_move_selected(to_cell, true):
failures.append("second tentative move should succeed")
if not state.commit_pending_move_events("cao_cao"):
failures.append("pending move commit should keep battle active")
if state.get_battle_gold_reward() != 77:
failures.append("pending move commit should fire deferred movement event")
func _check_auto_end_turn_prompt_flow(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
scene._create_audio()
scene.title_sfx_enabled = true
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for auto end turn prompt")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
scene.state.selected_unit_id = ""
if scene.auto_end_turn_prompt_panel == null:
failures.append("auto end turn prompt should be created with the HUD")
scene.free()
return
if scene.auto_end_turn_yes_button == null or scene.auto_end_turn_yes_button.text != "차례 넘김":
failures.append("auto end turn confirm button should use contextual Korean text")
elif scene.auto_end_turn_yes_button.icon == null or str(scene.auto_end_turn_yes_button.get_meta("command_icon", "")) != "end_turn":
failures.append("auto end turn confirm button should expose a generated end-turn icon")
if scene.auto_end_turn_no_button == null or scene.auto_end_turn_no_button.text != "머무름":
failures.append("auto end turn decline button should use contextual Korean text")
elif scene.auto_end_turn_no_button.icon == null or str(scene.auto_end_turn_no_button.get_meta("command_icon", "")) != "cancel":
failures.append("auto end turn decline button should expose a generated cancel icon")
for unit in scene.state.units:
if str(unit.get("team", "")) == BattleStateScript.TEAM_PLAYER and bool(unit.get("controllable", true)):
unit["acted"] = false
scene._update_hud()
if scene.auto_end_turn_prompt_panel.visible:
failures.append("auto end turn prompt should stay hidden while a controllable ally can still act")
for unit in scene.state.units:
if str(unit.get("team", "")) == BattleStateScript.TEAM_PLAYER and bool(unit.get("alive", true)) and bool(unit.get("deployed", true)) and bool(unit.get("controllable", true)):
unit["acted"] = true
unit["moved"] = true
scene.recent_sfx_keys.clear()
scene._update_hud()
if not scene.auto_end_turn_prompt_panel.visible:
failures.append("auto end turn prompt should appear once every controllable ally has acted")
if not scene._is_input_locked():
failures.append("auto end turn prompt should lock board input while visible")
if not scene.recent_sfx_keys.has("menu_confirm"):
failures.append("auto end turn prompt should play a confirmation cue when it first appears: %s" % str(scene.recent_sfx_keys))
scene.recent_sfx_keys.clear()
scene._update_hud()
if not scene.recent_sfx_keys.is_empty():
failures.append("auto end turn prompt should not repeat audio while already visible: %s" % str(scene.recent_sfx_keys))
if not scene._handle_cancel_input():
failures.append("right-click cancel should dismiss the auto end turn prompt")
if scene.auto_end_turn_prompt_panel.visible:
failures.append("auto end turn prompt should hide after cancel")
scene._update_hud()
if scene.auto_end_turn_prompt_panel.visible:
failures.append("declined auto end turn prompt should not reopen in the same turn")
scene.auto_end_turn_prompt_declined_turn = -1
scene._update_hud()
if not scene.auto_end_turn_prompt_panel.visible:
failures.append("auto end turn prompt should reopen after the declined-turn guard is cleared")
var escape_event := InputEventKey.new()
escape_event.keycode = KEY_ESCAPE
escape_event.pressed = true
scene._unhandled_input(escape_event)
if scene.auto_end_turn_prompt_panel.visible:
failures.append("Escape should decline the auto end turn prompt")
scene.auto_end_turn_prompt_declined_turn = -1
scene._update_hud()
var turn_before: int = scene.state.turn_number
var enter_event := InputEventKey.new()
enter_event.keycode = KEY_ENTER
enter_event.pressed = true
scene._unhandled_input(enter_event)
if scene.auto_end_turn_prompt_panel.visible:
failures.append("Enter should confirm and hide the auto end turn prompt")
if scene.state.turn_number <= turn_before and scene.state.battle_status == BattleStateScript.STATUS_ACTIVE:
failures.append("confirming auto end turn by keyboard should advance through the enemy phase")
scene.free()
func _check_scene_turn_notice_audio_cues(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
scene._create_audio()
scene.title_sfx_enabled = true
scene.recent_sfx_keys.clear()
scene._on_log_added("적군 차례가 열렸습니다.")
if not scene.recent_sfx_keys.has("guard_block"):
failures.append("enemy turn notice should play a warning cue: %s" % str(scene.recent_sfx_keys))
scene.recent_sfx_keys.clear()
scene._on_log_added("아군 차례가 열렸습니다.")
if not scene.recent_sfx_keys.has("menu_confirm"):
failures.append("player turn notice should play a confirmation cue: %s" % str(scene.recent_sfx_keys))
scene.title_sfx_enabled = false
scene.recent_sfx_keys.clear()
scene._on_log_added("아군 차례가 열렸습니다.")
if not scene.recent_sfx_keys.is_empty():
failures.append("disabled SFX should suppress turn notice audio: %s" % str(scene.recent_sfx_keys))
scene.free()
func _check_scene_post_move_menu_flow(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for scene flow")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
scene.state.get_unit("cao_cao")["pos"] = Vector2i(1, 3)
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for scene flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
if not scene._has_pending_move():
failures.append("scene should keep pending move metadata after move click")
if scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("post-move action menu should be visible after moving")
else:
_check_local_panel_position(failures, scene, scene.post_move_menu, Vector2i(2, 3), "post-move action menu")
_check_local_command_button_tooltips_present(failures, [
scene.post_move_attack_button,
scene.post_move_tactic_button,
scene.post_move_item_button,
scene.post_move_wait_button,
scene.post_move_cancel_button
], "post-move action menu")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("scene move should be pending with action still available")
if scene.state.get_selected_unit().is_empty():
failures.append("scene should keep moved unit selected for follow-up action")
if not scene.attack_cells.is_empty():
failures.append("attack cells should wait until Attack is chosen from the post-move menu")
var right_click := InputEventMouseButton.new()
right_click.button_index = MOUSE_BUTTON_RIGHT
right_click.pressed = true
right_click.position = _screen_for_cell(Vector2i(2, 3))
scene._unhandled_input(right_click)
cao_cao = scene.state.get_unit("cao_cao")
if scene._has_pending_move() or (scene.post_move_menu != null and scene.post_move_menu.visible):
failures.append("right-click cancel handler should clear pending move menu state")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(1, 3) or bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("right-click cancel handler should restore the starting cell")
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
scene._on_post_move_wait_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("post-move Wait should commit movement and end the unit action")
if not scene.state.get_selected_unit().is_empty() or scene._has_pending_move():
failures.append("post-move Wait should clear selection and pending metadata")
_check_scene_post_move_attack_targeting(failures)
scene.free()
func _check_scene_post_move_edge_positioning(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for edge menu positioning")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for edge menu positioning")
scene.free()
return
var edge_cell := Vector2i(scene.state.map_size.x - 1, 3)
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = edge_cell
cao_cao["moved"] = true
cao_cao["acted"] = false
scene.pending_move_unit_id = "cao_cao"
scene.pending_move_from_cell = Vector2i(edge_cell.x - 1, edge_cell.y)
scene.pending_move_to_cell = edge_cell
scene._show_post_move_menu()
if scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("edge post-move action menu should be visible")
else:
_check_local_panel_position(failures, scene, scene.post_move_menu, edge_cell, "edge post-move action menu")
scene.free()
func _check_scene_post_move_scrolled_view_positioning(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for scrolled menu positioning")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
scene.board_scroll_offset = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0))
var edge_cell := Vector2i(scene.state.map_size.x - 1, scene.state.map_size.y - 1)
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = edge_cell
cao_cao["moved"] = true
cao_cao["acted"] = false
scene.state.select_unit("cao_cao")
scene.pending_move_unit_id = "cao_cao"
scene.pending_move_from_cell = Vector2i(edge_cell.x - 1, edge_cell.y)
scene.pending_move_to_cell = edge_cell
scene._show_post_move_menu()
if scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("scrolled post-move action menu should be visible")
else:
_check_local_panel_position(failures, scene, scene.post_move_menu, edge_cell, "scrolled post-move action menu")
scene.free()
func _check_scene_post_move_menu_tracks_map_scroll(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for scroll-tracking menu")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
var pending_cell := Vector2i(10, 6)
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = pending_cell
cao_cao["moved"] = true
cao_cao["acted"] = false
scene.state.select_unit("cao_cao")
scene.pending_move_unit_id = "cao_cao"
scene.pending_move_from_cell = Vector2i(9, 6)
scene.pending_move_to_cell = pending_cell
scene._show_post_move_menu()
if scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("scroll-tracking post-move menu should be visible")
scene.free()
return
scene.post_move_menu.position = Vector2(1.0, 1.0)
var map_rect: Rect2 = scene._minimap_map_rect()
if not scene._scroll_board_to_minimap_position(map_rect.end):
failures.append("minimap scroll should move the opening large map")
var expected_position: Vector2 = scene._local_command_panel_position_for_cell(pending_cell, scene.POST_MOVE_MENU_SIZE)
if scene.post_move_menu.position.distance_squared_to(expected_position) > 0.01:
failures.append("post-move menu should track board scroll: %s vs %s" % [str(scene.post_move_menu.position), str(expected_position)])
_check_local_panel_position(failures, scene, scene.post_move_menu, pending_cell, "scroll-tracking post-move action menu")
scene.free()
func _check_scene_input_audio_cues(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
scene._create_audio()
scene.title_sfx_enabled = true
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for input audio cues")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
var cao_cell: Vector2i = cao_cao.get("pos", Vector2i(-1, -1))
scene.recent_sfx_keys.clear()
scene._handle_board_click(_screen_for_cell(cao_cell))
if not scene.recent_sfx_keys.has("ui_click"):
failures.append("selecting a unit from the map should request a UI click sound: %s" % str(scene.recent_sfx_keys))
scene.recent_sfx_keys.clear()
var minimap_press := InputEventMouseButton.new()
minimap_press.button_index = MOUSE_BUTTON_LEFT
minimap_press.pressed = true
minimap_press.position = scene._minimap_map_rect().get_center()
if not scene._handle_minimap_mouse_button(minimap_press):
failures.append("minimap press should be handled for audio cue validation")
if not scene.recent_sfx_keys.has("ui_click"):
failures.append("clicking the minimap should request a UI click sound: %s" % str(scene.recent_sfx_keys))
scene.title_sfx_enabled = false
scene.state.clear_selection()
scene.recent_sfx_keys.clear()
scene._handle_board_click(_screen_for_cell(cao_cell))
if not scene.recent_sfx_keys.is_empty():
failures.append("disabled SFX should suppress input audio requests: %s" % str(scene.recent_sfx_keys))
scene.free()
func _check_scene_post_move_text_fit(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for post-move text fit")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for post-move text fit")
scene.free()
return
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["name"] = "매우 긴 이름의 임시 선봉장"
cao_cao["pos"] = Vector2i(2, 3)
cao_cao["moved"] = true
cao_cao["acted"] = false
scene.pending_move_unit_id = "cao_cao"
scene.pending_move_from_cell = Vector2i(1, 3)
scene.pending_move_to_cell = Vector2i(2, 3)
scene._show_post_move_menu()
if scene.post_move_title_label == null:
failures.append("post-move menu should expose a fitted title label")
else:
_check_fitted_label_font(failures, scene.post_move_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "post-move long unit title")
scene._show_post_move_picker("tactic")
if scene.post_move_picker_title_label == null:
failures.append("post-move picker should expose a fitted title label")
else:
_check_fitted_label_font(failures, scene.post_move_picker_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "post-move picker long unit title")
if scene.post_move_picker_detail_label == null:
failures.append("post-move picker should expose a fitted detail label")
else:
_check_fitted_label_font(failures, scene.post_move_picker_detail_label, BattleSceneScript.LOCAL_COMMAND_DETAIL_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE, "post-move picker detail")
_check_local_command_button_tooltips_present(failures, [
scene.post_move_picker_back_button,
scene.post_move_picker_cancel_move_button,
scene.targeting_hint_back_button,
scene.targeting_hint_cancel_move_button
], "local command utility buttons")
scene.selected_item_id = "very_long_supply_order_marker_name"
scene.selected_skill_id = ""
scene.basic_attack_targeting = false
scene._update_targeting_hint_panel()
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible:
failures.append("targeting guidance should stay on the board instead of opening a hint panel")
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.mouse_filter != Control.MOUSE_FILTER_IGNORE:
failures.append("retired targeting hint panel should never block board clicks")
if scene.targeting_hint_title_label == null:
failures.append("targeting hint should expose a fitted title label")
else:
_check_fitted_label_font(failures, scene.targeting_hint_title_label, BattleSceneScript.LOCAL_COMMAND_TITLE_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, "targeting long item title")
if scene.targeting_hint_detail_label == null:
failures.append("targeting hint should expose a fitted detail label")
else:
_check_fitted_label_font(failures, scene.targeting_hint_detail_label, BattleSceneScript.LOCAL_COMMAND_DETAIL_FONT_SIZE, BattleSceneScript.LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE, "targeting detail")
scene.free()
func _check_scene_post_move_blocked_command_labels(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for blocked command labels")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
scene.state.set_inventory_snapshot({})
var far_cell := Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2)
for unit in scene.state.units:
if unit.get("team", "") == BattleStateScript.TEAM_ENEMY:
unit["pos"] = far_cell
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["mp"] = 0
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for blocked command labels")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("blocked command label setup should show the post-move menu")
else:
if scene.post_move_attack_button == null or not scene.post_move_attack_button.disabled or scene.post_move_attack_button.text != "" or str(scene.post_move_attack_button.get_meta("disabled_hint", "")) != "타격 없음":
failures.append("blocked attack command should explain itself through local hover metadata")
if scene.post_move_tactic_button == null or not scene.post_move_tactic_button.disabled or scene.post_move_tactic_button.text != "" or str(scene.post_move_tactic_button.get_meta("disabled_hint", "")) != "책략 없음":
failures.append("blocked tactic command should explain itself through local hover metadata")
if scene.post_move_item_button == null or not scene.post_move_item_button.disabled or scene.post_move_item_button.text != "" or str(scene.post_move_item_button.get_meta("disabled_hint", "")) != "보급 없음":
failures.append("blocked item command should explain itself through local hover metadata")
_check_local_command_button_tooltips_present(failures, [
scene.post_move_attack_button,
scene.post_move_tactic_button,
scene.post_move_item_button
], "blocked post-move commands")
scene._on_local_command_button_mouse_entered(scene.post_move_attack_button)
if scene.post_move_title_label == null or not scene.post_move_title_label.text.contains("타격 없음"):
failures.append("blocked attack hover should surface disabled reason in the local title plate: %s" % scene.post_move_title_label.text)
scene._on_local_command_button_mouse_exited()
scene.free()
func _check_scene_post_move_attack_targeting(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for attack targeting flow")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
enemy["pos"] = Vector2i(3, 3)
enemy["hp"] = 24
enemy["def"] = 1
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["agi"] = 999
cao_cao["atk"] = 60
scene.state.rng.seed = 1
scene.state.battle_events.append({
"id": "pending_attack_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [2, 3]
},
"actions": [{"type": "grant_gold", "amount": 33}]
})
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for attack targeting flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("attack targeting setup should leave the post-move menu visible")
if scene.post_move_attack_button == null or scene.post_move_attack_button.disabled or scene.post_move_attack_button.text != "" or str(scene.post_move_attack_button.get_meta("command_label", "")) != "타격령":
failures.append("available attack command should stay icon-first and enabled")
scene._on_local_command_button_mouse_entered(scene.post_move_attack_button)
if scene.post_move_title_label == null or not scene.post_move_title_label.text.contains("타격령"):
failures.append("available attack hover should explain the icon in the local title plate")
scene._on_local_command_button_mouse_exited()
if scene.basic_attack_targeting:
failures.append("attack targeting should be off before pressing Attack")
if not scene.attack_cells.is_empty():
failures.append("attack range should stay hidden before pressing Attack")
if scene.state.get_battle_gold_reward() != 0:
failures.append("attack setup should not commit pending move events early")
scene._on_post_move_attack_pressed()
if not scene.basic_attack_targeting:
failures.append("Attack should enter target selection mode")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move menu should hide while selecting an attack target")
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible:
failures.append("attack targeting should not open a separate hint panel")
if not scene.attack_cells.has(Vector2i(3, 3)):
failures.append("attack targeting should highlight the adjacent enemy cell")
var markers := scene._attack_target_marker_entries()
if markers.size() != 1 or markers[0].get("cell", Vector2i.ZERO) != Vector2i(3, 3):
failures.append("attack target markers should include only the reachable enemy: %s" % str(markers))
elif str(markers[0].get("icon", "")).strip_edges().is_empty():
failures.append("attack target markers should carry generated icon hints: %s" % str(markers))
elif str(markers[0].get("panel", "")) != "hud_jade":
failures.append("attack target markers should carry generated panel metadata: %s" % str(markers))
scene._on_targeting_back_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if scene.basic_attack_targeting or not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("Back should return to the post-move action menu")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("Back should preserve the pending moved unit without acting")
scene._on_post_move_attack_pressed()
var enemy_hp_before := int(enemy.get("hp", 0))
scene._handle_board_click(_screen_for_cell(Vector2i(3, 3)))
cao_cao = scene.state.get_unit("cao_cao")
enemy = scene.state.get_unit("yellow_turban_1")
if scene._has_pending_move() or scene.basic_attack_targeting:
failures.append("attack click should clear pending move and targeting state")
if not scene.state.get_selected_unit().is_empty():
failures.append("attack click should clear selected unit after acting")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("attack click should commit the moved attacker action")
if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)):
failures.append("attack click should damage or defeat the target")
if scene.state.get_battle_gold_reward() != 33:
failures.append("attack click should commit deferred movement event before resolving attack")
scene.free()
func _check_scene_enemy_click_auto_attack(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for enemy click auto attack")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["atk"] = 60
cao_cao["agi"] = 999
cao_cao["moved"] = false
cao_cao["acted"] = false
enemy["pos"] = Vector2i(4, 3)
enemy["hp"] = 24
enemy["max_hp"] = 24
enemy["def"] = 1
enemy["alive"] = true
scene.state.rng.seed = 1
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for enemy click auto attack")
scene.free()
return
scene._refresh_ranges()
if scene._is_targeting_mode():
failures.append("move+attack marker setup should not enter formal targeting mode")
if scene.attack_cells.has(Vector2i(4, 3)):
failures.append("auto attack setup enemy should start outside current attack range")
var auto_markers := scene._target_selection_marker_entries()
var found_auto_marker := false
for marker in auto_markers:
if marker.get("cell", Vector2i.ZERO) != Vector2i(4, 3):
continue
found_auto_marker = true
if marker.get("target_id", "") != "yellow_turban_1":
failures.append("move+attack marker should keep the clicked target id: %s" % str(marker))
if marker.get("text", "") != "진격":
failures.append("move+attack marker should use clear compact battlefield text: %s" % str(marker))
if not str(marker.get("tooltip", "")).contains("이동 후 공격"):
failures.append("move+attack marker should keep route detail in hover metadata: %s" % str(marker))
if marker.get("origin", Vector2i.ZERO) != Vector2i(3, 3):
failures.append("move+attack marker should expose the chosen attack origin: %s" % str(marker))
if marker.get("origin", Vector2i.ZERO) == Vector2i(1, 3):
failures.append("move+attack marker origin should differ from the current cell: %s" % str(marker))
if not bool(marker.get("requires_move", false)):
failures.append("move+attack marker should flag that movement is required: %s" % str(marker))
if str(marker.get("icon", "")).strip_edges().is_empty():
failures.append("move+attack marker should carry a generated icon hint: %s" % str(marker))
if str(marker.get("panel", "")) != "hud_jade":
failures.append("move+attack marker should carry generated panel metadata: %s" % str(marker))
if str(marker.get("origin_panel", "")) != "hud_jade":
failures.append("move+attack origin marker should carry generated panel metadata: %s" % str(marker))
if not found_auto_marker:
failures.append("selected unit should mark enemies reachable by move+attack: %s" % str(auto_markers))
scene.hover_cell = Vector2i(4, 3)
var auto_badge := scene._target_preview_badge()
if str(auto_badge.get("text", "")).contains("행공") or not str(auto_badge.get("tooltip", "")).contains("이동 후 공격"):
failures.append("hovering a move+attack target should use metadata instead of jargon text: %s" % str(auto_badge))
if not ["advance", "counter", "danger"].has(str(auto_badge.get("kind", ""))):
failures.append("move+attack hover badge should use an attack-risk color kind: %s" % str(auto_badge))
if str(auto_badge.get("icon", "")).strip_edges().is_empty():
failures.append("move+attack hover badge should carry a generated icon hint: %s" % str(auto_badge))
cao_cao["status_effects"] = [{
"type": "action_lock",
"status": "disarm",
"action": "attack",
"remaining_phases": 2
}]
scene._refresh_ranges()
for marker in scene._target_selection_marker_entries():
if marker.get("target_id", "") == "yellow_turban_1":
failures.append("attack-locked units should not mark move+attack targets: %s" % str(marker))
scene.hover_cell = Vector2i(4, 3)
var locked_badge := scene._target_preview_badge()
if str(locked_badge.get("text", "")).contains("진격") or str(locked_badge.get("text", "")).contains("행공"):
failures.append("attack-locked hover badge should not offer move+attack: %s" % str(locked_badge))
scene._handle_board_click(_screen_for_cell(Vector2i(4, 3)))
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(1, 3) or int(enemy.get("hp", 0)) != 24:
failures.append("attack-locked enemy click should not auto-move or attack")
cao_cao["status_effects"] = []
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(4, 3)))
cao_cao = scene.state.get_unit("cao_cao")
enemy = scene.state.get_unit("yellow_turban_1")
var auto_origin: Vector2i = cao_cao.get("pos", Vector2i.ZERO)
if absi(auto_origin.x - Vector2i(4, 3).x) + absi(auto_origin.y - Vector2i(4, 3).y) != 1:
failures.append("enemy click should auto-move to an attack origin, got %s" % str(auto_origin))
if not bool(cao_cao.get("acted", false)) or not bool(cao_cao.get("moved", false)):
failures.append("enemy click auto attack should spend the unit action")
if int(enemy.get("hp", 0)) >= 24 and bool(enemy.get("alive", true)):
failures.append("enemy click auto attack should damage or defeat the target")
if scene._has_pending_move() or scene.basic_attack_targeting:
failures.append("enemy click auto attack should not leave pending or targeting UI state")
scene.free()
func _check_scene_auto_attack_safe_origin_and_counter_preview(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for safe auto attack origin")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
for unit in scene.state.units:
if unit.get("team", "") == BattleStateScript.TEAM_ENEMY:
unit["pos"] = Vector2i(scene.state.map_size.x - 2, scene.state.map_size.y - 2)
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["move"] = 4
cao_cao["range"] = 2
cao_cao["min_range"] = 1
cao_cao["atk"] = 30
cao_cao["agi"] = 999
cao_cao["hp"] = 80
cao_cao["max_hp"] = 80
cao_cao["moved"] = false
cao_cao["acted"] = false
enemy["pos"] = Vector2i(5, 3)
enemy["range"] = 1
enemy["min_range"] = 1
enemy["atk"] = 70
enemy["agi"] = 0
enemy["hp"] = 80
enemy["max_hp"] = 80
enemy["def"] = 1
enemy["alive"] = true
enemy["controllable"] = true
scene.state.rng.seed = 1
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for safe auto attack origin")
scene.free()
return
scene._refresh_ranges()
var auto_markers := scene._target_selection_marker_entries()
var safe_marker := {}
for marker in auto_markers:
if marker.get("target_id", "") == "yellow_turban_1":
safe_marker = marker
break
if safe_marker.is_empty():
failures.append("safe auto attack setup should mark the reachable enemy: %s" % str(auto_markers))
else:
if safe_marker.get("origin", Vector2i.ZERO) != Vector2i(3, 3):
failures.append("auto attack should prefer the non-counter origin: %s" % str(safe_marker))
if bool(safe_marker.get("counter_in_range", true)):
failures.append("safe auto attack marker should expose no counter risk: %s" % str(safe_marker))
if str(safe_marker.get("kind", "")) != "advance":
failures.append("safe auto attack marker should use advance color: %s" % str(safe_marker))
var origin_marker_rect: Rect2 = scene._auto_attack_origin_marker_rect(safe_marker.get("origin", Vector2i.ZERO))
if not scene._rect_for_cell(Vector2i(3, 3)).encloses(origin_marker_rect):
failures.append("auto attack origin marker should fit inside the chosen origin cell: %s" % str(origin_marker_rect))
var route_points: PackedVector2Array = scene._auto_attack_route_points(safe_marker.get("origin", Vector2i.ZERO), safe_marker.get("cell", Vector2i.ZERO))
if route_points.size() != 2:
failures.append("auto attack route should expose two drawable points: %s" % str(route_points))
elif not scene._rect_for_cell(Vector2i(3, 3)).has_point(route_points[0]) or not scene._rect_for_cell(Vector2i(5, 3)).has_point(route_points[1]):
failures.append("auto attack route should connect the origin and target cells: %s" % str(route_points))
var far_route_points: PackedVector2Array = scene._auto_attack_route_points(Vector2i(-1, -1), safe_marker.get("cell", Vector2i.ZERO))
if not far_route_points.is_empty():
failures.append("auto attack route should not draw from invalid off-board origins: %s" % str(far_route_points))
scene.basic_attack_targeting = true
scene.attack_cells.clear()
scene.attack_cells.append(Vector2i(5, 3))
for marker in scene._target_selection_marker_entries():
if bool(marker.get("requires_move", false)):
failures.append("formal attack targeting should not show move+attack origin routes: %s" % str(marker))
scene.hover_cell = Vector2i(5, 3)
scene._update_forecast()
if scene.forecast_label.text.contains("진격 공격") or scene.forecast_label.text.contains("행공"):
failures.append("formal attack targeting forecast should not describe auto move+attack: %s" % scene.forecast_label.text)
scene.basic_attack_targeting = false
scene._refresh_ranges()
scene.hover_cell = Vector2i(5, 3)
var safe_badge := scene._target_preview_badge()
if str(safe_badge.get("kind", "")) != "advance" or str(safe_badge.get("text", "")).contains(""):
failures.append("safe auto attack hover should not show counter risk: %s" % str(safe_badge))
scene._update_forecast()
var safe_forecast: String = scene.forecast_label.text
var safe_detail := str(scene.forecast_label.tooltip_text)
if not safe_forecast.contains("진격 공격") or safe_forecast.contains("4,4") or safe_forecast.contains("사거리 밖"):
failures.append("safe auto attack forecast should summarize without raw origin coordinates: %s" % safe_forecast)
if safe_detail.contains("4,4") or safe_detail.contains("진입 위치"):
failures.append("safe auto attack tooltip should hide raw origin coordinates: %s" % safe_detail)
if not safe_detail.contains("진입 지형"):
failures.append("safe auto attack tooltip should keep the chosen advance origin as terrain context: %s" % safe_detail)
scene._handle_board_click(_screen_for_cell(Vector2i(5, 3)))
cao_cao = scene.state.get_unit("cao_cao")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(3, 3):
failures.append("auto attack click should use the same safe origin as marker and hover: %s" % str(cao_cao.get("pos", Vector2i.ZERO)))
scene.free()
var risky_scene = BattleSceneScript.new()
risky_scene._create_hud()
if not risky_scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for risky auto attack preview")
risky_scene.free()
return
risky_scene.battle_started = true
risky_scene.campaign_complete_screen = false
risky_scene.briefing_panel.visible = false
risky_scene.result_panel.visible = false
risky_scene._clear_pending_move_state()
for unit in risky_scene.state.units:
if unit.get("team", "") == BattleStateScript.TEAM_ENEMY:
unit["pos"] = Vector2i(risky_scene.state.map_size.x - 2, risky_scene.state.map_size.y - 2)
var risky_attacker: Dictionary = risky_scene.state.get_unit("cao_cao")
var risky_enemy: Dictionary = risky_scene.state.get_unit("yellow_turban_1")
risky_attacker["pos"] = Vector2i(1, 3)
risky_attacker["move"] = 8
risky_attacker["range"] = 1
risky_attacker["min_range"] = 1
risky_attacker["atk"] = 80
risky_attacker["agi"] = 0
risky_attacker["hp"] = 40
risky_attacker["max_hp"] = 40
risky_attacker["moved"] = false
risky_attacker["acted"] = false
risky_enemy["pos"] = Vector2i(5, 3)
risky_enemy["range"] = 1
risky_enemy["min_range"] = 1
risky_enemy["atk"] = 70
risky_enemy["agi"] = 50
risky_enemy["hp"] = 20
risky_enemy["max_hp"] = 20
risky_enemy["def"] = 1
risky_enemy["alive"] = true
risky_enemy["controllable"] = true
var risky_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3))
if risky_preview.is_empty() or not bool(risky_preview.get("would_defeat", false)) or not bool(risky_preview.get("counter_in_range", false)):
failures.append("risky auto attack preview should expose miss-counter risk on a potential KO: %s" % str(risky_preview))
var risky_badge := risky_scene._auto_attack_target_preview_badge_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3))
if not str(risky_badge.get("text", "")).contains("") or not ["counter", "danger"].has(str(risky_badge.get("kind", ""))):
failures.append("risky auto attack badge should show counter risk: %s" % str(risky_badge))
if not risky_scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for risky auto attack forecast")
else:
risky_scene.hover_cell = Vector2i(5, 3)
risky_scene._update_forecast()
var risky_forecast: String = risky_scene.forecast_label.text
var risky_detail := str(risky_scene.forecast_label.tooltip_text)
if not risky_forecast.contains("진격 공격") or not risky_detail.contains("반격"):
failures.append("risky auto attack forecast should summarize on the label and keep counter risk in the tooltip: %s / %s" % [risky_forecast, risky_detail])
risky_enemy["controllable"] = false
var escort_preview := risky_scene._auto_attack_preview_for_origin(risky_attacker, risky_enemy, Vector2i(4, 3))
if bool(escort_preview.get("counter_in_range", false)):
failures.append("non-controllable targets should not show counter risk: %s" % str(escort_preview))
risky_scene.free()
func _check_scene_post_move_tactic_picker_flow(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for tactic picker flow")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
enemy["pos"] = Vector2i(3, 3)
enemy["hp"] = 28
enemy["def"] = 1
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["int"] = 80
cao_cao["mp"] = 10
scene.state.battle_events.append({
"id": "pending_tactic_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [2, 3]
},
"actions": [{"type": "grant_gold", "amount": 44}]
})
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for tactic picker flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if scene.post_move_tactic_button == null or scene.post_move_tactic_button.disabled:
failures.append("post-move Tactic should be enabled when Spark has a valid target")
scene._on_tactic_pressed()
if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "tactic":
failures.append("post-move Tactic shortcut should open the local tactic picker")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move action menu should hide while the tactic picker is open")
if scene.tactic_menu != null and scene.tactic_menu.visible:
failures.append("post-move tactic shortcut should not use the side tactic menu")
_check_post_move_picker_option_tooltips_present(failures, scene, "post-move tactic picker")
_check_post_move_picker_option_visuals(failures, scene, "post-move tactic picker")
var escape := InputEventKey.new()
escape.keycode = KEY_ESCAPE
scene._handle_key(escape)
cao_cao = scene.state.get_unit("cao_cao")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("Escape should close the local tactic picker")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("Escape from the tactic picker should return to the post-move action menu")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("Escape from the tactic picker should preserve the pending move")
scene._on_post_move_tactic_pressed()
scene._on_post_move_tactic_option_pressed("spark")
if scene.selected_skill_id != "spark":
failures.append("choosing Spark should enter tactic targeting mode")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("tactic picker should hide after choosing a tactic")
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible:
failures.append("tactic targeting should not open a separate hint panel")
if not scene.skill_cells.has(Vector2i(3, 3)):
failures.append("Spark targeting should highlight the adjacent enemy cell")
var tactic_markers := scene._skill_target_marker_entries()
if tactic_markers.size() != 1 or tactic_markers[0].get("cell", Vector2i.ZERO) != Vector2i(3, 3):
failures.append("tactic target markers should include only the Spark target: %s" % str(tactic_markers))
else:
if str(tactic_markers[0].get("icon", "")).strip_edges().is_empty():
failures.append("tactic target marker should carry a generated icon hint: %s" % str(tactic_markers[0]))
if str(tactic_markers[0].get("panel", "")) != "hud_jade":
failures.append("tactic target marker should carry generated panel metadata: %s" % str(tactic_markers[0]))
if str(tactic_markers[0].get("text", "")) != "퇴각":
failures.append("tactic target marker should preview the deterministic KO: %s" % str(tactic_markers[0]))
if str(tactic_markers[0].get("kind", "")) != "damage":
failures.append("tactic target marker should use damage color: %s" % str(tactic_markers[0]))
scene._on_targeting_back_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("tactic Back should return to the post-move action menu")
if scene.selected_skill_id != "":
failures.append("tactic Back should clear the selected tactic")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("tactic Back should preserve the pending moved unit without acting")
scene._on_post_move_tactic_pressed()
scene._on_post_move_tactic_option_pressed("spark")
var enemy_hp_before := int(enemy.get("hp", 0))
scene._handle_board_click(_screen_for_cell(Vector2i(3, 3)))
cao_cao = scene.state.get_unit("cao_cao")
enemy = scene.state.get_unit("yellow_turban_1")
if scene._has_pending_move() or scene.selected_skill_id != "":
failures.append("tactic target click should clear pending move and selected tactic")
if not scene.state.get_selected_unit().is_empty():
failures.append("tactic target click should clear selected unit after acting")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("tactic target click should commit the moved caster action")
if int(enemy.get("hp", 0)) >= enemy_hp_before and bool(enemy.get("alive", true)):
failures.append("tactic target click should damage or defeat the target")
if scene.state.get_battle_gold_reward() != 44:
failures.append("tactic target click should commit deferred movement event before resolving tactic")
scene.free()
func _check_scene_post_move_item_picker_flow(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for item picker flow")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._clear_pending_move_state()
scene.state.set_inventory_snapshot({"bean": 1})
var ally: Dictionary = scene.state.get_unit("xiahou_dun")
ally["pos"] = Vector2i(2, 4)
ally["hp"] = 12
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
cao_cao["pos"] = Vector2i(1, 3)
cao_cao["mp"] = 10
scene.state.battle_events.append({
"id": "pending_item_gold",
"once": true,
"when": {
"type": "unit_reaches_tile",
"unit_ids": ["cao_cao"],
"pos": [2, 3]
},
"actions": [{"type": "grant_gold", "amount": 55}]
})
if not scene.state.select_unit("cao_cao"):
failures.append("could not select Cao Cao for item picker flow")
scene.free()
return
scene._refresh_ranges()
scene._handle_board_click(_screen_for_cell(Vector2i(2, 3)))
if scene.post_move_item_button == null or scene.post_move_item_button.disabled:
failures.append("post-move Item should be enabled when Bean can heal an adjacent ally")
scene._on_item_pressed()
if scene.post_move_picker_panel == null or not scene.post_move_picker_panel.visible or scene.post_move_picker_mode != "item":
failures.append("post-move Item shortcut should open the local item picker")
if scene.post_move_menu != null and scene.post_move_menu.visible:
failures.append("post-move action menu should hide while the item picker is open")
if scene.item_menu != null and scene.item_menu.visible:
failures.append("post-move item shortcut should not use the side item menu")
_check_post_move_picker_option_tooltips_present(failures, scene, "post-move item picker")
_check_post_move_picker_option_visuals(failures, scene, "post-move item picker")
scene._on_post_move_item_option_pressed("bean")
if scene.selected_item_id != "bean":
failures.append("choosing Bean should enter item targeting mode")
if scene.post_move_picker_panel != null and scene.post_move_picker_panel.visible:
failures.append("item picker should hide after choosing an item")
if scene.targeting_hint_panel != null and scene.targeting_hint_panel.visible:
failures.append("item targeting should not open a separate hint panel")
if not scene.item_cells.has(Vector2i(2, 4)):
failures.append("Bean targeting should highlight the adjacent injured ally")
var item_markers := scene._item_target_marker_entries()
if item_markers.size() != 1 or item_markers[0].get("cell", Vector2i.ZERO) != Vector2i(2, 4):
failures.append("item target markers should include only the Bean target: %s" % str(item_markers))
else:
if str(item_markers[0].get("icon", "")).strip_edges().is_empty():
failures.append("item target marker should carry a generated icon hint: %s" % str(item_markers[0]))
if str(item_markers[0].get("panel", "")) != "hud_jade":
failures.append("item target marker should carry generated panel metadata: %s" % str(item_markers[0]))
if str(item_markers[0].get("text", "")) != "+20 병력":
failures.append("item target marker should preview healing: %s" % str(item_markers[0]))
if str(item_markers[0].get("kind", "")) != "heal":
failures.append("item target marker should use heal color: %s" % str(item_markers[0]))
scene._on_targeting_back_pressed()
cao_cao = scene.state.get_unit("cao_cao")
if not scene._has_pending_move() or scene.post_move_menu == null or not scene.post_move_menu.visible:
failures.append("item Back should return to the post-move action menu")
if scene.selected_item_id != "":
failures.append("item Back should clear the selected item")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or bool(cao_cao.get("acted", false)):
failures.append("item Back should preserve the pending moved unit without acting")
scene._on_post_move_item_pressed()
scene._on_post_move_item_option_pressed("bean")
var ally_hp_before := int(ally.get("hp", 0))
scene._handle_board_click(_screen_for_cell(Vector2i(2, 4)))
cao_cao = scene.state.get_unit("cao_cao")
ally = scene.state.get_unit("xiahou_dun")
if scene._has_pending_move() or scene.selected_item_id != "":
failures.append("item target click should clear pending move and selected item")
if not scene.state.get_selected_unit().is_empty():
failures.append("item target click should clear selected unit after acting")
if cao_cao.get("pos", Vector2i.ZERO) != Vector2i(2, 3) or not bool(cao_cao.get("moved", false)) or not bool(cao_cao.get("acted", false)):
failures.append("item target click should commit the moved user action")
if int(ally.get("hp", 0)) <= ally_hp_before:
failures.append("item target click should heal the selected ally")
if int(scene.state.get_inventory_snapshot().get("bean", 0)) != 0:
failures.append("item target click should consume the Bean")
if scene.state.get_battle_gold_reward() != 55:
failures.append("item target click should commit deferred movement event before resolving item")
scene.free()
func _check_local_panel_position(failures: Array[String], scene, panel: Control, cell: Vector2i, label: String) -> void:
var padding := BattleSceneScript.LOCAL_COMMAND_PANEL_BOARD_PADDING
var board_rect: Rect2 = scene._board_rect().intersection(scene._map_view_rect())
if board_rect.size.x <= 0.0 or board_rect.size.y <= 0.0:
board_rect = scene._board_rect()
board_rect = board_rect.grow(-padding)
var panel_rect := Rect2(panel.position, panel.size)
var cell_rect: Rect2 = scene._rect_for_cell(cell).grow(2.0)
if panel_rect.size.x <= 0.0 or panel_rect.size.y <= 0.0:
failures.append("%s should have a stable visible size" % label)
if panel_rect.position.x < board_rect.position.x or panel_rect.position.y < board_rect.position.y:
failures.append("%s should stay inside the visible board minimum: %s" % [label, str(panel_rect)])
if panel_rect.end.x > board_rect.end.x or panel_rect.end.y > board_rect.end.y:
failures.append("%s should stay inside the visible board maximum: %s" % [label, str(panel_rect)])
if panel_rect.intersects(cell_rect):
failures.append("%s should not cover the moved unit cell: %s over %s" % [label, str(panel_rect), str(cell_rect)])
func _check_local_command_button_tooltips_present(failures: Array[String], buttons: Array, label: String) -> void:
for button in buttons:
if button == null:
failures.append("%s should expose local command buttons for tooltip checks" % label)
continue
if str(button.tooltip_text).strip_edges().is_empty():
failures.append("%s should explain icon function through hover tooltip" % label)
func _check_post_move_picker_option_tooltips_present(failures: Array[String], scene, label: String) -> void:
if scene.post_move_picker_list == null:
failures.append("%s should expose a picker option list" % label)
return
for child in scene.post_move_picker_list.get_children():
if child is Button and str(child.tooltip_text).strip_edges().is_empty():
failures.append("%s option should explain its function through hover tooltip" % label)
func _check_post_move_picker_option_visuals(failures: Array[String], scene, label: String) -> void:
if scene.post_move_picker_list == null:
failures.append("%s should expose a visual picker option list" % label)
return
var saw_button := false
for child in scene.post_move_picker_list.get_children():
if not child is Button:
continue
saw_button = true
var button := child as Button
if button.icon == null or str(button.get_meta("picker_icon", "")).strip_edges().is_empty():
failures.append("%s option should carry an immediate generated icon" % label)
if button.custom_minimum_size.x < BattleSceneScript.POST_MOVE_PICKER_OPTION_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.POST_MOVE_PICKER_OPTION_SIZE.y:
failures.append("%s option should reserve stable visual row space: %s" % [label, str(button.custom_minimum_size)])
if not button.text.contains("\n"):
failures.append("%s option should use compact two-line visual text: %s" % [label, button.text])
if button.get_theme_constant("icon_max_width") < BattleSceneScript.POST_MOVE_PICKER_OPTION_ICON_MAX_WIDTH:
failures.append("%s option should reserve readable icon width" % label)
if not saw_button:
failures.append("%s should create at least one visual option button" % label)
func _check_fitted_label_font(failures: Array[String], label: Label, base_size: int, min_size: int, label_name: String) -> void:
var font_size := label.get_theme_font_size("font_size")
if font_size < min_size or font_size > base_size:
failures.append("%s font size should stay inside fitted bounds %d..%d, got %d" % [label_name, min_size, base_size, font_size])
if label.custom_minimum_size.x <= 0.0 or label.custom_minimum_size.y <= 0.0:
failures.append("%s should reserve stable label space" % label_name)
func _screen_for_cell(cell: Vector2i) -> Vector2:
return BattleSceneScript.BOARD_OFFSET + Vector2(cell.x, cell.y) * BattleSceneScript.TILE_SIZE + Vector2.ONE * (BattleSceneScript.TILE_SIZE * 0.5)