extends SceneTree

const BattleStateScript := preload("res://scripts/core/battle_state.gd")
const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd")

const SCENARIO_BACKGROUNDS := [
	{
		"scenario_path": "res://data/scenarios/001_yellow_turbans.json",
		"background_path": "res://art/backgrounds/battlefield_frontier.png",
		"label": "001 opening field background",
		"allow_default": true
	},
	{
		"scenario_path": "res://data/scenarios/002_sishui_gate.json",
		"background_path": "res://art/backgrounds/sishui_gate_pass.png",
		"label": "002 Sishui Gate background"
	},
	{
		"scenario_path": "res://data/scenarios/008_wan_castle_escape.json",
		"background_path": "res://art/backgrounds/wan_castle_escape.png",
		"label": "008 Wan Castle background"
	},
	{
		"scenario_path": "res://data/scenarios/009_xiapi_siege.json",
		"background_path": "res://art/backgrounds/xiapi_flooded_siege.png",
		"label": "009 Xiapi flooded siege background"
	},
	{
		"scenario_path": "res://data/scenarios/012_guandu_showdown.json",
		"background_path": "res://art/backgrounds/guandu_defensive_camps.png",
		"label": "012 Guandu defensive camps background"
	},
	{
		"scenario_path": "res://data/scenarios/013_wuchao_raid.json",
		"background_path": "res://art/backgrounds/wuchao_night_granary.png",
		"label": "013 Wuchao night granary background"
	}
]


func _init() -> void:
	var failures: Array[String] = []
	_check_battle_visual_data(failures)
	_check_scene_texture_loading(failures)
	_check_hud_focus_text(failures)
	_check_hover_intent_badges(failures)
	_check_action_button_disabled_reasons(failures)
	_check_terrain_and_unit_presentation(failures)
	_check_attack_motion_profiles(failures)
	_check_attack_motion_signal(failures)

	if failures.is_empty():
		print("visual assets smoke ok")
		quit(0)
		return

	for failure in failures:
		push_error(failure)
	quit(1)


func _check_battle_visual_data(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")
		return

	var briefing := state.get_briefing()
	if (briefing.get("camp_dialogue", []) as Array).is_empty():
		failures.append("001 briefing should expose camp dialogue")
	var conversations: Array = briefing.get("camp_conversations", [])
	if conversations.size() < 3:
		failures.append("001 briefing should expose at least three camp conversations")
	else:
		_check_camp_conversation(failures, conversations[0], "cao_cao_strategy", "officer", "cao_cao")
		_check_camp_conversation(failures, conversations[1], "xiahou_dun_vanguard", "officer", "xiahou_dun")
		_check_camp_conversation(failures, conversations[2], "northern_woods_cache", "topic", "")
		_check_camp_conversation_effect(failures, conversations[2], "bean", 1)
	var merchant := state.get_shop_merchant()
	if merchant.is_empty() or (merchant.get("lines", []) as Array).is_empty():
		failures.append("001 shop should expose merchant lines")

	_check_image_path(failures, state.get_map_background_path(), "001 map background")
	_check_scenario_backgrounds_data(failures)
	for unit_id in ["cao_cao", "xiahou_dun", "yellow_turban_1", "yellow_turban_2", "yellow_turban_3"]:
		var unit := state.get_unit(unit_id)
		if unit.is_empty():
			failures.append("missing expected unit: %s" % unit_id)
			continue
		_check_image_path(failures, str(unit.get("sprite", "")), "unit %s sprite" % unit_id)
	_check_unit_sprite_resolution(failures, state, "cao_cao", "res://art/units/strategist.png", false)
	_check_unit_sprite_resolution(failures, state, "yellow_turban_1", "res://art/units/enemies/enemy_warrior.png", true)
	_check_unit_sprite_resolution(failures, state, "yellow_turban_2", "res://art/units/enemies/enemy_infantry.png", true)
	_check_unit_sprite_resolution(failures, state, "yellow_turban_3", "res://art/units/enemies/enemy_archer.png", true)
	for path in [
		"res://art/units/archer.png",
		"res://art/units/cavalry.png",
		"res://art/units/infantry.png",
		"res://art/units/strategist.png",
		"res://art/units/warrior.png",
		"res://art/units/enemies/enemy_archer.png",
		"res://art/units/enemies/enemy_cavalry.png",
		"res://art/units/enemies/enemy_infantry.png",
		"res://art/units/enemies/enemy_strategist.png",
		"res://art/units/enemies/enemy_warrior.png"
	]:
		_check_alpha_cutout_path(failures, path, "unit cutout %s" % path)

	for path in [
		"res://art/portraits/cao_cao.png",
		"res://art/portraits/cao_ren.png",
		"res://art/portraits/dian_wei.png",
		"res://art/portraits/guo_jia.png",
		"res://art/portraits/xiahou_dun.png",
		"res://art/portraits/xiahou_yuan.png",
		"res://art/portraits/zhang_he.png"
	]:
		_check_alpha_cutout_path(failures, path, "officer portrait cutout %s" % path)

	var gate_state = BattleStateScript.new()
	if not gate_state.load_battle("res://data/scenarios/002_sishui_gate.json"):
		failures.append("could not load Sishui Gate for enemy cavalry sprite")
	else:
		_check_unit_sprite_resolution(failures, gate_state, "hua_xiong_vanguard", "res://art/units/enemies/enemy_cavalry.png", true)
		var gate_briefing := gate_state.get_briefing()
		var gate_conversations: Array = gate_briefing.get("camp_conversations", [])
		if gate_conversations.size() < 3:
			failures.append("002 briefing should expose at least three camp conversations")
		else:
			_check_camp_conversation(failures, gate_conversations[0], "sishui_coalition_reading", "officer", "cao_cao")
			_check_camp_conversation(failures, gate_conversations[1], "sishui_xiahou_yuan_ridge", "officer", "xiahou_yuan")
			_check_camp_conversation(failures, gate_conversations[2], "sishui_quartermaster_antidote", "topic", "")
			_check_camp_conversation_effect(failures, gate_conversations[2], "antidote", 1)
		var gate_merchant := gate_state.get_shop_merchant()
		if str(gate_merchant.get("name", "")) != "Coalition Merchant":
			failures.append("002 shop should expose coalition merchant name")
		if (gate_merchant.get("lines", []) as Array).size() < 2:
			failures.append("002 shop should expose merchant flavor lines")

	var qingzhou_state = BattleStateScript.new()
	if not qingzhou_state.load_battle("res://data/scenarios/004_qingzhou_campaign.json"):
		failures.append("could not load Qingzhou Campaign camp data")
	else:
		var qingzhou_conversations: Array = qingzhou_state.get_briefing().get("camp_conversations", [])
		if qingzhou_conversations.size() != 2:
			failures.append("004 base briefing should expose exactly two camp conversations")
		else:
			_check_camp_conversation(failures, qingzhou_conversations[0], "qingzhou_settlement_council", "officer", "cao_cao")
			_check_camp_conversation(failures, qingzhou_conversations[1], "qingzhou_village_screen", "officer", "cao_ren")
		var qingzhou_merchant := qingzhou_state.get_shop_merchant()
		if str(qingzhou_merchant.get("name", "")) != "Qingzhou Road Merchant":
			failures.append("004 shop should expose Qingzhou merchant name")
		if (qingzhou_merchant.get("lines", []) as Array).size() < 2:
			failures.append("004 shop should expose merchant flavor lines")

	var qingzhou_pursuit_state = BattleStateScript.new()
	if not qingzhou_pursuit_state.load_battle("res://data/scenarios/004_qingzhou_campaign.json", {}, {}, {"pursued_dong_zhuo": true}):
		failures.append("could not load Qingzhou pursuit camp data")
	else:
		var veterans: Dictionary = _find_camp_conversation(
			qingzhou_pursuit_state.get_briefing().get("camp_conversations", []),
			"qingzhou_hardened_veterans"
		)
		if veterans.is_empty():
			failures.append("004 pursuit flag should expose hardened veterans conversation")
		else:
			_check_camp_conversation_effect(failures, veterans, "wine", 1)
		if not _find_camp_conversation(
			qingzhou_pursuit_state.get_briefing().get("camp_conversations", []),
			"qingzhou_sishui_stores"
		).is_empty():
			failures.append("004 pursuit flag should not expose Sishui stores conversation")

	var qingzhou_regroup_state = BattleStateScript.new()
	if not qingzhou_regroup_state.load_battle("res://data/scenarios/004_qingzhou_campaign.json", {}, {}, {"regrouped_after_sishui": true}):
		failures.append("could not load Qingzhou regroup camp data")
	else:
		var stores: Dictionary = _find_camp_conversation(
			qingzhou_regroup_state.get_briefing().get("camp_conversations", []),
			"qingzhou_sishui_stores"
		)
		if stores.is_empty():
			failures.append("004 regroup flag should expose Sishui stores conversation")
		else:
			_check_camp_conversation_effect(failures, stores, "bean", 1)
		if not _find_camp_conversation(
			qingzhou_regroup_state.get_briefing().get("camp_conversations", []),
			"qingzhou_hardened_veterans"
		).is_empty():
			failures.append("004 regroup flag should not expose hardened veterans conversation")

	var puyang_state = BattleStateScript.new()
	if not puyang_state.load_battle("res://data/scenarios/005_puyang_raid.json"):
		failures.append("could not load Puyang Raid camp data")
	else:
		var puyang_conversations: Array = puyang_state.get_briefing().get("camp_conversations", [])
		if puyang_conversations.size() != 3:
			failures.append("005 base briefing should expose exactly three camp conversations")
		else:
			_check_camp_conversation(failures, puyang_conversations[0], "puyang_night_raid_council", "officer", "cao_cao")
			_check_camp_conversation(failures, puyang_conversations[1], "puyang_dian_wei_shield", "officer", "dian_wei")
			_check_camp_conversation(failures, puyang_conversations[2], "puyang_raider_stores", "topic", "")
			_check_camp_conversation_effect(failures, puyang_conversations[2], "bean", 1)
		var puyang_merchant := puyang_state.get_shop_merchant()
		if str(puyang_merchant.get("name", "")) != "Puyang Night Trader":
			failures.append("005 shop should expose Puyang merchant name")
		if (puyang_merchant.get("lines", []) as Array).size() < 2:
			failures.append("005 shop should expose merchant flavor lines")

	var puyang_pursuit_state = BattleStateScript.new()
	if not puyang_pursuit_state.load_battle("res://data/scenarios/005_puyang_raid.json", {}, {}, {"pursued_dong_zhuo": true}):
		failures.append("could not load Puyang pursuit camp data")
	else:
		var vanguard: Dictionary = _find_camp_conversation(
			puyang_pursuit_state.get_briefing().get("camp_conversations", []),
			"puyang_hardened_vanguard"
		)
		if vanguard.is_empty():
			failures.append("005 pursuit flag should expose hardened vanguard conversation")
		else:
			_check_camp_conversation_effect(failures, vanguard, "wine", 1)
		if not _find_camp_conversation(
			puyang_pursuit_state.get_briefing().get("camp_conversations", []),
			"puyang_qingzhou_reserve_wagon"
		).is_empty():
			failures.append("005 pursuit flag should not expose Qingzhou reserve wagon conversation")

	var puyang_regroup_state = BattleStateScript.new()
	if not puyang_regroup_state.load_battle("res://data/scenarios/005_puyang_raid.json", {}, {}, {"regrouped_after_sishui": true}):
		failures.append("could not load Puyang regroup camp data")
	else:
		var reserve: Dictionary = _find_camp_conversation(
			puyang_regroup_state.get_briefing().get("camp_conversations", []),
			"puyang_qingzhou_reserve_wagon"
		)
		if reserve.is_empty():
			failures.append("005 regroup flag should expose Qingzhou reserve wagon conversation")
		else:
			_check_camp_conversation_effect(failures, reserve, "panacea", 1)
		if not _find_camp_conversation(
			puyang_regroup_state.get_briefing().get("camp_conversations", []),
			"puyang_hardened_vanguard"
		).is_empty():
			failures.append("005 regroup flag should not expose hardened vanguard conversation")

	var dingtao_state = BattleStateScript.new()
	if not dingtao_state.load_battle("res://data/scenarios/006_dingtao_counterattack.json"):
		failures.append("could not load Dingtao Counterattack camp data")
	else:
		var dingtao_conversations: Array = dingtao_state.get_briefing().get("camp_conversations", [])
		if dingtao_conversations.size() != 2:
			failures.append("006 base briefing should expose exactly two camp conversations")
		else:
			_check_camp_conversation(failures, dingtao_conversations[0], "dingtao_countercharge_council", "officer", "cao_cao")
			_check_camp_conversation(failures, dingtao_conversations[1], "dingtao_dian_wei_roadblock", "officer", "dian_wei")
		var dingtao_merchant := dingtao_state.get_shop_merchant()
		if str(dingtao_merchant.get("name", "")) != "Dingtao Field Sutler":
			failures.append("006 shop should expose Dingtao merchant name")
		if (dingtao_merchant.get("lines", []) as Array).size() < 2:
			failures.append("006 shop should expose merchant flavor lines")
		_check_shop_item_visibility(failures, dingtao_state, "iron_armor", false, "006 base")
		_check_shop_item_visibility(failures, dingtao_state, "war_drum", false, "006 base")

	var dingtao_fortified_state = BattleStateScript.new()
	if not dingtao_fortified_state.load_battle("res://data/scenarios/006_dingtao_counterattack.json", {}, {}, {"fortified_yan_province": true}):
		failures.append("could not load Dingtao fortified camp data")
	else:
		var fortified: Dictionary = _find_camp_conversation(
			dingtao_fortified_state.get_briefing().get("camp_conversations", []),
			"dingtao_fortified_reserves"
		)
		if fortified.is_empty():
			failures.append("006 fortified flag should expose fortified reserves conversation")
		else:
			_check_camp_conversation_effect(failures, fortified, "panacea", 1)
		if not _find_camp_conversation(
			dingtao_fortified_state.get_briefing().get("camp_conversations", []),
			"dingtao_pressed_vanguard"
		).is_empty():
			failures.append("006 fortified flag should not expose pressed vanguard conversation")
		_check_shop_item_visibility(failures, dingtao_fortified_state, "iron_armor", true, "006 fortified")
		_check_shop_item_visibility(failures, dingtao_fortified_state, "war_drum", false, "006 fortified")

	var dingtao_pressed_state = BattleStateScript.new()
	if not dingtao_pressed_state.load_battle("res://data/scenarios/006_dingtao_counterattack.json", {}, {}, {"pressed_lu_bu": true}):
		failures.append("could not load Dingtao pressed camp data")
	else:
		var pressed: Dictionary = _find_camp_conversation(
			dingtao_pressed_state.get_briefing().get("camp_conversations", []),
			"dingtao_pressed_vanguard"
		)
		if pressed.is_empty():
			failures.append("006 pressed flag should expose pressed vanguard conversation")
		else:
			_check_camp_conversation_effect(failures, pressed, "wine", 1)
		if not _find_camp_conversation(
			dingtao_pressed_state.get_briefing().get("camp_conversations", []),
			"dingtao_fortified_reserves"
		).is_empty():
			failures.append("006 pressed flag should not expose fortified reserves conversation")
		_check_shop_item_visibility(failures, dingtao_pressed_state, "iron_armor", false, "006 pressed")
		_check_shop_item_visibility(failures, dingtao_pressed_state, "war_drum", true, "006 pressed")

	var xian_state = BattleStateScript.new()
	if not xian_state.load_battle("res://data/scenarios/007_xian_emperor_escort.json"):
		failures.append("could not load Xian Emperor Escort camp data")
	else:
		var xian_conversations: Array = xian_state.get_briefing().get("camp_conversations", [])
		if xian_conversations.size() != 2:
			failures.append("007 base briefing should expose exactly two camp conversations")
		else:
			_check_camp_conversation(failures, xian_conversations[0], "xian_escort_council", "officer", "cao_cao")
			_check_camp_conversation(failures, xian_conversations[1], "xian_dian_wei_envoy_screen", "officer", "dian_wei")
		var xian_merchant := xian_state.get_shop_merchant()
		if str(xian_merchant.get("name", "")) != "Escort Road Sutler":
			failures.append("007 shop should expose escort merchant name")
		if (xian_merchant.get("lines", []) as Array).size() < 2:
			failures.append("007 shop should expose merchant flavor lines")
		_check_shop_item_visibility(failures, xian_state, "iron_armor", false, "007 base")
		_check_shop_item_visibility(failures, xian_state, "war_axe", false, "007 base")

	var xian_fortified_state = BattleStateScript.new()
	if not xian_fortified_state.load_battle("res://data/scenarios/007_xian_emperor_escort.json", {}, {}, {"fortified_yan_province": true}):
		failures.append("could not load Xian fortified camp data")
	else:
		var road_supplies: Dictionary = _find_camp_conversation(
			xian_fortified_state.get_briefing().get("camp_conversations", []),
			"xian_fortified_road_supplies"
		)
		if road_supplies.is_empty():
			failures.append("007 fortified flag should expose fortified road supplies conversation")
		else:
			_check_camp_conversation_effect(failures, road_supplies, "bean", 1)
		if not _find_camp_conversation(
			xian_fortified_state.get_briefing().get("camp_conversations", []),
			"xian_forced_march_canteens"
		).is_empty():
			failures.append("007 fortified flag should not expose forced march canteens conversation")
		_check_shop_item_visibility(failures, xian_fortified_state, "iron_armor", true, "007 fortified")
		_check_shop_item_visibility(failures, xian_fortified_state, "war_axe", false, "007 fortified")

	var xian_pressed_state = BattleStateScript.new()
	if not xian_pressed_state.load_battle("res://data/scenarios/007_xian_emperor_escort.json", {}, {}, {"pressed_lu_bu": true}):
		failures.append("could not load Xian pressed camp data")
	else:
		var canteens: Dictionary = _find_camp_conversation(
			xian_pressed_state.get_briefing().get("camp_conversations", []),
			"xian_forced_march_canteens"
		)
		if canteens.is_empty():
			failures.append("007 pressed flag should expose forced march canteens conversation")
		else:
			_check_camp_conversation_effect(failures, canteens, "wine", 1)
		if not _find_camp_conversation(
			xian_pressed_state.get_briefing().get("camp_conversations", []),
			"xian_fortified_road_supplies"
		).is_empty():
			failures.append("007 pressed flag should not expose fortified road supplies conversation")
		_check_shop_item_visibility(failures, xian_pressed_state, "iron_armor", false, "007 pressed")
		_check_shop_item_visibility(failures, xian_pressed_state, "war_axe", true, "007 pressed")

	var wan_state = BattleStateScript.new()
	if not wan_state.load_battle("res://data/scenarios/008_wan_castle_escape.json"):
		failures.append("could not load Wan Castle Escape camp data")
	else:
		var wan_conversations: Array = wan_state.get_briefing().get("camp_conversations", [])
		if wan_conversations.size() != 2:
			failures.append("008 base briefing should expose exactly two camp conversations")
		else:
			_check_camp_conversation(failures, wan_conversations[0], "wan_escape_route_council", "officer", "cao_cao")
			_check_camp_conversation(failures, wan_conversations[1], "wan_dian_wei_rear_guard", "officer", "dian_wei")
		var wan_merchant := wan_state.get_shop_merchant()
		if str(wan_merchant.get("name", "")) != "Wan Castle Sutler":
			failures.append("008 shop should expose Wan Castle merchant name")
		if (wan_merchant.get("lines", []) as Array).size() < 2:
			failures.append("008 shop should expose merchant flavor lines")
		_check_shop_item_visibility(failures, wan_state, "imperial_seal", false, "008 base")
		_check_shop_item_visibility(failures, wan_state, "war_drum", false, "008 base")

	var wan_court_state = BattleStateScript.new()
	if not wan_court_state.load_battle("res://data/scenarios/008_wan_castle_escape.json", {}, {}, {"secured_imperial_court": true}):
		failures.append("could not load Wan court seal camp data")
	else:
		var postern: Dictionary = _find_camp_conversation(
			wan_court_state.get_briefing().get("camp_conversations", []),
			"wan_court_seal_postern"
		)
		if postern.is_empty():
			failures.append("008 secured court flag should expose court seal postern conversation")
		else:
			_check_camp_conversation_effect(failures, postern, "bean", 1)
		if not _find_camp_conversation(
			wan_court_state.get_briefing().get("camp_conversations", []),
			"wan_hard_pursuit_drums"
		).is_empty():
			failures.append("008 secured court flag should not expose hard pursuit drums conversation")
		_check_shop_item_visibility(failures, wan_court_state, "imperial_seal", true, "008 secured court")
		_check_shop_item_visibility(failures, wan_court_state, "war_drum", false, "008 secured court")

	var wan_pursuit_state = BattleStateScript.new()
	if not wan_pursuit_state.load_battle("res://data/scenarios/008_wan_castle_escape.json", {}, {}, {"pursued_li_jue_remnants": true}):
		failures.append("could not load Wan hard pursuit camp data")
	else:
		var drums: Dictionary = _find_camp_conversation(
			wan_pursuit_state.get_briefing().get("camp_conversations", []),
			"wan_hard_pursuit_drums"
		)
		if drums.is_empty():
			failures.append("008 hard pursuit flag should expose hard pursuit drums conversation")
		else:
			_check_camp_conversation_effect(failures, drums, "wine", 1)
		if not _find_camp_conversation(
			wan_pursuit_state.get_briefing().get("camp_conversations", []),
			"wan_court_seal_postern"
		).is_empty():
			failures.append("008 hard pursuit flag should not expose court seal postern conversation")
		_check_shop_item_visibility(failures, wan_pursuit_state, "imperial_seal", false, "008 hard pursuit")
		_check_shop_item_visibility(failures, wan_pursuit_state, "war_drum", true, "008 hard pursuit")

	for item_id in ["bronze_sword", "training_spear", "short_bow", "hand_axe", "iron_armor", "panacea"]:
		var item := state.get_item_def(item_id)
		if item.is_empty():
			failures.append("missing expected item: %s" % item_id)
			continue
		var icon_path := str(item.get("icon", ""))
		_check_image_path(failures, icon_path, "item %s icon" % item_id)
		_check_alpha_cutout_path(failures, icon_path, "item %s icon cutout" % item_id)


func _check_scenario_backgrounds_data(failures: Array[String]) -> void:
	for expectation in SCENARIO_BACKGROUNDS:
		var entry: Dictionary = expectation
		var scenario_path := str(entry.get("scenario_path", ""))
		var expected_path := str(entry.get("background_path", ""))
		var label := str(entry.get("label", scenario_path))
		var state = BattleStateScript.new()
		if not state.load_battle(scenario_path):
			failures.append("could not load scenario for background: %s" % label)
			continue
		var background_path: String = state.get_map_background_path()
		if background_path != expected_path:
			failures.append("%s should use %s, got %s" % [label, expected_path, background_path])
			continue
		_check_image_path(failures, background_path, label)


func _check_scene_texture_loading(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 battle scene state")
		scene.free()
		return
	if scene._current_battle_background_texture() == null:
		failures.append("battle scene should load a battlefield background texture")
	var archer: Dictionary = scene.state.get_unit("yellow_turban_3")
	if scene._load_art_texture(str(archer.get("sprite", ""))) == null:
		failures.append("battle scene should load archer sprite texture")
	var panacea: Dictionary = scene.state.get_item_def("panacea")
	if scene._load_art_texture(str(panacea.get("icon", ""))) == null:
		failures.append("battle scene should load panacea icon texture")
	var conversations: Array = scene.state.get_briefing().get("camp_conversations", [])
	if not conversations.is_empty():
		var first_lines: Array = (conversations[0] as Dictionary).get("lines", [])
		if not first_lines.is_empty():
			var first_line: Dictionary = first_lines[0]
			if scene._load_art_texture(str(first_line.get("portrait", ""))) == null:
				failures.append("battle scene should load camp conversation speaker portrait")
	var scene_conversations: Array = scene._camp_conversation_entries()
	if scene_conversations.size() < 3:
		failures.append("battle scene should expose camp conversation entries")
	var strategy := scene._camp_conversation_by_id("cao_cao_strategy")
	if strategy.is_empty():
		failures.append("battle scene should find Cao Cao camp conversation by id")
	elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"):
		failures.append("camp conversation button text should include label/speaker")
	elif scene._camp_conversation_preview_portrait_path(strategy).is_empty():
		failures.append("camp conversation preview should resolve Cao Cao portrait")
	elif scene._load_art_texture(scene._camp_conversation_preview_portrait_path(strategy)) == null:
		failures.append("camp conversation preview portrait should load")
	elif not scene._format_camp_conversation_row_title(strategy).contains("Officer"):
		failures.append("camp conversation row title should include group")
	elif not scene._format_camp_conversation_preview_text(strategy).contains("Yingchuan"):
		failures.append("camp conversation preview should include first dialogue text")
	var cache := scene._camp_conversation_by_id("northern_woods_cache")
	if cache.is_empty():
		failures.append("battle scene should find cache camp conversation by id")
	elif not scene._format_camp_conversation_button_text(cache).contains("Supply Bean"):
		failures.append("camp conversation button text should preview supply effect")
	var merchant_notice := scene._format_shop_merchant_notice_text(scene.state.get_shop_merchant())
	if not merchant_notice.contains("Camp Merchant") or not merchant_notice.contains("Roadside stores"):
		failures.append("shop merchant notice should include name and flavor lines")
	var camp_overview := scene._format_briefing_camp_overview_text(scene.state.get_briefing(), false)
	for expected in ["Yingchuan, 184 CE", "Gold 0G", "Talk 3", "1 supply", "Shop 2 buy", "Deploy 2/2", "Formation 4 tiles", "Armory ready"]:
		if not camp_overview.contains(expected):
			failures.append("camp overview should include %s: %s" % [expected, camp_overview])
	var bean: Dictionary = scene.state.get_item_def("bean")
	var bean_buy_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 0)
	if not bean_buy_detail.contains("Heal 20") or not bean_buy_detail.contains("Owned x0"):
		failures.append("shop buy detail should summarize item effect and owned count")
	scene.campaign_state.gold = 10
	var need_gold_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 0)
	if not need_gold_detail.contains("Need 40G"):
		failures.append("shop buy detail should explain missing gold")
	var sold_out_detail := scene._format_shop_buy_item_detail_text("bean", bean, int(bean.get("price", 0)), 2, 3, 0)
	if not sold_out_detail.contains("Stock 0/3") or not sold_out_detail.contains("Sold out"):
		failures.append("shop buy detail should explain exhausted finite stock")
	var sell_detail := scene._format_shop_sell_item_detail_text("bean", bean, 25, 2)
	if not sell_detail.contains("Half-price sale") or not sell_detail.contains("Owned x2"):
		failures.append("shop sell detail should summarize sale rule and owned count")
	var bean_action := scene._format_shop_buy_action_text("bean", bean, int(bean.get("price", 0)))
	if not bean_action.contains("Buy Bean") or not bean_action.contains("50G"):
		failures.append("shop buy action should be concise and priced")
	var bean_tooltip := scene._format_shop_item_tooltip_text(
		scene._format_shop_item_button_text("bean", bean, int(bean.get("price", 0)), 0),
		bean_buy_detail
	)
	if not bean_tooltip.contains("\n") or not bean_tooltip.contains("Owned x0"):
		failures.append("shop item tooltip should preserve full item detail")
	scene.free()

	_check_scenario_background_texture_loading(failures)


func _check_scenario_background_texture_loading(failures: Array[String]) -> void:
	for expectation in SCENARIO_BACKGROUNDS:
		var entry: Dictionary = expectation
		var scenario_path := str(entry.get("scenario_path", ""))
		var expected_path := str(entry.get("background_path", ""))
		var label := str(entry.get("label", scenario_path))
		var scene = BattleSceneScript.new()
		if not scene.state.load_battle(scenario_path):
			failures.append("could not load scene state for background: %s" % label)
			scene.free()
			continue
		var actual_path: String = scene.state.get_map_background_path()
		if actual_path != expected_path:
			failures.append("%s scene loaded %s instead of %s" % [label, actual_path, expected_path])
		var allow_default := bool(entry.get("allow_default", false))
		if not allow_default and actual_path == BattleSceneScript.DEFAULT_BATTLE_BACKGROUND_PATH:
			failures.append("%s should not rely on the default battlefield background" % label)
		if scene._current_battle_background_texture() == null:
			failures.append("battle scene should load texture for %s" % label)
		scene.free()


func _check_hud_focus_text(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 battle scene state for HUD focus text")
		scene.free()
		return
	var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
	var cao_text := scene._format_unit_focus_text(cao_cao, "Selected")
	if not cao_text.contains("Command tactics"):
		failures.append("Cao Cao HUD focus should describe command role: %s" % cao_text)
	if not cao_text.contains("Move 4 (Foot)") or not cao_text.contains("Attack range 1-1"):
		failures.append("Cao Cao HUD focus should clarify move/attack affordance: %s" % cao_text)
	if not cao_text.contains("Action:") or not cao_text.contains("Move") or not cao_text.contains("Attack") or not cao_text.contains("Tactic") or not cao_text.contains("Equip") or not cao_text.contains("Wait"):
		failures.append("Cao Cao HUD focus should summarize available actions: %s" % cao_text)
	if not cao_text.contains("Tile 2,4"):
		failures.append("Cao Cao HUD focus should include current tile: %s" % cao_text)
	if not cao_text.contains("Cost 1"):
		failures.append("Cao Cao HUD focus should include terrain move cost: %s" % cao_text)
	if not cao_text.contains("DEF +0"):
		failures.append("Cao Cao HUD focus should include terrain defense: %s" % cao_text)
	var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun")
	xiahou_dun["pos"] = Vector2i(3, 1)
	var forest_text := scene._unit_current_terrain_text(xiahou_dun)
	if not forest_text.contains("Forest") or not forest_text.contains("Cost 3"):
		failures.append("Cavalry movement should use mounted move_type cost on forest: %s" % forest_text)
	var archer: Dictionary = scene.state.get_unit("yellow_turban_3")
	var archer_text := scene._format_unit_focus_text(archer, "Hover")
	if not archer_text.contains("Ranged pressure"):
		failures.append("Archer HUD focus should describe ranged role: %s" % archer_text)
	if not archer_text.contains("Move 4 (Archer)") or not archer_text.contains("Attack range 2-2"):
		failures.append("Archer HUD focus should clarify ranged attack affordance: %s" % archer_text)
	if not archer_text.contains("Action: Enemy AI"):
		failures.append("Enemy archer HUD focus should describe enemy control: %s" % archer_text)
	if not scene._unit_current_terrain_text(archer).contains("Plain"):
		failures.append("Archer terrain text should reflect its current plain tile")
	scene.free()


func _check_action_button_disabled_reasons(failures: Array[String]) -> void:
	var scene = BattleSceneScript.new()
	scene.wait_button = Button.new()
	scene.tactic_button = Button.new()
	scene.item_button = Button.new()
	scene.equip_button = Button.new()
	scene.end_turn_button = Button.new()
	scene.threat_button = Button.new()
	if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
		failures.append("could not load battle scene state for action button reasons")
		_free_action_button_test_scene(scene)
		return
	scene.battle_started = true
	var cao_cao: Dictionary = scene.state.get_unit("cao_cao")

	cao_cao["status_effects"] = [{
		"type": "action_lock",
		"status": "seal",
		"action": "skill",
		"remaining_phases": 2
	}]
	scene._update_tactic_button(cao_cao)
	if scene.tactic_button.text != "Tactic - Sealed" or not scene.tactic_button.tooltip_text.to_lower().contains("sealed"):
		failures.append("sealed unit should explain disabled tactic button: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text])
	cao_cao["status_effects"] = []

	cao_cao["acted"] = true
	scene._update_wait_button(cao_cao)
	scene._update_tactic_button(cao_cao)
	scene._update_item_button(cao_cao)
	scene._update_equip_button(cao_cao)
	if not scene.wait_button.text.contains("Done"):
		failures.append("acted unit wait button should show Done: %s" % scene.wait_button.text)
	if not scene.tactic_button.text.contains("Done"):
		failures.append("acted unit tactic button should show Done: %s" % scene.tactic_button.text)
	if not scene.item_button.text.contains("Done"):
		failures.append("acted unit item button should show Done: %s" % scene.item_button.text)
	if not scene.equip_button.text.contains("Done"):
		failures.append("acted unit equip button should show Done: %s" % scene.equip_button.text)

	cao_cao["acted"] = false
	cao_cao["moved"] = true
	scene._update_equip_button(cao_cao)
	if scene.equip_button.text != "Equip - Moved" or not scene.equip_button.tooltip_text.to_lower().contains("before"):
		failures.append("moved unit should explain disabled equip button: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text])

	cao_cao["moved"] = false
	scene.state.set_inventory_snapshot({})
	scene._update_item_button(cao_cao)
	if scene.item_button.text != "Item - None" or not scene.item_button.tooltip_text.to_lower().contains("no consumable"):
		failures.append("empty inventory should explain disabled item button: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text])

	scene.state.current_team = BattleState.TEAM_ENEMY
	scene._update_end_turn_button()
	if scene.end_turn_button.text != "End Turn - Enemy Turn" or not scene.end_turn_button.tooltip_text.to_lower().contains("player phase"):
		failures.append("enemy phase should explain disabled end turn button: %s | %s" % [scene.end_turn_button.text, scene.end_turn_button.tooltip_text])

	scene.state.current_team = BattleState.TEAM_PLAYER
	scene.battle_started = false
	scene._update_threat_button()
	if scene.threat_button.text != "Threat - Start Battle" or not scene.threat_button.tooltip_text.to_lower().contains("begin"):
		failures.append("inactive battle should explain disabled threat button: %s | %s" % [scene.threat_button.text, scene.threat_button.tooltip_text])
	_free_action_button_test_scene(scene)


func _free_action_button_test_scene(scene) -> void:
	for button in [
		scene.wait_button,
		scene.tactic_button,
		scene.item_button,
		scene.equip_button,
		scene.end_turn_button,
		scene.threat_button
	]:
		if button != null and is_instance_valid(button):
			button.free()
	scene.free()


func _check_hover_intent_badges(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 battle scene state for hover intent badges")
		scene.free()
		return
	scene.battle_started = true
	scene.state.select_unit("cao_cao")
	scene._refresh_ranges()

	scene.hover_cell = Vector2i(2, 3)
	var move_badge := scene._target_preview_badge()
	if str(move_badge.get("text", "")) != "MOVE" or str(move_badge.get("kind", "")) != "move":
		failures.append("reachable empty tile should show MOVE badge: %s" % str(move_badge))

	scene.hover_cell = Vector2i(1, 4)
	var select_badge := scene._target_preview_badge()
	if str(select_badge.get("text", "")) != "SELECT" or str(select_badge.get("kind", "")) != "select":
		failures.append("friendly selectable unit should show SELECT badge: %s" % str(select_badge))

	scene.hover_cell = Vector2i(7, 2)
	var attack_badge := scene._target_preview_badge()
	if attack_badge.is_empty() or str(attack_badge.get("kind", "")) == "move" or str(attack_badge.get("text", "")) == "MOVE":
		failures.append("enemy hover should keep attack badge priority: %s" % str(attack_badge))

	var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
	cao_cao["moved"] = true
	scene._refresh_ranges()
	scene.hover_cell = Vector2i(2, 3)
	var moved_badge := scene._target_preview_badge()
	if str(moved_badge.get("kind", "")) == "move" or str(moved_badge.get("text", "")) == "MOVE":
		failures.append("moved unit should not show MOVE badge: %s" % str(moved_badge))

	var default_color := scene._target_preview_badge_color("")
	if scene._target_preview_badge_color("move") == default_color:
		failures.append("MOVE badge should use a distinct color")
	if scene._target_preview_badge_color("select") == default_color:
		failures.append("SELECT badge should use a distinct color")
	scene.free()


func _check_terrain_and_unit_presentation(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 battle scene state for presentation helpers")
		scene.free()
		return
	if scene._terrain_key_at(Vector2i(3, 1)) != "F":
		failures.append("terrain helper should read forest cells")
	if not scene._terrain_key_at(Vector2i(-1, 0)).is_empty():
		failures.append("terrain helper should return empty outside the board")
	var background_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", true)
	var fallback_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", false)
	if background_fill.a >= 0.20:
		failures.append("background terrain fill should stay translucent over high-res art")
	if fallback_fill.a <= background_fill.a:
		failures.append("fallback terrain fill should be stronger than background fill")
	if scene._map_grid_color(true).a >= scene._map_grid_color(false).a:
		failures.append("background grid should be lighter than fallback grid")
	if scene._terrain_edge_color("F", "G").a <= 0.0:
		failures.append("forest edge blend should be visible")
	if not scene._should_draw_terrain_edge("F", "G", Vector2i(1, 0)):
		failures.append("forest should own forest/plain edge drawing")
	if scene._should_draw_terrain_edge("G", "F", Vector2i(-1, 0)):
		failures.append("plain should not redraw forest-owned edge")
	if scene._should_draw_terrain_edge("F", "F", Vector2i(1, 0)):
		failures.append("matching terrain should not draw edge blends")
	if scene._terrain_edge_width("W", "G") <= scene._terrain_edge_width("G", "F"):
		failures.append("water shoreline edge should be wider than grass blend")
	if not scene.state.load_battle("res://data/scenarios/002_sishui_gate.json"):
		failures.append("could not load Sishui Gate for road presentation helpers")
	else:
		var road_flags: Dictionary = scene._terrain_connection_flags(Vector2i(2, 4), "R")
		if not bool(road_flags.get("west", false)) or not bool(road_flags.get("east", false)):
			failures.append("Sishui Gate road should connect west/east at 3,5: %s" % str(road_flags))
		if bool(road_flags.get("north", false)) or bool(road_flags.get("south", false)):
			failures.append("Sishui Gate road should not connect north/south at 3,5: %s" % str(road_flags))
	if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
		failures.append("could not reload opening battle for unit presentation helpers")
		scene.free()
		return
	var generic_enemy: Dictionary = scene.state.get_unit("yellow_turban_2")
	var named_officer: Dictionary = scene.state.get_unit("cao_cao")
	if not scene._is_generic_enemy_unit(generic_enemy):
		failures.append("generic enemy unit should be marked as generic enemy")
	if scene._is_generic_enemy_unit(named_officer):
		failures.append("named officer should not be marked as generic enemy")
	if scene._is_generic_enemy_unit({"team": "enemy", "officer_id": "named_enemy"}):
		failures.append("enemy with officer_id should not be marked as generic")
	if scene._is_generic_enemy_unit({"team": "player", "officer_id": ""}):
		failures.append("player unit should not be marked as generic enemy")
	var enemy_modulate: Color = scene._unit_sprite_modulate(generic_enemy, false)
	if enemy_modulate.r < 0.99 or enemy_modulate.g < 0.99 or enemy_modulate.b < 0.99 or enemy_modulate.a < 0.97:
		failures.append("transparent enemy class sprite should render near its original colors")
	var fallback_enemy_modulate: Color = scene._unit_sprite_modulate({"team": "enemy", "officer_id": ""}, false)
	if fallback_enemy_modulate == Color.WHITE or fallback_enemy_modulate.a >= 1.0:
		failures.append("generic enemy fallback sprite should still receive a distinct semi-real tint")
	var acted_modulate: Color = scene._unit_sprite_modulate(generic_enemy, true)
	if acted_modulate.a >= enemy_modulate.a:
		failures.append("acted generic enemy sprite should still dim below active tint")
	scene.free()


func _check_attack_motion_profiles(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 battle scene state for attack profiles")
		scene.free()
		return
	_check_attack_profile(failures, scene, "cao_cao", Vector2i(1, 3), Vector2i(2, 3), "command_strike")
	_check_attack_profile(failures, scene, "xiahou_dun", Vector2i(1, 4), Vector2i(2, 4), "cavalry_charge")
	_check_attack_profile(failures, scene, "yellow_turban_1", Vector2i(7, 2), Vector2i(6, 2), "heavy_strike")
	_check_attack_profile(failures, scene, "yellow_turban_2", Vector2i(8, 5), Vector2i(7, 5), "infantry_strike")
	_check_attack_profile(failures, scene, "yellow_turban_3", Vector2i(7, 6), Vector2i(7, 4), "arrow")
	_check_synthetic_attack_profile(failures, scene, "elite_cavalry", "cavalry_charge")
	_check_synthetic_attack_profile(failures, scene, "guard_captain", "infantry_strike")
	_check_synthetic_attack_profile(failures, scene, "marksman", "arrow", 2)
	_check_synthetic_attack_profile(failures, scene, "military_advisor", "command_strike")
	_check_synthetic_attack_profile(failures, scene, "commander", "command_strike")
	_check_synthetic_attack_profile(failures, scene, "champion", "heavy_strike")
	_check_synthetic_attack_profile(failures, scene, "bandit", "heavy_strike")
	_check_skill_action_profile(failures, scene, "skill_damage", "tactic_damage", "tactic_flare")
	_check_skill_action_profile(failures, scene, "skill_heal", "tactic_heal", "healing_sigil")
	_check_skill_action_profile(failures, scene, "skill_support", "tactic_support", "order_banner")
	scene._on_unit_action_motion_requested("xiahou_dun", Vector2i(1, 4), Vector2i(2, 4), "attack")
	_check_stored_attack_motion(failures, scene, "xiahou_dun", "cavalry_charge", "melee", "attack", "charge_dust")
	scene._on_unit_action_motion_requested("yellow_turban_3", Vector2i(7, 6), Vector2i(7, 4), "attack")
	_check_stored_attack_motion(failures, scene, "yellow_turban_3", "arrow", "ranged", "attack", "piercing_trace")
	scene._on_unit_action_motion_requested("cao_cao", Vector2i(1, 3), Vector2i(3, 3), "skill_damage")
	_check_stored_attack_motion(failures, scene, "cao_cao", "tactic_damage", "tactic", "skill_damage", "tactic_flare")
	scene._on_unit_action_motion_requested("cao_cao", Vector2i(1, 3), Vector2i(1, 3), "skill_heal")
	_check_stored_attack_motion(failures, scene, "cao_cao", "tactic_heal", "tactic", "skill_heal", "healing_sigil")
	_check_attack_vfx_signature(failures, scene, "arrow", "piercing_trace")
	_check_attack_vfx_signature(failures, scene, "cavalry_charge", "charge_dust")
	_check_attack_vfx_signature(failures, scene, "infantry_strike", "shield_spark")
	_check_attack_vfx_signature(failures, scene, "command_strike", "command_ring")
	_check_attack_vfx_signature(failures, scene, "heavy_strike", "shock_crack")
	_check_attack_vfx_signature(failures, scene, "tactic_damage", "tactic_flare")
	_check_attack_vfx_signature(failures, scene, "tactic_heal", "healing_sigil")
	_check_attack_vfx_signature(failures, scene, "tactic_support", "order_banner")
	_check_unique_attack_vfx_signatures(failures, scene)
	if scene._action_motion_duration("cavalry_charge") <= scene._action_motion_duration("slash"):
		failures.append("cavalry charge animation should last longer than default slash")
	if scene._action_motion_duration("tactic_damage") <= scene._action_motion_duration("command_strike"):
		failures.append("tactic cast animation should linger longer than command physical strike")
	if scene._action_motion_lunge_scale("arrow") >= scene._action_motion_lunge_scale("slash"):
		failures.append("arrow animation should keep the archer mostly planted")
	if scene._action_motion_lunge_scale("tactic_damage") >= scene._action_motion_lunge_scale("arrow"):
		failures.append("tactic animation should keep the caster planted like a spell cast")
	if scene._action_motion_impact_radius("heavy_strike") <= scene._action_motion_impact_radius("infantry_strike"):
		failures.append("heavy strike impact radius should exceed infantry strike")
	if scene._action_motion_impact_radius("cavalry_charge") <= scene._action_motion_impact_radius("arrow"):
		failures.append("cavalry charge impact radius should exceed arrow impact")
	if scene._action_motion_sfx("arrow") != "bow_release":
		failures.append("arrow animation should use bow SFX")
	if scene._action_motion_sfx("infantry_strike") != "slash":
		failures.append("infantry animation should use slash SFX")
	if scene._action_motion_sfx("heavy_strike") != "slash":
		failures.append("heavy animation should use swing SFX before hit resolution")
	if scene._action_motion_sfx("tactic_damage") != "skill_cast":
		failures.append("tactic animation should use skill cast SFX")
	_check_attack_motion_curves(failures, scene)
	scene.free()


func _check_attack_profile(failures: Array[String], scene, unit_id: String, from_cell: Vector2i, to_cell: Vector2i, expected_profile: String) -> void:
	var unit: Dictionary = scene.state.get_unit(unit_id)
	if unit.is_empty():
		failures.append("missing unit for attack profile: %s" % unit_id)
		return
	var profile: String = scene._unit_action_motion_profile(unit, from_cell, to_cell, "attack")
	if profile != expected_profile:
		failures.append("%s attack profile mismatch: expected %s got %s" % [unit_id, expected_profile, profile])


func _check_synthetic_attack_profile(failures: Array[String], scene, class_id: String, expected_profile: String, attack_range := 1) -> void:
	var unit := {
		"class_id": class_id,
		"range": attack_range
	}
	var profile: String = scene._unit_action_motion_profile(unit, Vector2i(0, 0), Vector2i(0, max(1, attack_range)), "attack")
	if profile != expected_profile:
		failures.append("%s synthetic attack profile mismatch: expected %s got %s" % [class_id, expected_profile, profile])


func _check_skill_action_profile(failures: Array[String], scene, action_kind: String, expected_profile: String, expected_signature: String) -> void:
	var profile: String = scene._unit_action_motion_profile({"class_id": "hero", "range": 1}, Vector2i(0, 0), Vector2i(0, 0), action_kind)
	if profile != expected_profile:
		failures.append("%s action profile mismatch: expected %s got %s" % [action_kind, expected_profile, profile])
	if not scene._action_motion_allows_same_cell(profile):
		failures.append("%s should allow same-cell tactic motion" % profile)
	if scene._action_motion_vfx_signature(profile) != expected_signature:
		failures.append("%s action VFX mismatch: expected %s got %s" % [action_kind, expected_signature, scene._action_motion_vfx_signature(profile)])


func _check_attack_vfx_signature(failures: Array[String], scene, profile: String, expected_signature: String) -> void:
	var signature: String = scene._action_motion_vfx_signature(profile)
	if signature != expected_signature:
		failures.append("%s VFX signature mismatch: expected %s got %s" % [profile, expected_signature, signature])
	if str(scene._action_motion_impact_cue(profile)).is_empty():
		failures.append("%s should expose an impact cue" % profile)
	if float(scene._action_motion_impact_radius(profile)) <= 0.0:
		failures.append("%s should expose a positive impact radius" % profile)


func _check_unique_attack_vfx_signatures(failures: Array[String], scene) -> void:
	var seen := {}
	for profile in ["arrow", "cavalry_charge", "infantry_strike", "command_strike", "heavy_strike"]:
		var signature: String = scene._action_motion_vfx_signature(profile)
		if seen.has(signature):
			failures.append("attack VFX signature should be unique: %s" % signature)
		seen[signature] = true
	if scene._action_motion_vfx_signature("slash") == scene._action_motion_vfx_signature("heavy_strike"):
		failures.append("default slash should not share heavy strike VFX signature")


func _check_stored_attack_motion(failures: Array[String], scene, unit_id: String, expected_profile: String, expected_style: String, expected_kind: String, expected_vfx_signature: String) -> void:
	if not scene.unit_action_motion_by_unit.has(unit_id):
		failures.append("%s should store an action motion" % unit_id)
		return
	var motion: Dictionary = scene.unit_action_motion_by_unit[unit_id]
	var profile := str(motion.get("profile", ""))
	var family := str(motion.get("attack_family", ""))
	var style := str(motion.get("style", ""))
	var kind := str(motion.get("kind", ""))
	var signature := str(motion.get("vfx_signature", ""))
	if profile != expected_profile or family != expected_profile:
		failures.append("%s stored motion profile mismatch: %s" % [unit_id, str(motion)])
	if style != expected_style:
		failures.append("%s stored motion style mismatch: %s" % [unit_id, str(motion)])
	if kind != expected_kind:
		failures.append("%s stored motion kind mismatch: %s" % [unit_id, str(motion)])
	if signature != expected_vfx_signature:
		failures.append("%s stored motion VFX signature mismatch: %s" % [unit_id, str(motion)])
	if str(motion.get("impact_cue", "")).is_empty():
		failures.append("%s stored motion should include an impact cue: %s" % [unit_id, str(motion)])
	if float(motion.get("impact_radius", 0.0)) <= 0.0:
		failures.append("%s stored motion should include a positive impact radius: %s" % [unit_id, str(motion)])
	if float(motion.get("duration", 0.0)) <= 0.0:
		failures.append("%s stored motion duration should be positive: %s" % [unit_id, str(motion)])


func _check_attack_motion_curves(failures: Array[String], scene) -> void:
	var profiles := ["arrow", "cavalry_charge", "infantry_strike", "command_strike", "heavy_strike", "tactic_damage", "tactic_heal", "tactic_support", "slash"]
	for profile in profiles:
		for sample in [0.0, 0.25, 0.5, 0.75, 1.0]:
			var lunge: float = scene._action_motion_lunge_curve(profile, sample)
			var peak: float = scene._action_motion_effect_peak(profile, sample)
			if lunge < -0.001 or lunge > 1.001:
				failures.append("%s lunge curve out of bounds at %.2f: %.3f" % [profile, sample, lunge])
			if peak < -0.001 or peak > 1.001:
				failures.append("%s effect peak out of bounds at %.2f: %.3f" % [profile, sample, peak])
		if absf(scene._action_motion_lunge_curve(profile, 0.0)) > 0.001:
			failures.append("%s lunge should start near zero" % profile)
		if absf(scene._action_motion_lunge_curve(profile, 1.0)) > 0.001:
			failures.append("%s lunge should recover near zero" % profile)

	var arrow_mid: float = scene._action_motion_lunge_curve("arrow", 0.5) * scene._action_motion_lunge_scale("arrow")
	var infantry_mid: float = scene._action_motion_lunge_curve("infantry_strike", 0.5) * scene._action_motion_lunge_scale("infantry_strike")
	var cavalry_mid: float = scene._action_motion_lunge_curve("cavalry_charge", 0.5) * scene._action_motion_lunge_scale("cavalry_charge")
	var heavy_early: float = scene._action_motion_lunge_curve("heavy_strike", 0.25) * scene._action_motion_lunge_scale("heavy_strike")
	var heavy_late: float = scene._action_motion_lunge_curve("heavy_strike", 0.72) * scene._action_motion_lunge_scale("heavy_strike")
	var tactic_mid: float = scene._action_motion_lunge_curve("tactic_damage", 0.5) * scene._action_motion_lunge_scale("tactic_damage")
	if not (arrow_mid < infantry_mid and tactic_mid < infantry_mid):
		failures.append("lunge curves should keep arrow and tactic planted below infantry: arrow %.3f tactic %.3f infantry %.3f" % [arrow_mid, tactic_mid, infantry_mid])
	if cavalry_mid <= infantry_mid:
		failures.append("cavalry mid-lunge should exceed infantry: cavalry %.3f infantry %.3f" % [cavalry_mid, infantry_mid])
	if heavy_late <= heavy_early:
		failures.append("heavy strike should build toward a later lunge: early %.3f late %.3f" % [heavy_early, heavy_late])

	if scene._action_motion_effect_peak("arrow", 0.34) <= scene._action_motion_effect_peak("arrow", 0.82):
		failures.append("arrow effect should peak early and fade after flight")
	if scene._action_motion_effect_peak("heavy_strike", 0.62) <= scene._action_motion_effect_peak("heavy_strike", 0.25):
		failures.append("heavy effect should peak later than its wind-up")
	if scene._action_motion_effect_peak("heavy_strike", 1.0) > 0.001:
		failures.append("heavy effect should fade near zero by the end")
	if scene._action_motion_effect_peak("command_strike", 0.0) <= 0.0:
		failures.append("command effect should keep a visible ring during wind-up")
	if scene._action_motion_effect_peak("tactic_heal", 0.0) <= 0.0:
		failures.append("tactic heal effect should keep a visible sigil during wind-up")


func _check_attack_motion_signal(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 battle for attack motion")
		return

	var attacker := state.get_unit("cao_cao")
	var target := state.get_unit("yellow_turban_1")
	if attacker.is_empty() or target.is_empty():
		failures.append("missing attacker or target for attack motion")
		return
	attacker["pos"] = Vector2i(6, 2)
	target["pos"] = Vector2i(7, 2)

	var motions: Array[String] = []
	state.unit_action_motion_requested.connect(func(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void:
		motions.append("%s|%s|%d,%d|%d,%d" % [unit_id, action_kind, from_cell.x, from_cell.y, to_cell.x, to_cell.y])
	)
	if not state.select_unit("cao_cao"):
		failures.append("could not select Cao Cao for attack motion")
		return
	if not state.try_attack_selected("yellow_turban_1"):
		failures.append("Cao Cao should be able to attack adjacent target")
		return
	if not motions.has("cao_cao|attack|6,2|7,2"):
		failures.append("attack motion signal missing expected Cao Cao attack: %s" % str(motions))
	_check_skill_motion_signal(failures, "spark", "skill_damage", Vector2i(3, 3), func(skill_state) -> void:
		var enemy: Dictionary = skill_state.get_unit("yellow_turban_1")
		enemy["pos"] = Vector2i(3, 3)
	)
	_check_skill_motion_signal(failures, "mend", "skill_heal", Vector2i(1, 3), func(skill_state) -> void:
		var caster: Dictionary = skill_state.get_unit("cao_cao")
		caster["hp"] = max(1, int(caster.get("hp", 1)) - 10)
	)
	_check_skill_motion_signal(failures, "guard_order", "skill_support", Vector2i(1, 3), func(_skill_state) -> void:
		pass
	)


func _check_skill_motion_signal(failures: Array[String], skill_id: String, expected_kind: String, target_cell: Vector2i, setup: Callable) -> void:
	var state = BattleStateScript.new()
	if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
		failures.append("could not load battle for %s motion" % skill_id)
		return
	setup.call(state)
	var motions: Array[String] = []
	state.unit_action_motion_requested.connect(func(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void:
		motions.append("%s|%s|%d,%d|%d,%d" % [unit_id, action_kind, from_cell.x, from_cell.y, to_cell.x, to_cell.y])
	)
	if not state.select_unit("cao_cao"):
		failures.append("could not select Cao Cao for %s motion" % skill_id)
		return
	if not state.try_cast_selected_skill(skill_id, target_cell):
		failures.append("%s should cast successfully for motion check" % skill_id)
		return
	var expected := "cao_cao|%s|1,3|%d,%d" % [expected_kind, target_cell.x, target_cell.y]
	if not motions.has(expected):
		failures.append("%s motion signal missing `%s`: %s" % [skill_id, expected, str(motions)])


func _find_camp_conversation(conversations: Array, conversation_id: String) -> Dictionary:
	for conversation in conversations:
		if typeof(conversation) == TYPE_DICTIONARY and str(conversation.get("id", "")) == conversation_id:
			return conversation
	return {}


func _check_shop_item_visibility(failures: Array[String], state, item_id: String, expected_visible: bool, context: String) -> void:
	var visible: bool = state.get_shop_item_ids().has(item_id)
	if visible != expected_visible:
		failures.append("%s shop visibility for %s should be %s" % [context, item_id, str(expected_visible)])


func _check_camp_conversation_effect(failures: Array[String], conversation: Dictionary, expected_item_id: String, expected_count: int) -> void:
	var effects: Array = conversation.get("effects", [])
	if effects.is_empty():
		failures.append("camp conversation %s should expose effects" % str(conversation.get("id", "")))
		return
	var effect: Dictionary = effects[0]
	if str(effect.get("type", "")) != "grant_item":
		failures.append("camp conversation effect should be grant_item: %s" % str(effect))
	if str(effect.get("item_id", "")) != expected_item_id:
		failures.append("camp conversation effect item mismatch: %s" % str(effect))
	if int(effect.get("count", 0)) != expected_count:
		failures.append("camp conversation effect count mismatch: %s" % str(effect))


func _check_camp_conversation(failures: Array[String], conversation: Dictionary, expected_id: String, expected_group: String, expected_officer_id: String) -> void:
	if str(conversation.get("id", "")) != expected_id:
		failures.append("camp conversation id mismatch: expected %s got %s" % [expected_id, str(conversation.get("id", ""))])
	if str(conversation.get("group", "")) != expected_group:
		failures.append("camp conversation group mismatch for %s" % expected_id)
	if str(conversation.get("officer_id", "")) != expected_officer_id:
		failures.append("camp conversation officer mismatch for %s" % expected_id)
	if str(conversation.get("label", "")).is_empty():
		failures.append("camp conversation label missing for %s" % expected_id)
	var lines: Array = conversation.get("lines", [])
	if lines.is_empty():
		failures.append("camp conversation lines missing for %s" % expected_id)
		return
	var first_line: Dictionary = lines[0]
	if str(first_line.get("text", "")).is_empty():
		failures.append("camp conversation first text missing for %s" % expected_id)
	if expected_group == "officer" and str(first_line.get("portrait", "")).is_empty():
		failures.append("camp conversation officer portrait missing for %s" % expected_id)


func _check_unit_sprite_resolution(failures: Array[String], state, unit_id: String, expected_sprite: String, expected_enemy_sprite: bool) -> void:
	var unit: Dictionary = state.get_unit(unit_id)
	if unit.is_empty():
		failures.append("missing unit for sprite resolution: %s" % unit_id)
		return
	var sprite := str(unit.get("sprite", ""))
	if sprite != expected_sprite:
		failures.append("%s sprite mismatch: expected %s got %s" % [unit_id, expected_sprite, sprite])
	if bool(unit.get("uses_enemy_sprite", false)) != expected_enemy_sprite:
		failures.append("%s enemy sprite flag mismatch: expected %s got %s" % [unit_id, str(expected_enemy_sprite), str(unit.get("uses_enemy_sprite", false))])
	_check_image_path(failures, sprite, "unit %s resolved sprite" % unit_id)


func _check_alpha_cutout_path(failures: Array[String], path: String, context: String) -> void:
	if path.is_empty():
		failures.append("%s has an empty image path" % context)
		return
	if not path.begins_with("res://"):
		failures.append("%s must use a res:// path: %s" % [context, path])
		return
	if not FileAccess.file_exists(path):
		failures.append("%s references missing image: %s" % [context, path])
		return
	var image := Image.new()
	var err := image.load(path)
	if err != OK:
		failures.append("%s references unreadable image: %s" % [context, path])
		return
	if image.get_width() < 512 or image.get_height() < 512:
		failures.append("%s image should be at least 512x512: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
		return
	for corner in [Vector2i(0, 0), Vector2i(image.get_width() - 1, 0), Vector2i(0, image.get_height() - 1), Vector2i(image.get_width() - 1, image.get_height() - 1)]:
		if image.get_pixelv(corner).a > 0.01:
			failures.append("%s should have transparent corners: %s" % [context, path])
			return
	var transparent_samples := 0
	var total_samples := 0
	for y in range(0, image.get_height(), 4):
		for x in range(0, image.get_width(), 4):
			total_samples += 1
			if image.get_pixel(x, y).a <= 0.01:
				transparent_samples += 1
	if transparent_samples < int(total_samples * 0.20):
		failures.append("%s should have substantial transparent background: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])


func _check_image_path(failures: Array[String], path: String, context: String) -> void:
	if path.is_empty():
		failures.append("%s has an empty image path" % context)
		return
	if not path.begins_with("res://"):
		failures.append("%s must use a res:// path: %s" % [context, path])
		return
	if not FileAccess.file_exists(path):
		failures.append("%s references missing image: %s" % [context, path])
		return
	var image := Image.new()
	var err := image.load(path)
	if err != OK:
		failures.append("%s references unreadable image: %s" % [context, path])
		return
	if image.get_width() < 512 or image.get_height() < 512:
		failures.append("%s image should be at least 512x512: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
