Files
heros/tools/smoke_visual_assets.gd

7126 lines
426 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/yingchuan_opening_battlefield.png",
"label": "001 opening field background",
"allow_default": false
},
{
"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/003_xingyang_ambush.json",
"background_path": "res://art/backgrounds/xingyang_forest_ambush.png",
"label": "003 Xingyang forest ambush background"
},
{
"scenario_path": "res://data/scenarios/004_qingzhou_campaign.json",
"background_path": "res://art/backgrounds/qingzhou_village_road.png",
"label": "004 Qingzhou village road background"
},
{
"scenario_path": "res://data/scenarios/005_puyang_raid.json",
"background_path": "res://art/backgrounds/puyang_raid_camp.png",
"label": "005 Puyang raid camp background"
},
{
"scenario_path": "res://data/scenarios/006_dingtao_counterattack.json",
"background_path": "res://art/backgrounds/qingzhou_village_road.png",
"label": "006 Dingtao field background"
},
{
"scenario_path": "res://data/scenarios/007_xian_emperor_escort.json",
"background_path": "res://art/backgrounds/luoyang_escort_road.png",
"label": "007 Luoyang escort road 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/010_white_horse_relief.json",
"background_path": "res://art/backgrounds/white_horse_relief.png",
"label": "010 White Horse relief 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"
},
{
"scenario_path": "res://data/scenarios/014_cangting_pursuit.json",
"background_path": "res://art/backgrounds/cangting_pursuit.png",
"label": "014 Cangting pursuit background"
},
{
"scenario_path": "res://data/scenarios/015_ye_campaign.json",
"background_path": "res://art/backgrounds/ye_outer_defense.png",
"label": "015 Ye outer defense background"
},
{
"scenario_path": "res://data/scenarios/016_ye_siege.json",
"background_path": "res://art/backgrounds/ye_inner_gate.png",
"label": "016 Ye inner gate background"
},
{
"scenario_path": "res://data/scenarios/017_ye_surrender.json",
"background_path": "res://art/backgrounds/ye_palace_road.png",
"label": "017 Ye palace road background"
}
]
func _init() -> void:
var failures: Array[String] = []
_check_battle_visual_data(failures)
_check_generated_toolbar_icons(failures)
_check_generated_panel_textures(failures)
_check_generated_button_textures(failures)
_check_qhd_ui_import_settings(failures)
_check_generated_tile_marker_textures(failures)
_check_generated_class_icon_textures(failures)
_check_generated_map_badge_textures(failures)
_check_generated_terrain_textures(failures)
_check_generated_terrain_feature_textures(failures)
_check_scene_texture_loading(failures)
_check_map_overlay_softness(failures)
_check_core_text_visibility(failures)
_check_qhd_design_layout_contract(failures)
_check_hud_focus_text(failures)
_check_ancient_ui_theme(failures)
_check_opening_prologue_presentation(failures)
_check_hover_intent_badges(failures)
_check_counter_attack_badge_variants(failures)
_check_hud_command_grid_layout(failures)
_check_battle_unit_list_panel(failures)
_check_action_button_disabled_reasons(failures)
_check_terrain_and_unit_presentation(failures)
_check_objective_and_status_marker_helpers(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() < 4:
failures.append("001 briefing should expose at least four 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")
var villager_report := _find_camp_conversation(conversations, "yingchuan_villager_report")
_check_camp_conversation(failures, villager_report, "yingchuan_villager_report", "topic", "")
if not str(villager_report).contains("영천 백성") or not str(villager_report).contains("마을 우물가"):
failures.append("001 villager report should explain the route and recovery point")
var cache := _find_camp_conversation(conversations, "northern_woods_cache")
_check_camp_conversation(failures, cache, "northern_woods_cache", "topic", "")
_check_camp_conversation_effect(failures, cache, "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")
if state.get_map_background_path() != "res://art/backgrounds/yingchuan_opening_battlefield.png":
failures.append("001 should use a dedicated Yingchuan battlefield background")
var opening_story_images: Array[String] = []
for page in BattleSceneScript.OPENING_PROLOGUE_PAGES:
var image_path := str((page as Dictionary).get("image", ""))
if image_path.is_empty():
failures.append("opening prologue pages should reference generated story artwork")
continue
if opening_story_images.has(image_path):
failures.append("opening prologue pages should use distinct story artwork: %s" % image_path)
opening_story_images.append(image_path)
_check_image_path(failures, image_path, "opening prologue story art")
if opening_story_images.size() != 4:
failures.append("opening prologue should expose four generated story panels")
_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_camp_dialogue: Array = gate_briefing.get("camp_dialogue", [])
if gate_camp_dialogue.size() < 3 or not str(gate_camp_dialogue).contains("사수관") or not str(gate_camp_dialogue).contains("화웅"):
failures.append("002 briefing should expose camp dialogue that bridges Sishui Gate context")
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", "")) != "연합 상인":
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 청주 평정전 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", "")) != "청주 길상인":
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", "")) != "복양 야상":
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", "")) != "정도 야상":
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", "")) != "호송 야상":
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", "")) != "완성 야상":
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")
var xiapi_state = BattleStateScript.new()
if not xiapi_state.load_battle("res://data/scenarios/009_xiapi_siege.json"):
failures.append("could not load Xiapi Siege camp data")
else:
var xiapi_conversations: Array = xiapi_state.get_briefing().get("camp_conversations", [])
if xiapi_conversations.size() != 2:
failures.append("009 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, xiapi_conversations[0], "xiapi_floodgate_council", "officer", "cao_cao")
_check_camp_conversation(failures, xiapi_conversations[1], "xiapi_dian_wei_siege_road", "officer", "dian_wei")
var xiapi_merchant := xiapi_state.get_shop_merchant()
if str(xiapi_merchant.get("name", "")) != "하비 수문 야상":
failures.append("009 shop should expose Xiapi merchant name")
if (xiapi_merchant.get("lines", []) as Array).size() < 2:
failures.append("009 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, xiapi_state, "war_drum", false, "009 base")
_check_shop_item_visibility(failures, xiapi_state, "war_axe", false, "009 base")
var xiapi_held_state = BattleStateScript.new()
if not xiapi_held_state.load_battle("res://data/scenarios/009_xiapi_siege.json", {}, {}, {"held_wan_gate": true}):
failures.append("could not load Xiapi held Wan gate camp data")
else:
var medicine: Dictionary = _find_camp_conversation(
xiapi_held_state.get_briefing().get("camp_conversations", []),
"xiapi_wan_baggage_dressings"
)
if medicine.is_empty():
failures.append("009 held Wan gate flag should expose Wan baggage dressings conversation")
else:
_check_camp_conversation_effect(failures, medicine, "panacea", 1)
if not _find_camp_conversation(
xiapi_held_state.get_briefing().get("camp_conversations", []),
"xiapi_forced_march_rations"
).is_empty():
failures.append("009 held Wan gate flag should not expose forced march rations conversation")
_check_shop_item_visibility(failures, xiapi_held_state, "war_drum", true, "009 held Wan gate")
_check_shop_item_visibility(failures, xiapi_held_state, "war_axe", false, "009 held Wan gate")
var xiapi_swift_state = BattleStateScript.new()
if not xiapi_swift_state.load_battle("res://data/scenarios/009_xiapi_siege.json", {}, {}, {"swift_wan_escape": true}):
failures.append("could not load Xiapi swift Wan escape camp data")
else:
var canteens: Dictionary = _find_camp_conversation(
xiapi_swift_state.get_briefing().get("camp_conversations", []),
"xiapi_forced_march_rations"
)
if canteens.is_empty():
failures.append("009 swift Wan escape flag should expose forced march rations conversation")
else:
_check_camp_conversation_effect(failures, canteens, "bean", 1)
if not _find_camp_conversation(
xiapi_swift_state.get_briefing().get("camp_conversations", []),
"xiapi_wan_baggage_dressings"
).is_empty():
failures.append("009 swift Wan escape flag should not expose Wan baggage dressings conversation")
_check_shop_item_visibility(failures, xiapi_swift_state, "war_drum", false, "009 swift Wan escape")
_check_shop_item_visibility(failures, xiapi_swift_state, "war_axe", true, "009 swift Wan escape")
var white_horse_state = BattleStateScript.new()
if not white_horse_state.load_battle("res://data/scenarios/010_white_horse_relief.json"):
failures.append("could not load White Horse Relief shop data")
else:
var white_horse_conversations: Array = white_horse_state.get_briefing().get("camp_conversations", [])
if white_horse_conversations.size() != 2:
failures.append("010 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, white_horse_conversations[0], "white_horse_decoy_council", "officer", "guo_jia")
_check_camp_conversation(failures, white_horse_conversations[1], "white_horse_xiahou_dun_false_retreat", "officer", "xiahou_dun")
var white_horse_merchant := white_horse_state.get_shop_merchant()
if str(white_horse_merchant.get("name", "")) != "백마 야상":
failures.append("010 shop should expose White Horse merchant name")
if (white_horse_merchant.get("lines", []) as Array).size() < 2:
failures.append("010 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, white_horse_state, "imperial_seal", false, "010 base")
_check_shop_item_visibility(failures, white_horse_state, "war_drum", false, "010 base")
var white_horse_xu_state = BattleStateScript.new()
if not white_horse_xu_state.load_battle("res://data/scenarios/010_white_horse_relief.json", {}, {}, {"integrated_xu_province": true}):
failures.append("could not load White Horse integrated Xu shop data")
else:
var medicine: Dictionary = _find_camp_conversation(
white_horse_xu_state.get_briefing().get("camp_conversations", []),
"white_horse_xu_reserve_medicine"
)
if medicine.is_empty():
failures.append("010 integrated Xu flag should expose Xu reserve medicine conversation")
else:
_check_camp_conversation_effect(failures, medicine, "panacea", 1)
if not _find_camp_conversation(
white_horse_xu_state.get_briefing().get("camp_conversations", []),
"white_horse_fast_march_beans"
).is_empty():
failures.append("010 integrated Xu flag should not expose fast march beans conversation")
_check_shop_item_visibility(failures, white_horse_xu_state, "imperial_seal", true, "010 integrated Xu")
_check_shop_item_visibility(failures, white_horse_xu_state, "war_drum", false, "010 integrated Xu")
var white_horse_pressed_state = BattleStateScript.new()
if not white_horse_pressed_state.load_battle("res://data/scenarios/010_white_horse_relief.json", {}, {}, {"pressed_northern_campaign": true}):
failures.append("could not load White Horse pressed north shop data")
else:
var beans: Dictionary = _find_camp_conversation(
white_horse_pressed_state.get_briefing().get("camp_conversations", []),
"white_horse_fast_march_beans"
)
if beans.is_empty():
failures.append("010 pressed north flag should expose fast march beans conversation")
else:
_check_camp_conversation_effect(failures, beans, "bean", 1)
if not _find_camp_conversation(
white_horse_pressed_state.get_briefing().get("camp_conversations", []),
"white_horse_xu_reserve_medicine"
).is_empty():
failures.append("010 pressed north flag should not expose Xu reserve medicine conversation")
_check_shop_item_visibility(failures, white_horse_pressed_state, "imperial_seal", false, "010 pressed north")
_check_shop_item_visibility(failures, white_horse_pressed_state, "war_drum", true, "010 pressed north")
var yan_ford_state = BattleStateScript.new()
if not yan_ford_state.load_battle("res://data/scenarios/011_yan_ford_pursuit.json"):
failures.append("could not load Yan Ford Pursuit shop data")
else:
_check_shop_items_unique(failures, yan_ford_state, "011 base")
var yan_ford_conversations: Array = yan_ford_state.get_briefing().get("camp_conversations", [])
if yan_ford_conversations.size() != 2:
failures.append("011 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, yan_ford_conversations[0], "yan_ford_pursuit_council", "officer", "guo_jia")
_check_camp_conversation(failures, yan_ford_conversations[1], "yan_ford_xiahou_yuan_banks", "officer", "xiahou_yuan")
var yan_ford_merchant := yan_ford_state.get_shop_merchant()
if str(yan_ford_merchant.get("name", "")) != "연진 강둑 야상":
failures.append("011 shop should expose Yan Ford merchant name")
if (yan_ford_merchant.get("lines", []) as Array).size() < 2:
failures.append("011 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, yan_ford_state, "imperial_seal", false, "011 base")
_check_shop_item_visibility(failures, yan_ford_state, "war_axe", false, "011 base")
var yan_ford_fortified_state = BattleStateScript.new()
if not yan_ford_fortified_state.load_battle("res://data/scenarios/011_yan_ford_pursuit.json", {}, {}, {"fortified_white_horse": true}):
failures.append("could not load Yan Ford fortified White Horse shop data")
else:
_check_shop_items_unique(failures, yan_ford_fortified_state, "011 fortified White Horse")
var crossing_medicine: Dictionary = _find_camp_conversation(
yan_ford_fortified_state.get_briefing().get("camp_conversations", []),
"yan_ford_fortified_crossing_medicine"
)
if crossing_medicine.is_empty():
failures.append("011 fortified White Horse flag should expose crossing medicine conversation")
else:
_check_camp_conversation_effect(failures, crossing_medicine, "panacea", 1)
if not _find_camp_conversation(
yan_ford_fortified_state.get_briefing().get("camp_conversations", []),
"yan_ford_raided_supply_beans"
).is_empty():
failures.append("011 fortified White Horse flag should not expose raided supply beans conversation")
_check_shop_item_visibility(failures, yan_ford_fortified_state, "imperial_seal", true, "011 fortified White Horse")
_check_shop_item_visibility(failures, yan_ford_fortified_state, "war_axe", false, "011 fortified White Horse")
var yan_ford_raided_state = BattleStateScript.new()
if not yan_ford_raided_state.load_battle("res://data/scenarios/011_yan_ford_pursuit.json", {}, {}, {"raided_yuan_supplies": true}):
failures.append("could not load Yan Ford raided Yuan supplies shop data")
else:
_check_shop_items_unique(failures, yan_ford_raided_state, "011 raided Yuan supplies")
var supply_beans: Dictionary = _find_camp_conversation(
yan_ford_raided_state.get_briefing().get("camp_conversations", []),
"yan_ford_raided_supply_beans"
)
if supply_beans.is_empty():
failures.append("011 raided Yuan supplies flag should expose raided supply beans conversation")
else:
_check_camp_conversation_effect(failures, supply_beans, "bean", 1)
if not _find_camp_conversation(
yan_ford_raided_state.get_briefing().get("camp_conversations", []),
"yan_ford_fortified_crossing_medicine"
).is_empty():
failures.append("011 raided Yuan supplies flag should not expose crossing medicine conversation")
_check_shop_item_visibility(failures, yan_ford_raided_state, "imperial_seal", false, "011 raided Yuan supplies")
_check_shop_item_visibility(failures, yan_ford_raided_state, "war_axe", true, "011 raided Yuan supplies")
var guandu_state = BattleStateScript.new()
if not guandu_state.load_battle("res://data/scenarios/012_guandu_showdown.json"):
failures.append("could not load Guandu Showdown shop data")
else:
_check_shop_items_unique(failures, guandu_state, "012 base")
var guandu_conversations: Array = guandu_state.get_briefing().get("camp_conversations", [])
if guandu_conversations.size() != 2:
failures.append("012 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, guandu_conversations[0], "guandu_main_line_council", "officer", "cao_cao")
_check_camp_conversation(failures, guandu_conversations[1], "guandu_guo_jia_supply_wound", "officer", "guo_jia")
var guandu_merchant := guandu_state.get_shop_merchant()
if str(guandu_merchant.get("name", "")) != "관도 영채 야상":
failures.append("012 shop should expose Guandu merchant name")
if (guandu_merchant.get("lines", []) as Array).size() < 2:
failures.append("012 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, guandu_state, "imperial_seal", false, "012 base")
_check_shop_item_visibility(failures, guandu_state, "war_drum", false, "012 base")
_check_shop_item_visibility(failures, guandu_state, "yitian_sword", false, "012 base")
_check_shop_item_visibility(failures, guandu_state, "panacea", false, "012 base")
_check_shop_item_visibility(failures, guandu_state, "antidote", false, "012 base")
var guandu_secured_state = BattleStateScript.new()
if not guandu_secured_state.load_battle("res://data/scenarios/012_guandu_showdown.json", {}, {}, {"secured_guandu_line": true}):
failures.append("could not load Guandu secured line shop data")
else:
_check_shop_items_unique(failures, guandu_secured_state, "012 secured Guandu line")
var line_medicine: Dictionary = _find_camp_conversation(
guandu_secured_state.get_briefing().get("camp_conversations", []),
"guandu_secured_line_medicine"
)
if line_medicine.is_empty():
failures.append("012 secured Guandu line flag should expose secured line medicine conversation")
else:
_check_camp_conversation_effect(failures, line_medicine, "panacea", 1)
if not _find_camp_conversation(
guandu_secured_state.get_briefing().get("camp_conversations", []),
"guandu_wuchao_scout_wine"
).is_empty():
failures.append("012 secured Guandu line flag should not expose Wuchao scout wine conversation")
_check_shop_item_visibility(failures, guandu_secured_state, "imperial_seal", false, "012 secured Guandu line")
_check_shop_item_visibility(failures, guandu_secured_state, "war_drum", false, "012 secured Guandu line")
_check_shop_item_visibility(failures, guandu_secured_state, "yitian_sword", false, "012 secured Guandu line")
_check_shop_item_visibility(failures, guandu_secured_state, "panacea", true, "012 secured Guandu line")
_check_shop_item_visibility(failures, guandu_secured_state, "antidote", false, "012 secured Guandu line")
var guandu_scouted_state = BattleStateScript.new()
if not guandu_scouted_state.load_battle("res://data/scenarios/012_guandu_showdown.json", {}, {}, {"scouted_wuchao_depots": true}):
failures.append("could not load Guandu scouted Wuchao shop data")
else:
_check_shop_items_unique(failures, guandu_scouted_state, "012 scouted Wuchao")
var scout_wine: Dictionary = _find_camp_conversation(
guandu_scouted_state.get_briefing().get("camp_conversations", []),
"guandu_wuchao_scout_wine"
)
if scout_wine.is_empty():
failures.append("012 scouted Wuchao flag should expose Wuchao scout wine conversation")
else:
_check_camp_conversation_effect(failures, scout_wine, "wine", 1)
if not _find_camp_conversation(
guandu_scouted_state.get_briefing().get("camp_conversations", []),
"guandu_secured_line_medicine"
).is_empty():
failures.append("012 scouted Wuchao flag should not expose secured line medicine conversation")
_check_shop_item_visibility(failures, guandu_scouted_state, "imperial_seal", false, "012 scouted Wuchao")
_check_shop_item_visibility(failures, guandu_scouted_state, "war_drum", false, "012 scouted Wuchao")
_check_shop_item_visibility(failures, guandu_scouted_state, "yitian_sword", false, "012 scouted Wuchao")
_check_shop_item_visibility(failures, guandu_scouted_state, "panacea", false, "012 scouted Wuchao")
_check_shop_item_visibility(failures, guandu_scouted_state, "antidote", true, "012 scouted Wuchao")
var wuchao_state = BattleStateScript.new()
if not wuchao_state.load_battle("res://data/scenarios/013_wuchao_raid.json"):
failures.append("could not load Wuchao Raid shop data")
else:
_check_shop_items_unique(failures, wuchao_state, "013 base")
_check_shop_item_visibility(failures, wuchao_state, "war_drum", false, "013 base")
_check_shop_item_visibility(failures, wuchao_state, "imperial_seal", false, "013 base")
var wuchao_prepared_state = BattleStateScript.new()
if not wuchao_prepared_state.load_battle("res://data/scenarios/013_wuchao_raid.json", {}, {}, {"prepared_wuchao_raid": true}):
failures.append("could not load Wuchao prepared raid shop data")
else:
_check_shop_items_unique(failures, wuchao_prepared_state, "013 prepared Wuchao raid")
_check_shop_item_visibility(failures, wuchao_prepared_state, "war_drum", true, "013 prepared Wuchao raid")
_check_shop_item_visibility(failures, wuchao_prepared_state, "imperial_seal", false, "013 prepared Wuchao raid")
var wuchao_strengthened_state = BattleStateScript.new()
if not wuchao_strengthened_state.load_battle("res://data/scenarios/013_wuchao_raid.json", {}, {}, {"strengthened_guandu_defense": true}):
failures.append("could not load Wuchao strengthened Guandu shop data")
else:
_check_shop_items_unique(failures, wuchao_strengthened_state, "013 strengthened Guandu defense")
_check_shop_item_visibility(failures, wuchao_strengthened_state, "war_drum", false, "013 strengthened Guandu defense")
_check_shop_item_visibility(failures, wuchao_strengthened_state, "imperial_seal", true, "013 strengthened Guandu defense")
var wuchao_old_flags_state = BattleStateScript.new()
if not wuchao_old_flags_state.load_battle("res://data/scenarios/013_wuchao_raid.json", {}, {}, {"secured_guandu_line": true, "scouted_wuchao_depots": true}):
failures.append("could not load Wuchao old Guandu flag shop data")
else:
_check_shop_items_unique(failures, wuchao_old_flags_state, "013 old Guandu flags")
_check_shop_item_visibility(failures, wuchao_old_flags_state, "war_drum", false, "013 old Guandu flags")
_check_shop_item_visibility(failures, wuchao_old_flags_state, "imperial_seal", false, "013 old Guandu flags")
if not _find_camp_conversation(
wuchao_old_flags_state.get_briefing().get("camp_conversations", []),
"wuchao_fast_raid_supplies"
).is_empty():
failures.append("013 old Guandu flags should not expose fast raid supplies")
if not _find_camp_conversation(
wuchao_old_flags_state.get_briefing().get("camp_conversations", []),
"wuchao_guandu_reserve_wagon"
).is_empty():
failures.append("013 old Guandu flags should not expose reserve wagon supplies")
var cangting_state = BattleStateScript.new()
if not cangting_state.load_battle("res://data/scenarios/014_cangting_pursuit.json"):
failures.append("could not load Cangting Pursuit camp data")
else:
_check_shop_items_unique(failures, cangting_state, "014 base")
var cangting_conversations: Array = cangting_state.get_briefing().get("camp_conversations", [])
if cangting_conversations.size() != 2:
failures.append("014 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, cangting_conversations[0], "cangting_pursuit_council", "topic", "")
_check_camp_conversation(failures, cangting_conversations[1], "cangting_guo_jia_rearguard", "officer", "guo_jia")
var cangting_merchant := cangting_state.get_shop_merchant()
if str(cangting_merchant.get("name", "")) != "창정 길목 야상":
failures.append("014 shop should expose Cangting merchant name")
if (cangting_merchant.get("lines", []) as Array).size() < 2:
failures.append("014 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, cangting_state, "war_drum", false, "014 base")
_check_shop_item_visibility(failures, cangting_state, "imperial_seal", false, "014 base")
_check_shop_item_visibility(failures, cangting_state, "dragon_spear", false, "014 base")
var cangting_pressed_state = BattleStateScript.new()
if not cangting_pressed_state.load_battle("res://data/scenarios/014_cangting_pursuit.json", {}, {}, {"pressed_yuan_rout": true}):
failures.append("could not load Cangting pressed rout camp data")
else:
_check_shop_items_unique(failures, cangting_pressed_state, "014 pressed Yuan rout")
var fast_rations: Dictionary = _find_camp_conversation(
cangting_pressed_state.get_briefing().get("camp_conversations", []),
"cangting_fast_pursuit_beans"
)
if fast_rations.is_empty():
failures.append("014 pressed Yuan rout flag should expose fast pursuit rations")
else:
_check_camp_conversation_effect(failures, fast_rations, "bean", 1)
if not _find_camp_conversation(
cangting_pressed_state.get_briefing().get("camp_conversations", []),
"cangting_wuchao_store_medicine"
).is_empty():
failures.append("014 pressed Yuan rout flag should not expose Wuchao store medicine")
_check_shop_item_visibility(failures, cangting_pressed_state, "war_drum", true, "014 pressed Yuan rout")
_check_shop_item_visibility(failures, cangting_pressed_state, "imperial_seal", false, "014 pressed Yuan rout")
_check_shop_item_visibility(failures, cangting_pressed_state, "dragon_spear", false, "014 pressed Yuan rout")
var cangting_stores_state = BattleStateScript.new()
if not cangting_stores_state.load_battle("res://data/scenarios/014_cangting_pursuit.json", {}, {}, {"secured_wuchao_stores": true}):
failures.append("could not load Cangting secured Wuchao stores camp data")
else:
_check_shop_items_unique(failures, cangting_stores_state, "014 secured Wuchao stores")
var store_medicine: Dictionary = _find_camp_conversation(
cangting_stores_state.get_briefing().get("camp_conversations", []),
"cangting_wuchao_store_medicine"
)
if store_medicine.is_empty():
failures.append("014 secured Wuchao stores flag should expose store medicine")
else:
_check_camp_conversation_effect(failures, store_medicine, "panacea", 1)
if not _find_camp_conversation(
cangting_stores_state.get_briefing().get("camp_conversations", []),
"cangting_fast_pursuit_beans"
).is_empty():
failures.append("014 secured Wuchao stores flag should not expose fast pursuit rations")
_check_shop_item_visibility(failures, cangting_stores_state, "war_drum", false, "014 secured Wuchao stores")
_check_shop_item_visibility(failures, cangting_stores_state, "imperial_seal", true, "014 secured Wuchao stores")
_check_shop_item_visibility(failures, cangting_stores_state, "dragon_spear", false, "014 secured Wuchao stores")
var cangting_old_flags_state = BattleStateScript.new()
if not cangting_old_flags_state.load_battle("res://data/scenarios/014_cangting_pursuit.json", {}, {}, {"prepared_wuchao_raid": true, "strengthened_guandu_defense": true}):
failures.append("could not load Cangting old Wuchao flag camp data")
else:
_check_shop_item_visibility(failures, cangting_old_flags_state, "war_drum", false, "014 old Wuchao flags")
_check_shop_item_visibility(failures, cangting_old_flags_state, "imperial_seal", false, "014 old Wuchao flags")
_check_shop_item_visibility(failures, cangting_old_flags_state, "dragon_spear", false, "014 old Wuchao flags")
if not _find_camp_conversation(
cangting_old_flags_state.get_briefing().get("camp_conversations", []),
"cangting_fast_pursuit_beans"
).is_empty():
failures.append("014 old Wuchao flags should not expose fast pursuit rations")
if not _find_camp_conversation(
cangting_old_flags_state.get_briefing().get("camp_conversations", []),
"cangting_wuchao_store_medicine"
).is_empty():
failures.append("014 old Wuchao flags should not expose Wuchao store medicine")
var ye_state = BattleStateScript.new()
if not ye_state.load_battle("res://data/scenarios/015_ye_campaign.json"):
failures.append("could not load Ye Campaign camp data")
else:
_check_shop_items_unique(failures, ye_state, "015 base")
var ye_conversations: Array = ye_state.get_briefing().get("camp_conversations", [])
if ye_conversations.size() != 2:
failures.append("015 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, ye_conversations[0], "ye_outer_council", "topic", "")
_check_camp_conversation(failures, ye_conversations[1], "ye_zhang_he_city_map", "officer", "zhang_he")
var ye_merchant := ye_state.get_shop_merchant()
if str(ye_merchant.get("name", "")) != "업성 외곽 야상":
failures.append("015 shop should expose Ye merchant name")
if (ye_merchant.get("lines", []) as Array).size() < 2:
failures.append("015 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, ye_state, "war_drum", false, "015 base")
_check_shop_item_visibility(failures, ye_state, "imperial_seal", false, "015 base")
_check_shop_item_visibility(failures, ye_state, "wind_chaser_bow", false, "015 base")
var ye_advanced_state = BattleStateScript.new()
if not ye_advanced_state.load_battle("res://data/scenarios/015_ye_campaign.json", {}, {}, {"advanced_to_ye": true}):
failures.append("could not load Ye advanced camp data")
else:
_check_shop_items_unique(failures, ye_advanced_state, "015 advanced to Ye")
var rapid_rations: Dictionary = _find_camp_conversation(
ye_advanced_state.get_briefing().get("camp_conversations", []),
"ye_rapid_march_rations"
)
if rapid_rations.is_empty():
failures.append("015 advanced to Ye flag should expose rapid march rations")
else:
_check_camp_conversation_effect(failures, rapid_rations, "bean", 1)
if not _find_camp_conversation(
ye_advanced_state.get_briefing().get("camp_conversations", []),
"ye_crossing_medicine"
).is_empty():
failures.append("015 advanced to Ye flag should not expose crossing medicine")
_check_shop_item_visibility(failures, ye_advanced_state, "war_drum", true, "015 advanced to Ye")
_check_shop_item_visibility(failures, ye_advanced_state, "imperial_seal", false, "015 advanced to Ye")
_check_shop_item_visibility(failures, ye_advanced_state, "wind_chaser_bow", false, "015 advanced to Ye")
var ye_crossing_state = BattleStateScript.new()
if not ye_crossing_state.load_battle("res://data/scenarios/015_ye_campaign.json", {}, {}, {"secured_hebei_crossings": true}):
failures.append("could not load Ye secured crossings camp data")
else:
_check_shop_items_unique(failures, ye_crossing_state, "015 secured Hebei crossings")
var crossing_medicine: Dictionary = _find_camp_conversation(
ye_crossing_state.get_briefing().get("camp_conversations", []),
"ye_crossing_medicine"
)
if crossing_medicine.is_empty():
failures.append("015 secured Hebei crossings flag should expose crossing medicine")
else:
_check_camp_conversation_effect(failures, crossing_medicine, "panacea", 1)
if not _find_camp_conversation(
ye_crossing_state.get_briefing().get("camp_conversations", []),
"ye_rapid_march_rations"
).is_empty():
failures.append("015 secured Hebei crossings flag should not expose rapid march rations")
_check_shop_item_visibility(failures, ye_crossing_state, "war_drum", false, "015 secured Hebei crossings")
_check_shop_item_visibility(failures, ye_crossing_state, "imperial_seal", true, "015 secured Hebei crossings")
_check_shop_item_visibility(failures, ye_crossing_state, "wind_chaser_bow", false, "015 secured Hebei crossings")
var ye_old_flags_state = BattleStateScript.new()
if not ye_old_flags_state.load_battle("res://data/scenarios/015_ye_campaign.json", {}, {}, {"pressed_yuan_rout": true, "secured_wuchao_stores": true}):
failures.append("could not load Ye old Cangting flag camp data")
else:
_check_shop_item_visibility(failures, ye_old_flags_state, "war_drum", false, "015 old Cangting flags")
_check_shop_item_visibility(failures, ye_old_flags_state, "imperial_seal", false, "015 old Cangting flags")
if not _find_camp_conversation(
ye_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_rapid_march_rations"
).is_empty():
failures.append("015 old Cangting flags should not expose rapid march rations")
if not _find_camp_conversation(
ye_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_crossing_medicine"
).is_empty():
failures.append("015 old Cangting flags should not expose crossing medicine")
var ye_siege_state = BattleStateScript.new()
if not ye_siege_state.load_battle("res://data/scenarios/016_ye_siege.json"):
failures.append("could not load Ye Siege camp data")
else:
_check_shop_items_unique(failures, ye_siege_state, "016 base")
var ye_siege_conversations: Array = ye_siege_state.get_briefing().get("camp_conversations", [])
if ye_siege_conversations.size() != 2:
failures.append("016 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, ye_siege_conversations[0], "ye_siege_inner_gate_council", "topic", "")
_check_camp_conversation(failures, ye_siege_conversations[1], "ye_siege_zhang_he_streets", "officer", "zhang_he")
var ye_siege_merchant := ye_siege_state.get_shop_merchant()
if str(ye_siege_merchant.get("name", "")) != "업성 문전 야상":
failures.append("016 shop should expose Ye Gate merchant name")
if (ye_siege_merchant.get("lines", []) as Array).size() < 2:
failures.append("016 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, ye_siege_state, "war_drum", false, "016 base")
_check_shop_item_visibility(failures, ye_siege_state, "imperial_seal", false, "016 base")
_check_shop_item_visibility(failures, ye_siege_state, "iron_armor", true, "016 base")
var ye_siege_pressed_state = BattleStateScript.new()
if not ye_siege_pressed_state.load_battle("res://data/scenarios/016_ye_siege.json", {}, {}, {"pressed_ye_siege": true}):
failures.append("could not load Ye Siege pressed camp data")
else:
_check_shop_items_unique(failures, ye_siege_pressed_state, "016 pressed Ye siege")
var ladder_rations: Dictionary = _find_camp_conversation(
ye_siege_pressed_state.get_briefing().get("camp_conversations", []),
"ye_siege_ladder_rations"
)
if ladder_rations.is_empty():
failures.append("016 pressed Ye siege flag should expose ladder rations")
else:
_check_camp_conversation_effect(failures, ladder_rations, "bean", 1)
if not _find_camp_conversation(
ye_siege_pressed_state.get_briefing().get("camp_conversations", []),
"ye_siege_defector_medicine"
).is_empty():
failures.append("016 pressed Ye siege flag should not expose defector medicine")
_check_shop_item_visibility(failures, ye_siege_pressed_state, "war_drum", true, "016 pressed Ye siege")
_check_shop_item_visibility(failures, ye_siege_pressed_state, "imperial_seal", false, "016 pressed Ye siege")
var ye_siege_split_state = BattleStateScript.new()
if not ye_siege_split_state.load_battle("res://data/scenarios/016_ye_siege.json", {}, {}, {"split_yuan_heirs": true}):
failures.append("could not load Ye Siege split heirs camp data")
else:
_check_shop_items_unique(failures, ye_siege_split_state, "016 split Yuan heirs")
var defector_medicine: Dictionary = _find_camp_conversation(
ye_siege_split_state.get_briefing().get("camp_conversations", []),
"ye_siege_defector_medicine"
)
if defector_medicine.is_empty():
failures.append("016 split Yuan heirs flag should expose defector medicine")
else:
_check_camp_conversation_effect(failures, defector_medicine, "panacea", 1)
if not _find_camp_conversation(
ye_siege_split_state.get_briefing().get("camp_conversations", []),
"ye_siege_ladder_rations"
).is_empty():
failures.append("016 split Yuan heirs flag should not expose ladder rations")
_check_shop_item_visibility(failures, ye_siege_split_state, "war_drum", false, "016 split Yuan heirs")
_check_shop_item_visibility(failures, ye_siege_split_state, "imperial_seal", true, "016 split Yuan heirs")
var ye_siege_old_flags_state = BattleStateScript.new()
if not ye_siege_old_flags_state.load_battle("res://data/scenarios/016_ye_siege.json", {}, {}, {"advanced_to_ye": true, "secured_hebei_crossings": true}):
failures.append("could not load Ye Siege old Ye flag camp data")
else:
_check_shop_item_visibility(failures, ye_siege_old_flags_state, "war_drum", false, "016 old Ye flags")
_check_shop_item_visibility(failures, ye_siege_old_flags_state, "imperial_seal", false, "016 old Ye flags")
if not _find_camp_conversation(
ye_siege_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_siege_ladder_rations"
).is_empty():
failures.append("016 old Ye flags should not expose ladder rations")
if not _find_camp_conversation(
ye_siege_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_siege_defector_medicine"
).is_empty():
failures.append("016 old Ye flags should not expose defector medicine")
var ye_surrender_state = BattleStateScript.new()
if not ye_surrender_state.load_battle("res://data/scenarios/017_ye_surrender.json"):
failures.append("could not load Ye Surrender camp data")
else:
_check_shop_items_unique(failures, ye_surrender_state, "017 base")
var ye_surrender_conversations: Array = ye_surrender_state.get_briefing().get("camp_conversations", [])
if ye_surrender_conversations.size() != 2:
failures.append("017 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, ye_surrender_conversations[0], "ye_surrender_palace_road_council", "topic", "")
_check_camp_conversation(failures, ye_surrender_conversations[1], "ye_surrender_zhang_he_last_loyalists", "officer", "zhang_he")
var ye_surrender_merchant := ye_surrender_state.get_shop_merchant()
if str(ye_surrender_merchant.get("name", "")) != "업성 궁도 야상":
failures.append("017 shop should expose Ye Palace Road merchant name")
if (ye_surrender_merchant.get("lines", []) as Array).size() < 2:
failures.append("017 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, ye_surrender_state, "war_drum", false, "017 base")
_check_shop_item_visibility(failures, ye_surrender_state, "imperial_seal", false, "017 base")
_check_shop_item_visibility(failures, ye_surrender_state, "iron_armor", true, "017 base")
var ye_surrender_forced_state = BattleStateScript.new()
if not ye_surrender_forced_state.load_battle("res://data/scenarios/017_ye_surrender.json", {}, {}, {"forced_ye_surrender": true}):
failures.append("could not load Ye Surrender forced camp data")
else:
_check_shop_items_unique(failures, ye_surrender_forced_state, "017 forced Ye surrender")
var pressure_rations: Dictionary = _find_camp_conversation(
ye_surrender_forced_state.get_briefing().get("camp_conversations", []),
"ye_surrender_pressure_rations"
)
if pressure_rations.is_empty():
failures.append("017 forced Ye surrender flag should expose pressure rations")
else:
_check_camp_conversation_effect(failures, pressure_rations, "bean", 1)
if not _find_camp_conversation(
ye_surrender_forced_state.get_briefing().get("camp_conversations", []),
"ye_surrender_defector_medicine"
).is_empty():
failures.append("017 forced Ye surrender flag should not expose defector medicine")
_check_shop_item_visibility(failures, ye_surrender_forced_state, "war_drum", true, "017 forced Ye surrender")
_check_shop_item_visibility(failures, ye_surrender_forced_state, "imperial_seal", false, "017 forced Ye surrender")
var ye_surrender_negotiated_state = BattleStateScript.new()
if not ye_surrender_negotiated_state.load_battle("res://data/scenarios/017_ye_surrender.json", {}, {}, {"negotiated_yuan_defections": true}):
failures.append("could not load Ye Surrender negotiated camp data")
else:
_check_shop_items_unique(failures, ye_surrender_negotiated_state, "017 negotiated Yuan defections")
var surrender_defector_medicine: Dictionary = _find_camp_conversation(
ye_surrender_negotiated_state.get_briefing().get("camp_conversations", []),
"ye_surrender_defector_medicine"
)
if surrender_defector_medicine.is_empty():
failures.append("017 negotiated Yuan defections flag should expose defector medicine")
else:
_check_camp_conversation_effect(failures, surrender_defector_medicine, "panacea", 1)
if not _find_camp_conversation(
ye_surrender_negotiated_state.get_briefing().get("camp_conversations", []),
"ye_surrender_pressure_rations"
).is_empty():
failures.append("017 negotiated Yuan defections flag should not expose pressure rations")
_check_shop_item_visibility(failures, ye_surrender_negotiated_state, "war_drum", false, "017 negotiated Yuan defections")
_check_shop_item_visibility(failures, ye_surrender_negotiated_state, "imperial_seal", true, "017 negotiated Yuan defections")
var ye_surrender_old_flags_state = BattleStateScript.new()
if not ye_surrender_old_flags_state.load_battle("res://data/scenarios/017_ye_surrender.json", {}, {}, {"pressed_ye_siege": true, "split_yuan_heirs": true}):
failures.append("could not load Ye Surrender old Ye Siege flag camp data")
else:
_check_shop_item_visibility(failures, ye_surrender_old_flags_state, "war_drum", false, "017 old Ye Siege flags")
_check_shop_item_visibility(failures, ye_surrender_old_flags_state, "imperial_seal", false, "017 old Ye Siege flags")
if not _find_camp_conversation(
ye_surrender_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_surrender_pressure_rations"
).is_empty():
failures.append("017 old Ye Siege flags should not expose pressure rations")
if not _find_camp_conversation(
ye_surrender_old_flags_state.get_briefing().get("camp_conversations", []),
"ye_surrender_defector_medicine"
).is_empty():
failures.append("017 old Ye Siege flags should not expose defector medicine")
var northern_pursuit_state = BattleStateScript.new()
if not northern_pursuit_state.load_battle("res://data/scenarios/018_northern_pursuit.json"):
failures.append("could not load Northern Pursuit camp data")
else:
_check_shop_items_unique(failures, northern_pursuit_state, "018 base")
var northern_pursuit_conversations: Array = northern_pursuit_state.get_briefing().get("camp_conversations", [])
if northern_pursuit_conversations.size() != 2:
failures.append("018 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, northern_pursuit_conversations[0], "northern_pursuit_road_council", "topic", "")
_check_camp_conversation(failures, northern_pursuit_conversations[1], "northern_pursuit_zhang_he_retreat_road", "officer", "zhang_he")
var northern_pursuit_merchant := northern_pursuit_state.get_shop_merchant()
if str(northern_pursuit_merchant.get("name", "")) != "북도 야상":
failures.append("018 shop should expose Northern Road merchant name")
if (northern_pursuit_merchant.get("lines", []) as Array).size() < 2:
failures.append("018 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, northern_pursuit_state, "war_drum", false, "018 base")
_check_shop_item_visibility(failures, northern_pursuit_state, "imperial_seal", false, "018 base")
_check_shop_item_visibility(failures, northern_pursuit_state, "iron_armor", true, "018 base")
var northern_pursuit_hard_state = BattleStateScript.new()
if not northern_pursuit_hard_state.load_battle("res://data/scenarios/018_northern_pursuit.json", {}, {}, {"pursued_yuan_shang": true}):
failures.append("could not load Northern Pursuit hard march camp data")
else:
_check_shop_items_unique(failures, northern_pursuit_hard_state, "018 pursued Yuan Shang")
var hard_march_beans: Dictionary = _find_camp_conversation(
northern_pursuit_hard_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_hard_march_beans"
)
if hard_march_beans.is_empty():
failures.append("018 pursued Yuan Shang flag should expose hard march beans")
else:
_check_camp_conversation_effect(failures, hard_march_beans, "bean", 1)
if not _find_camp_conversation(
northern_pursuit_hard_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_ji_supply_medicine"
).is_empty():
failures.append("018 pursued Yuan Shang flag should not expose Ji supply medicine")
_check_shop_item_visibility(failures, northern_pursuit_hard_state, "war_drum", true, "018 pursued Yuan Shang")
_check_shop_item_visibility(failures, northern_pursuit_hard_state, "imperial_seal", false, "018 pursued Yuan Shang")
var northern_pursuit_stabilized_state = BattleStateScript.new()
if not northern_pursuit_stabilized_state.load_battle("res://data/scenarios/018_northern_pursuit.json", {}, {}, {"stabilized_ye": true}):
failures.append("could not load Northern Pursuit stabilized camp data")
else:
_check_shop_items_unique(failures, northern_pursuit_stabilized_state, "018 stabilized Ye")
var ji_supply_medicine: Dictionary = _find_camp_conversation(
northern_pursuit_stabilized_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_ji_supply_medicine"
)
if ji_supply_medicine.is_empty():
failures.append("018 stabilized Ye flag should expose Ji supply medicine")
else:
_check_camp_conversation_effect(failures, ji_supply_medicine, "panacea", 1)
if not _find_camp_conversation(
northern_pursuit_stabilized_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_hard_march_beans"
).is_empty():
failures.append("018 stabilized Ye flag should not expose hard march beans")
_check_shop_item_visibility(failures, northern_pursuit_stabilized_state, "war_drum", false, "018 stabilized Ye")
_check_shop_item_visibility(failures, northern_pursuit_stabilized_state, "imperial_seal", true, "018 stabilized Ye")
var northern_pursuit_old_flags_state = BattleStateScript.new()
if not northern_pursuit_old_flags_state.load_battle("res://data/scenarios/018_northern_pursuit.json", {}, {}, {"forced_ye_surrender": true, "negotiated_yuan_defections": true}):
failures.append("could not load Northern Pursuit old Ye Surrender flag camp data")
else:
_check_shop_item_visibility(failures, northern_pursuit_old_flags_state, "war_drum", false, "018 old Ye Surrender flags")
_check_shop_item_visibility(failures, northern_pursuit_old_flags_state, "imperial_seal", false, "018 old Ye Surrender flags")
if not _find_camp_conversation(
northern_pursuit_old_flags_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_hard_march_beans"
).is_empty():
failures.append("018 old Ye Surrender flags should not expose hard march beans")
if not _find_camp_conversation(
northern_pursuit_old_flags_state.get_briefing().get("camp_conversations", []),
"northern_pursuit_ji_supply_medicine"
).is_empty():
failures.append("018 old Ye Surrender flags should not expose Ji supply medicine")
var nanpi_state = BattleStateScript.new()
if not nanpi_state.load_battle("res://data/scenarios/019_nanpi_pressure.json"):
failures.append("could not load Nanpi camp data")
else:
_check_shop_items_unique(failures, nanpi_state, "019 base")
var nanpi_conversations: Array = nanpi_state.get_briefing().get("camp_conversations", [])
if nanpi_conversations.size() != 2:
failures.append("019 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, nanpi_conversations[0], "nanpi_pressure_gate_council", "topic", "")
_check_camp_conversation(failures, nanpi_conversations[1], "nanpi_pressure_zhang_he_gate_names", "officer", "zhang_he")
var nanpi_merchant := nanpi_state.get_shop_merchant()
if str(nanpi_merchant.get("name", "")) != "남피 성문 야상":
failures.append("019 shop should expose Nanpi Gate merchant name")
if (nanpi_merchant.get("lines", []) as Array).size() < 2:
failures.append("019 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, nanpi_state, "war_drum", false, "019 base")
_check_shop_item_visibility(failures, nanpi_state, "imperial_seal", false, "019 base")
_check_shop_item_visibility(failures, nanpi_state, "iron_armor", true, "019 base")
var nanpi_pressed_state = BattleStateScript.new()
if not nanpi_pressed_state.load_battle("res://data/scenarios/019_nanpi_pressure.json", {}, {}, {"pressed_north_to_nanpi": true}):
failures.append("could not load Nanpi pressed north camp data")
else:
_check_shop_items_unique(failures, nanpi_pressed_state, "019 pressed north")
var hard_push_beans: Dictionary = _find_camp_conversation(
nanpi_pressed_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_hard_push_beans"
)
if hard_push_beans.is_empty():
failures.append("019 pressed north flag should expose hard push beans")
else:
_check_camp_conversation_effect(failures, hard_push_beans, "bean", 1)
if not _find_camp_conversation(
nanpi_pressed_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_admin_medicine"
).is_empty():
failures.append("019 pressed north flag should not expose admin medicine")
_check_shop_item_visibility(failures, nanpi_pressed_state, "war_drum", true, "019 pressed north")
_check_shop_item_visibility(failures, nanpi_pressed_state, "imperial_seal", false, "019 pressed north")
var nanpi_secured_state = BattleStateScript.new()
if not nanpi_secured_state.load_battle("res://data/scenarios/019_nanpi_pressure.json", {}, {}, {"secured_ji_province": true}):
failures.append("could not load Nanpi secured Ji camp data")
else:
_check_shop_items_unique(failures, nanpi_secured_state, "019 secured Ji")
var admin_medicine: Dictionary = _find_camp_conversation(
nanpi_secured_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_admin_medicine"
)
if admin_medicine.is_empty():
failures.append("019 secured Ji flag should expose admin medicine")
else:
_check_camp_conversation_effect(failures, admin_medicine, "panacea", 1)
if not _find_camp_conversation(
nanpi_secured_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_hard_push_beans"
).is_empty():
failures.append("019 secured Ji flag should not expose hard push beans")
_check_shop_item_visibility(failures, nanpi_secured_state, "war_drum", false, "019 secured Ji")
_check_shop_item_visibility(failures, nanpi_secured_state, "imperial_seal", true, "019 secured Ji")
var nanpi_old_flags_state = BattleStateScript.new()
if not nanpi_old_flags_state.load_battle("res://data/scenarios/019_nanpi_pressure.json", {}, {}, {"pursued_yuan_shang": true, "stabilized_ye": true}):
failures.append("could not load Nanpi old Northern Pursuit flag camp data")
else:
_check_shop_item_visibility(failures, nanpi_old_flags_state, "war_drum", false, "019 old Northern Pursuit flags")
_check_shop_item_visibility(failures, nanpi_old_flags_state, "imperial_seal", false, "019 old Northern Pursuit flags")
if not _find_camp_conversation(
nanpi_old_flags_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_hard_push_beans"
).is_empty():
failures.append("019 old Northern Pursuit flags should not expose hard push beans")
if not _find_camp_conversation(
nanpi_old_flags_state.get_briefing().get("camp_conversations", []),
"nanpi_pressure_admin_medicine"
).is_empty():
failures.append("019 old Northern Pursuit flags should not expose admin medicine")
var nanpi_surrender_state = BattleStateScript.new()
if not nanpi_surrender_state.load_battle("res://data/scenarios/020_nanpi_surrender.json"):
failures.append("could not load Nanpi Surrender camp data")
else:
_check_shop_items_unique(failures, nanpi_surrender_state, "020 base")
var nanpi_surrender_conversations: Array = nanpi_surrender_state.get_briefing().get("camp_conversations", [])
if nanpi_surrender_conversations.size() != 2:
failures.append("020 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, nanpi_surrender_conversations[0], "nanpi_surrender_gate_terms", "topic", "")
_check_camp_conversation(failures, nanpi_surrender_conversations[1], "nanpi_surrender_zhang_he_loyalists", "officer", "zhang_he")
var nanpi_surrender_merchant := nanpi_surrender_state.get_shop_merchant()
if str(nanpi_surrender_merchant.get("name", "")) != "남피 항복 야상":
failures.append("020 shop should expose Nanpi Surrender merchant name")
if (nanpi_surrender_merchant.get("lines", []) as Array).size() < 2:
failures.append("020 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, nanpi_surrender_state, "war_drum", false, "020 base")
_check_shop_item_visibility(failures, nanpi_surrender_state, "imperial_seal", false, "020 base")
_check_shop_item_visibility(failures, nanpi_surrender_state, "iron_armor", true, "020 base")
var nanpi_surrender_pressure_state = BattleStateScript.new()
if not nanpi_surrender_pressure_state.load_battle("res://data/scenarios/020_nanpi_surrender.json", {}, {}, {"pressed_yuan_tan_surrender": true}):
failures.append("could not load Nanpi Surrender pressure camp data")
else:
_check_shop_items_unique(failures, nanpi_surrender_pressure_state, "020 pressed Yuan Tan surrender")
var pressure_wine: Dictionary = _find_camp_conversation(
nanpi_surrender_pressure_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_pressure_wine"
)
if pressure_wine.is_empty():
failures.append("020 pressed Yuan Tan surrender flag should expose pressure wine")
else:
_check_camp_conversation_effect(failures, pressure_wine, "wine", 1)
if not _find_camp_conversation(
nanpi_surrender_pressure_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_admin_medicine"
).is_empty():
failures.append("020 pressed Yuan Tan surrender flag should not expose admin medicine")
_check_shop_item_visibility(failures, nanpi_surrender_pressure_state, "war_drum", true, "020 pressed Yuan Tan surrender")
_check_shop_item_visibility(failures, nanpi_surrender_pressure_state, "imperial_seal", false, "020 pressed Yuan Tan surrender")
var nanpi_surrender_admin_state = BattleStateScript.new()
if not nanpi_surrender_admin_state.load_battle("res://data/scenarios/020_nanpi_surrender.json", {}, {}, {"secured_nanpi_admin": true}):
failures.append("could not load Nanpi Surrender admin camp data")
else:
_check_shop_items_unique(failures, nanpi_surrender_admin_state, "020 secured Nanpi admin")
var surrender_admin_medicine: Dictionary = _find_camp_conversation(
nanpi_surrender_admin_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_admin_medicine"
)
if surrender_admin_medicine.is_empty():
failures.append("020 secured Nanpi admin flag should expose admin medicine")
else:
_check_camp_conversation_effect(failures, surrender_admin_medicine, "panacea", 1)
if not _find_camp_conversation(
nanpi_surrender_admin_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_pressure_wine"
).is_empty():
failures.append("020 secured Nanpi admin flag should not expose pressure wine")
_check_shop_item_visibility(failures, nanpi_surrender_admin_state, "war_drum", false, "020 secured Nanpi admin")
_check_shop_item_visibility(failures, nanpi_surrender_admin_state, "imperial_seal", true, "020 secured Nanpi admin")
var nanpi_surrender_old_flags_state = BattleStateScript.new()
if not nanpi_surrender_old_flags_state.load_battle("res://data/scenarios/020_nanpi_surrender.json", {}, {}, {"pressed_north_to_nanpi": true, "secured_ji_province": true}):
failures.append("could not load Nanpi Surrender old Nanpi Pressure flag camp data")
else:
_check_shop_item_visibility(failures, nanpi_surrender_old_flags_state, "war_drum", false, "020 old Nanpi Pressure flags")
_check_shop_item_visibility(failures, nanpi_surrender_old_flags_state, "imperial_seal", false, "020 old Nanpi Pressure flags")
if not _find_camp_conversation(
nanpi_surrender_old_flags_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_pressure_wine"
).is_empty():
failures.append("020 old Nanpi Pressure flags should not expose pressure wine")
if not _find_camp_conversation(
nanpi_surrender_old_flags_state.get_briefing().get("camp_conversations", []),
"nanpi_surrender_admin_medicine"
).is_empty():
failures.append("020 old Nanpi Pressure flags should not expose admin medicine")
var bohai_state = BattleStateScript.new()
if not bohai_state.load_battle("res://data/scenarios/021_bohai_remnants.json"):
failures.append("could not load Bohai camp data")
else:
_check_shop_items_unique(failures, bohai_state, "021 base")
var bohai_conversations: Array = bohai_state.get_briefing().get("camp_conversations", [])
if bohai_conversations.size() != 2:
failures.append("021 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, bohai_conversations[0], "bohai_remnants_exile_council", "topic", "")
_check_camp_conversation(failures, bohai_conversations[1], "bohai_remnants_zhang_he_escape_road", "officer", "zhang_he")
var bohai_merchant := bohai_state.get_shop_merchant()
if str(bohai_merchant.get("name", "")) != "발해 길목 야상":
failures.append("021 shop should expose Bohai Road merchant name")
if (bohai_merchant.get("lines", []) as Array).size() < 2:
failures.append("021 shop should expose merchant flavor lines")
_check_shop_item_visibility(failures, bohai_state, "war_drum", false, "021 base")
_check_shop_item_visibility(failures, bohai_state, "imperial_seal", false, "021 base")
_check_shop_item_visibility(failures, bohai_state, "iron_armor", true, "021 base")
var bohai_pursued_state = BattleStateScript.new()
if not bohai_pursued_state.load_battle("res://data/scenarios/021_bohai_remnants.json", {}, {}, {"pursued_yuan_brothers": true}):
failures.append("could not load Bohai pursued Yuan brothers camp data")
else:
_check_shop_items_unique(failures, bohai_pursued_state, "021 pursued Yuan brothers")
var fast_wine: Dictionary = _find_camp_conversation(
bohai_pursued_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_fast_column_wine"
)
if fast_wine.is_empty():
failures.append("021 pursued Yuan brothers flag should expose fast column wine")
else:
_check_camp_conversation_effect(failures, fast_wine, "wine", 1)
if not _find_camp_conversation(
bohai_pursued_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_commandery_medicine"
).is_empty():
failures.append("021 pursued Yuan brothers flag should not expose commandery medicine")
_check_shop_item_visibility(failures, bohai_pursued_state, "war_drum", true, "021 pursued Yuan brothers")
_check_shop_item_visibility(failures, bohai_pursued_state, "imperial_seal", false, "021 pursued Yuan brothers")
var bohai_secured_state = BattleStateScript.new()
if not bohai_secured_state.load_battle("res://data/scenarios/021_bohai_remnants.json", {}, {}, {"secured_bohai_commandery": true}):
failures.append("could not load Bohai secured commandery camp data")
else:
_check_shop_items_unique(failures, bohai_secured_state, "021 secured Bohai")
var commandery_medicine: Dictionary = _find_camp_conversation(
bohai_secured_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_commandery_medicine"
)
if commandery_medicine.is_empty():
failures.append("021 secured Bohai flag should expose commandery medicine")
else:
_check_camp_conversation_effect(failures, commandery_medicine, "panacea", 1)
if not _find_camp_conversation(
bohai_secured_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_fast_column_wine"
).is_empty():
failures.append("021 secured Bohai flag should not expose fast column wine")
_check_shop_item_visibility(failures, bohai_secured_state, "war_drum", false, "021 secured Bohai")
_check_shop_item_visibility(failures, bohai_secured_state, "imperial_seal", true, "021 secured Bohai")
var bohai_old_flags_state = BattleStateScript.new()
if not bohai_old_flags_state.load_battle("res://data/scenarios/021_bohai_remnants.json", {}, {}, {"pressed_yuan_tan_surrender": true, "secured_nanpi_admin": true}):
failures.append("could not load Bohai old Nanpi Surrender flag camp data")
else:
_check_shop_item_visibility(failures, bohai_old_flags_state, "war_drum", false, "021 old Nanpi Surrender flags")
_check_shop_item_visibility(failures, bohai_old_flags_state, "imperial_seal", false, "021 old Nanpi Surrender flags")
if not _find_camp_conversation(
bohai_old_flags_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_fast_column_wine"
).is_empty():
failures.append("021 old Nanpi Surrender flags should not expose fast column wine")
if not _find_camp_conversation(
bohai_old_flags_state.get_briefing().get("camp_conversations", []),
"bohai_remnants_commandery_medicine"
).is_empty():
failures.append("021 old Nanpi Surrender flags should not expose commandery medicine")
var liaoxi_state = BattleStateScript.new()
if not liaoxi_state.load_battle("res://data/scenarios/022_liaoxi_pursuit.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Liaoxi Pursuit camp data")
else:
_check_shop_items_unique(failures, liaoxi_state, "022 base")
var liaoxi_conversations: Array = liaoxi_state.get_briefing().get("camp_conversations", [])
if liaoxi_conversations.size() != 2:
failures.append("022 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, liaoxi_conversations[0], "liaoxi_frontier_council", "topic", "")
_check_camp_conversation(failures, liaoxi_conversations[1], "liaoxi_zhang_he_hill_roads", "officer", "zhang_he")
var liaoxi_merchant := liaoxi_state.get_shop_merchant()
if str(liaoxi_merchant.get("name", "")) != "Liaoxi Frontier Sutler":
failures.append("022 shop should expose Liaoxi Frontier merchant name")
if (liaoxi_merchant.get("lines", []) as Array).size() < 2:
failures.append("022 shop should expose merchant flavor lines")
if liaoxi_state.get_unit("zhang_he_ch22").is_empty() or not liaoxi_state.is_required_deployment("zhang_he_ch22"):
failures.append("022 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, liaoxi_state, "war_drum", false, "022 base")
_check_shop_item_visibility(failures, liaoxi_state, "imperial_seal", false, "022 base")
_check_shop_item_visibility(failures, liaoxi_state, "iron_armor", true, "022 base")
var liaoxi_pressed_state = BattleStateScript.new()
if not liaoxi_pressed_state.load_battle("res://data/scenarios/022_liaoxi_pursuit.json", {}, {}, {"pressed_liaoxi_pursuit": true}, ["zhang_he"]):
failures.append("could not load Liaoxi pressed pursuit camp data")
else:
_check_shop_items_unique(failures, liaoxi_pressed_state, "022 pressed Liaoxi")
var fast_drums: Dictionary = _find_camp_conversation(
liaoxi_pressed_state.get_briefing().get("camp_conversations", []),
"liaoxi_fast_pursuit_drums"
)
if fast_drums.is_empty():
failures.append("022 pressed Liaoxi flag should expose fast pursuit drums")
else:
_check_camp_conversation_effect(failures, fast_drums, "war_drum", 1)
if not _find_camp_conversation(
liaoxi_pressed_state.get_briefing().get("camp_conversations", []),
"liaoxi_approach_medicine"
).is_empty():
failures.append("022 pressed Liaoxi flag should not expose approach medicine")
_check_shop_item_visibility(failures, liaoxi_pressed_state, "war_drum", true, "022 pressed Liaoxi")
_check_shop_item_visibility(failures, liaoxi_pressed_state, "imperial_seal", false, "022 pressed Liaoxi")
var liaoxi_secured_state = BattleStateScript.new()
if not liaoxi_secured_state.load_battle("res://data/scenarios/022_liaoxi_pursuit.json", {}, {}, {"secured_liaoxi_approaches": true}, ["zhang_he"]):
failures.append("could not load Liaoxi secured approaches camp data")
else:
_check_shop_items_unique(failures, liaoxi_secured_state, "022 secured Liaoxi")
var approach_medicine: Dictionary = _find_camp_conversation(
liaoxi_secured_state.get_briefing().get("camp_conversations", []),
"liaoxi_approach_medicine"
)
if approach_medicine.is_empty():
failures.append("022 secured Liaoxi flag should expose approach medicine")
else:
_check_camp_conversation_effect(failures, approach_medicine, "panacea", 1)
if not _find_camp_conversation(
liaoxi_secured_state.get_briefing().get("camp_conversations", []),
"liaoxi_fast_pursuit_drums"
).is_empty():
failures.append("022 secured Liaoxi flag should not expose fast pursuit drums")
_check_shop_item_visibility(failures, liaoxi_secured_state, "war_drum", false, "022 secured Liaoxi")
_check_shop_item_visibility(failures, liaoxi_secured_state, "imperial_seal", true, "022 secured Liaoxi")
var liaoxi_old_flags_state = BattleStateScript.new()
if not liaoxi_old_flags_state.load_battle("res://data/scenarios/022_liaoxi_pursuit.json", {}, {}, {"pursued_yuan_brothers": true, "secured_bohai_commandery": true}, ["zhang_he"]):
failures.append("could not load Liaoxi old Bohai flag camp data")
else:
_check_shop_item_visibility(failures, liaoxi_old_flags_state, "war_drum", false, "022 old Bohai flags")
_check_shop_item_visibility(failures, liaoxi_old_flags_state, "imperial_seal", false, "022 old Bohai flags")
if not _find_camp_conversation(
liaoxi_old_flags_state.get_briefing().get("camp_conversations", []),
"liaoxi_fast_pursuit_drums"
).is_empty():
failures.append("022 old Bohai flags should not expose fast pursuit drums")
if not _find_camp_conversation(
liaoxi_old_flags_state.get_briefing().get("camp_conversations", []),
"liaoxi_approach_medicine"
).is_empty():
failures.append("022 old Bohai flags should not expose approach medicine")
var white_wolf_state = BattleStateScript.new()
if not white_wolf_state.load_battle("res://data/scenarios/023_white_wolf_mountain.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load White Wolf Mountain data")
else:
_check_shop_items_unique(failures, white_wolf_state, "023 base")
var white_wolf_conversations: Array = white_wolf_state.get_briefing().get("camp_conversations", [])
if white_wolf_conversations.size() != 2:
failures.append("023 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, white_wolf_conversations[0], "white_wolf_ridge_council", "topic", "")
_check_camp_conversation(failures, white_wolf_conversations[1], "white_wolf_zhang_he_ridge_wind", "officer", "zhang_he")
var white_wolf_merchant := white_wolf_state.get_shop_merchant()
if str(white_wolf_merchant.get("name", "")) != "White Wolf Ridge Sutler":
failures.append("023 shop should expose White Wolf Ridge merchant name")
if (white_wolf_merchant.get("lines", []) as Array).size() < 2:
failures.append("023 shop should expose merchant flavor lines")
if white_wolf_state.get_unit("zhang_he_ch23").is_empty() or not white_wolf_state.is_required_deployment("zhang_he_ch23"):
failures.append("023 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, white_wolf_state, "war_drum", false, "023 base")
_check_shop_item_visibility(failures, white_wolf_state, "imperial_seal", false, "023 base")
_check_shop_item_visibility(failures, white_wolf_state, "iron_armor", true, "023 base")
var white_wolf_struck_state = BattleStateScript.new()
if not white_wolf_struck_state.load_battle("res://data/scenarios/023_white_wolf_mountain.json", {}, {}, {"struck_wuhuan_vanguard": true}, ["zhang_he"]):
failures.append("could not load White Wolf struck vanguard camp data")
else:
_check_shop_items_unique(failures, white_wolf_struck_state, "023 struck Wuhuan")
var canteens: Dictionary = _find_camp_conversation(
white_wolf_struck_state.get_briefing().get("camp_conversations", []),
"white_wolf_scattered_canteens"
)
if canteens.is_empty():
failures.append("023 struck Wuhuan flag should expose scattered canteens")
else:
_check_camp_conversation_effect(failures, canteens, "wine", 1)
if not _find_camp_conversation(
white_wolf_struck_state.get_briefing().get("camp_conversations", []),
"white_wolf_route_medicine"
).is_empty():
failures.append("023 struck Wuhuan flag should not expose route medicine")
_check_shop_item_visibility(failures, white_wolf_struck_state, "war_drum", true, "023 struck Wuhuan")
_check_shop_item_visibility(failures, white_wolf_struck_state, "imperial_seal", false, "023 struck Wuhuan")
var white_wolf_fortified_state = BattleStateScript.new()
if not white_wolf_fortified_state.load_battle("res://data/scenarios/023_white_wolf_mountain.json", {}, {}, {"fortified_white_wolf_route": true}, ["zhang_he"]):
failures.append("could not load White Wolf fortified route camp data")
else:
_check_shop_items_unique(failures, white_wolf_fortified_state, "023 fortified White Wolf")
var route_medicine: Dictionary = _find_camp_conversation(
white_wolf_fortified_state.get_briefing().get("camp_conversations", []),
"white_wolf_route_medicine"
)
if route_medicine.is_empty():
failures.append("023 fortified White Wolf flag should expose route medicine")
else:
_check_camp_conversation_effect(failures, route_medicine, "panacea", 1)
if not _find_camp_conversation(
white_wolf_fortified_state.get_briefing().get("camp_conversations", []),
"white_wolf_scattered_canteens"
).is_empty():
failures.append("023 fortified White Wolf flag should not expose scattered canteens")
_check_shop_item_visibility(failures, white_wolf_fortified_state, "war_drum", false, "023 fortified White Wolf")
_check_shop_item_visibility(failures, white_wolf_fortified_state, "imperial_seal", true, "023 fortified White Wolf")
var white_wolf_old_flags_state = BattleStateScript.new()
if not white_wolf_old_flags_state.load_battle("res://data/scenarios/023_white_wolf_mountain.json", {}, {}, {"pressed_liaoxi_pursuit": true, "secured_liaoxi_approaches": true}, ["zhang_he"]):
failures.append("could not load White Wolf old Liaoxi flag camp data")
else:
_check_shop_item_visibility(failures, white_wolf_old_flags_state, "war_drum", false, "023 old Liaoxi flags")
_check_shop_item_visibility(failures, white_wolf_old_flags_state, "imperial_seal", false, "023 old Liaoxi flags")
if not _find_camp_conversation(
white_wolf_old_flags_state.get_briefing().get("camp_conversations", []),
"white_wolf_scattered_canteens"
).is_empty():
failures.append("023 old Liaoxi flags should not expose scattered canteens")
if not _find_camp_conversation(
white_wolf_old_flags_state.get_briefing().get("camp_conversations", []),
"white_wolf_route_medicine"
).is_empty():
failures.append("023 old Liaoxi flags should not expose route medicine")
var liaodong_state = BattleStateScript.new()
if not liaodong_state.load_battle("res://data/scenarios/024_liaodong_pursuit.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Liaodong Pursuit data")
else:
_check_shop_items_unique(failures, liaodong_state, "024 base")
var liaodong_conversations: Array = liaodong_state.get_briefing().get("camp_conversations", [])
if liaodong_conversations.size() != 2:
failures.append("024 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, liaodong_conversations[0], "liaodong_border_council", "topic", "")
_check_camp_conversation(failures, liaodong_conversations[1], "liaodong_zhang_he_border_price", "officer", "zhang_he")
var liaodong_merchant := liaodong_state.get_shop_merchant()
if str(liaodong_merchant.get("name", "")) != "Liaodong Border Sutler":
failures.append("024 shop should expose Liaodong Border merchant name")
if (liaodong_merchant.get("lines", []) as Array).size() < 2:
failures.append("024 shop should expose merchant flavor lines")
if liaodong_state.get_unit("zhang_he_ch24").is_empty() or not liaodong_state.is_required_deployment("zhang_he_ch24"):
failures.append("024 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, liaodong_state, "war_drum", false, "024 base")
_check_shop_item_visibility(failures, liaodong_state, "imperial_seal", false, "024 base")
_check_shop_item_visibility(failures, liaodong_state, "iron_armor", true, "024 base")
var liaodong_pressed_state = BattleStateScript.new()
if not liaodong_pressed_state.load_battle("res://data/scenarios/024_liaodong_pursuit.json", {}, {}, {"pressed_liaodong_pursuit": true}, ["zhang_he"]):
failures.append("could not load Liaodong pressed pursuit data")
else:
_check_shop_items_unique(failures, liaodong_pressed_state, "024 pressed Liaodong")
var hard_wine: Dictionary = _find_camp_conversation(
liaodong_pressed_state.get_briefing().get("camp_conversations", []),
"liaodong_hard_pursuit_wine"
)
if hard_wine.is_empty():
failures.append("024 pressed Liaodong flag should expose hard pursuit wine")
else:
_check_camp_conversation_effect(failures, hard_wine, "wine", 1)
if not _find_camp_conversation(
liaodong_pressed_state.get_briefing().get("camp_conversations", []),
"liaodong_wuhuan_guides_medicine"
).is_empty():
failures.append("024 pressed Liaodong flag should not expose Wuhuan guide medicine")
_check_shop_item_visibility(failures, liaodong_pressed_state, "war_drum", true, "024 pressed Liaodong")
_check_shop_item_visibility(failures, liaodong_pressed_state, "imperial_seal", false, "024 pressed Liaodong")
var liaodong_secured_state = BattleStateScript.new()
if not liaodong_secured_state.load_battle("res://data/scenarios/024_liaodong_pursuit.json", {}, {}, {"secured_wuhuan_submission": true}, ["zhang_he"]):
failures.append("could not load Liaodong secured Wuhuan data")
else:
_check_shop_items_unique(failures, liaodong_secured_state, "024 secured Wuhuan")
var guide_medicine: Dictionary = _find_camp_conversation(
liaodong_secured_state.get_briefing().get("camp_conversations", []),
"liaodong_wuhuan_guides_medicine"
)
if guide_medicine.is_empty():
failures.append("024 secured Wuhuan flag should expose guide medicine")
else:
_check_camp_conversation_effect(failures, guide_medicine, "panacea", 1)
if not _find_camp_conversation(
liaodong_secured_state.get_briefing().get("camp_conversations", []),
"liaodong_hard_pursuit_wine"
).is_empty():
failures.append("024 secured Wuhuan flag should not expose hard pursuit wine")
_check_shop_item_visibility(failures, liaodong_secured_state, "war_drum", false, "024 secured Wuhuan")
_check_shop_item_visibility(failures, liaodong_secured_state, "imperial_seal", true, "024 secured Wuhuan")
var liaodong_old_flags_state = BattleStateScript.new()
if not liaodong_old_flags_state.load_battle("res://data/scenarios/024_liaodong_pursuit.json", {}, {}, {"struck_wuhuan_vanguard": true, "fortified_white_wolf_route": true}, ["zhang_he"]):
failures.append("could not load Liaodong old White Wolf flag camp data")
else:
_check_shop_item_visibility(failures, liaodong_old_flags_state, "war_drum", false, "024 old White Wolf flags")
_check_shop_item_visibility(failures, liaodong_old_flags_state, "imperial_seal", false, "024 old White Wolf flags")
if not _find_camp_conversation(
liaodong_old_flags_state.get_briefing().get("camp_conversations", []),
"liaodong_hard_pursuit_wine"
).is_empty():
failures.append("024 old White Wolf flags should not expose hard pursuit wine")
if not _find_camp_conversation(
liaodong_old_flags_state.get_briefing().get("camp_conversations", []),
"liaodong_wuhuan_guides_medicine"
).is_empty():
failures.append("024 old White Wolf flags should not expose guide medicine")
var xinye_state = BattleStateScript.new()
if not xinye_state.load_battle("res://data/scenarios/025_xinye_advance.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Xinye Advance data")
else:
_check_shop_items_unique(failures, xinye_state, "025 base")
var xinye_conversations: Array = xinye_state.get_briefing().get("camp_conversations", [])
if xinye_conversations.size() != 2:
failures.append("025 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, xinye_conversations[0], "xinye_southern_war_council", "topic", "")
_check_camp_conversation(failures, xinye_conversations[1], "xinye_zhang_he_south_road", "officer", "zhang_he")
var xinye_merchant := xinye_state.get_shop_merchant()
if str(xinye_merchant.get("name", "")) != "Xinye Road Sutler":
failures.append("025 shop should expose Xinye Road merchant name")
if (xinye_merchant.get("lines", []) as Array).size() < 2:
failures.append("025 shop should expose merchant flavor lines")
if xinye_state.get_unit("zhang_he_ch25").is_empty() or not xinye_state.is_required_deployment("zhang_he_ch25"):
failures.append("025 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, xinye_state, "war_drum", false, "025 base")
_check_shop_item_visibility(failures, xinye_state, "imperial_seal", false, "025 base")
var xinye_terms_state = BattleStateScript.new()
if not xinye_terms_state.load_battle("res://data/scenarios/025_xinye_advance.json", {}, {}, {"accepted_liaodong_terms": true}, ["zhang_he"]):
failures.append("could not load Xinye accepted Liaodong terms data")
else:
var ordered_medicine: Dictionary = _find_camp_conversation(
xinye_terms_state.get_briefing().get("camp_conversations", []),
"xinye_ordered_medicine"
)
if ordered_medicine.is_empty():
failures.append("025 accepted Liaodong terms should expose ordered medicine")
else:
_check_camp_conversation_effect(failures, ordered_medicine, "panacea", 1)
if not _find_camp_conversation(
xinye_terms_state.get_briefing().get("camp_conversations", []),
"xinye_authority_wine"
).is_empty():
failures.append("025 accepted Liaodong terms should not expose authority wine")
_check_shop_item_visibility(failures, xinye_terms_state, "imperial_seal", true, "025 accepted Liaodong terms")
_check_shop_item_visibility(failures, xinye_terms_state, "war_drum", false, "025 accepted Liaodong terms")
var xinye_authority_state = BattleStateScript.new()
if not xinye_authority_state.load_battle("res://data/scenarios/025_xinye_advance.json", {}, {}, {"pressed_liaodong_authority": true}, ["zhang_he"]):
failures.append("could not load Xinye pressed Liaodong authority data")
else:
var authority_wine: Dictionary = _find_camp_conversation(
xinye_authority_state.get_briefing().get("camp_conversations", []),
"xinye_authority_wine"
)
if authority_wine.is_empty():
failures.append("025 pressed Liaodong authority should expose authority wine")
else:
_check_camp_conversation_effect(failures, authority_wine, "wine", 1)
if not _find_camp_conversation(
xinye_authority_state.get_briefing().get("camp_conversations", []),
"xinye_ordered_medicine"
).is_empty():
failures.append("025 pressed Liaodong authority should not expose ordered medicine")
_check_shop_item_visibility(failures, xinye_authority_state, "war_drum", true, "025 pressed Liaodong authority")
_check_shop_item_visibility(failures, xinye_authority_state, "imperial_seal", false, "025 pressed Liaodong authority")
var xinye_old_flags_state = BattleStateScript.new()
if not xinye_old_flags_state.load_battle("res://data/scenarios/025_xinye_advance.json", {}, {}, {"pressed_liaodong_pursuit": true, "secured_wuhuan_submission": true}, ["zhang_he"]):
failures.append("could not load Xinye old Liaodong Pursuit flag data")
else:
_check_shop_items_unique(failures, xinye_old_flags_state, "025 old Liaodong Pursuit flags")
_check_shop_item_visibility(failures, xinye_old_flags_state, "war_drum", false, "025 old Liaodong Pursuit flags")
_check_shop_item_visibility(failures, xinye_old_flags_state, "imperial_seal", false, "025 old Liaodong Pursuit flags")
if not _find_camp_conversation(
xinye_old_flags_state.get_briefing().get("camp_conversations", []),
"xinye_ordered_medicine"
).is_empty():
failures.append("025 old Liaodong Pursuit flags should not expose ordered medicine")
if not _find_camp_conversation(
xinye_old_flags_state.get_briefing().get("camp_conversations", []),
"xinye_authority_wine"
).is_empty():
failures.append("025 old Liaodong Pursuit flags should not expose authority wine")
var changban_state = BattleStateScript.new()
if not changban_state.load_battle("res://data/scenarios/026_changban_pursuit.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Changban Pursuit data")
else:
_check_shop_items_unique(failures, changban_state, "026 base")
var changban_conversations: Array = changban_state.get_briefing().get("camp_conversations", [])
if changban_conversations.size() != 2:
failures.append("026 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, changban_conversations[0], "changban_bridge_council", "topic", "")
_check_camp_conversation(failures, changban_conversations[1], "changban_zhang_he_bridge_shadow", "officer", "zhang_he")
var changban_merchant := changban_state.get_shop_merchant()
if str(changban_merchant.get("name", "")) != "Changban Bridge Sutler":
failures.append("026 shop should expose Changban Bridge merchant name")
if (changban_merchant.get("lines", []) as Array).size() < 2:
failures.append("026 shop should expose merchant flavor lines")
if changban_state.get_unit("zhang_he_ch26").is_empty() or not changban_state.is_required_deployment("zhang_he_ch26"):
failures.append("026 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, changban_state, "war_drum", false, "026 base")
_check_shop_item_visibility(failures, changban_state, "imperial_seal", false, "026 base")
var changban_vanguard_state = BattleStateScript.new()
if not changban_vanguard_state.load_battle("res://data/scenarios/026_changban_pursuit.json", {}, {}, {"pressed_xinye_vanguard": true}, ["zhang_he"]):
failures.append("could not load Changban pressed Xinye vanguard data")
else:
var vanguard_rations: Dictionary = _find_camp_conversation(
changban_vanguard_state.get_briefing().get("camp_conversations", []),
"changban_vanguard_rations"
)
if vanguard_rations.is_empty():
failures.append("026 pressed Xinye vanguard should expose vanguard rations")
else:
_check_camp_conversation_effect(failures, vanguard_rations, "bean", 1)
if not _find_camp_conversation(
changban_vanguard_state.get_briefing().get("camp_conversations", []),
"changban_han_supply_medicine"
).is_empty():
failures.append("026 pressed Xinye vanguard should not expose Han supply medicine")
_check_shop_item_visibility(failures, changban_vanguard_state, "war_drum", true, "026 pressed Xinye vanguard")
_check_shop_item_visibility(failures, changban_vanguard_state, "imperial_seal", false, "026 pressed Xinye vanguard")
var changban_supply_state = BattleStateScript.new()
if not changban_supply_state.load_battle("res://data/scenarios/026_changban_pursuit.json", {}, {}, {"secured_han_river_supply": true}, ["zhang_he"]):
failures.append("could not load Changban secured Han supply data")
else:
var han_medicine: Dictionary = _find_camp_conversation(
changban_supply_state.get_briefing().get("camp_conversations", []),
"changban_han_supply_medicine"
)
if han_medicine.is_empty():
failures.append("026 secured Han supply should expose Han supply medicine")
else:
_check_camp_conversation_effect(failures, han_medicine, "panacea", 1)
if not _find_camp_conversation(
changban_supply_state.get_briefing().get("camp_conversations", []),
"changban_vanguard_rations"
).is_empty():
failures.append("026 secured Han supply should not expose vanguard rations")
_check_shop_item_visibility(failures, changban_supply_state, "imperial_seal", true, "026 secured Han supply")
_check_shop_item_visibility(failures, changban_supply_state, "war_drum", false, "026 secured Han supply")
var changban_old_flags_state = BattleStateScript.new()
if not changban_old_flags_state.load_battle("res://data/scenarios/026_changban_pursuit.json", {}, {}, {"accepted_liaodong_terms": true, "pressed_liaodong_authority": true}, ["zhang_he"]):
failures.append("could not load Changban old Xinye setup flag data")
else:
_check_shop_items_unique(failures, changban_old_flags_state, "026 old Xinye setup flags")
_check_shop_item_visibility(failures, changban_old_flags_state, "war_drum", false, "026 old Xinye setup flags")
_check_shop_item_visibility(failures, changban_old_flags_state, "imperial_seal", false, "026 old Xinye setup flags")
if not _find_camp_conversation(
changban_old_flags_state.get_briefing().get("camp_conversations", []),
"changban_vanguard_rations"
).is_empty():
failures.append("026 old Xinye setup flags should not expose vanguard rations")
if not _find_camp_conversation(
changban_old_flags_state.get_briefing().get("camp_conversations", []),
"changban_han_supply_medicine"
).is_empty():
failures.append("026 old Xinye setup flags should not expose Han supply medicine")
var jiangling_state = BattleStateScript.new()
if not jiangling_state.load_battle("res://data/scenarios/027_jiangling_chase.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Jiangling Chase data")
else:
_check_shop_items_unique(failures, jiangling_state, "027 base")
var jiangling_conversations: Array = jiangling_state.get_briefing().get("camp_conversations", [])
if jiangling_conversations.size() != 2:
failures.append("027 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, jiangling_conversations[0], "jiangling_storehouse_council", "topic", "")
_check_camp_conversation(failures, jiangling_conversations[1], "jiangling_zhang_he_granary_road", "officer", "zhang_he")
var jiangling_merchant := jiangling_state.get_shop_merchant()
if str(jiangling_merchant.get("name", "")) != "Jiangling Storehouse Sutler":
failures.append("027 shop should expose Jiangling Storehouse merchant name")
if (jiangling_merchant.get("lines", []) as Array).size() < 2:
failures.append("027 shop should expose merchant flavor lines")
if jiangling_state.get_unit("zhang_he_ch27").is_empty() or not jiangling_state.is_required_deployment("zhang_he_ch27"):
failures.append("027 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, jiangling_state, "war_drum", false, "027 base")
_check_shop_item_visibility(failures, jiangling_state, "imperial_seal", false, "027 base")
var jiangling_chase_state = BattleStateScript.new()
if not jiangling_chase_state.load_battle("res://data/scenarios/027_jiangling_chase.json", {}, {}, {"pressed_jiangling_chase": true}, ["zhang_he"]):
failures.append("could not load Jiangling pressed chase data")
else:
var fast_beans: Dictionary = _find_camp_conversation(
jiangling_chase_state.get_briefing().get("camp_conversations", []),
"jiangling_fast_column_beans"
)
if fast_beans.is_empty():
failures.append("027 pressed Jiangling chase should expose fast column beans")
else:
_check_camp_conversation_effect(failures, fast_beans, "bean", 1)
if not _find_camp_conversation(
jiangling_chase_state.get_briefing().get("camp_conversations", []),
"jiangling_crossing_medicine"
).is_empty():
failures.append("027 pressed Jiangling chase should not expose crossing medicine")
_check_shop_item_visibility(failures, jiangling_chase_state, "war_drum", true, "027 pressed Jiangling chase")
_check_shop_item_visibility(failures, jiangling_chase_state, "imperial_seal", false, "027 pressed Jiangling chase")
var jiangling_crossings_state = BattleStateScript.new()
if not jiangling_crossings_state.load_battle("res://data/scenarios/027_jiangling_chase.json", {}, {}, {"secured_changban_crossings": true}, ["zhang_he"]):
failures.append("could not load Jiangling secured crossings data")
else:
var crossing_medicine: Dictionary = _find_camp_conversation(
jiangling_crossings_state.get_briefing().get("camp_conversations", []),
"jiangling_crossing_medicine"
)
if crossing_medicine.is_empty():
failures.append("027 secured Changban crossings should expose crossing medicine")
else:
_check_camp_conversation_effect(failures, crossing_medicine, "panacea", 1)
if not _find_camp_conversation(
jiangling_crossings_state.get_briefing().get("camp_conversations", []),
"jiangling_fast_column_beans"
).is_empty():
failures.append("027 secured Changban crossings should not expose fast column beans")
_check_shop_item_visibility(failures, jiangling_crossings_state, "imperial_seal", true, "027 secured Changban crossings")
_check_shop_item_visibility(failures, jiangling_crossings_state, "war_drum", false, "027 secured Changban crossings")
var jiangling_old_flags_state = BattleStateScript.new()
if not jiangling_old_flags_state.load_battle("res://data/scenarios/027_jiangling_chase.json", {}, {}, {"pressed_xinye_vanguard": true, "secured_han_river_supply": true}, ["zhang_he"]):
failures.append("could not load Jiangling old Changban setup flag data")
else:
_check_shop_items_unique(failures, jiangling_old_flags_state, "027 old Changban setup flags")
_check_shop_item_visibility(failures, jiangling_old_flags_state, "war_drum", false, "027 old Changban setup flags")
_check_shop_item_visibility(failures, jiangling_old_flags_state, "imperial_seal", false, "027 old Changban setup flags")
if not _find_camp_conversation(
jiangling_old_flags_state.get_briefing().get("camp_conversations", []),
"jiangling_fast_column_beans"
).is_empty():
failures.append("027 old Changban setup flags should not expose fast column beans")
if not _find_camp_conversation(
jiangling_old_flags_state.get_briefing().get("camp_conversations", []),
"jiangling_crossing_medicine"
).is_empty():
failures.append("027 old Changban setup flags should not expose crossing medicine")
var xiakou_state = BattleStateScript.new()
if not xiakou_state.load_battle("res://data/scenarios/028_xiakou_pursuit.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Xiakou Pursuit data")
else:
_check_shop_items_unique(failures, xiakou_state, "028 base")
var xiakou_conversations: Array = xiakou_state.get_briefing().get("camp_conversations", [])
if xiakou_conversations.size() != 2:
failures.append("028 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, xiakou_conversations[0], "xiakou_harbor_council", "topic", "")
_check_camp_conversation(failures, xiakou_conversations[1], "xiakou_zhang_he_river_mouth", "officer", "zhang_he")
var xiakou_merchant := xiakou_state.get_shop_merchant()
if str(xiakou_merchant.get("name", "")) != "Xiakou Harbor Sutler":
failures.append("028 shop should expose Xiakou Harbor merchant name")
if (xiakou_merchant.get("lines", []) as Array).size() < 2:
failures.append("028 shop should expose merchant flavor lines")
if xiakou_state.get_unit("zhang_he_ch28").is_empty() or not xiakou_state.is_required_deployment("zhang_he_ch28"):
failures.append("028 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, xiakou_state, "war_drum", false, "028 base")
_check_shop_item_visibility(failures, xiakou_state, "imperial_seal", false, "028 base")
var xiakou_pursuit_state = BattleStateScript.new()
if not xiakou_pursuit_state.load_battle("res://data/scenarios/028_xiakou_pursuit.json", {}, {}, {"pressed_xiakou_pursuit": true}, ["zhang_he"]):
failures.append("could not load Xiakou pressed pursuit data")
else:
var envoy_beans: Dictionary = _find_camp_conversation(
xiakou_pursuit_state.get_briefing().get("camp_conversations", []),
"xiakou_envoy_chase_beans"
)
if envoy_beans.is_empty():
failures.append("028 pressed Xiakou pursuit should expose envoy chase beans")
else:
_check_camp_conversation_effect(failures, envoy_beans, "bean", 1)
if not _find_camp_conversation(
xiakou_pursuit_state.get_briefing().get("camp_conversations", []),
"xiakou_storehouse_medicine"
).is_empty():
failures.append("028 pressed Xiakou pursuit should not expose storehouse medicine")
_check_shop_item_visibility(failures, xiakou_pursuit_state, "war_drum", true, "028 pressed Xiakou pursuit")
_check_shop_item_visibility(failures, xiakou_pursuit_state, "imperial_seal", false, "028 pressed Xiakou pursuit")
var xiakou_store_state = BattleStateScript.new()
if not xiakou_store_state.load_battle("res://data/scenarios/028_xiakou_pursuit.json", {}, {}, {"secured_jiangling_storehouses": true}, ["zhang_he"]):
failures.append("could not load Xiakou secured Jiangling stores data")
else:
var storehouse_medicine: Dictionary = _find_camp_conversation(
xiakou_store_state.get_briefing().get("camp_conversations", []),
"xiakou_storehouse_medicine"
)
if storehouse_medicine.is_empty():
failures.append("028 secured Jiangling stores should expose storehouse medicine")
else:
_check_camp_conversation_effect(failures, storehouse_medicine, "panacea", 1)
if not _find_camp_conversation(
xiakou_store_state.get_briefing().get("camp_conversations", []),
"xiakou_envoy_chase_beans"
).is_empty():
failures.append("028 secured Jiangling stores should not expose envoy chase beans")
_check_shop_item_visibility(failures, xiakou_store_state, "imperial_seal", true, "028 secured Jiangling stores")
_check_shop_item_visibility(failures, xiakou_store_state, "war_drum", false, "028 secured Jiangling stores")
var xiakou_old_flags_state = BattleStateScript.new()
if not xiakou_old_flags_state.load_battle("res://data/scenarios/028_xiakou_pursuit.json", {}, {}, {"pressed_jiangling_chase": true, "secured_changban_crossings": true}, ["zhang_he"]):
failures.append("could not load Xiakou old Jiangling setup flag data")
else:
_check_shop_items_unique(failures, xiakou_old_flags_state, "028 old Jiangling setup flags")
_check_shop_item_visibility(failures, xiakou_old_flags_state, "war_drum", false, "028 old Jiangling setup flags")
_check_shop_item_visibility(failures, xiakou_old_flags_state, "imperial_seal", false, "028 old Jiangling setup flags")
if not _find_camp_conversation(
xiakou_old_flags_state.get_briefing().get("camp_conversations", []),
"xiakou_envoy_chase_beans"
).is_empty():
failures.append("028 old Jiangling setup flags should not expose envoy chase beans")
if not _find_camp_conversation(
xiakou_old_flags_state.get_briefing().get("camp_conversations", []),
"xiakou_storehouse_medicine"
).is_empty():
failures.append("028 old Jiangling setup flags should not expose storehouse medicine")
var red_cliffs_state = BattleStateScript.new()
if not red_cliffs_state.load_battle("res://data/scenarios/029_red_cliffs_fleet.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Red Cliffs Fleet data")
else:
_check_shop_items_unique(failures, red_cliffs_state, "029 base")
var red_cliffs_conversations: Array = red_cliffs_state.get_briefing().get("camp_conversations", [])
if red_cliffs_conversations.size() != 2:
failures.append("029 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, red_cliffs_conversations[0], "red_cliffs_fleet_council", "topic", "")
_check_camp_conversation(failures, red_cliffs_conversations[1], "red_cliffs_zhang_he_chain_line", "officer", "zhang_he")
var red_cliffs_merchant := red_cliffs_state.get_shop_merchant()
if str(red_cliffs_merchant.get("name", "")) != "Red Cliffs Deck Sutler":
failures.append("029 shop should expose Red Cliffs Deck merchant name")
if (red_cliffs_merchant.get("lines", []) as Array).size() < 2:
failures.append("029 shop should expose merchant flavor lines")
if red_cliffs_state.get_unit("zhang_he_ch29").is_empty() or not red_cliffs_state.is_required_deployment("zhang_he_ch29"):
failures.append("029 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, red_cliffs_state, "war_drum", false, "029 base")
_check_shop_item_visibility(failures, red_cliffs_state, "imperial_seal", false, "029 base")
var red_cliffs_pressed_state = BattleStateScript.new()
if not red_cliffs_pressed_state.load_battle("res://data/scenarios/029_red_cliffs_fleet.json", {}, {}, {"pressed_red_cliffs_fleet": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs pressed fleet data")
else:
var forward_beans: Dictionary = _find_camp_conversation(
red_cliffs_pressed_state.get_briefing().get("camp_conversations", []),
"red_cliffs_forward_deck_beans"
)
if forward_beans.is_empty():
failures.append("029 pressed Red Cliffs fleet should expose forward deck beans")
else:
_check_camp_conversation_effect(failures, forward_beans, "bean", 1)
if not _find_camp_conversation(
red_cliffs_pressed_state.get_briefing().get("camp_conversations", []),
"red_cliffs_harbor_medicine"
).is_empty():
failures.append("029 pressed Red Cliffs fleet should not expose harbor medicine")
_check_shop_item_visibility(failures, red_cliffs_pressed_state, "war_drum", true, "029 pressed Red Cliffs fleet")
_check_shop_item_visibility(failures, red_cliffs_pressed_state, "imperial_seal", false, "029 pressed Red Cliffs fleet")
var red_cliffs_harbor_state = BattleStateScript.new()
if not red_cliffs_harbor_state.load_battle("res://data/scenarios/029_red_cliffs_fleet.json", {}, {}, {"secured_xiakou_harbor": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs secured Xiakou harbor data")
else:
var harbor_medicine: Dictionary = _find_camp_conversation(
red_cliffs_harbor_state.get_briefing().get("camp_conversations", []),
"red_cliffs_harbor_medicine"
)
if harbor_medicine.is_empty():
failures.append("029 secured Xiakou harbor should expose harbor medicine")
else:
_check_camp_conversation_effect(failures, harbor_medicine, "panacea", 1)
if not _find_camp_conversation(
red_cliffs_harbor_state.get_briefing().get("camp_conversations", []),
"red_cliffs_forward_deck_beans"
).is_empty():
failures.append("029 secured Xiakou harbor should not expose forward deck beans")
_check_shop_item_visibility(failures, red_cliffs_harbor_state, "imperial_seal", true, "029 secured Xiakou harbor")
_check_shop_item_visibility(failures, red_cliffs_harbor_state, "war_drum", false, "029 secured Xiakou harbor")
var red_cliffs_old_flags_state = BattleStateScript.new()
if not red_cliffs_old_flags_state.load_battle("res://data/scenarios/029_red_cliffs_fleet.json", {}, {}, {"pressed_xiakou_pursuit": true, "secured_jiangling_storehouses": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs old Xiakou setup flag data")
else:
_check_shop_items_unique(failures, red_cliffs_old_flags_state, "029 old Xiakou setup flags")
_check_shop_item_visibility(failures, red_cliffs_old_flags_state, "war_drum", false, "029 old Xiakou setup flags")
_check_shop_item_visibility(failures, red_cliffs_old_flags_state, "imperial_seal", false, "029 old Xiakou setup flags")
if not _find_camp_conversation(
red_cliffs_old_flags_state.get_briefing().get("camp_conversations", []),
"red_cliffs_forward_deck_beans"
).is_empty():
failures.append("029 old Xiakou setup flags should not expose forward deck beans")
if not _find_camp_conversation(
red_cliffs_old_flags_state.get_briefing().get("camp_conversations", []),
"red_cliffs_harbor_medicine"
).is_empty():
failures.append("029 old Xiakou setup flags should not expose harbor medicine")
var red_fire_state = BattleStateScript.new()
if not red_fire_state.load_battle("res://data/scenarios/030_red_cliffs_fire.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Red Cliffs Fire data")
else:
_check_shop_items_unique(failures, red_fire_state, "030 base")
var red_fire_conversations: Array = red_fire_state.get_briefing().get("camp_conversations", [])
if red_fire_conversations.size() != 2:
failures.append("030 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, red_fire_conversations[0], "red_cliffs_fire_smoke_council", "topic", "")
_check_camp_conversation(failures, red_fire_conversations[1], "red_cliffs_zhang_he_fire_boats", "officer", "zhang_he")
var red_fire_merchant := red_fire_state.get_shop_merchant()
if str(red_fire_merchant.get("name", "")) != "Red Cliffs Fire Sutler":
failures.append("030 shop should expose Red Cliffs Fire merchant name")
if (red_fire_merchant.get("lines", []) as Array).size() < 2:
failures.append("030 shop should expose merchant flavor lines")
if red_fire_state.get_unit("zhang_he_ch30").is_empty() or not red_fire_state.is_required_deployment("zhang_he_ch30"):
failures.append("030 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, red_fire_state, "war_drum", false, "030 base")
_check_shop_item_visibility(failures, red_fire_state, "imperial_seal", false, "030 base")
var red_fire_chain_state = BattleStateScript.new()
if not red_fire_chain_state.load_battle("res://data/scenarios/030_red_cliffs_fire.json", {}, {}, {"reinforced_chain_line": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs Fire reinforced chain data")
else:
var chain_medicine: Dictionary = _find_camp_conversation(
red_fire_chain_state.get_briefing().get("camp_conversations", []),
"red_cliffs_reinforced_chain_medicine"
)
if chain_medicine.is_empty():
failures.append("030 reinforced chain should expose chain medicine")
else:
_check_camp_conversation_effect(failures, chain_medicine, "panacea", 1)
if not _find_camp_conversation(
red_fire_chain_state.get_briefing().get("camp_conversations", []),
"red_cliffs_night_raid_beans"
).is_empty():
failures.append("030 reinforced chain should not expose night raid beans")
_check_shop_item_visibility(failures, red_fire_chain_state, "imperial_seal", true, "030 reinforced chain")
_check_shop_item_visibility(failures, red_fire_chain_state, "war_drum", false, "030 reinforced chain")
var red_fire_raid_state = BattleStateScript.new()
if not red_fire_raid_state.load_battle("res://data/scenarios/030_red_cliffs_fire.json", {}, {}, {"ordered_night_raid": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs Fire night raid data")
else:
var night_beans: Dictionary = _find_camp_conversation(
red_fire_raid_state.get_briefing().get("camp_conversations", []),
"red_cliffs_night_raid_beans"
)
if night_beans.is_empty():
failures.append("030 ordered night raid should expose night raid beans")
else:
_check_camp_conversation_effect(failures, night_beans, "bean", 1)
if not _find_camp_conversation(
red_fire_raid_state.get_briefing().get("camp_conversations", []),
"red_cliffs_reinforced_chain_medicine"
).is_empty():
failures.append("030 ordered night raid should not expose chain medicine")
_check_shop_item_visibility(failures, red_fire_raid_state, "war_drum", true, "030 ordered night raid")
_check_shop_item_visibility(failures, red_fire_raid_state, "imperial_seal", false, "030 ordered night raid")
var red_fire_old_flags_state = BattleStateScript.new()
if not red_fire_old_flags_state.load_battle("res://data/scenarios/030_red_cliffs_fire.json", {}, {}, {"pressed_red_cliffs_fleet": true, "secured_xiakou_harbor": true}, ["zhang_he"]):
failures.append("could not load Red Cliffs Fire old fleet setup flag data")
else:
_check_shop_items_unique(failures, red_fire_old_flags_state, "030 old Red Cliffs setup flags")
_check_shop_item_visibility(failures, red_fire_old_flags_state, "war_drum", false, "030 old Red Cliffs setup flags")
_check_shop_item_visibility(failures, red_fire_old_flags_state, "imperial_seal", false, "030 old Red Cliffs setup flags")
if not _find_camp_conversation(
red_fire_old_flags_state.get_briefing().get("camp_conversations", []),
"red_cliffs_reinforced_chain_medicine"
).is_empty():
failures.append("030 old Red Cliffs setup flags should not expose chain medicine")
if not _find_camp_conversation(
red_fire_old_flags_state.get_briefing().get("camp_conversations", []),
"red_cliffs_night_raid_beans"
).is_empty():
failures.append("030 old Red Cliffs setup flags should not expose night raid beans")
var huarong_state = BattleStateScript.new()
if not huarong_state.load_battle("res://data/scenarios/031_huarong_retreat.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Huarong Retreat data")
else:
_check_shop_items_unique(failures, huarong_state, "031 base")
var huarong_conversations: Array = huarong_state.get_briefing().get("camp_conversations", [])
if huarong_conversations.size() != 2:
failures.append("031 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, _find_camp_conversation(huarong_conversations, "huarong_retreat_council"), "huarong_retreat_council", "topic", "")
_check_camp_conversation(failures, _find_camp_conversation(huarong_conversations, "huarong_zhang_he_mud_road"), "huarong_zhang_he_mud_road", "officer", "zhang_he")
var huarong_merchant := huarong_state.get_shop_merchant()
if str(huarong_merchant.get("name", "")) != "Huarong Road Sutler":
failures.append("031 shop should expose Huarong Road merchant name")
if (huarong_merchant.get("lines", []) as Array).size() < 2:
failures.append("031 shop should expose merchant flavor lines")
if huarong_state.get_unit("zhang_he_ch31").is_empty() or not huarong_state.is_required_deployment("zhang_he_ch31"):
failures.append("031 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, huarong_state, "war_drum", false, "031 base")
_check_shop_item_visibility(failures, huarong_state, "imperial_seal", false, "031 base")
var huarong_held_state = BattleStateScript.new()
if not huarong_held_state.load_battle("res://data/scenarios/031_huarong_retreat.json", {}, {}, {"held_red_cliffs_line": true}, ["zhang_he"]):
failures.append("could not load Huarong held Red Cliffs line data")
else:
var held_medicine: Dictionary = _find_camp_conversation(
huarong_held_state.get_briefing().get("camp_conversations", []),
"huarong_held_line_medicine"
)
if held_medicine.is_empty():
failures.append("031 held Red Cliffs line should expose held line medicine")
else:
_check_camp_conversation_effect(failures, held_medicine, "panacea", 1)
if not _find_camp_conversation(
huarong_held_state.get_briefing().get("camp_conversations", []),
"huarong_open_retreat_beans"
).is_empty():
failures.append("031 held Red Cliffs line should not expose opened retreat beans")
_check_shop_item_visibility(failures, huarong_held_state, "imperial_seal", true, "031 held Red Cliffs line")
_check_shop_item_visibility(failures, huarong_held_state, "war_drum", false, "031 held Red Cliffs line")
var huarong_opened_state = BattleStateScript.new()
if not huarong_opened_state.load_battle("res://data/scenarios/031_huarong_retreat.json", {}, {}, {"opened_huarong_retreat": true}, ["zhang_he"]):
failures.append("could not load Huarong opened retreat data")
else:
var open_retreat_beans: Dictionary = _find_camp_conversation(
huarong_opened_state.get_briefing().get("camp_conversations", []),
"huarong_open_retreat_beans"
)
if open_retreat_beans.is_empty():
failures.append("031 opened Huarong retreat should expose retreat beans")
else:
_check_camp_conversation_effect(failures, open_retreat_beans, "bean", 1)
if not _find_camp_conversation(
huarong_opened_state.get_briefing().get("camp_conversations", []),
"huarong_held_line_medicine"
).is_empty():
failures.append("031 opened Huarong retreat should not expose held line medicine")
_check_shop_item_visibility(failures, huarong_opened_state, "war_drum", true, "031 opened Huarong retreat")
_check_shop_item_visibility(failures, huarong_opened_state, "imperial_seal", false, "031 opened Huarong retreat")
var huarong_old_flags_state = BattleStateScript.new()
if not huarong_old_flags_state.load_battle("res://data/scenarios/031_huarong_retreat.json", {}, {}, {"reinforced_chain_line": true, "ordered_night_raid": true}, ["zhang_he"]):
failures.append("could not load Huarong old Red Cliffs Fire setup flag data")
else:
_check_shop_items_unique(failures, huarong_old_flags_state, "031 old Red Cliffs Fire setup flags")
_check_shop_item_visibility(failures, huarong_old_flags_state, "war_drum", false, "031 old Red Cliffs Fire setup flags")
_check_shop_item_visibility(failures, huarong_old_flags_state, "imperial_seal", false, "031 old Red Cliffs Fire setup flags")
if not _find_camp_conversation(
huarong_old_flags_state.get_briefing().get("camp_conversations", []),
"huarong_held_line_medicine"
).is_empty():
failures.append("031 old Red Cliffs Fire setup flags should not expose held line medicine")
if not _find_camp_conversation(
huarong_old_flags_state.get_briefing().get("camp_conversations", []),
"huarong_open_retreat_beans"
).is_empty():
failures.append("031 old Red Cliffs Fire setup flags should not expose opened retreat beans")
var jiangling_rearguard_state = BattleStateScript.new()
if not jiangling_rearguard_state.load_battle("res://data/scenarios/032_jiangling_rearguard.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Jiangling Rearguard data")
else:
_check_shop_items_unique(failures, jiangling_rearguard_state, "032 base")
var jiangling_rearguard_conversations: Array = jiangling_rearguard_state.get_briefing().get("camp_conversations", [])
if jiangling_rearguard_conversations.size() != 2:
failures.append("032 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, _find_camp_conversation(jiangling_rearguard_conversations, "jiangling_rearguard_council"), "jiangling_rearguard_council", "topic", "")
_check_camp_conversation(failures, _find_camp_conversation(jiangling_rearguard_conversations, "jiangling_zhang_he_rear_line"), "jiangling_zhang_he_rear_line", "officer", "zhang_he")
var jiangling_rearguard_merchant := jiangling_rearguard_state.get_shop_merchant()
if str(jiangling_rearguard_merchant.get("name", "")) != "Jiangling Rear Road Sutler":
failures.append("032 shop should expose Jiangling rear road merchant name")
if (jiangling_rearguard_merchant.get("lines", []) as Array).size() < 2:
failures.append("032 shop should expose merchant flavor lines")
if jiangling_rearguard_state.get_unit("zhang_he_ch32").is_empty() or not jiangling_rearguard_state.is_required_deployment("zhang_he_ch32"):
failures.append("032 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, jiangling_rearguard_state, "war_drum", false, "032 base")
_check_shop_item_visibility(failures, jiangling_rearguard_state, "imperial_seal", false, "032 base")
var jiangling_rearguard_secured_state = BattleStateScript.new()
if not jiangling_rearguard_secured_state.load_battle("res://data/scenarios/032_jiangling_rearguard.json", {}, {}, {"secured_huarong_rearguard": true}, ["zhang_he"]):
failures.append("could not load Jiangling Rearguard secured Huarong data")
else:
_check_shop_items_unique(failures, jiangling_rearguard_secured_state, "032 secured Huarong rearguard")
var stability_medicine: Dictionary = _find_camp_conversation(
jiangling_rearguard_secured_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_stability_medicine"
)
if stability_medicine.is_empty():
failures.append("032 secured Huarong rearguard should expose stability medicine")
else:
_check_camp_conversation_effect(failures, stability_medicine, "panacea", 1)
if not _find_camp_conversation(
jiangling_rearguard_secured_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_forced_march_beans"
).is_empty():
failures.append("032 secured Huarong rearguard should not expose forced march beans")
_check_shop_item_visibility(failures, jiangling_rearguard_secured_state, "imperial_seal", true, "032 secured Huarong rearguard")
_check_shop_item_visibility(failures, jiangling_rearguard_secured_state, "war_drum", false, "032 secured Huarong rearguard")
var jiangling_rearguard_rushed_state = BattleStateScript.new()
if not jiangling_rearguard_rushed_state.load_battle("res://data/scenarios/032_jiangling_rearguard.json", {}, {}, {"rushed_xuchang_return": true}, ["zhang_he"]):
failures.append("could not load Jiangling Rearguard rushed Xuchang data")
else:
_check_shop_items_unique(failures, jiangling_rearguard_rushed_state, "032 rushed Xuchang return")
var forced_march_beans: Dictionary = _find_camp_conversation(
jiangling_rearguard_rushed_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_forced_march_beans"
)
if forced_march_beans.is_empty():
failures.append("032 rushed Xuchang return should expose forced march beans")
else:
_check_camp_conversation_effect(failures, forced_march_beans, "bean", 1)
if not _find_camp_conversation(
jiangling_rearguard_rushed_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_stability_medicine"
).is_empty():
failures.append("032 rushed Xuchang return should not expose stability medicine")
_check_shop_item_visibility(failures, jiangling_rearguard_rushed_state, "war_drum", true, "032 rushed Xuchang return")
_check_shop_item_visibility(failures, jiangling_rearguard_rushed_state, "imperial_seal", false, "032 rushed Xuchang return")
var jiangling_rearguard_old_flags_state = BattleStateScript.new()
if not jiangling_rearguard_old_flags_state.load_battle("res://data/scenarios/032_jiangling_rearguard.json", {}, {}, {"held_red_cliffs_line": true, "opened_huarong_retreat": true}, ["zhang_he"]):
failures.append("could not load Jiangling Rearguard old Huarong setup flag data")
else:
_check_shop_items_unique(failures, jiangling_rearguard_old_flags_state, "032 old Huarong setup flags")
_check_shop_item_visibility(failures, jiangling_rearguard_old_flags_state, "war_drum", false, "032 old Huarong setup flags")
_check_shop_item_visibility(failures, jiangling_rearguard_old_flags_state, "imperial_seal", false, "032 old Huarong setup flags")
if not _find_camp_conversation(
jiangling_rearguard_old_flags_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_stability_medicine"
).is_empty():
failures.append("032 old Huarong setup flags should not expose stability medicine")
if not _find_camp_conversation(
jiangling_rearguard_old_flags_state.get_briefing().get("camp_conversations", []),
"jiangling_rearguard_forced_march_beans"
).is_empty():
failures.append("032 old Huarong setup flags should not expose forced march beans")
var tong_pass_state = BattleStateScript.new()
if not tong_pass_state.load_battle("res://data/scenarios/033_tong_pass_vanguard.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Tong Pass Vanguard data")
else:
_check_shop_items_unique(failures, tong_pass_state, "033 base")
var tong_pass_conversations: Array = tong_pass_state.get_briefing().get("camp_conversations", [])
if tong_pass_conversations.size() != 2:
failures.append("033 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, _find_camp_conversation(tong_pass_conversations, "tong_pass_vanguard_council"), "tong_pass_vanguard_council", "topic", "")
_check_camp_conversation(failures, _find_camp_conversation(tong_pass_conversations, "tong_pass_zhang_he_pass_road"), "tong_pass_zhang_he_pass_road", "officer", "zhang_he")
var tong_pass_merchant := tong_pass_state.get_shop_merchant()
if str(tong_pass_merchant.get("name", "")) != "Tong Pass Sutler":
failures.append("033 shop should expose Tong Pass merchant name")
if (tong_pass_merchant.get("lines", []) as Array).size() < 2:
failures.append("033 shop should expose merchant flavor lines")
if tong_pass_state.get_unit("zhang_he_ch33").is_empty() or not tong_pass_state.is_required_deployment("zhang_he_ch33"):
failures.append("033 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, tong_pass_state, "war_drum", false, "033 base")
_check_shop_item_visibility(failures, tong_pass_state, "imperial_seal", false, "033 base")
var tong_pass_stabilized_state = BattleStateScript.new()
if not tong_pass_stabilized_state.load_battle("res://data/scenarios/033_tong_pass_vanguard.json", {}, {}, {"stabilized_jiangling_rearguard": true}, ["zhang_he"]):
failures.append("could not load Tong Pass stabilized Jiangling data")
else:
_check_shop_items_unique(failures, tong_pass_stabilized_state, "033 stabilized Jiangling rearguard")
var jiangling_supply_medicine: Dictionary = _find_camp_conversation(
tong_pass_stabilized_state.get_briefing().get("camp_conversations", []),
"tong_pass_jiangling_supply_medicine"
)
if jiangling_supply_medicine.is_empty():
failures.append("033 stabilized Jiangling rearguard should expose Jiangling supply medicine")
else:
_check_camp_conversation_effect(failures, jiangling_supply_medicine, "panacea", 1)
if not _find_camp_conversation(
tong_pass_stabilized_state.get_briefing().get("camp_conversations", []),
"tong_pass_vanguard_rations"
).is_empty():
failures.append("033 stabilized Jiangling rearguard should not expose vanguard rations")
_check_shop_item_visibility(failures, tong_pass_stabilized_state, "imperial_seal", true, "033 stabilized Jiangling rearguard")
_check_shop_item_visibility(failures, tong_pass_stabilized_state, "war_drum", false, "033 stabilized Jiangling rearguard")
var tong_pass_mobilized_state = BattleStateScript.new()
if not tong_pass_mobilized_state.load_battle("res://data/scenarios/033_tong_pass_vanguard.json", {}, {}, {"mobilized_tong_pass_vanguard": true}, ["zhang_he"]):
failures.append("could not load Tong Pass mobilized vanguard data")
else:
_check_shop_items_unique(failures, tong_pass_mobilized_state, "033 mobilized Tong Pass vanguard")
var vanguard_rations: Dictionary = _find_camp_conversation(
tong_pass_mobilized_state.get_briefing().get("camp_conversations", []),
"tong_pass_vanguard_rations"
)
if vanguard_rations.is_empty():
failures.append("033 mobilized Tong Pass vanguard should expose vanguard rations")
else:
_check_camp_conversation_effect(failures, vanguard_rations, "bean", 1)
if not _find_camp_conversation(
tong_pass_mobilized_state.get_briefing().get("camp_conversations", []),
"tong_pass_jiangling_supply_medicine"
).is_empty():
failures.append("033 mobilized Tong Pass vanguard should not expose Jiangling supply medicine")
_check_shop_item_visibility(failures, tong_pass_mobilized_state, "war_drum", true, "033 mobilized Tong Pass vanguard")
_check_shop_item_visibility(failures, tong_pass_mobilized_state, "imperial_seal", false, "033 mobilized Tong Pass vanguard")
var tong_pass_old_flags_state = BattleStateScript.new()
if not tong_pass_old_flags_state.load_battle("res://data/scenarios/033_tong_pass_vanguard.json", {}, {}, {"secured_huarong_rearguard": true, "rushed_xuchang_return": true}, ["zhang_he"]):
failures.append("could not load Tong Pass old Jiangling setup flag data")
else:
_check_shop_items_unique(failures, tong_pass_old_flags_state, "033 old Jiangling setup flags")
_check_shop_item_visibility(failures, tong_pass_old_flags_state, "war_drum", false, "033 old Jiangling setup flags")
_check_shop_item_visibility(failures, tong_pass_old_flags_state, "imperial_seal", false, "033 old Jiangling setup flags")
if not _find_camp_conversation(
tong_pass_old_flags_state.get_briefing().get("camp_conversations", []),
"tong_pass_jiangling_supply_medicine"
).is_empty():
failures.append("033 old Jiangling setup flags should not expose Jiangling supply medicine")
if not _find_camp_conversation(
tong_pass_old_flags_state.get_briefing().get("camp_conversations", []),
"tong_pass_vanguard_rations"
).is_empty():
failures.append("033 old Jiangling setup flags should not expose vanguard rations")
var ma_chao_counterstroke_state = BattleStateScript.new()
if not ma_chao_counterstroke_state.load_battle("res://data/scenarios/034_ma_chao_counterstroke.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Ma Chao Counterstroke data")
else:
_check_shop_items_unique(failures, ma_chao_counterstroke_state, "034 base")
var ma_chao_conversations: Array = ma_chao_counterstroke_state.get_briefing().get("camp_conversations", [])
if ma_chao_conversations.size() != 2:
failures.append("034 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, _find_camp_conversation(ma_chao_conversations, "ma_chao_counterstroke_council"), "ma_chao_counterstroke_council", "topic", "")
_check_camp_conversation(failures, _find_camp_conversation(ma_chao_conversations, "ma_chao_zhang_he_charge_line"), "ma_chao_zhang_he_charge_line", "officer", "zhang_he")
var ma_chao_merchant := ma_chao_counterstroke_state.get_shop_merchant()
if str(ma_chao_merchant.get("name", "")) != "Counterstroke Sutler":
failures.append("034 shop should expose counterstroke merchant name")
if (ma_chao_merchant.get("lines", []) as Array).size() < 2:
failures.append("034 shop should expose merchant flavor lines")
if ma_chao_counterstroke_state.get_unit("zhang_he_ch34").is_empty() or not ma_chao_counterstroke_state.is_required_deployment("zhang_he_ch34"):
failures.append("034 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, ma_chao_counterstroke_state, "war_drum", false, "034 base")
_check_shop_item_visibility(failures, ma_chao_counterstroke_state, "imperial_seal", false, "034 base")
var ma_chao_fortified_state = BattleStateScript.new()
if not ma_chao_fortified_state.load_battle("res://data/scenarios/034_ma_chao_counterstroke.json", {}, {}, {"fortified_tong_pass": true}, ["zhang_he"]):
failures.append("could not load Ma Chao Counterstroke fortified Tong Pass data")
else:
_check_shop_items_unique(failures, ma_chao_fortified_state, "034 fortified Tong Pass")
var fortified_medicine: Dictionary = _find_camp_conversation(
ma_chao_fortified_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_fortified_medicine"
)
if fortified_medicine.is_empty():
failures.append("034 fortified Tong Pass should expose fortified medicine")
else:
_check_camp_conversation_effect(failures, fortified_medicine, "panacea", 1)
if not _find_camp_conversation(
ma_chao_fortified_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_pressed_beans"
).is_empty():
failures.append("034 fortified Tong Pass should not expose pressed beans")
_check_shop_item_visibility(failures, ma_chao_fortified_state, "imperial_seal", true, "034 fortified Tong Pass")
_check_shop_item_visibility(failures, ma_chao_fortified_state, "war_drum", false, "034 fortified Tong Pass")
var ma_chao_pressed_state = BattleStateScript.new()
if not ma_chao_pressed_state.load_battle("res://data/scenarios/034_ma_chao_counterstroke.json", {}, {}, {"pressed_ma_chao_vanguard": true}, ["zhang_he"]):
failures.append("could not load Ma Chao Counterstroke pressed vanguard data")
else:
_check_shop_items_unique(failures, ma_chao_pressed_state, "034 pressed Ma Chao vanguard")
var pressed_beans: Dictionary = _find_camp_conversation(
ma_chao_pressed_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_pressed_beans"
)
if pressed_beans.is_empty():
failures.append("034 pressed Ma Chao vanguard should expose pressed beans")
else:
_check_camp_conversation_effect(failures, pressed_beans, "bean", 1)
if not _find_camp_conversation(
ma_chao_pressed_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_fortified_medicine"
).is_empty():
failures.append("034 pressed Ma Chao vanguard should not expose fortified medicine")
_check_shop_item_visibility(failures, ma_chao_pressed_state, "war_drum", true, "034 pressed Ma Chao vanguard")
_check_shop_item_visibility(failures, ma_chao_pressed_state, "imperial_seal", false, "034 pressed Ma Chao vanguard")
var ma_chao_old_flags_state = BattleStateScript.new()
if not ma_chao_old_flags_state.load_battle("res://data/scenarios/034_ma_chao_counterstroke.json", {}, {}, {"stabilized_jiangling_rearguard": true, "mobilized_tong_pass_vanguard": true}, ["zhang_he"]):
failures.append("could not load Ma Chao Counterstroke old Tong Pass setup flag data")
else:
_check_shop_items_unique(failures, ma_chao_old_flags_state, "034 old Tong Pass setup flags")
_check_shop_item_visibility(failures, ma_chao_old_flags_state, "war_drum", false, "034 old Tong Pass setup flags")
_check_shop_item_visibility(failures, ma_chao_old_flags_state, "imperial_seal", false, "034 old Tong Pass setup flags")
if not _find_camp_conversation(
ma_chao_old_flags_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_fortified_medicine"
).is_empty():
failures.append("034 old Tong Pass setup flags should not expose fortified medicine")
if not _find_camp_conversation(
ma_chao_old_flags_state.get_briefing().get("camp_conversations", []),
"ma_chao_counterstroke_pressed_beans"
).is_empty():
failures.append("034 old Tong Pass setup flags should not expose pressed beans")
var weishui_camps_state = BattleStateScript.new()
if not weishui_camps_state.load_battle("res://data/scenarios/035_weishui_camps.json", {}, {}, {}, ["zhang_he"]):
failures.append("could not load Weishui Camps data")
else:
_check_shop_items_unique(failures, weishui_camps_state, "035 base")
var weishui_conversations: Array = weishui_camps_state.get_briefing().get("camp_conversations", [])
if weishui_conversations.size() != 2:
failures.append("035 base briefing should expose exactly two camp conversations")
else:
_check_camp_conversation(failures, _find_camp_conversation(weishui_conversations, "weishui_camps_council"), "weishui_camps_council", "topic", "")
_check_camp_conversation(failures, _find_camp_conversation(weishui_conversations, "weishui_zhang_he_river_line"), "weishui_zhang_he_river_line", "officer", "zhang_he")
var weishui_merchant := weishui_camps_state.get_shop_merchant()
if str(weishui_merchant.get("name", "")) != "Weishui Camp Sutler":
failures.append("035 shop should expose Weishui Camp merchant name")
if (weishui_merchant.get("lines", []) as Array).size() < 2:
failures.append("035 shop should expose merchant flavor lines")
if weishui_camps_state.get_unit("zhang_he_ch35").is_empty() or not weishui_camps_state.is_required_deployment("zhang_he_ch35"):
failures.append("035 should deploy Zhang He as a required officer")
_check_shop_item_visibility(failures, weishui_camps_state, "war_drum", false, "035 base")
_check_shop_item_visibility(failures, weishui_camps_state, "imperial_seal", false, "035 base")
var weishui_anchored_state = BattleStateScript.new()
if not weishui_anchored_state.load_battle("res://data/scenarios/035_weishui_camps.json", {}, {}, {"anchored_weishui_camps": true}, ["zhang_he"]):
failures.append("could not load Weishui Camps anchored data")
else:
_check_shop_items_unique(failures, weishui_anchored_state, "035 anchored Weishui camps")
var anchor_medicine: Dictionary = _find_camp_conversation(
weishui_anchored_state.get_briefing().get("camp_conversations", []),
"weishui_anchor_medicine"
)
if anchor_medicine.is_empty():
failures.append("035 anchored Weishui camps should expose anchor medicine")
else:
_check_camp_conversation_effect(failures, anchor_medicine, "panacea", 1)
if not _find_camp_conversation(
weishui_anchored_state.get_briefing().get("camp_conversations", []),
"weishui_doubt_rations"
).is_empty():
failures.append("035 anchored Weishui camps should not expose doubt rations")
_check_shop_item_visibility(failures, weishui_anchored_state, "imperial_seal", true, "035 anchored Weishui camps")
_check_shop_item_visibility(failures, weishui_anchored_state, "war_drum", false, "035 anchored Weishui camps")
var weishui_doubt_state = BattleStateScript.new()
if not weishui_doubt_state.load_battle("res://data/scenarios/035_weishui_camps.json", {}, {}, {"sowed_han_sui_doubt": true}, ["zhang_he"]):
failures.append("could not load Weishui Camps Han Sui doubt data")
else:
_check_shop_items_unique(failures, weishui_doubt_state, "035 sowed Han Sui doubt")
var doubt_rations: Dictionary = _find_camp_conversation(
weishui_doubt_state.get_briefing().get("camp_conversations", []),
"weishui_doubt_rations"
)
if doubt_rations.is_empty():
failures.append("035 sowed Han Sui doubt should expose doubt rations")
else:
_check_camp_conversation_effect(failures, doubt_rations, "bean", 1)
if not _find_camp_conversation(
weishui_doubt_state.get_briefing().get("camp_conversations", []),
"weishui_anchor_medicine"
).is_empty():
failures.append("035 sowed Han Sui doubt should not expose anchor medicine")
_check_shop_item_visibility(failures, weishui_doubt_state, "war_drum", true, "035 sowed Han Sui doubt")
_check_shop_item_visibility(failures, weishui_doubt_state, "imperial_seal", false, "035 sowed Han Sui doubt")
var weishui_old_flags_state = BattleStateScript.new()
if not weishui_old_flags_state.load_battle("res://data/scenarios/035_weishui_camps.json", {}, {}, {"fortified_tong_pass": true, "pressed_ma_chao_vanguard": true}, ["zhang_he"]):
failures.append("could not load Weishui Camps old Counterstroke setup flag data")
else:
_check_shop_items_unique(failures, weishui_old_flags_state, "035 old Counterstroke setup flags")
_check_shop_item_visibility(failures, weishui_old_flags_state, "war_drum", false, "035 old Counterstroke setup flags")
_check_shop_item_visibility(failures, weishui_old_flags_state, "imperial_seal", false, "035 old Counterstroke setup flags")
if not _find_camp_conversation(
weishui_old_flags_state.get_briefing().get("camp_conversations", []),
"weishui_anchor_medicine"
).is_empty():
failures.append("035 old Counterstroke setup flags should not expose anchor medicine")
if not _find_camp_conversation(
weishui_old_flags_state.get_briefing().get("camp_conversations", []),
"weishui_doubt_rations"
).is_empty():
failures.append("035 old Counterstroke setup flags should not expose doubt rations")
var western_hanzhong_expectations := [
{
"path": "res://data/scenarios/036_weishui_crossing.json",
"label": "036 Weishui crossing",
"merchant": "위수 도하 야상",
"conversations": [
["weishui_crossing_council", "topic", ""],
["weishui_guo_jia_alliance_rift", "officer", "guo_jia"]
],
"imperial_flag": {"secured_weishui_crossing": true},
"war_drum_flag": {"deepened_han_sui_rift": true}
},
{
"path": "res://data/scenarios/037_guanzhong_foothold.json",
"label": "037 Guanzhong foothold",
"merchant": "관중 교두보 야상",
"conversations": [
["guanzhong_foothold_council", "topic", ""],
["guanzhong_zhang_he_rearguard", "officer", "zhang_he"]
],
"imperial_flag": {"secured_guanzhong_foothold": true},
"war_drum_flag": {"split_western_alliance": true}
},
{
"path": "res://data/scenarios/038_ma_chao_retreat.json",
"label": "038 Ma Chao retreat",
"merchant": "서량 퇴로 야상",
"conversations": [
["ma_chao_retreat_council", "topic", ""],
["ma_chao_guo_jia_hanzhong_shadow", "officer", "guo_jia"]
],
"imperial_flag": {"secured_guanzhong_commandery": true},
"war_drum_flag": {"pressed_ma_chao_retreat": true}
},
{
"path": "res://data/scenarios/039_hanzhong_approach.json",
"label": "039 Hanzhong approach",
"merchant": "한중 산문 야상",
"conversations": [
["hanzhong_approach_council", "topic", ""],
["hanzhong_guo_jia_pass_faith", "officer", "guo_jia"]
],
"imperial_flag": {"pacified_guanzhong": true},
"war_drum_flag": {"prepared_hanzhong_front": true}
},
{
"path": "res://data/scenarios/040_hanzhong_gate.json",
"label": "040 Hanzhong gate",
"merchant": "한중 관문 야상",
"conversations": [
["hanzhong_gate_council", "topic", ""],
["hanzhong_xiahou_yuan_beacon", "officer", "xiahou_yuan"]
],
"imperial_flag": {"secured_hanzhong_approach": true},
"war_drum_flag": {"pressed_zhang_lu_gate": true}
},
{
"path": "res://data/scenarios/041_hanzhong_settlement.json",
"label": "041 Hanzhong settlement",
"merchant": "한중 창고 야상",
"conversations": [
["hanzhong_settlement_council", "topic", ""],
["hanzhong_zhang_he_holdouts", "officer", "zhang_he"]
],
"imperial_flag": {"accepted_zhang_lu_submission": true},
"war_drum_flag": {"pacified_hanzhong": true}
}
]
for expectation in western_hanzhong_expectations:
_check_campaign_entry_expectation(failures, expectation)
var ruxu_expectations := [
{
"path": "res://data/scenarios/042_ruxu_advance.json",
"label": "042 Ruxu advance",
"merchant": "유수 강안 야상",
"conversations": [
["ruxu_advance_council", "topic", ""],
["ruxu_guo_jia_tide", "officer", "guo_jia"]
],
"imperial_flag": {"garrisoned_hanzhong": true},
"war_drum_flag": {"pivoted_to_ruxu": true}
},
{
"path": "res://data/scenarios/043_ruxu_river_line.json",
"label": "043 Ruxu river line",
"merchant": "유수 수로 야상",
"conversations": [
["ruxu_river_line_council", "topic", ""],
["ruxu_guo_jia_two_roads", "officer", "guo_jia"]
],
"imperial_flag": {"secured_ruxu_bank": true},
"war_drum_flag": {"pressed_sun_quan_line": true}
},
{
"path": "res://data/scenarios/044_ruxu_crossing.json",
"label": "044 Ruxu crossing",
"merchant": "유수 도하 야상",
"conversations": [
["ruxu_crossing_council", "topic", ""],
["ruxu_guo_jia_crossing_fear", "officer", "guo_jia"]
],
"imperial_flag": {"anchored_ruxu_camps": true},
"war_drum_flag": {"forced_ruxu_crossing": true}
}
]
for expectation in ruxu_expectations:
_check_campaign_entry_expectation(failures, expectation)
var jing_expectations := [
{
"path": "res://data/scenarios/045_fancheng_relief.json",
"label": "045 Fancheng relief",
"merchant": "번성 구원 야상",
"conversations": [
["fancheng_relief_council", "topic", ""],
["fancheng_guo_jia_siege_clock", "officer", "guo_jia"]
],
"imperial_flag": {"secured_ruxu_crossing": true},
"war_drum_flag": {"prepared_fancheng_relief": true}
},
{
"path": "res://data/scenarios/046_jingzhou_supply_line.json",
"label": "046 Jingzhou supply line",
"merchant": "형주 보급로 야상",
"conversations": [
["jingzhou_supply_council", "topic", ""],
["jingzhou_guo_jia_hungry_courage", "officer", "guo_jia"]
],
"imperial_flag": {"reinforced_fancheng": true},
"war_drum_flag": {"struck_jingzhou_supply": true}
},
{
"path": "res://data/scenarios/047_maicheng_pursuit.json",
"label": "047 Maicheng pursuit",
"merchant": "맥성 추격 야상",
"conversations": [
["maicheng_pursuit_council", "topic", ""],
["maicheng_guo_jia_pride", "officer", "guo_jia"]
],
"imperial_flag": {"secured_fancheng_line": true},
"war_drum_flag": {"pursued_guan_yu_rearguard": true}
},
{
"path": "res://data/scenarios/048_jing_province_settlement.json",
"label": "048 Jing province settlement",
"merchant": "형주 평정 야상",
"conversations": [
["jing_settlement_council", "topic", ""],
["jing_guo_jia_bowstring", "officer", "guo_jia"]
],
"imperial_flag": {"coordinated_wu_encirclement": true},
"war_drum_flag": {"secured_xiangfan_front": true}
}
]
for expectation in jing_expectations:
_check_campaign_entry_expectation(failures, expectation)
var wei_succession_expectations := [
{
"path": "res://data/scenarios/049_luoyang_summons.json",
"label": "049 Luoyang summons",
"merchant": "낙양 외조 야상",
"conversations": [
["luoyang_summons_council", "topic", ""],
["luoyang_guo_jia_summons_shadow", "officer", "guo_jia"]
],
"imperial_flag": {"stabilized_jing_province": true},
"war_drum_flag": {"answered_luoyang_summons": true}
},
{
"path": "res://data/scenarios/050_wei_succession.json",
"label": "050 Wei succession",
"merchant": "위왕 계승 야상",
"conversations": [
["wei_succession_council", "topic", ""],
["wei_guo_jia_seal_hands", "officer", "guo_jia"]
],
"imperial_flag": {"secured_luoyang_court": true},
"war_drum_flag": {"prepared_wei_succession": true}
},
{
"path": "res://data/scenarios/051_eastern_front_alarm.json",
"label": "051 Eastern front alarm",
"merchant": "동부 강안 야상",
"conversations": [
["eastern_alarm_council", "topic", ""],
["eastern_guo_jia_river_test", "officer", "guo_jia"]
],
"imperial_flag": {"proclaimed_wei_authority": true},
"war_drum_flag": {"prepared_eastern_front": true}
}
]
for expectation in wei_succession_expectations:
_check_campaign_entry_expectation(failures, expectation)
var jianye_campaign_expectations := [
{
"path": "res://data/scenarios/052_wu_river_line.json",
"label": "052 Wu river line",
"merchant": "유수 수채 야상",
"conversations": [
["wu_river_line_council", "topic", ""],
["wu_river_line_guo_jia_current", "officer", "guo_jia"]
],
"imperial_flag": {"secured_eastern_fleet": true},
"war_drum_flag": {"pressed_wu_river_line": true}
},
{
"path": "res://data/scenarios/053_jianye_approach.json",
"label": "053 Jianye approach",
"merchant": "건업 길목 야상",
"conversations": [
["jianye_approach_council", "topic", ""],
["jianye_guo_jia_approach_road", "officer", "guo_jia"]
],
"imperial_flag": {"anchored_ruxu_fleet": true},
"war_drum_flag": {"prepared_jianye_pressure": true}
},
{
"path": "res://data/scenarios/054_wu_counterstroke.json",
"label": "054 Wu counterstroke",
"merchant": "건업 반격 야상",
"conversations": [
["wu_counterstroke_council", "topic", ""],
["wu_counterstroke_guo_jia_spent_courage", "officer", "guo_jia"]
],
"imperial_flag": {"secured_jianye_approach": true},
"war_drum_flag": {"forced_wu_counterstroke": true}
},
{
"path": "res://data/scenarios/055_jianye_gate.json",
"label": "055 Jianye gate",
"merchant": "건업 외문 야상",
"conversations": [
["jianye_gate_council", "topic", ""],
["jianye_gate_guo_jia_outer_line", "officer", "guo_jia"]
],
"imperial_flag": {"held_lower_yangtze_route": true},
"war_drum_flag": {"pressed_jianye_gate": true}
},
{
"path": "res://data/scenarios/056_jianye_sortie.json",
"label": "056 Jianye sortie",
"merchant": "건업 출격 야상",
"conversations": [
["jianye_sortie_council", "topic", ""],
["jianye_sortie_guo_jia_open_door", "officer", "guo_jia"]
],
"imperial_flag": {"secured_jianye_gate": true},
"war_drum_flag": {"forced_capital_sortie": true}
},
{
"path": "res://data/scenarios/057_inner_jianye.json",
"label": "057 Inner Jianye",
"merchant": "건업 내성 야상",
"conversations": [
["inner_jianye_council", "topic", ""],
["inner_jianye_guo_jia_doors", "officer", "guo_jia"]
],
"imperial_flag": {"contained_capital_sortie": true},
"war_drum_flag": {"pressed_inner_jianye": true}
},
{
"path": "res://data/scenarios/058_jianye_command.json",
"label": "058 Jianye command",
"merchant": "건업 지휘구 야상",
"conversations": [
["jianye_command_council", "topic", ""],
["jianye_command_guo_jia_orders", "officer", "guo_jia"]
],
"imperial_flag": {"secured_inner_jianye": true},
"war_drum_flag": {"forced_wu_last_stand": true}
},
{
"path": "res://data/scenarios/059_wu_surrender.json",
"label": "059 Wu surrender",
"merchant": "건업 항복로 야상",
"conversations": [
["wu_surrender_council", "topic", ""],
["wu_surrender_guo_jia_terms", "officer", "guo_jia"]
],
"imperial_flag": {"secured_jianye_command": true},
"war_drum_flag": {"forced_wu_surrender": true}
},
{
"path": "res://data/scenarios/060_southern_holdouts.json",
"label": "060 Southern holdouts",
"merchant": "남방 운하 야상",
"conversations": [
["southern_holdouts_council", "topic", ""],
["southern_holdouts_guo_jia_rumor", "officer", "guo_jia"]
],
"imperial_flag": {"accepted_wu_surrender": true},
"war_drum_flag": {"pursued_wu_holdouts": true}
},
{
"path": "res://data/scenarios/061_nanhai_route.json",
"label": "061 Nanhai route",
"merchant": "남해 길목 야상",
"conversations": [
["nanhai_route_council", "topic", ""],
["nanhai_route_guo_jia_passage", "officer", "guo_jia"]
],
"imperial_flag": {"settled_southern_holdouts": true},
"war_drum_flag": {"pressed_nanhai_route": true}
},
{
"path": "res://data/scenarios/062_southern_fleet.json",
"label": "062 Southern fleet",
"merchant": "남방 선단 야상",
"conversations": [
["southern_fleet_council", "topic", ""],
["southern_fleet_guo_jia_weather", "officer", "guo_jia"]
],
"imperial_flag": {"secured_nanhai_route": true},
"war_drum_flag": {"pressed_southern_fleet": true}
},
{
"path": "res://data/scenarios/063_hainan_coast.json",
"label": "063 Hainan coast",
"merchant": "해남 해안 야상",
"conversations": [
["hainan_coast_council", "topic", ""],
["hainan_coast_guo_jia_door", "officer", "guo_jia"]
],
"imperial_flag": {"secured_southern_fleet": true},
"war_drum_flag": {"pressed_hainan_coast": true}
},
{
"path": "res://data/scenarios/064_final_harbor.json",
"label": "064 Final harbor",
"merchant": "최종 항구 야상",
"conversations": [
["final_harbor_council", "topic", ""],
["final_harbor_guo_jia_last_shore", "officer", "guo_jia"]
],
"imperial_flag": {"secured_hainan_coast": true},
"war_drum_flag": {"pressed_final_harbor": true}
},
{
"path": "res://data/scenarios/065_last_captains.json",
"label": "065 Last captains",
"merchant": "마지막 대장 야상",
"conversations": [
["last_captains_council", "topic", ""],
["last_captains_guo_jia_names", "officer", "guo_jia"]
],
"imperial_flag": {"secured_final_harbor": true},
"war_drum_flag": {"pursued_last_captains": true}
},
{
"path": "res://data/scenarios/066_sea_exiles.json",
"label": "066 Sea exiles",
"merchant": "외해 망명로 야상",
"conversations": [
["sea_exiles_council", "topic", ""],
["sea_exiles_guo_jia_distance", "officer", "guo_jia"]
],
"imperial_flag": {"settled_last_captains": true},
"war_drum_flag": {"pursued_sea_exiles": true}
},
{
"path": "res://data/scenarios/067_outer_islands.json",
"label": "067 Outer islands",
"merchant": "외섬 상륙 야상",
"conversations": [
["outer_islands_council", "topic", ""],
["outer_islands_guo_jia_carried_road", "officer", "guo_jia"]
],
"imperial_flag": {"settled_sea_exiles": true},
"war_drum_flag": {"pressed_outer_islands": true}
}
]
for expectation in jianye_campaign_expectations:
_check_campaign_entry_expectation(failures, expectation)
for item_id in ["bronze_sword", "training_spear", "short_bow", "hand_axe", "iron_armor", "war_drum", "imperial_seal", "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_generated_toolbar_icons(failures: Array[String]) -> void:
var icon_kinds := [
"attack",
"tactic",
"item",
"wait",
"end_turn",
"threat",
"unit_list",
"save",
"load",
"settings",
"campaign",
"shop",
"talk",
"equip",
"roster",
"formation",
"status",
"objective",
"terrain",
"forecast",
"move",
"cancel",
"restart",
"new"
]
for icon_kind in icon_kinds:
_check_toolbar_icon_path(failures, "res://art/ui/icons/toolbar_%s.png" % icon_kind, "toolbar icon %s" % icon_kind)
func _check_generated_panel_textures(failures: Array[String]) -> void:
var panel_keys := ["lacquer", "paper_scroll", "hud_jade", "command_seal", "title_command"]
for panel_key in panel_keys:
_check_panel_texture_path(failures, "res://art/ui/panels/panel_%s.png" % panel_key, "panel texture %s" % panel_key)
func _check_generated_button_textures(failures: Array[String]) -> void:
var button_keys := ["normal", "hover", "pressed", "disabled"]
for button_key in button_keys:
_check_button_texture_path(failures, "res://art/ui/buttons/button_%s.png" % button_key, "button texture %s" % button_key)
for button_key in button_keys:
_check_icon_button_texture_path(failures, "res://art/ui/buttons/button_icon_%s.png" % button_key, "icon button texture %s" % button_key)
func _check_qhd_ui_import_settings(failures: Array[String]) -> void:
var import_paths := _collect_png_import_files("res://art/ui")
if import_paths.size() < 50:
failures.append("UI import check should cover the generated interface atlas, got %d files" % import_paths.size())
for import_path in import_paths:
var text := FileAccess.get_file_as_string(import_path)
if text.is_empty():
failures.append("UI import metadata should be readable: %s" % import_path)
continue
if not text.contains("mipmaps/generate=true"):
failures.append("QHD UI texture should generate mipmaps to avoid jagged fullscreen scaling: %s" % import_path)
if not text.contains("process/fix_alpha_border=true"):
failures.append("UI texture import should keep alpha border fix for clean transparent edges: %s" % import_path)
func _collect_png_import_files(root_path: String) -> Array[String]:
var result: Array[String] = []
var dir := DirAccess.open(root_path)
if dir == null:
return result
dir.list_dir_begin()
var file_name := dir.get_next()
while not file_name.is_empty():
if file_name.begins_with("."):
file_name = dir.get_next()
continue
var full_path := "%s/%s" % [root_path, file_name]
if dir.current_is_dir():
result.append_array(_collect_png_import_files(full_path))
elif file_name.ends_with(".png.import"):
result.append(full_path)
file_name = dir.get_next()
dir.list_dir_end()
return result
func _check_generated_tile_marker_textures(failures: Array[String]) -> void:
var marker_keys := ["move", "attack", "select", "target", "objective", "recover"]
for marker_key in marker_keys:
_check_tile_marker_texture_path(failures, "res://art/ui/tiles/tile_marker_%s.png" % marker_key, "tile marker %s" % marker_key)
func _check_generated_class_icon_textures(failures: Array[String]) -> void:
var icon_keys := ["infantry", "cavalry", "ranged", "tactic", "heavy", "generic"]
for icon_key in icon_keys:
_check_class_icon_texture_path(failures, "res://art/ui/class_icons/class_%s.png" % icon_key, "class icon %s" % icon_key)
func _check_generated_map_badge_textures(failures: Array[String]) -> void:
var badge_keys := ["objective", "supply", "gold", "tactic", "recover", "event"]
for badge_key in badge_keys:
_check_map_badge_texture_path(failures, "res://art/ui/map_badges/map_badge_%s.png" % badge_key, "map badge %s" % badge_key)
func _check_generated_terrain_textures(failures: Array[String]) -> void:
var terrain_keys := ["g", "f", "h", "d", "r", "w", "t", "c"]
for terrain_key in terrain_keys:
_check_terrain_texture_path(failures, "res://art/terrain/terrain_%s.png" % terrain_key, "terrain texture %s" % terrain_key)
func _check_generated_terrain_feature_textures(failures: Array[String]) -> void:
var feature_keys := [
"road_node",
"road_n", "road_s", "road_e", "road_w",
"road_ns", "road_ew",
"road_ne", "road_es", "road_sw", "road_wn",
"road_nse", "road_esw", "road_nsw", "road_new", "road_nesw",
"town", "castle", "water", "wasteland"
]
for feature_key in feature_keys:
_check_terrain_feature_texture_path(failures, "res://art/terrain/features/terrain_feature_%s.png" % feature_key, "terrain feature %s" % feature_key)
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")
if scene._load_tile_marker_texture("move") == null or scene._load_tile_marker_texture("target") == null:
failures.append("battle scene should load generated tactical tile marker textures")
if scene._load_class_icon_texture("infantry") == null or scene._load_class_icon_texture("tactic") == null:
failures.append("battle scene should load generated class icon textures")
if scene._load_map_badge_texture("objective") == null or scene._load_map_badge_texture("recover") == null:
failures.append("battle scene should load generated map badge textures")
if scene._load_terrain_feature_texture("road_ew") == null or scene._load_terrain_feature_texture("town") == null or scene._load_terrain_feature_texture("castle") == null:
failures.append("battle scene should load generated terrain feature overlays")
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("조조"):
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("조조 - 군략") or not scene._format_camp_conversation_row_title(strategy).contains("조조"):
failures.append("camp conversation row title should stay compact with label/speaker")
elif not scene._format_camp_conversation_preview_text(strategy).contains("영천"):
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("보급 콩"):
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("군막 상인") or not merchant_notice.contains("길가의 물자"):
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 ["위치: 영천, 중평 원년", "전장:", "적세 16명", "회복지 2곳", "군막:", "회의 4건", "물품 2종", "출진 2/2명"]:
if not camp_overview.contains(expected):
failures.append("camp overview should include %s: %s" % [expected, camp_overview])
for dense_text in ["군자금 0냥", "보급 대기", "진형 배치", "비축 물자"]:
if camp_overview.contains(dense_text):
failures.append("camp overview should move dense detail to tooltip: %s" % camp_overview)
var camp_overview_tooltip := scene._format_briefing_camp_overview_tooltip_text(scene.state.get_briefing(), false)
for expected_detail in ["군자금 0냥", "군막 회의 · 4건", "보급 대기 1건", "군상 물품 · 2종", "출진 명부 2명 중 2명", "진형 배치 6칸", "비축 물자"]:
if not camp_overview_tooltip.contains(expected_detail):
failures.append("camp overview tooltip should retain %s: %s" % [expected_detail, camp_overview_tooltip])
if camp_overview.contains("칸 x") or camp_overview.contains("×") or camp_overview.contains("칸x"):
failures.append("camp overview should not expose raw map dimensions: %s" % camp_overview)
if camp_overview.contains("칸 +"):
failures.append("camp overview should keep recovery cell counts out of visible text: %s" % camp_overview)
var briefing_marker_entries := scene._briefing_tactical_marker_entries()
if briefing_marker_entries.size() < 3:
failures.append("briefing should expose image-first tactical marker entries")
var briefing_marker_labels := []
var briefing_marker_hints := []
var briefing_marker_summaries := []
for marker_entry in briefing_marker_entries:
briefing_marker_labels.append(str(marker_entry.get("label", "")))
briefing_marker_hints.append(str(marker_entry.get("hint", "")))
briefing_marker_summaries.append(str(marker_entry.get("summary", "")))
if scene._load_map_badge_texture(str(marker_entry.get("badge", ""))) == null:
failures.append("briefing tactical marker should resolve generated badge art: %s" % str(marker_entry))
if scene._load_tile_marker_texture(str(marker_entry.get("tile", ""))) == null:
failures.append("briefing tactical marker should resolve generated tile art: %s" % str(marker_entry))
for expected_marker in ["유인선", "북숲 보급고", "마을 보급"]:
if not briefing_marker_labels.has(expected_marker):
failures.append("briefing tactical markers should include %s: %s" % [expected_marker, str(briefing_marker_labels)])
var joined_marker_hints := "\n".join(briefing_marker_hints)
for expected_hint in ["끌어낼", "콩 보급", "치료소"]:
if not joined_marker_hints.contains(expected_hint):
failures.append("briefing tactical marker hints should include %s: %s" % [expected_hint, joined_marker_hints])
for expected_summary in ["앞줄 유인", "숨은 콩", "회복 보급"]:
if not briefing_marker_summaries.has(expected_summary):
failures.append("briefing tactical marker summaries should surface %s: %s" % [expected_summary, str(briefing_marker_summaries)])
var briefing_allies := scene._briefing_force_preview_allies()
if briefing_allies.size() < 2:
failures.append("briefing force preview should expose Cao Cao and Xiahou Dun")
var briefing_enemy_types := scene._briefing_force_preview_enemy_types()
if briefing_enemy_types.size() < 3:
failures.append("briefing force preview should expose representative enemy unit art")
for preview_unit in briefing_allies + briefing_enemy_types:
var preview_side := "ally" if str(preview_unit.get("team", "")) == BattleStateScript.TEAM_PLAYER else "enemy"
if scene._briefing_force_preview_texture(preview_unit, preview_side) == null:
failures.append("briefing force preview unit should resolve portrait or unit art: %s" % str(preview_unit.get("name", "")))
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("병력 +20") or not bean_buy_detail.contains("보유 0점"):
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("부족 40냥"):
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("잔량 0/3") or not sold_out_detail.contains("매진"):
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("반값으로 넘김") or not sell_detail.contains("보유 2점"):
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 bean_action.contains("매입") or not bean_action.contains("") or not bean_action.contains("50냥"):
failures.append("shop buy action should stay compact 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("보유 0점"):
failures.append("shop item tooltip should preserve full item detail")
scene.campaign_state.load_campaign(BattleSceneScript.CAMPAIGN_PATH)
scene.campaign_state.start_new("001_yellow_turbans")
scene._create_hud()
scene.campaign_state.gold = 320
scene.shop_sell_mode = false
scene._rebuild_shop_menu()
var shop_visible_text := _collect_child_text(scene.shop_list)
var shop_tooltips := _collect_child_tooltips(scene.shop_list)
if shop_visible_text.contains("병력 +20") or shop_visible_text.contains("보유 0점") or shop_visible_text.contains("잔량 0/"):
failures.append("shop rows should keep effects and inventory details in hover text: %s" % shop_visible_text)
if not shop_visible_text.contains("") or not shop_visible_text.contains("50냥"):
failures.append("shop row should still show item name and price compactly: %s" % shop_visible_text)
if not shop_tooltips.contains("병력 +20") or not shop_tooltips.contains("보유 0점"):
failures.append("shop row tooltip should retain full item detail: %s" % shop_tooltips)
if not _has_descendant_texture(scene.shop_list):
failures.append("shop rows should use item icon artwork")
scene._rebuild_talk_menu()
var talk_visible_text := _collect_child_text(scene.talk_list)
var talk_tooltips := _collect_child_tooltips(scene.talk_list)
if not talk_visible_text.contains("조조 - 군략") or not talk_visible_text.contains("장수") or not talk_visible_text.contains("보급 대기"):
failures.append("talk rows should show compact labels and status badges: %s" % talk_visible_text)
if talk_visible_text.contains("첫 행군") or talk_visible_text.contains("보급 콩") or talk_visible_text.contains("수령 가능"):
failures.append("talk rows should keep preview and supply details in hover text: %s" % talk_visible_text)
if not talk_tooltips.contains("영천") or not talk_tooltips.contains("보급 콩") or not talk_tooltips.contains("수령 가능"):
failures.append("talk row tooltip should retain dialogue preview and supply detail: %s" % talk_tooltips)
if not _has_descendant_texture(scene.talk_list):
failures.append("talk rows should use speaker portrait artwork")
scene._rebuild_formation_menu()
var formation_visible_text := _collect_child_text(scene.formation_list)
var formation_tooltips := _collect_child_tooltips(scene.formation_list)
if not formation_visible_text.contains("조조") or not formation_visible_text.contains("배치") or not formation_visible_text.contains("선택"):
failures.append("formation rows should show officer name and compact placement badges: %s" % formation_visible_text)
if formation_visible_text.contains("좌표") or formation_visible_text.contains("전장도의") or formation_visible_text.contains("클릭하면"):
failures.append("formation rows should keep placement instructions in hover text: %s" % formation_visible_text)
if not formation_tooltips.contains("금빛 진형 칸") or not formation_tooltips.contains("현재 배치"):
failures.append("formation row tooltip should retain placement instructions: %s" % formation_tooltips)
if formation_tooltips.contains("2,7") or formation_tooltips.contains("1,6"):
failures.append("formation row tooltip should describe placement by battlefield zone, not raw coordinates: %s" % formation_tooltips)
if not _has_descendant_texture(scene.formation_list):
failures.append("formation rows should reuse officer portrait artwork")
if scene._format_formation_unit_button_text(scene.state.get_unit("cao_cao")).contains(","):
failures.append("formation unit button text should stay compact")
scene._rebuild_chapter_menu()
var chapter_status_text: String = scene.chapter_status_label.text if scene.chapter_status_label != null else ""
var chapter_visible_text := _collect_child_text(scene.chapter_list)
var chapter_tooltips := _collect_child_tooltips(scene.chapter_list)
if not chapter_status_text.contains("조조전") or not chapter_status_text.contains("진행"):
failures.append("chapter status should stay compact and campaign-aware: %s" % chapter_status_text)
if not chapter_visible_text.contains("난세의 기치") or not chapter_visible_text.contains("영천 소전") or not chapter_visible_text.contains("현 전장"):
failures.append("chapter rows should show compact titles and state badges: %s" % chapter_visible_text)
if chapter_visible_text.contains("[") or chapter_visible_text.contains("현 전장:") or chapter_visible_text.contains("앞 전장을 마치면"):
failures.append("chapter rows should keep dense state detail in hover text: %s" % chapter_visible_text)
if chapter_visible_text.contains("백마 구원전"):
failures.append("chapter rows should not dump unopened future chapter battles: %s" % chapter_visible_text)
if not chapter_tooltips.contains("현 전장: 영천 소전") or not chapter_tooltips.contains("클릭하면 이 전장의 군막으로 이동합니다.") or not chapter_tooltips.contains("앞 전장을 마치면 열립니다."):
failures.append("chapter row tooltips should retain current and locked battle details: %s" % chapter_tooltips)
if not _has_descendant_texture(scene.chapter_list):
failures.append("chapter scenario rows should use battlefield thumbnail artwork")
if not scene.campaign_state.save_manual_game():
failures.append("visual smoke should be able to create a manual checkpoint for save menu checks")
scene._rebuild_save_menu()
var save_status_text: String = scene.save_status_label.text if scene.save_status_label != null else ""
var save_visible_text := _collect_child_text(scene.save_slot_list)
var save_tooltips := _collect_child_tooltips(scene.save_slot_list)
if not save_status_text.contains("전기록") or not save_status_text.contains("군막"):
failures.append("save status should stay compact and mode-aware: %s" % save_status_text)
if not save_visible_text.contains("현재 군막") or not save_visible_text.contains("수동 봉인") or not save_visible_text.contains("진행") or not save_visible_text.contains("열기"):
failures.append("save rows should show compact slot titles, action, and badges: %s" % save_visible_text)
if save_visible_text.contains("현재 봉인:") or save_visible_text.contains("군자금"):
failures.append("save rows should keep checkpoint detail in hover text: %s" % save_visible_text)
if not save_tooltips.contains("현재 봉인:") or not save_tooltips.contains("클릭하면 이 군막 준비를 수동 봉인합니다.") or not save_tooltips.contains("클릭하면 이 수동 봉인을 열어 군막으로 돌아갑니다."):
failures.append("save row tooltips should retain checkpoint detail and action hints: %s" % save_tooltips)
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_map_overlay_softness(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 map overlay softness")
scene.free()
return
var grass_fill: Color = scene._terrain_fill_color(Vector2i(1, 1), "G", true)
if grass_fill.a > 0.055:
failures.append("background-backed grass fill should stay subtle, got %.3f" % grass_fill.a)
var water_fill: Color = scene._terrain_fill_color(Vector2i(1, 1), "W", true)
if water_fill.a > 0.070:
failures.append("background-backed water fill should avoid blocky square color, got %.3f" % water_fill.a)
var grid_color: Color = scene._map_grid_color(true)
if grid_color.a > 0.010:
failures.append("background-backed grid should be nearly invisible, got %.3f" % grid_color.a)
var fallback_grid: Color = scene._map_grid_color(false)
if fallback_grid.a > 0.24:
failures.append("fallback grid should not become a harsh square net, got %.3f" % fallback_grid.a)
var grass_texture: Color = scene._terrain_texture_modulate(Vector2i(3, 4), "G", true)
if grass_texture.a > 0.095:
failures.append("background-backed grass texture should blend into the high-res map, got %.3f" % grass_texture.a)
var town_texture: Color = scene._terrain_texture_modulate(Vector2i(7, 5), "T", true)
if town_texture.a > 0.130:
failures.append("background-backed town texture should signal terrain without tiling loudly, got %.3f" % town_texture.a)
var fallback_texture: Color = scene._terrain_texture_modulate(Vector2i(3, 4), "G", false)
if fallback_texture.a < 0.50:
failures.append("fallback terrain texture should remain readable without a background, got %.3f" % fallback_texture.a)
if scene._terrain_detail_alpha_scale(true) >= 0.28 or scene._terrain_detail_alpha_scale(false) < 0.99:
failures.append("background-backed terrain detail should be much quieter than fallback detail")
if scene._terrain_edge_alpha_scale(true) >= 0.36 or scene._terrain_edge_alpha_scale(false) < 0.99:
failures.append("background-backed terrain edge blends should not read as hard tile seams")
scene.free()
func _check_core_text_visibility(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._ready()
_check_readable_text_control(failures, scene.title_caption_label, "title campaign name", 26)
_check_readable_text_control(failures, scene.title_subtitle_label, "title subtitle", 13)
var title_feature: Node = scene.title_screen_root.find_child("TitleStoryFeaturePanel", true, false) if scene.title_screen_root != null else null
if title_feature != null:
failures.append("title text visibility should keep story artwork out of the first menu")
_check_readable_text_control(failures, scene.title_start_button, "title start button", 12)
_check_readable_text_control(failures, scene.title_load_button, "title load button", 12)
_check_readable_text_control(failures, scene.title_settings_button, "title settings button", 12)
scene._show_opening_prologue()
_check_readable_text_control(failures, scene.opening_prologue_title_label, "opening title", 16)
_check_readable_text_control(failures, scene.opening_prologue_body_label, "opening body", 18)
_check_readable_text_control(failures, scene.opening_prologue_step_label, "opening step", 11)
_check_readable_text_control(failures, scene.opening_prologue_next_button, "opening next button", 12)
_check_readable_text_control(failures, scene.opening_prologue_skip_button, "opening skip button", 11)
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load battle scene state for text visibility")
scene.free()
return
scene._show_briefing()
_check_readable_text_control(failures, scene.briefing_title_label, "briefing title", 18)
_check_readable_text_control(failures, scene.briefing_objective_label, "briefing objective", 14)
_check_readable_text_control(failures, scene.briefing_label, "briefing body", 15)
scene._hide_opening_prologue()
scene.battle_started = true
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._update_hud()
_check_readable_text_control(failures, scene.status_label, "top turn status", 11)
_check_readable_text_control(failures, scene.objective_label, "top objective", 12)
_check_readable_text_control(failures, scene.campaign_status_label, "top campaign status", 11)
scene._show_command_notice("군령 갱신", "목표: 성채를 장악하라.\n주의: 조조가 퇴각하면 패한다.")
_check_readable_text_control(failures, scene.objective_notice_label, "command notice", 14)
_check_readable_text_control(failures, scene.auto_end_turn_prompt_label, "auto end turn prompt", 13)
_check_readable_text_control(failures, scene.auto_end_turn_yes_button, "auto end turn confirm", 12)
_check_readable_text_control(failures, scene.auto_end_turn_no_button, "auto end turn decline", 12)
var lines := scene._normalized_dialogue_lines([{"speaker": "Cao Cao", "display_speaker": "조조", "text": "영천으로 향한다. 백성이 빠져나갈 길을 연다."}])
scene.active_dialogue_lines = lines
scene.active_dialogue_index = 0
scene._render_dialogue_line()
_check_readable_text_control(failures, scene.dialogue_speaker_label, "dialogue speaker", 16)
_check_readable_text_control(failures, scene.dialogue_text_label, "dialogue body", 16)
scene.free()
func _check_readable_text_control(failures: Array[String], control: Control, context: String, min_font_size: int) -> void:
if control == null:
failures.append("%s text control missing" % context)
return
var text := ""
if control is Label:
text = str((control as Label).text).strip_edges()
elif control is Button:
text = str((control as Button).text).strip_edges()
else:
failures.append("%s should be a label or button for text visibility checks" % context)
return
if text.is_empty():
failures.append("%s should not render empty text" % context)
var font_size := control.get_theme_font_size("font_size")
if font_size < min_font_size:
failures.append("%s text should stay readable: font size %d < %d" % [context, font_size, min_font_size])
var font_color := control.get_theme_color("font_color")
if font_color.a < 0.72:
failures.append("%s text should not be transparent: %s" % [context, str(font_color)])
var outline_size := control.get_theme_constant("outline_size")
if outline_size < 1:
failures.append("%s text should carry a thin outline for image-backed panels" % context)
if font_size >= 13 and outline_size < 2:
failures.append("%s text should carry a stronger outline on generated panels: %d" % [context, outline_size])
if font_size >= 16 and outline_size < 3:
failures.append("%s large text should carry enough outline for QHD scaling: %d" % [context, outline_size])
var outline_color := control.get_theme_color("font_outline_color")
if outline_color.a < 0.25:
failures.append("%s outline should be visible enough: %s" % [context, str(outline_color)])
var shadow_color := control.get_theme_color("font_shadow_color")
if shadow_color.a < 0.32:
failures.append("%s text should keep a soft shadow: %s" % [context, str(shadow_color)])
if control is Label and control.get_theme_constant("line_spacing") < 3:
failures.append("%s label should reserve readable Korean line spacing" % context)
func _check_qhd_design_layout_contract(failures: Array[String]) -> void:
var design_width := int(ProjectSettings.get_setting("display/window/size/viewport_width", 0))
var design_height := int(ProjectSettings.get_setting("display/window/size/viewport_height", 0))
if design_width != 1280 or design_height != 720:
failures.append("QHD layout should keep a 1280x720 design viewport, got %dx%d" % [design_width, design_height])
if str(ProjectSettings.get_setting("display/window/stretch/mode", "")) != "canvas_items":
failures.append("QHD layout should scale UI through canvas_items stretch mode")
if str(ProjectSettings.get_setting("display/window/stretch/aspect", "")) != "keep":
failures.append("QHD fullscreen should keep aspect ratio so buttons are not distorted")
if design_width > 0 and design_height > 0:
var qhd_scale := Vector2(2560.0 / float(design_width), 1440.0 / float(design_height))
if absf(qhd_scale.x - qhd_scale.y) > 0.001 or absf(qhd_scale.x - 2.0) > 0.001:
failures.append("QHD should scale the design viewport evenly at 2x, got %s" % str(qhd_scale))
var scene = BattleSceneScript.new()
scene._create_hud()
var top_bar := _find_descendant_by_name(scene.ui_root_control, "TopHudBar") as Control
_check_control_design_rect(failures, top_bar, "top icon toolbar", 0.0)
var side_panel := _find_descendant_by_name(scene.ui_root_control, "BattleSidePanel") as Control
_check_control_design_rect(failures, side_panel, "right battle information panel", 0.0)
if side_panel != null and str((side_panel as PanelContainer).get_meta("panel_style_variant", "")) != "hud_readable":
failures.append("right battle information panel should use the readable HUD surface")
if not scene.mission_detail_collapsed:
failures.append("right mission detail should start collapsed to avoid clutter")
if scene.mission_detail_panel != null and scene.mission_detail_panel.visible:
failures.append("right mission detail panel should not consume space by default")
_check_control_design_rect(failures, scene.briefing_panel, "pre-battle briefing sheet", 0.0)
if scene.briefing_panel != null and str(scene.briefing_panel.get_meta("panel_style_variant", "")) != "briefing_sheet":
failures.append("pre-battle briefing should use the readable sheet surface")
if scene.briefing_objective_panel != null and str(scene.briefing_objective_panel.get_meta("panel_style_variant", "")) != "briefing_card":
failures.append("briefing objective should use a readable card surface")
if scene.briefing_camp_overview_panel != null and str(scene.briefing_camp_overview_panel.get_meta("panel_style_variant", "")) != "briefing_card":
failures.append("briefing map report should use a readable card surface")
var briefing_body_scroll := _find_descendant_by_name(scene.briefing_panel, "BriefingBodyScroll") as Control
if briefing_body_scroll == null:
failures.append("briefing should keep a named body scroll for capture/readability QA")
elif briefing_body_scroll.custom_minimum_size.y > 96.0:
failures.append("briefing body summary should stay compact enough for prep commands: %s" % str(briefing_body_scroll.custom_minimum_size))
elif (briefing_body_scroll as ScrollContainer).vertical_scroll_mode != ScrollContainer.SCROLL_MODE_DISABLED:
failures.append("briefing body summary should hide the scrollbar and keep full copy in tooltip")
var briefing_prep_row := _find_descendant_by_name(scene.briefing_panel, "BriefingPrepCommandRow") as Control
if briefing_prep_row == null:
failures.append("briefing prep command row should remain visible in the first sheet")
if BattleSceneScript.BRIEFING_VISIBLE_BODY_LINES > 2:
failures.append("briefing visible body should stay as a short two-line summary")
_check_button_uses_flat_readable_surface(failures, scene.briefing_start_button, "briefing start")
var opening_caption := _find_descendant_by_name(scene.opening_prologue_root, "OpeningCaptionScroll") as Control
_check_control_design_rect(failures, opening_caption, "opening story caption", 0.0)
var opening_ledger := _find_descendant_by_name(scene.opening_prologue_root, "OpeningStoryLedgerStrip") as Control
_check_control_design_rect(failures, opening_ledger, "opening story ledger", 0.0)
_check_button_uses_flat_readable_surface(failures, scene.opening_prologue_next_button, "opening next")
_check_button_uses_flat_readable_surface(failures, scene.opening_prologue_skip_button, "opening skip")
scene.free()
func _check_control_design_rect(failures: Array[String], control: Control, context: String, margin: float = 10.0) -> void:
if control == null:
failures.append("%s missing for design-view fit check" % context)
return
var size := control.size
if size.x <= 0.0 or size.y <= 0.0:
size = control.custom_minimum_size
if size.x <= 0.0 or size.y <= 0.0:
failures.append("%s should reserve stable layout size before QHD scaling" % context)
return
var rect := Rect2(control.position, size)
var design_rect := Rect2(Vector2(margin, margin), Vector2(1280.0 - margin * 2.0, 720.0 - margin * 2.0))
if not design_rect.encloses(rect):
failures.append("%s should fit inside the 1280x720 design viewport before QHD scaling: %s" % [context, str(rect)])
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")
var cao_summary := scene._format_unit_focus_summary_text(cao_cao, "Selected")
if not cao_text.contains("군령 지휘"):
failures.append("Cao Cao HUD focus should describe command role: %s" % cao_text)
if not cao_text.contains("행군 4 (보병)") or not cao_text.contains("타격 1-1"):
failures.append("Cao Cao HUD focus should clarify move/attack affordance: %s" % cao_text)
if not cao_text.contains("시선 동쪽"):
failures.append("Cao Cao HUD focus should expose facing direction: %s" % cao_text)
if not cao_text.contains("군령:") or not cao_text.contains("행군") or not cao_text.contains("타격") or not cao_text.contains("책략") or not cao_text.contains("병장") or not cao_text.contains("대기"):
failures.append("Cao Cao HUD focus should summarize available actions: %s" % cao_text)
if not cao_text.contains("지형:") or cao_text.contains("2,7"):
failures.append("Cao Cao HUD focus should include terrain effects without raw coordinates: %s" % cao_text)
if not cao_text.contains("행군 1"):
failures.append("Cao Cao HUD focus should include terrain move cost: %s" % cao_text)
if not cao_text.contains("방비 +0"):
failures.append("Cao Cao HUD focus should include terrain defense: %s" % cao_text)
if not cao_summary.contains("군기: 조조") or cao_summary.contains("병력") or cao_summary.contains("기력") or cao_summary.contains("무위") or cao_summary.contains("지형"):
failures.append("Cao Cao visible HUD summary should leave resource/detail text to bars and tooltip: %s" % cao_summary)
scene._create_hud()
scene.battle_started = true
scene.state.selected_unit_id = "cao_cao"
scene.hover_cell = Vector2i(1, 6)
scene._update_hud()
if scene.hud_unit_hp_bar == null or int(scene.hud_unit_hp_bar.value) != int(cao_cao.get("hp", 0)) or int(scene.hud_unit_hp_bar.max_value) != int(cao_cao.get("max_hp", 1)):
failures.append("selected HUD should expose Cao Cao HP through a resource bar")
if scene.hud_unit_mp_bar == null or int(scene.hud_unit_mp_bar.value) != int(cao_cao.get("mp", 0)) or int(scene.hud_unit_mp_bar.max_value) != int(cao_cao.get("max_mp", 0)):
failures.append("selected HUD should expose Cao Cao MP through a resource bar")
if scene.hud_unit_exp_bar == null or int(scene.hud_unit_exp_bar.value) != int(cao_cao.get("exp", 0)) or int(scene.hud_unit_exp_bar.max_value) != BattleStateScript.EXP_LEVEL:
failures.append("selected HUD should expose Cao Cao merit through a resource bar")
if not scene.hud_unit_hp_bar.tooltip_text.contains("병력") or not scene.hud_unit_hp_bar.tooltip_text.contains("군기: 조조"):
failures.append("selected HP bar tooltip should preserve unit detail: %s" % scene.hud_unit_hp_bar.tooltip_text)
if not scene.hud_unit_exp_bar.tooltip_text.contains("전공") or not scene.hud_unit_exp_bar.tooltip_text.contains("군기: 조조"):
failures.append("selected merit bar tooltip should preserve unit detail: %s" % scene.hud_unit_exp_bar.tooltip_text)
var portrait_team_seal := _find_descendant_by_name(scene.hud_unit_portrait_panel, "HudUnitTeamSeal")
if portrait_team_seal == null:
failures.append("selected unit portrait should overlay a generated team seal")
else:
_check_panel_uses_generated_texture(failures, portrait_team_seal, "selected portrait team seal")
if not _collect_child_text(portrait_team_seal).contains(""):
failures.append("selected portrait team seal should show ally mark: %s" % _collect_child_text(portrait_team_seal))
var portrait_class_icon := _find_descendant_by_name(scene.hud_unit_portrait_panel, "HudUnitClassIcon")
if portrait_class_icon == null or not portrait_class_icon is TextureRect or (portrait_class_icon as TextureRect).texture == null:
failures.append("selected unit portrait should overlay generated class crest artwork")
var portrait_class_panel := _find_descendant_by_name(scene.hud_unit_portrait_panel, "HudUnitClassCrest")
if portrait_class_panel == null:
failures.append("selected unit portrait class crest frame missing")
else:
_check_panel_uses_generated_texture(failures, portrait_class_panel, "selected portrait class crest frame")
if _count_descendants_by_name(scene.hud_unit_info_column, "HudUnitResourceIcon") < 3:
failures.append("selected unit resource rows should use generated HP/MP/merit icons")
if scene.selected_label.text.contains("병력") or scene.selected_label.text.contains("기력") or scene.selected_label.text.contains("전공"):
failures.append("selected label should not duplicate HP/MP/merit bars: %s" % scene.selected_label.text)
if scene.selected_label.text.contains("군령:"):
failures.append("selected label should move command availability into visual badges: %s" % scene.selected_label.text)
if scene.inventory_label == null or not scene.inventory_label.text.begins_with("군수:") or scene.inventory_label.text.contains("소모품"):
failures.append("inventory HUD summary should name the ledger without dense stock wording: %s" % ("" if scene.inventory_label == null else scene.inventory_label.text))
elif not scene.inventory_label.tooltip_text.contains("군수:"):
failures.append("inventory HUD tooltip should preserve full inventory detail: %s" % scene.inventory_label.tooltip_text)
_check_side_action_option_text(failures, scene, cao_cao)
if scene.inventory_status_panel == null:
failures.append("inventory HUD summary should sit inside a readable generated status chip")
else:
_check_panel_uses_flat_readable_backing(failures, scene.inventory_status_panel, "inventory HUD status chip")
if scene.inventory_status_panel.tooltip_text != scene.inventory_label.tooltip_text:
failures.append("inventory HUD status chip should expose the same tooltip as the compact label")
if scene.inventory_status_icon == null or scene.inventory_status_icon.texture == null:
failures.append("inventory HUD status chip should use a generated item icon")
elif scene.inventory_status_icon.tooltip_text != scene.inventory_label.tooltip_text:
failures.append("inventory HUD status icon should expose the same tooltip as the compact label")
scene.state.selected_unit_id = ""
scene.hover_cell = Vector2i(-1, -1)
scene._update_hud()
if scene.hud_unit_focus_row == null or scene.hud_unit_focus_row.visible:
failures.append("empty battle HUD should replace the selected-unit card with a unit overview list")
if scene.side_unit_overview_panel == null or not scene.side_unit_overview_panel.visible:
failures.append("empty battle HUD should show the ally/enemy overview list")
else:
_check_panel_uses_flat_readable_backing(failures, scene.side_unit_overview_panel, "side unit overview readable panel")
if scene.side_unit_overview_panel.size_flags_vertical != Control.SIZE_EXPAND_FILL:
failures.append("idle overview should expand vertically as the main side panel")
if scene.mission_title_row != null and scene.mission_title_row.visible:
failures.append("idle overview should hide mission chrome so the unit list is the side-panel focus")
if scene.cell_info_panel != null and scene.cell_info_panel.visible:
failures.append("idle overview should hide terrain chip until a unit or cell is inspected")
if scene.forecast_panel != null and scene.forecast_panel.visible:
failures.append("idle overview should hide forecast chip until a unit is selected")
if scene.inventory_status_panel != null and scene.inventory_status_panel.visible:
failures.append("idle overview should hide inventory chip until a unit is selected")
if scene.log_box != null and scene.log_box.visible:
failures.append("idle overview should hide battle log so the ally/enemy list is the side-panel focus")
if scene.side_unit_overview_rows == null or scene.side_unit_overview_rows.get_child_count() < 2:
failures.append("empty battle HUD should list available forces in the side panel")
else:
var ally_overview_tooltips := _collect_child_tooltips(scene.side_unit_overview_rows)
if not ally_overview_tooltips.contains("클릭하면 이 장수를 선택"):
failures.append("ally overview rows should explain click-to-select through tooltips: %s" % ally_overview_tooltips)
if not ally_overview_tooltips.contains("아직 명령할 수 있습니다.") or not ally_overview_tooltips.contains("클릭하면"):
failures.append("ally overview action state tooltip should preserve action and click hints: %s" % ally_overview_tooltips)
var ally_overview_row := scene.side_unit_overview_rows.get_child(0) as Control
_check_side_overview_click_target(failures, ally_overview_row, "ally overview row")
var ally_overview_icon := _find_descendant_by_name(ally_overview_row, "SideUnitOverviewIcon") as Control
if ally_overview_icon == null or not _has_descendant_texture(ally_overview_icon):
failures.append("ally overview rows should show portrait/class artwork instead of a thin text-only list")
_check_side_overview_click_target(failures, _find_descendant_by_name(ally_overview_row, "SideUnitOverviewHp") as Control, "ally overview hp")
_check_side_overview_click_target(failures, _find_descendant_by_name(ally_overview_row, "SideUnitOverviewState") as Control, "ally overview state")
var ally_hp_text := _find_descendant_by_name(ally_overview_row, "SideUnitOverviewHpText") as Label
if ally_hp_text == null or not ally_hp_text.text.contains("병력"):
failures.append("ally overview rows should show readable troop strength beside the HP bar")
if scene.side_unit_overview_status_label == null or not scene.side_unit_overview_status_label.text.contains("명령"):
failures.append("ally overview status should summarize ready and completed units: %s" % ("" if scene.side_unit_overview_status_label == null else scene.side_unit_overview_status_label.text))
if scene.side_unit_overview_ally_button == null or scene.side_unit_overview_enemy_button == null:
failures.append("side unit overview should expose ally/enemy tabs")
elif not scene.side_unit_overview_ally_button.button_pressed or scene.side_unit_overview_enemy_button.button_pressed:
failures.append("side unit overview should default to the ally tab")
scene._on_side_unit_overview_row_pressed("xiahou_dun")
if scene.state.selected_unit_id != "xiahou_dun":
failures.append("ally overview row click should select the chosen unit: %s" % scene.state.selected_unit_id)
if scene.move_cells.is_empty():
failures.append("ally overview row click should immediately refresh movement range")
if scene.side_unit_overview_panel != null and scene.side_unit_overview_panel.visible:
failures.append("ally overview row click should swap the list back to selected-unit detail")
if scene.mission_title_row != null and not scene.mission_title_row.visible:
failures.append("selected unit HUD should restore mission chrome below the detail card")
if scene.inventory_status_panel != null and not scene.inventory_status_panel.visible:
failures.append("selected unit HUD should restore inventory status below the detail card")
if scene.log_box != null and not scene.log_box.visible:
failures.append("selected unit HUD should restore the compact battle log below the detail card")
scene.state.clear_selection()
scene._refresh_ranges()
scene._update_hud()
scene._on_side_unit_overview_tab_pressed("enemy")
if scene.side_unit_overview_status_label == null or not scene.side_unit_overview_status_label.text.contains("적군"):
failures.append("enemy overview tab should summarize remaining enemies: %s" % ("" if scene.side_unit_overview_status_label == null else scene.side_unit_overview_status_label.text))
if scene.side_unit_overview_enemy_button == null or not scene.side_unit_overview_enemy_button.button_pressed:
failures.append("enemy overview tab should stay visually selected")
if scene.side_unit_overview_rows == null or scene.side_unit_overview_rows.get_child_count() < 4:
failures.append("enemy overview tab should list visible enemy forces")
else:
var enemy_overview_tooltips := _collect_child_tooltips(scene.side_unit_overview_rows)
if not enemy_overview_tooltips.contains("클릭하면 위치로 이동"):
failures.append("enemy overview rows should explain click-to-focus through tooltips: %s" % enemy_overview_tooltips)
var enemy_overview_row := scene.side_unit_overview_rows.get_child(0) as Control
_check_side_overview_click_target(failures, enemy_overview_row, "enemy overview row")
var enemy_overview_icon := _find_descendant_by_name(enemy_overview_row, "SideUnitOverviewIcon") as Control
if enemy_overview_icon == null or not _has_descendant_texture(enemy_overview_icon):
failures.append("enemy overview rows should show unit artwork instead of a thin text-only list")
_check_side_overview_click_target(failures, _find_descendant_by_name(enemy_overview_row, "SideUnitOverviewHp") as Control, "enemy overview hp")
_check_side_overview_click_target(failures, _find_descendant_by_name(enemy_overview_row, "SideUnitOverviewState") as Control, "enemy overview state")
var enemy_hp_text := _find_descendant_by_name(enemy_overview_row, "SideUnitOverviewHpText") as Label
if enemy_hp_text == null or not enemy_hp_text.text.contains("병력"):
failures.append("enemy overview rows should show readable troop strength beside the HP bar")
scene._on_side_unit_overview_row_pressed("yellow_turban_1")
if scene.state.selected_unit_id != "":
failures.append("enemy overview row click should focus without selecting enemy units: %s" % scene.state.selected_unit_id)
if not scene.move_cells.is_empty():
failures.append("enemy overview row click should not leave player movement ranges visible")
if scene.side_inspected_unit_id != "yellow_turban_1":
failures.append("enemy overview row click should open an inspection card: %s" % scene.side_inspected_unit_id)
if scene.side_unit_overview_panel == null or scene.side_unit_overview_panel.visible:
failures.append("enemy inspection should replace the overview list with a unit detail card")
if scene.hud_unit_focus_row == null or not scene.hud_unit_focus_row.visible:
failures.append("enemy inspection should show the focused unit detail card")
if scene.selected_label == null or not scene.selected_label.text.contains("정찰:"):
failures.append("enemy inspection card should be labeled as scout detail: %s" % ("" if scene.selected_label == null else scene.selected_label.text))
if scene.hud_command_grid != null and scene.hud_command_grid.visible:
failures.append("enemy inspection should not expose player command buttons")
if not scene._hover_info_badge().is_empty():
failures.append("enemy inspection should keep duplicate map hover badges hidden")
if scene.side_inspection_back_button == null:
failures.append("enemy inspection should expose a readable return-to-list button")
elif not scene.side_inspection_back_button.visible or scene.side_inspection_back_button.text != "목록으로":
failures.append("enemy inspection return button should be visible and clearly labeled: %s" % ("" if scene.side_inspection_back_button == null else scene.side_inspection_back_button.text))
elif not scene.side_inspection_back_button.tooltip_text.contains("일람") or not scene.side_inspection_back_button.tooltip_text.contains("장만성"):
failures.append("enemy inspection return button should explain the inspected unit and list return: %s" % scene.side_inspection_back_button.tooltip_text)
scene._on_side_inspection_back_pressed()
if scene.side_inspected_unit_id != "":
failures.append("return button should close enemy inspection: %s" % scene.side_inspected_unit_id)
if scene.side_unit_overview_panel == null or not scene.side_unit_overview_panel.visible:
failures.append("return button should restore the overview list")
if scene.side_unit_overview_enemy_button == null or not scene.side_unit_overview_enemy_button.button_pressed:
failures.append("return button should preserve the enemy overview tab")
if scene.side_inspection_back_button != null and scene.side_inspection_back_button.visible:
failures.append("return button should hide again after returning to the overview")
scene._on_side_unit_overview_row_pressed("yellow_turban_1")
scene._handle_cancel_input()
if scene.side_inspected_unit_id != "":
failures.append("cancel should close enemy inspection: %s" % scene.side_inspected_unit_id)
if scene.side_unit_overview_panel == null or not scene.side_unit_overview_panel.visible:
failures.append("canceling enemy inspection should return to the overview list")
if scene.side_unit_overview_enemy_button == null or not scene.side_unit_overview_enemy_button.button_pressed:
failures.append("canceling enemy inspection should preserve the enemy overview tab")
scene._on_side_unit_overview_tab_pressed("ally")
if scene.inventory_label == null or scene.inventory_label.text != "군수 대기":
failures.append("empty battle inventory line should not show noisy stock text: %s" % ("" if scene.inventory_label == null else scene.inventory_label.text))
if scene.inventory_label != null and (not scene.inventory_label.tooltip_text.contains("장수 선택") or not scene.inventory_label.tooltip_text.contains("도구")):
failures.append("empty battle inventory tooltip should explain where readable item detail appears: %s" % scene.inventory_label.tooltip_text)
if scene.inventory_status_panel == null or scene.inventory_status_icon == null:
failures.append("empty battle inventory prompt should still be framed as an icon status chip")
elif scene.inventory_status_panel.tooltip_text != scene.inventory_label.tooltip_text or scene.inventory_status_icon.tooltip_text != scene.inventory_label.tooltip_text:
failures.append("empty battle inventory chip should carry the readable inventory tooltip")
if scene.hud_command_grid == null or scene.hud_command_grid.visible:
failures.append("empty battle HUD should hide disabled unit command buttons until a unit is selected")
scene.state.selected_unit_id = "cao_cao"
scene.hover_cell = Vector2i(1, 6)
scene._update_hud()
if scene.hud_unit_focus_row == null or not scene.hud_unit_focus_row.visible:
failures.append("selected unit HUD should restore the selected-unit detail card")
if scene.side_unit_overview_panel != null and scene.side_unit_overview_panel.visible:
failures.append("selected unit HUD should hide the idle unit overview list")
if scene.hud_command_grid == null or not scene.hud_command_grid.visible:
failures.append("selected unit HUD should show the unit command grid")
if scene.hud_unit_badge_row == null or not scene.hud_unit_badge_row.visible:
failures.append("selected unit HUD should expose compact hover badges")
else:
var badge_tooltips := _collect_child_tooltips(scene.hud_unit_badge_row)
if scene.hud_unit_badge_row.get_child_count() < 6:
failures.append("selected unit HUD should show role/facing/action badges, got %d" % scene.hud_unit_badge_row.get_child_count())
if not badge_tooltips.contains("시선 동쪽") or not badge_tooltips.contains("등 뒤"):
failures.append("selected unit facing badge should explain directional defense: %s" % badge_tooltips)
if not badge_tooltips.contains("행군") or not badge_tooltips.contains("타격") or not badge_tooltips.contains("대기"):
failures.append("selected unit action badges should expose available commands through tooltips: %s" % badge_tooltips)
if not _has_descendant_texture(scene.hud_unit_badge_row):
failures.append("selected unit action badges should use icon imagery, not only text")
var badge_visible_text := _collect_child_text(scene.hud_unit_badge_row).strip_edges()
if not badge_visible_text.is_empty():
failures.append("selected unit compact badges should avoid one-letter class/action text and rely on icons: %s" % badge_visible_text)
var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun")
xiahou_dun["pos"] = Vector2i(4, 1)
var forest_text := scene._unit_current_terrain_text(xiahou_dun)
if not forest_text.contains("") or not forest_text.contains("행군 3"):
failures.append("Cavalry movement should use mounted move_type cost on forest: %s" % forest_text)
var village_cell_detail := scene._format_cell_info_detail_text(Vector2i(6, 5))
if village_cell_detail.contains("7,6"):
failures.append("cell terrain detail should avoid raw coordinates: %s" % village_cell_detail)
if not village_cell_detail.contains("회복 +6/차례") or not village_cell_detail.contains("차례 시작"):
failures.append("cell terrain detail should explain recovery timing: %s" % village_cell_detail)
var village_summary := scene._format_cell_info_summary_text(Vector2i(6, 5))
if not village_summary.contains("회복 +6/차례"):
failures.append("cell terrain summary should show per-turn recovery: %s" % village_summary)
var village_badge_lines := scene._join_strings(scene._hover_info_badge_lines(Vector2i(6, 5)), "\n")
if not village_badge_lines.contains("회복 +6/차례"):
failures.append("hover badge should show per-turn recovery: %s" % village_badge_lines)
var recovery_overview := scene._format_briefing_recovery_terrain_text()
if not recovery_overview.contains("차례 시작"):
failures.append("briefing recovery overview should mention recovery timing: %s" % recovery_overview)
if not recovery_overview.contains("마을 회복 +6") or not recovery_overview.contains("성채 회복 +8"):
failures.append("briefing recovery overview should keep recovery amount but hide cell counts: %s" % recovery_overview)
if recovery_overview.contains("칸 +"):
failures.append("briefing recovery overview should not expose recovery cell counts: %s" % recovery_overview)
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("원거리 견제"):
failures.append("Archer HUD focus should describe ranged role: %s" % archer_text)
if not archer_text.contains("행군 4 (궁병)") or not archer_text.contains("타격 2-2"):
failures.append("Archer HUD focus should clarify ranged attack affordance: %s" % archer_text)
if not archer_text.contains("시선 서쪽"):
failures.append("Enemy archer HUD focus should expose facing direction: %s" % archer_text)
if not archer_text.contains("군령: 적군 군기"):
failures.append("Enemy archer HUD focus should describe enemy control: %s" % archer_text)
if not scene._unit_current_terrain_text(archer).contains("산지"):
failures.append("Archer terrain text should reflect its current hill tile")
var guard: Dictionary = scene.state.get_unit("yellow_turban_7")
var guard_text := scene._format_unit_focus_text(guard, "Hover")
if not guard_text.contains("수비: 거점 경계 반경 3") or guard_text.contains("20,3"):
failures.append("Guard HUD focus should describe the defended zone without raw coordinates: %s" % guard_text)
if scene._recovery_marker_text(Vector2i(6, 5)) != "+6":
failures.append("Village recovery marker should show healing amount")
if scene._recovery_marker_text(Vector2i(20, 1)) != "+8":
failures.append("Castle recovery marker should show healing amount")
if not scene._recovery_marker_text(Vector2i(1, 6)).is_empty():
failures.append("Plain terrain should not show a recovery marker")
var marker_rect: Rect2 = scene._recovery_marker_rect(Vector2i(6, 5))
if not scene._rect_for_cell(Vector2i(6, 5)).encloses(marker_rect):
failures.append("Recovery terrain marker should stay inside its tile: %s" % str(marker_rect))
if marker_rect.size.x < BattleSceneScript.RECOVERY_TERRAIN_MARKER_SIZE.x or marker_rect.size.y < BattleSceneScript.RECOVERY_TERRAIN_MARKER_SIZE.y:
failures.append("Recovery terrain marker should reserve readable generated badge space: %s" % str(marker_rect.size))
var recovery_amount_rect: Rect2 = scene._recovery_marker_amount_rect(marker_rect)
if not marker_rect.encloses(recovery_amount_rect):
failures.append("Recovery terrain amount badge should sit inside generated marker: %s in %s" % [str(recovery_amount_rect), str(marker_rect)])
var recovery_tile_modulate: Color = scene._recovery_tile_marker_modulate(Vector2i(6, 5))
if recovery_tile_modulate.a < 0.055 or recovery_tile_modulate.a > 0.11:
failures.append("Recovery terrain tile backing should stay quiet but visible over high-res map art: %.3f" % recovery_tile_modulate.a)
if not scene._should_draw_recovery_terrain_marker(Vector2i(6, 5)):
failures.append("Recovery terrain should draw one marker at the village region anchor")
if scene._should_draw_recovery_terrain_marker(Vector2i(7, 5)) or scene._should_draw_recovery_terrain_marker(Vector2i(6, 6)):
failures.append("Recovery terrain should not repeat markers on every connected village tile")
if not scene._should_draw_recovery_terrain_marker(Vector2i(19, 0)):
failures.append("Recovery terrain should draw one marker at the castle region anchor")
if scene._should_draw_recovery_terrain_marker(Vector2i(20, 1)):
failures.append("Recovery terrain should not repeat markers across the castle region")
if not scene._should_draw_objective_map_marker(Vector2i(19, 0)):
failures.append("Objective foreground should draw one marker at the visible castle objective anchor")
if scene._should_draw_objective_map_marker(Vector2i(20, 1)) or scene._should_draw_objective_map_marker(Vector2i(21, 2)):
failures.append("Objective foreground should not repeat markers across every castle objective tile")
var facing_tile_rect: Rect2 = scene._rect_for_cell(Vector2i(1, 6))
var facing_marker_points: PackedVector2Array = scene._unit_facing_marker_points(facing_tile_rect, cao_cao)
var facing_points_inside := facing_marker_points.size() == 3
for point in facing_marker_points:
if not facing_tile_rect.has_point(point):
facing_points_inside = false
if not facing_points_inside:
failures.append("Unit facing marker should stay anchored inside its tile: %s" % str(facing_marker_points))
if scene._unit_board_panel_key() != "hud_jade":
failures.append("Board unit nameplate and HP bar should use generated jade panel art")
if scene._load_art_texture("res://art/ui/panels/panel_%s.png" % scene._unit_board_panel_key()) == null:
failures.append("Board unit nameplate and HP bar should load generated panel texture")
if scene._target_marker_panel_key() != "hud_jade":
failures.append("Battlefield target markers should use generated jade panel art")
if scene._load_art_texture("res://art/ui/panels/panel_%s.png" % scene._target_marker_panel_key()) == null:
failures.append("Battlefield target markers should load generated panel texture")
var board_unit_rect: Rect2 = scene._visual_rect_for_unit(cao_cao)
var nameplate_rect: Rect2 = scene._unit_nameplate_rect(board_unit_rect)
var hp_bar_rect: Rect2 = scene._unit_hp_bar_rect(board_unit_rect)
if not facing_tile_rect.encloses(nameplate_rect):
failures.append("Unit nameplate should stay inside its tile: %s within %s" % [str(nameplate_rect), str(facing_tile_rect)])
if not facing_tile_rect.encloses(hp_bar_rect):
failures.append("Unit HP bar should stay inside its tile: %s within %s" % [str(hp_bar_rect), str(facing_tile_rect)])
if nameplate_rect.intersects(hp_bar_rect):
failures.append("Unit nameplate should not collide with HP bar: %s / %s" % [str(nameplate_rect), str(hp_bar_rect)])
_check_unit_class_family(failures, scene, "cao_cao", "tactic")
_check_unit_class_family(failures, scene, "xiahou_dun", "cavalry")
_check_unit_class_family(failures, scene, "yellow_turban_2", "infantry")
_check_unit_class_family(failures, scene, "yellow_turban_3", "ranged")
_check_unit_class_family(failures, scene, "yellow_turban_1", "heavy")
scene.free()
func _check_side_action_option_text(failures: Array[String], scene, selected: Dictionary) -> void:
var spark: Dictionary = scene.state.get_skill_def("spark")
if spark.is_empty():
failures.append("side action option text check needs Spark skill data")
else:
var tactic_text: String = scene._format_tactic_button_text("spark", spark, selected)
var tactic_tooltip: String = scene._format_tactic_button_tooltip_text("spark", spark, selected)
if not tactic_text.contains("\n") or tactic_text.contains("위력") or tactic_text.contains("사거리"):
failures.append("side tactic option visible text should stay compact and two-line: %s" % tactic_text)
if not tactic_tooltip.contains("위력") or not tactic_tooltip.contains("사거리"):
failures.append("side tactic option tooltip should preserve full detail: %s" % tactic_tooltip)
var bean: Dictionary = scene.state.get_item_def("bean")
if bean.is_empty():
failures.append("side action option text check needs Bean item data")
else:
var item_text: String = scene._format_item_button_text("bean", bean, 2)
var item_tooltip: String = scene._format_item_button_tooltip_text("bean", bean, 2)
if not item_text.contains("\n") or item_text.contains("군수:") or item_text.contains("소모품"):
failures.append("side item option visible text should stay compact and two-line: %s" % item_text)
if not item_tooltip.contains("잔량 2") or not item_tooltip.contains("병력"):
failures.append("side item option tooltip should preserve item detail: %s" % item_tooltip)
scene._show_tactic_menu(selected)
var tactic_button := _first_button_child(scene.tactic_list)
if tactic_button == null:
failures.append("side tactic menu should create option buttons")
else:
if not tactic_button.text.contains("\n") or tactic_button.text.contains("위력") or tactic_button.text.contains("사거리"):
failures.append("side tactic option button should show compact text: %s" % tactic_button.text)
if tactic_button.tooltip_text.strip_edges().is_empty() or not tactic_button.tooltip_text.contains("사거리"):
failures.append("side tactic option button should keep detailed tooltip: %s" % tactic_button.tooltip_text)
if tactic_button.icon == null or tactic_button.get_theme_constant("icon_max_width") < BattleSceneScript.SIDE_MENU_OPTION_ICON_MAX_WIDTH:
failures.append("side tactic option button should use a generated icon and stable icon width")
if tactic_button.custom_minimum_size != BattleSceneScript.SIDE_MENU_OPTION_SIZE:
failures.append("side tactic option button should reserve stable row size: %s" % str(tactic_button.custom_minimum_size))
scene._hide_tactic_menu()
scene.state.set_inventory_snapshot({"bean": 2})
scene._show_item_menu(selected)
var item_button := _first_button_child(scene.item_list)
if item_button == null:
failures.append("side item menu should create option buttons")
else:
if not item_button.text.contains("\n") or item_button.text.contains("군수:") or item_button.text.contains("소모품"):
failures.append("side item option button should show compact text: %s" % item_button.text)
if item_button.tooltip_text.strip_edges().is_empty() or not item_button.tooltip_text.contains("잔량 2"):
failures.append("side item option button should keep detailed tooltip: %s" % item_button.tooltip_text)
if item_button.icon == null:
failures.append("side item option button should use generated item icon art")
if item_button.custom_minimum_size != BattleSceneScript.SIDE_MENU_OPTION_SIZE:
failures.append("side item option button should reserve stable row size: %s" % str(item_button.custom_minimum_size))
scene._hide_item_menu()
func _check_side_overview_click_target(failures: Array[String], control: Control, context: String) -> void:
if control == null:
failures.append("%s click target missing" % context)
return
if control.mouse_filter != Control.MOUSE_FILTER_STOP:
failures.append("%s should accept mouse clicks across the whole overview row" % context)
if control.mouse_default_cursor_shape != Control.CURSOR_POINTING_HAND:
failures.append("%s should show the hand cursor for clickable overview rows" % context)
func _first_button_child(root: Node) -> Button:
if root == null:
return null
for child in root.get_children():
if child is Button:
return child as Button
return null
func _check_unit_class_family(failures: Array[String], scene, unit_id: String, expected_family: String) -> void:
var unit: Dictionary = scene.state.get_unit(unit_id)
if unit.is_empty():
failures.append("missing unit for class family check: %s" % unit_id)
return
var family := str(scene._unit_class_family(unit))
if family != expected_family:
failures.append("%s class family should be %s, got %s" % [unit_id, expected_family, family])
var color: Color = scene._unit_class_badge_color(unit, Color(0.20, 0.42, 0.82))
if color.a <= 0.0:
failures.append("%s class silhouette color should be visible" % unit_id)
if scene._load_unit_class_icon_texture(unit) == null:
failures.append("%s should resolve a generated class icon texture" % unit_id)
func _check_ancient_ui_theme(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 battle scene state for ancient UI theme")
scene.free()
return
var briefing_text := scene._format_briefing_objectives()
scene.briefing_objective_label.text = briefing_text
scene._update_briefing_objective_visibility()
for expected in ["승리:", "패배:", "보상:"]:
if not briefing_text.contains(expected):
failures.append("briefing objective parchment should use Korean heading `%s`: %s" % [expected, briefing_text])
for clutter in ["승리 조건", "패배 조건", "진행", "위험"]:
if briefing_text.contains(clutter):
failures.append("briefing objective parchment should stay concise without `%s`: %s" % [clutter, briefing_text])
var hud_objective := scene._format_objective_hud_text()
var hud_objective_detail := scene._format_objective_hud_detail_text()
if hud_objective.begins_with("목표:") or hud_objective.contains("승리 조건") or hud_objective.contains("패배 조건"):
failures.append("HUD objective should hide labels and keep only a concise current-goal summary: %s" % hud_objective)
if not hud_objective.contains("성채 장악") or not hud_objective.contains("제11군령"):
failures.append("HUD objective should show the current destination and turn-gated objective first: %s" % hud_objective)
if hud_objective.contains("적군 격파 0/") or hud_objective.contains("(대기)"):
failures.append("HUD objective should hide pending defeat counts from the visible top chip: %s" % hud_objective)
if not hud_objective_detail.contains("승리:") or not hud_objective_detail.contains("패배:"):
failures.append("HUD objective tooltip should retain win/loss details: %s" % hud_objective_detail)
if not hud_objective_detail.contains("진행:") or not hud_objective_detail.contains("적군 격파") or not hud_objective_detail.contains("조조 건재"):
failures.append("HUD objective tooltip should preserve full progress and risk details: %s" % hud_objective_detail)
scene.battle_started = true
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._update_hud()
if scene.status_label == null or scene.status_label.text.contains("군령") or scene.status_label.text.contains("") or scene.status_label.text.contains(""):
failures.append("top turn status should stay compact and leave full wording to tooltip: %s" % ("" if scene.status_label == null else scene.status_label.text))
elif not scene.status_label.text.contains("/") or not scene.status_label.text.contains("아군"):
failures.append("top turn status should use readable numeric turn/team text: %s" % scene.status_label.text)
elif not scene.status_label.tooltip_text.contains("군령"):
failures.append("top turn status tooltip should retain full turn wording: %s" % scene.status_label.tooltip_text)
if scene.campaign_status_label == null or scene.campaign_status_label.text.contains("전기") or scene.campaign_status_label.text.contains("군자금") or scene.campaign_status_label.text.contains(""):
failures.append("top campaign status should use compact seal/gold counts: %s" % ("" if scene.campaign_status_label == null else scene.campaign_status_label.text))
elif not scene.campaign_status_label.text.contains("/") or not scene.campaign_status_label.text.contains(""):
failures.append("top campaign status should use readable scenario/gold counts: %s" % scene.campaign_status_label.text)
elif not scene.campaign_status_label.tooltip_text.contains("군자금"):
failures.append("top campaign status tooltip should retain full campaign progress: %s" % scene.campaign_status_label.tooltip_text)
if scene.objective_label == null or scene.objective_label.text.begins_with("목표:"):
failures.append("top objective label should avoid repeated visible heading: %s" % ("" if scene.objective_label == null else scene.objective_label.text))
elif scene.objective_label.text.contains("적군 격파 0/") or scene.objective_label.text.contains("(대기)"):
failures.append("top objective label should keep pending counts in tooltip only: %s" % scene.objective_label.text)
elif not scene.objective_label.tooltip_text.contains("승리:") or not scene.objective_label.tooltip_text.contains("패배:"):
failures.append("top objective tooltip should retain objective detail: %s" % ("" if scene.objective_label == null else scene.objective_label.tooltip_text))
_check_top_hud_chip(failures, scene, "TopStatusChip", scene.status_label, "군령")
_check_top_hud_chip(failures, scene, "TopObjectiveChip", scene.objective_label, "승리:")
_check_top_hud_chip(failures, scene, "TopCampaignChip", scene.campaign_status_label, "군자금")
_check_panel_uses_flat_readable_backing(failures, _find_descendant_by_name(scene.ui_root_control, "TopHudBar") as PanelContainer, "top HUD compact text backing")
var mission_text := scene._format_mission_panel_text()
for expected in ["승리:", "패배:", "진행:", "주의:"]:
if not mission_text.contains(expected):
failures.append("mission panel should use Korean campaign wording `%s`: %s" % [expected, mission_text])
for clutter in ["목표|", "주의|", "현황|", "경고|"]:
if mission_text.contains(clutter):
failures.append("mission panel should avoid old verbose marker `%s`: %s" % [clutter, mission_text])
_check_panel_style_fill(failures, scene.briefing_panel, "briefing panel", Color(0.64, 0.65, 0.60, 0.999))
_check_panel_style_frame(failures, scene.briefing_panel, "briefing panel", Color(0.14, 0.11, 0.075, 1.0), 5)
_check_panel_style_fill(failures, scene.briefing_objective_panel, "briefing objective readable panel", Color(0.74, 0.74, 0.68, 0.999))
_check_panel_style_frame(failures, scene.briefing_objective_panel, "briefing objective readable panel", Color(0.16, 0.13, 0.090, 1.0), 3)
_check_panel_style_fill(failures, scene.briefing_camp_overview_panel, "briefing readable map panel", Color(0.74, 0.74, 0.68, 0.999))
_check_panel_style_frame(failures, scene.briefing_camp_overview_panel, "briefing readable map panel", Color(0.16, 0.13, 0.090, 1.0), 3)
_check_panel_uses_flat_text_surface(failures, _find_descendant_by_name(scene.briefing_panel, "BriefingBodyTextSurface") as PanelContainer, "briefing body text plate")
_check_panel_style_fill(failures, scene.dialogue_panel, "dialogue panel", Color(0.030, 0.028, 0.024, 0.996))
_check_panel_style_frame(failures, scene.dialogue_panel, "dialogue panel", Color(0.28, 0.23, 0.16, 1.0), 8)
_check_panel_style_fill(failures, scene.auto_end_turn_prompt_panel, "auto end turn prompt panel", Color(0.030, 0.028, 0.024, 0.996))
_check_panel_style_fill(failures, scene.dialogue_portrait_panel, "dialogue portrait panel", Color(0.025, 0.023, 0.020, 1.0))
_check_panel_style_fill(failures, scene.dialogue_speaker_panel, "dialogue speaker seal panel", Color(0.26, 0.035, 0.030, 0.995))
_check_panel_style_fill(failures, scene.dialogue_text_panel, "dialogue text scroll panel", Color(0.78, 0.77, 0.69, 0.999))
_check_panel_uses_flat_text_surface(failures, scene.dialogue_panel, "dialogue flat text frame")
_check_panel_uses_flat_text_surface(failures, scene.dialogue_text_panel, "dialogue flat text scroll")
_check_panel_uses_flat_text_surface(failures, scene.auto_end_turn_prompt_panel, "auto end turn flat text frame")
if scene.screen_backdrop == null:
failures.append("ancient briefing screen should create an ink-wash backdrop")
else:
if scene.screen_backdrop.color.a < 0.70:
failures.append("ink-wash backdrop should darken modern screen edges: %s" % str(scene.screen_backdrop.color))
scene.briefing_panel.visible = true
scene.result_panel.visible = false
scene._refresh_screen_backdrop_visibility()
if not scene.screen_backdrop.visible:
failures.append("ink-wash backdrop should appear behind briefing documents")
scene.briefing_panel.visible = false
scene._refresh_screen_backdrop_visibility()
var map_lattice_overlay := _find_descendant_by_name(scene.briefing_camp_overview_panel, "MapLatticeOverlay")
if map_lattice_overlay == null:
failures.append("briefing map thumbnail should carry old silk-map lattice overlay")
elif map_lattice_overlay.get_child_count() < 10:
failures.append("briefing map thumbnail should carry wash, lattice, and four seal corners")
_check_seal_ribbon(failures, scene.briefing_seal_ribbon, "briefing seal ribbon")
_check_seal_ribbon(failures, scene.dialogue_seal_ribbon, "dialogue seal ribbon")
_check_seal_ribbon(failures, scene.result_seal_ribbon, "result seal ribbon")
_check_bamboo_gutter_row(failures, scene.dialogue_text_panel, "dialogue scroll bamboo gutters")
_check_bamboo_gutter_row(failures, scene.mission_detail_panel, "mission detail bamboo gutters")
_check_edict_marker_stack(failures, scene.mission_detail_panel, "mission detail edict markers")
_check_panel_style_fill(failures, scene.cell_info_panel, "HUD terrain info panel", Color(0.060, 0.056, 0.048, 0.982))
_check_panel_style_frame(failures, scene.cell_info_panel, "HUD terrain info panel", Color(0.42, 0.35, 0.24, 0.98), 2)
_check_panel_style_fill(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.060, 0.056, 0.048, 0.982))
_check_panel_style_frame(failures, scene.forecast_panel, "HUD forecast info panel", Color(0.42, 0.35, 0.24, 0.98), 2)
_check_panel_style_fill(failures, scene.combat_result_panel, "combat result readable frame", Color(0.060, 0.056, 0.048, 0.982))
_check_panel_style_frame(failures, scene.combat_result_panel, "combat result readable frame", Color(0.42, 0.35, 0.24, 0.98), 2)
if scene.combat_result_panel == null or scene.combat_result_panel.custom_minimum_size != BattleSceneScript.COMBAT_RESULT_PANEL_SIZE:
failures.append("combat result report should reserve a stable QHD layout size")
elif scene.combat_result_panel.position.x + scene.combat_result_panel.custom_minimum_size.x >= BattleSceneScript.SIDE_PANEL_POSITION.x:
failures.append("combat result report should not cover the right-side unit overview list: %s" % str(scene.combat_result_panel.get_rect()))
scene._on_combat_result_reported({
"attacker_name": "조조",
"target_name": "황건병",
"attacker_merit": 10,
"counter_merit": 0,
"attacker_progress": {
"name": "조조",
"amount": 10,
"exp_before": 34,
"exp_after": 44,
"level_before": 1,
"level_after": 1,
"class_before": "지휘관",
"class_after": "지휘관"
},
"entries": [{
"attacker_name": "조조",
"target_name": "황건병",
"is_counter": false,
"hit": true,
"defeated": false,
"damage": 18,
"hit_chance": 92,
"target_hp": 12,
"target_max_hp": 30,
"directional_bonus": 2,
"directional_label": "후방 공격",
"effective_bonus": 0
}]
})
if scene.combat_result_panel == null or not scene.combat_result_panel.visible:
failures.append("combat result report should appear after a battle result signal")
elif not scene.combat_result_primary_label.text.contains("피해") or not scene.combat_result_detail_label.text.contains("전공 +10") or not scene.combat_result_detail_label.text.contains("누적 34→44/100"):
failures.append("combat result report should show damage and merit in readable Korean: %s / %s" % [scene.combat_result_primary_label.text, scene.combat_result_detail_label.text])
if scene.combat_result_growth_label == null or not scene.combat_result_growth_label.visible:
failures.append("combat result report should expose a visible growth summary")
elif not scene.combat_result_growth_label.text.contains("조조 전공 +10") or not scene.combat_result_growth_label.text.contains("다음 등급까지 56"):
failures.append("combat result growth summary should make merit gain and next level clear: %s" % scene.combat_result_growth_label.text)
if scene.combat_result_merit_bar == null or not scene.combat_result_merit_bar.visible:
failures.append("combat result report should expose a visible merit progress bar")
elif int(scene.combat_result_merit_bar.value) != 44 or int(scene.combat_result_merit_bar.max_value) != BattleStateScript.EXP_LEVEL:
failures.append("combat result merit bar should show current progress 44/100: %s/%s" % [str(scene.combat_result_merit_bar.value), str(scene.combat_result_merit_bar.max_value)])
scene.combat_result_panel.visible = false
_check_panel_uses_flat_readable_backing(failures, scene.title_load_panel, "title load readable panel")
_check_panel_uses_flat_readable_backing(failures, scene.title_settings_panel, "title settings readable panel")
_check_panel_uses_flat_readable_backing(failures, _find_descendant_by_name(scene.ui_root_control, "SettingsPanel") as PanelContainer, "battle settings readable panel")
_check_rich_text_readability(failures, scene.log_box, "battle log text")
if scene.cell_info_icon == null:
failures.append("HUD terrain info panel should expose an icon chip")
if scene.forecast_icon == null:
failures.append("HUD forecast info panel should expose an icon chip")
_check_hanging_tassel(failures, scene.dialogue_left_tassel, "dialogue left tassel")
_check_hanging_tassel(failures, scene.dialogue_right_tassel, "dialogue right tassel")
_check_dialogue_column_budget(failures, scene)
if scene.DIALOGUE_PANEL_POSITION.y < 336.0 or scene.DIALOGUE_PANEL_SIZE.y > 344.0:
failures.append("dialogue panel should stay compact and leave battlefield context visible: pos=%s size=%s" % [str(scene.DIALOGUE_PANEL_POSITION), str(scene.DIALOGUE_PANEL_SIZE)])
if scene.dialogue_text_label == null or scene.dialogue_text_label.custom_minimum_size.y < 160.0:
failures.append("compact dialogue should still reserve readable multi-line Korean body space")
if scene.dialogue_continue_button == null or scene.dialogue_continue_button.text != "다음":
failures.append("dialogue continue button should use Korean text")
if scene.dialogue_previous_button == null or scene.dialogue_previous_button.text != "이전":
failures.append("dialogue previous button should use Korean text")
if scene.dialogue_progress_label == null:
failures.append("dialogue progress badge should exist")
else:
scene.active_dialogue_lines = scene._normalized_dialogue_lines([
{"speaker": "Cao Cao", "text": "전열을 세워라."},
{"speaker": "Xiahou Dun", "text": "우익에서 응하겠습니다."}
])
scene.active_dialogue_index = 0
scene._render_dialogue_line()
if scene.dialogue_progress_label.text != "1/2":
failures.append("dialogue progress should stay as a compact numeric badge: %s" % scene.dialogue_progress_label.text)
if scene.dialogue_progress_label.text.contains("대화"):
failures.append("dialogue progress should move the explanatory wording into tooltip: %s" % scene.dialogue_progress_label.text)
if not scene.dialogue_progress_label.tooltip_text.contains("대화 진행"):
failures.append("dialogue progress tooltip should explain the compact badge: %s" % scene.dialogue_progress_label.tooltip_text)
scene._hide_dialogue_panel()
if scene.auto_end_turn_prompt_label == null or not _collect_child_text(scene.auto_end_turn_prompt_panel).contains("군령 완료"):
failures.append("auto end turn prompt should show an old command-complete caption")
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 command wording")
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 use 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 command wording")
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 use a generated cancel icon")
scene._on_log_added("적군 차례가 열렸습니다.")
if scene.objective_notice_panel == null or not scene.objective_notice_panel.visible:
failures.append("enemy turn log should show the command notice panel")
elif scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("적군 군령") or not scene.objective_notice_label.text.contains("적군 차례"):
failures.append("enemy turn notice should use compact command wording: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 2:
failures.append("enemy turn notice should show generated visual command icons")
elif not _has_descendant_texture(scene.objective_notice_icon_strip):
failures.append("enemy turn notice icons should use generated textures")
scene._on_log_added("아군 차례가 열렸습니다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("아군 군령") or not scene.objective_notice_label.text.contains("아군 차례"):
failures.append("player turn notice should use compact command wording: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 3:
failures.append("player turn notice should show generated visual command icons")
elif not _has_descendant_texture(scene.objective_notice_icon_strip):
failures.append("player turn notice icons should use generated textures")
if scene.objective_notice_panel != null:
scene.objective_notice_panel.visible = false
scene.battle_started = false
scene._on_log_added("동쪽 들판에서 황건 잔병이 합류했다.")
if scene.objective_notice_panel != null and scene.objective_notice_panel.visible:
failures.append("pre-battle event-like logs should not create battlefield command notices")
scene.battle_started = true
scene._on_log_added("조조군이 마을 앞 유인선에 닿았다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("적 경계 변화") or not scene.objective_notice_label.text.contains("유인선"):
failures.append("lure-line event log should become a readable battlefield notice: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or not _has_descendant_texture(scene.objective_notice_icon_strip):
failures.append("lure-line event notice should show generated visual icons")
scene._on_log_added("동쪽 들판에서 황건 잔병이 합류했다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("적 증원") or not scene.objective_notice_label.text.contains("잔병"):
failures.append("reinforcement event log should become a battlefield notice: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
if scene.objective_notice_icon_strip == null or scene.objective_notice_icon_strip.get_child_count() < 2:
failures.append("reinforcement event notice should include threat and warning icons")
if scene._floating_text_color("reinforcement") == scene._floating_text_color("priority"):
failures.append("reinforcement feedback should use a distinct floating text color")
if scene._combat_feedback_sfx_key("증원", "reinforcement") != "footstep":
failures.append("reinforcement feedback should use the footstep arrival SFX")
if scene._combat_feedback_sfx_key("-18", "damage") != "hit_heavy":
failures.append("damage feedback should play a clear impact SFX")
if scene._combat_feedback_sfx_key("헛침", "miss") != "guard_block":
failures.append("miss feedback should play a guarded/missed SFX")
if scene._combat_feedback_sfx_key("퇴각", "defeat") != "defeat_sting":
failures.append("defeat feedback should play a retreat sting SFX")
if scene._combat_feedback_sfx_key("+8 병력", "heal") != "skill_cast":
failures.append("healing feedback should play a skill SFX")
if scene._combat_feedback_sfx_key("공적 +10", "progress") != "menu_confirm":
failures.append("merit feedback should play a clear confirmation SFX")
if not scene._should_suppress_floating_feedback_text("공적 +10", "progress"):
failures.append("routine merit feedback should be shown in the combat report instead of cluttering the map")
if scene._should_suppress_floating_feedback_text("품계 2", "progress"):
failures.append("level-up feedback should still appear as a floating battlefield cue")
scene.title_sfx_enabled = true
scene.title_sfx_volume_percent = BattleSceneScript.DEFAULT_AUDIO_VOLUME_PERCENT
scene.recent_sfx_keys.clear()
var floating_count_before_merit: int = scene.floating_texts.size()
scene._on_combat_feedback_requested("cao_cao", "공적 +10", "progress")
if scene.floating_texts.size() != floating_count_before_merit:
failures.append("routine merit feedback should not add overlapping floating text")
if not scene.recent_sfx_keys.has("menu_confirm"):
failures.append("routine merit feedback should still play an audible confirmation")
var progress_popup_offset: Vector2 = scene._floating_text_kind_offset("progress")
if progress_popup_offset.x >= 0.0 or progress_popup_offset.y <= 0.0:
failures.append("merit feedback should be offset from impact text so experience remains readable: %s" % str(progress_popup_offset))
var miss_popup_offset: Vector2 = scene._floating_text_kind_offset("miss")
if miss_popup_offset.y >= 0.0:
failures.append("miss feedback should be lifted away from merit and damage text: %s" % str(miss_popup_offset))
if not scene._combat_feedback_sfx_key("표적", "priority").is_empty():
failures.append("priority targeting feedback should not inherit reinforcement arrival SFX")
scene.recent_sfx_keys.clear()
scene._play_log_sfx("조조, 서쪽 초원으로 진군.")
if not scene.recent_sfx_keys.has("footstep"):
failures.append("movement logs should play the footstep SFX")
scene.recent_sfx_keys.clear()
scene._play_log_sfx("황건병 퇴각.")
if not scene.recent_sfx_keys.has("defeat_sting"):
failures.append("defeat logs should play the defeat SFX")
scene.recent_sfx_keys.clear()
scene._play_log_sfx("북쪽 숲 보급고를 거두었다.")
if not scene.recent_sfx_keys.has("item_pickup"):
failures.append("supply logs should play the item pickup SFX")
scene.recent_sfx_keys.clear()
scene._play_log_sfx("동쪽 들판에서 황건 잔병이 합류했다.")
if not scene.recent_sfx_keys.has("footstep"):
failures.append("reinforcement logs should play the footstep SFX")
scene._on_log_added("북쪽 숲 보급고를 거두었다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("보급 확보") or not scene.objective_notice_label.text.contains("보급고"):
failures.append("supply event log should become a battlefield notice: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
scene._on_log_added("조조군이 동쪽 성채에 발을 들였다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("거점 변화") or not scene.objective_notice_label.text.contains("성채"):
failures.append("castle event log should become a battlefield notice: %s" % ("" if scene.objective_notice_label == null else scene.objective_notice_label.text))
if scene.log_box == null:
failures.append("battle log summary missing")
else:
var visible_log_text: String = scene.log_box.get_parsed_text()
if not visible_log_text.contains("북쪽 숲 보급고") or not visible_log_text.contains("동쪽 성채"):
failures.append("battle log should show the latest two readable entries: %s" % visible_log_text)
if visible_log_text.contains("적군 차례") or visible_log_text.contains("아군 차례"):
failures.append("battle log visible text should hide older turn churn: %s" % visible_log_text)
if not scene.log_box.tooltip_text.contains("적군 차례") or not scene.log_box.tooltip_text.contains("마을 앞 유인선"):
failures.append("battle log tooltip should preserve earlier history: %s" % scene.log_box.tooltip_text)
if scene.log_box.scroll_active:
failures.append("battle log should avoid a visible scrollbar in the compact side panel")
scene._clear_battle_log()
scene._append_battle_log_entry("조조 공격 황건병, 12 피해. 명중 88%.")
scene._append_battle_log_entry("황건병 후방 공격 +2.")
scene._append_battle_log_entry("황건병 퇴각.")
scene._append_battle_log_entry("조조 공적 +12.")
visible_log_text = scene.log_box.get_parsed_text()
if visible_log_text.contains("공적 +") or visible_log_text.contains("후방 공격 +"):
failures.append("battle log visible text should keep bookkeeping detail out of the compact panel: %s" % visible_log_text)
if not visible_log_text.contains("조조 공격") or not visible_log_text.contains("황건병 퇴각"):
failures.append("battle log visible text should prefer core action outcomes over detail lines: %s" % visible_log_text)
if not scene.log_box.tooltip_text.contains("공적 +12") or not scene.log_box.tooltip_text.contains("후방 공격 +2"):
failures.append("battle log tooltip should preserve filtered combat detail: %s" % scene.log_box.tooltip_text)
var localized_lines := scene._normalized_dialogue_lines([{"speaker": "Cao Cao", "display_speaker": "조조", "text": "군령을 전하라."}])
if localized_lines.is_empty() or str((localized_lines[0] as Dictionary).get("speaker", "")) != "조조":
failures.append("dialogue normalization should preserve display_speaker for localized names")
else:
scene.active_dialogue_lines = localized_lines
scene.active_dialogue_index = 0
scene._render_dialogue_line()
if scene.dialogue_speaker_label == null or scene.dialogue_speaker_label.text != "조조":
failures.append("dialogue speaker label should render localized display_speaker")
if scene.dialogue_text_label == null or scene.dialogue_text_label.text != "「군령을 전하라.」":
failures.append("dialogue text should render as a concise quotation scroll")
if scene.screen_backdrop == null or not scene.screen_backdrop.visible:
failures.append("dialogue should dim and lock the battlefield behind the text panel")
elif scene.screen_backdrop.color.a < 0.74 or scene.screen_backdrop.color.a > 0.82:
failures.append("dialogue backdrop should push battlefield HUD behind the story panel without full blackout: %s" % str(scene.screen_backdrop.color))
scene.floating_texts.clear()
scene.floating_texts.append({
"text": "-10",
"kind": "damage",
"pos": Vector2.ZERO,
"age": 0.25,
"duration": 1.0,
"sequence": true
})
scene.unit_motion_by_unit = {
"cao_cao": {
"from": Vector2i(1, 1),
"to": Vector2i(2, 1),
"age": 0.25,
"duration": 1.0,
"sequence": true
}
}
scene.unit_action_motion_by_unit = {
"xiahou_dun": {
"from": Vector2i(1, 2),
"to": Vector2i(2, 2),
"age": 0.25,
"duration": 1.0,
"sequence": true
}
}
scene._process(0.5)
var frozen_text_age := float((scene.floating_texts[0] as Dictionary).get("age", -1.0))
var frozen_move_age := float((scene.unit_motion_by_unit["cao_cao"] as Dictionary).get("age", -1.0))
var frozen_action_age := float((scene.unit_action_motion_by_unit["xiahou_dun"] as Dictionary).get("age", -1.0))
if absf(frozen_text_age - 0.25) > 0.001 or absf(frozen_move_age - 0.25) > 0.001 or absf(frozen_action_age - 0.25) > 0.001:
failures.append("dialogue should pause battlefield presentation timers, got text %.2f move %.2f action %.2f" % [frozen_text_age, frozen_move_age, frozen_action_age])
scene._hide_dialogue_panel()
if scene.screen_backdrop != null and scene.screen_backdrop.visible:
failures.append("dialogue backdrop should hide after closing the dialogue panel")
scene._process(0.5)
var resumed_text_age := float((scene.floating_texts[0] as Dictionary).get("age", -1.0)) if not scene.floating_texts.is_empty() else 1.0
var resumed_move_age := float((scene.unit_motion_by_unit["cao_cao"] as Dictionary).get("age", -1.0)) if scene.unit_motion_by_unit.has("cao_cao") else 1.0
var resumed_action_age := float((scene.unit_action_motion_by_unit["xiahou_dun"] as Dictionary).get("age", -1.0)) if scene.unit_action_motion_by_unit.has("xiahou_dun") else 1.0
if resumed_text_age <= frozen_text_age or resumed_move_age <= frozen_move_age or resumed_action_age <= frozen_action_age:
failures.append("battlefield presentation timers should resume after dialogue closes, got text %.2f move %.2f action %.2f" % [resumed_text_age, resumed_move_age, resumed_action_age])
scene.floating_texts.clear()
scene.unit_motion_by_unit.clear()
scene.unit_action_motion_by_unit.clear()
var narrator_lines := scene._normalized_dialogue_lines([{"text": "성채 위로 먼지가 올랐다."}])
if narrator_lines.is_empty():
failures.append("dialogue normalization should keep narration lines without a speaker")
else:
var portrait_index_before_narration: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel)
scene.active_dialogue_lines = narrator_lines
scene.active_dialogue_index = 0
scene._render_dialogue_line()
if scene.dialogue_portrait_panel == null or not scene.dialogue_portrait_panel.visible:
failures.append("speakerless narration should keep the dialogue portrait slot visible")
if scene.dialogue_portrait_texture == null or not scene.dialogue_portrait_texture.visible or scene.dialogue_portrait_texture.texture == null:
failures.append("speakerless narration should use a battlefield record image instead of an empty portrait slot")
if scene.dialogue_portrait_label == null or not scene.dialogue_portrait_label.text.contains("전황"):
failures.append("speakerless narration should show a stable battle-record placeholder")
var portrait_index_after_narration: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel)
if portrait_index_after_narration != portrait_index_before_narration:
failures.append("speakerless narration should not move the portrait slot")
scene._hide_dialogue_panel()
var explicit_narrator_lines := scene._normalized_dialogue_lines([{"speaker": "Narrator", "display_speaker": "이야기", "text": "천하가 어지러워졌다."}])
if explicit_narrator_lines.is_empty():
failures.append("dialogue normalization should keep explicit narrator lines")
else:
scene.active_dialogue_lines = explicit_narrator_lines
scene.active_dialogue_index = 0
scene._render_dialogue_line()
if scene.dialogue_speaker_label == null or scene.dialogue_speaker_label.text != "이야기":
failures.append("explicit narrator should keep the story speaker label: %s" % ("" if scene.dialogue_speaker_label == null else scene.dialogue_speaker_label.text))
if scene.dialogue_portrait_label == null or not scene.dialogue_portrait_label.text.contains("전황"):
failures.append("explicit narrator should use the battle-record placeholder instead of a single initial: %s" % ("" if scene.dialogue_portrait_label == null else scene.dialogue_portrait_label.text))
if scene.dialogue_portrait_texture == null or not scene.dialogue_portrait_texture.visible or scene.dialogue_portrait_texture.texture == null:
failures.append("explicit narrator should keep a readable record image in the portrait slot")
scene._hide_dialogue_panel()
var enemy_lines := scene._normalized_dialogue_lines([{"speaker": "Wen Chou", "text": "전진!"}])
if enemy_lines.is_empty() or str((enemy_lines[0] as Dictionary).get("speaker", "")) != "문추":
failures.append("dialogue normalization should localize enemy speaker names")
var right_lines := scene._normalized_dialogue_lines([{"speaker": "Xiahou Dun", "display_speaker": "하후돈", "side": "right", "text": "우익에서 응하겠습니다."}])
if right_lines.is_empty():
failures.append("dialogue normalization should keep right-side lines")
else:
var portrait_index_before: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel)
scene.active_dialogue_lines = right_lines
scene.active_dialogue_index = 0
scene._render_dialogue_line()
_check_right_side_dialogue_layout(failures, scene)
var portrait_index_after: int = scene.dialogue_row.get_children().find(scene.dialogue_portrait_panel)
if portrait_index_after != portrait_index_before:
failures.append("right-side dialogue should not move the portrait panel between lines")
if scene.dialogue_continue_button == null or scene.dialogue_continue_button.text != "다음":
failures.append("dialogue continue button should stay stable even on the final line")
scene._hide_dialogue_panel()
if scene._ui_font(false) == null:
failures.append("ancient UI regular font should load from project assets")
if scene._ui_font(true) == null:
failures.append("ancient UI bold font should load from project assets")
if scene.ui_root_control == null or scene.ui_root_control.theme == null or scene.ui_root_control.theme.default_font == null:
failures.append("ancient UI root should install the project serif font as its default theme font")
else:
_check_readable_tooltip_theme(failures, scene.ui_root_control.theme)
if scene._draw_ui_font(false) != scene._ui_font(false):
failures.append("map labels should draw with the project serif font")
if scene._draw_ui_font(true) != scene._ui_font(true):
failures.append("map emphasis labels should draw with the bold project serif font")
if scene.dialogue_text_label == null or not scene.dialogue_text_label.has_theme_font_override("font"):
failures.append("dialogue text should use the project serif font override")
if scene.dialogue_continue_button == null or not scene.dialogue_continue_button.has_theme_font_override("font"):
failures.append("dialogue continue button should use the project serif font override")
if scene.mission_title_label == null or scene.mission_title_label.text != "전투 목표":
failures.append("mission panel title should use clear Korean wording")
elif scene.mission_title_label.get_theme_color("font_color").get_luminance() < 0.68:
failures.append("mission panel title should stay bright enough to read on the dark side panel: %s" % str(scene.mission_title_label.get_theme_color("font_color")))
elif scene.mission_title_label.get_theme_font_size("font_size") < 15:
failures.append("mission panel title should keep a readable QHD size: %d" % scene.mission_title_label.get_theme_font_size("font_size"))
elif not scene.mission_title_label.tooltip_text.contains("승리 조건"):
failures.append("mission panel title tooltip should explain the objective section: %s" % scene.mission_title_label.tooltip_text)
if scene.mission_toggle_button == null or scene.mission_toggle_button.text != "" or scene.mission_toggle_button.icon == null or not bool(scene.mission_toggle_button.get_meta("icon_only", false)):
failures.append("mission objective panel should start compact with an icon-only open control")
else:
if not scene.mission_toggle_button.tooltip_text.contains("펼치기"):
failures.append("mission objective icon tooltip should explain opening the panel: %s" % scene.mission_toggle_button.tooltip_text)
scene._on_mission_toggle_pressed()
if scene.mission_detail_panel == null or not scene.mission_detail_panel.visible or not scene.mission_toggle_button.tooltip_text.contains("접기"):
failures.append("mission objective panel should reopen cleanly")
scene._on_mission_toggle_pressed()
if scene.mission_detail_panel == null or scene.mission_detail_panel.visible or not scene.mission_toggle_button.tooltip_text.contains("펼치기"):
failures.append("mission objective panel should collapse cleanly")
if scene.briefing_start_button == null or scene.briefing_start_button.text != "전투 시작":
failures.append("briefing start button should clearly start after briefing")
if scene.briefing_objective_toggle_button == null or scene.briefing_objective_toggle_button.text != "" or scene.briefing_objective_toggle_button.icon == null or not bool(scene.briefing_objective_toggle_button.get_meta("icon_only", false)):
failures.append("briefing objective panel should expose an icon-only close button")
else:
_check_button_uses_generated_toolbar_icon(failures, scene.briefing_objective_toggle_button, "briefing objective toggle")
_check_button_uses_generated_surface(failures, scene.briefing_objective_toggle_button, "briefing objective toggle")
if not scene.briefing_objective_toggle_button.tooltip_text.contains("접기"):
failures.append("briefing objective icon tooltip should explain closing the panel")
scene._set_briefing_objective_collapsed(true)
if scene.briefing_objective_panel == null or scene.briefing_objective_panel.visible or scene.briefing_objective_toggle_button.text != "" or not scene.briefing_objective_toggle_button.tooltip_text.contains("펼치기"):
failures.append("briefing objective panel should collapse cleanly")
scene._set_briefing_objective_collapsed(false)
if scene.briefing_objective_panel == null or not scene.briefing_objective_panel.visible or scene.briefing_objective_toggle_button.text != "" or not scene.briefing_objective_toggle_button.tooltip_text.contains("접기"):
failures.append("briefing objective panel should reopen cleanly")
scene.briefing_panel.visible = true
scene.battle_started = false
var escape_event := InputEventKey.new()
escape_event.keycode = KEY_ESCAPE
scene._handle_key(escape_event)
if not scene.briefing_objective_collapsed or scene.battle_started:
failures.append("briefing Escape should collapse conditions before starting battle")
scene.briefing_panel.visible = false
_check_prep_menu_icon_button(failures, scene.chapter_button, "군기록", "campaign")
_check_prep_menu_icon_button(failures, scene.shop_button, "군상", "shop")
_check_prep_menu_icon_button(failures, scene.talk_button, "군막 회의", "talk")
_check_prep_menu_icon_button(failures, scene.armory_button, "병장고", "equip")
_check_prep_menu_icon_button(failures, scene.roster_button, "출진 명부", "unit_list")
_check_prep_menu_icon_button(failures, scene.formation_button, "진형 배치", "formation")
_check_prep_menu_icon_button(failures, scene.save_button, "전기록", "save")
scene.briefing_panel.visible = true
scene.battle_started = false
_check_prep_menu_button_active_selection(failures, scene)
if scene.shop_buy_button == null or scene.shop_buy_button.text != "사들이기":
failures.append("briefing shop buy button should avoid modern purchase wording")
if scene.shop_sell_button == null or scene.shop_sell_button.text != "내다팔기":
failures.append("briefing shop sell button should avoid modern sale wording")
if scene.post_move_title_label == null or scene.post_move_title_label.text != "행동 선택":
failures.append("post-move command title should use immediately readable action wording")
_check_panel_uses_flat_readable_backing(failures, scene.post_move_menu, "post-move command flat panel")
_check_panel_uses_flat_readable_backing(failures, scene.post_move_picker_panel, "post-move picker flat panel")
if scene.post_move_menu != null:
if bool(scene.post_move_menu.get_meta("generated_panel_texture", false)):
failures.append("post-move command panel should not use a busy generated image")
if scene.post_move_menu.find_child("LocalCommandHeaderSeal", true, false) != null:
failures.append("post-move command panel should not show decorative seal imagery")
if scene.post_move_menu.find_child("GeneratedSealRibbon", true, false) != null:
failures.append("post-move command panel should not show decorative ribbon imagery")
_check_local_command_labeled_button(failures, scene.post_move_attack_button, "공격", "attack")
_check_local_command_labeled_button(failures, scene.post_move_tactic_button, "책략", "tactic")
_check_local_command_labeled_button(failures, scene.post_move_item_button, "도구", "item")
_check_local_command_labeled_button(failures, scene.post_move_wait_button, "대기", "wait")
_check_picker_utility_button(failures, scene.post_move_cancel_button, "이동 취소", "cancel", BattleSceneScript.LOCAL_COMMAND_CANCEL_BUTTON_SIZE)
if scene.post_move_picker_back_button == null or scene.post_move_picker_back_button.text != "목록 닫기":
failures.append("post-move picker back button should use immediately readable wording")
if scene.post_move_picker_cancel_move_button == null or scene.post_move_picker_cancel_move_button.text != "이동 취소":
failures.append("post-move picker cancel-move button should use immediately readable wording")
_check_picker_utility_button(failures, scene.post_move_picker_back_button, "목록 닫기", "cancel", BattleSceneScript.POST_MOVE_PICKER_UTILITY_BUTTON_SIZE)
_check_picker_utility_button(failures, scene.post_move_picker_cancel_move_button, "이동 취소", "move", BattleSceneScript.POST_MOVE_PICKER_UTILITY_BUTTON_SIZE)
if scene.targeting_hint_title_label == null or scene.targeting_hint_title_label.text != "적장 지목":
failures.append("targeting hint title should use mark foe wording")
if scene.targeting_hint_back_button == null or scene.targeting_hint_back_button.text != "선택 취소":
failures.append("targeting hint back button should use immediately readable wording")
if scene.targeting_hint_cancel_move_button == null or scene.targeting_hint_cancel_move_button.text != "이동 취소":
failures.append("targeting hint cancel-move button should use immediately readable wording")
_check_picker_utility_button(failures, scene.targeting_hint_back_button, "선택 취소", "cancel", BattleSceneScript.TARGETING_HINT_UTILITY_BUTTON_SIZE)
_check_picker_utility_button(failures, scene.targeting_hint_cancel_move_button, "이동 취소", "move", BattleSceneScript.TARGETING_HINT_UTILITY_BUTTON_SIZE)
scene._on_objective_updated("동문에 깃발을 세워라.", "조조가 퇴각하면 패전이다.")
if scene.objective_notice_label == null or not scene.objective_notice_label.text.contains("목표:") or not scene.objective_notice_label.text.contains("주의:"):
failures.append("objective update notice should use Korean annotation wording")
elif scene.objective_notice_label.text.split("\n").size() > 3:
failures.append("objective update notice should stay within a title plus two readable body lines: %s" % scene.objective_notice_label.text)
elif not scene.objective_notice_label.tooltip_text.contains("동문에 깃발") or not scene.objective_notice_label.tooltip_text.contains("조조가 퇴각"):
failures.append("objective update notice tooltip should keep the full notice text: %s" % scene.objective_notice_label.tooltip_text)
if scene.objective_notice_icon_strip == null or not scene.objective_notice_icon_strip.visible or scene.objective_notice_icon_strip.get_child_count() < 2:
failures.append("objective update notice should show objective and warning icons")
elif not _has_descendant_texture(scene.objective_notice_icon_strip):
failures.append("objective update notice icons should use generated textures")
scene._update_briefing_camp_overview(scene.state.get_briefing(), false)
if scene.briefing_camp_overview_fallback_label == null or scene.briefing_camp_overview_fallback_label.text != "비단 전장도":
failures.append("briefing map fallback should read as an old campaign map")
if scene.briefing_force_preview_overlay == null:
failures.append("briefing map thumbnail should include a force preview overlay")
else:
var ally_icons: Array = scene.briefing_force_preview_overlay.find_children("BriefingForcePreviewAllyIcon*", "", false, false)
var enemy_icons: Array = scene.briefing_force_preview_overlay.find_children("BriefingForcePreviewEnemyIcon*", "", false, false)
if ally_icons.size() < 2:
failures.append("briefing force preview should show allied portrait icons")
if enemy_icons.size() < 3:
failures.append("briefing force preview should show representative enemy unit icons")
if not _has_descendant_texture(scene.briefing_force_preview_overlay):
failures.append("briefing force preview should render generated portrait/unit textures")
if scene.briefing_tactical_marker_list == null:
failures.append("briefing should include a tactical marker card list")
elif scene.briefing_tactical_marker_list.get_child_count() < 3:
failures.append("briefing tactical marker card list should expose three image-first markers")
elif not _has_descendant_texture(scene.briefing_tactical_marker_list):
failures.append("briefing tactical marker cards should render generated badge/tile textures")
else:
var marker_tooltips := _collect_child_tooltips(scene.briefing_tactical_marker_list)
for expected_hint in ["끌어낼", "콩 보급", "치료소"]:
if not marker_tooltips.contains(expected_hint):
failures.append("briefing tactical marker card tooltips should preserve %s: %s" % [expected_hint, marker_tooltips])
var marker_visible_text := _collect_child_text(scene.briefing_tactical_marker_list)
for expected_summary in ["앞줄 유인", "숨은 콩", "회복 보급"]:
if not marker_visible_text.contains(expected_summary):
failures.append("briefing tactical marker cards should show compact hint %s: %s" % [expected_summary, marker_visible_text])
_check_no_clipped_labels(failures, scene.briefing_tactical_marker_list, "briefing tactical marker card text")
scene._apply_panel_style(scene.result_panel, "result_victory_tablet")
_check_panel_style_fill(failures, scene.result_panel, "victory result panel", Color(0.27, 0.34, 0.28, 0.997))
_check_panel_uses_generated_texture(failures, scene.result_panel, "victory result generated frame")
scene._apply_panel_style(scene.result_panel, "result_defeat_tablet")
_check_panel_style_fill(failures, scene.result_panel, "defeat result panel", Color(0.10, 0.035, 0.028, 0.997))
_check_panel_uses_generated_texture(failures, scene.result_panel, "defeat result generated frame")
if not scene._format_victory_result_text().contains("승리"):
failures.append("victory result should use Korean title")
if not scene._format_defeat_result_text().contains("패배"):
failures.append("defeat result should use Korean title")
var scroll_rod := scene._make_scroll_rod(120, 4)
_check_scroll_rod(failures, scroll_rod, "generated scroll rod")
scroll_rod.free()
var tablet_binding := scene._make_tablet_binding(120, 16)
_check_tablet_binding(failures, tablet_binding, "generated tablet binding")
tablet_binding.free()
var ink_rule := scene._make_ink_wash_rule(120, 6)
_check_ink_wash_rule(failures, ink_rule, "generated ink wash rule")
ink_rule.free()
var seal_ribbon := scene._make_seal_ribbon(120, 18)
_check_seal_ribbon(failures, seal_ribbon, "generated seal ribbon")
seal_ribbon.free()
var seal_tile := scene._make_seal_tile("1", 24)
_check_panel_uses_generated_texture(failures, seal_tile, "numbered seal tile")
seal_tile.free()
var seal_tick := scene._make_seal_tick()
_check_panel_uses_generated_texture(failures, seal_tick, "section seal tick")
seal_tick.free()
scene.free()
func _check_opening_prologue_presentation(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
var caption_panel := _find_descendant_by_name(scene.opening_prologue_root, "OpeningCaptionScroll") as PanelContainer
if caption_panel == null:
failures.append("opening prologue should build a cinematic caption panel")
else:
if caption_panel.position.y < 492.0:
failures.append("opening caption should sit low enough to let generated story art dominate: %s" % str(caption_panel.position))
if caption_panel.custom_minimum_size.y < 160.0 or caption_panel.custom_minimum_size.y > 188.0:
failures.append("opening caption should be large enough for readable Korean without covering the story art: %s" % str(caption_panel.custom_minimum_size))
if str(caption_panel.get_meta("panel_style_variant", "")) != "cinematic_caption" or bool(caption_panel.get_meta("generated_panel_texture", true)):
failures.append("opening caption should use a flat high-contrast cinematic surface so text stays visible")
if caption_panel.z_index < 20:
failures.append("opening caption should render above the generated story image")
if scene.opening_prologue_title_label != null and scene.opening_prologue_title_label.get_theme_color("font_color").get_luminance() < 0.78:
failures.append("opening title should use bright text over the story image")
if scene.opening_prologue_body_label != null:
if scene.opening_prologue_body_label.get_theme_color("font_color").get_luminance() < 0.86:
failures.append("opening body should use bright text over the story image")
if scene.opening_prologue_body_label.custom_minimum_size.x < 730.0:
failures.append("opening body should be wide enough for Korean opening text: %s" % str(scene.opening_prologue_body_label.custom_minimum_size))
if scene.opening_prologue_body_label.get_theme_constant("outline_size") < 3:
failures.append("opening body should have a strong outline over generated art")
var opening_body_surface := _find_descendant_by_name(scene.opening_prologue_root, "OpeningStoryBodyTextSurface") as PanelContainer
_check_panel_uses_flat_text_surface(failures, opening_body_surface, "opening story body text plate")
if scene.opening_prologue_next_button == null:
failures.append("opening prologue should expose a next button")
else:
if scene.opening_prologue_next_button.text != "다음 기록":
failures.append("opening next button should use story-fluent Korean text")
if scene.opening_prologue_next_button.icon == null:
failures.append("opening next button should carry generated icon art")
if scene.opening_prologue_next_button.expand_icon or scene.opening_prologue_next_button.get_theme_constant("icon_max_width") > 22:
failures.append("opening next icon should stay capped so it cannot cover story text")
_check_button_uses_flat_readable_surface(failures, scene.opening_prologue_next_button, "opening next")
if scene.opening_prologue_skip_button == null:
failures.append("opening prologue should expose a skip button")
else:
if scene.opening_prologue_skip_button.text != "군막으로":
failures.append("opening skip button should avoid modern skip-button wording")
if scene.opening_prologue_skip_button.icon == null:
failures.append("opening skip button should carry generated icon art")
if scene.opening_prologue_skip_button.expand_icon or scene.opening_prologue_skip_button.get_theme_constant("icon_max_width") > 22:
failures.append("opening skip icon should stay capped so it cannot cover story text")
_check_button_uses_flat_readable_surface(failures, scene.opening_prologue_skip_button, "opening skip")
var opening_ledger := _find_descendant_by_name(scene.opening_prologue_root, "OpeningStoryLedgerStrip") as PanelContainer
if opening_ledger == null:
failures.append("opening prologue should use a story ledger instead of numbered progress cards")
else:
if str(opening_ledger.get_meta("panel_style_variant", "")) != "opening_ledger" or bool(opening_ledger.get_meta("generated_panel_texture", true)):
failures.append("opening story ledger should use a flat dark backing so chapter text stays readable over sky art")
if opening_ledger.position.x < 300.0 or opening_ledger.position.x > 680.0:
failures.append("opening story ledger should sit beside the chapter seal instead of the upper-right corner: %s" % str(opening_ledger.position))
if opening_ledger.custom_minimum_size.x > 340.0 or opening_ledger.custom_minimum_size.y > 54.0:
failures.append("opening story ledger should stay compact at QHD scale: %s" % str(opening_ledger.custom_minimum_size))
var progress_caption := opening_ledger.find_child("OpeningStoryProgressCaption", true, false) as Label
if progress_caption == null or progress_caption.text != "제1장 영천으로":
failures.append("opening story ledger should read as a chapter record, got %s" % ("" if progress_caption == null else progress_caption.text))
var old_cards := opening_ledger.find_children("OpeningStoryBeatCard*", "", true, false)
if not old_cards.is_empty():
failures.append("opening story ledger should not expose old numbered-card style nodes")
var tick_cards := opening_ledger.find_children("OpeningStoryLedgerTick*", "", true, false)
if not tick_cards.is_empty():
failures.append("opening story ledger should not expose unreadable progress tick nodes")
for index in range(BattleSceneScript.OPENING_PROLOGUE_PAGES.size()):
scene.opening_prologue_index = index
scene._update_opening_prologue_page()
var body_text: String = scene.opening_prologue_body_label.text if scene.opening_prologue_body_label != null else ""
var body_area: Vector2 = scene.opening_prologue_body_label.custom_minimum_size if scene.opening_prologue_body_label != null else Vector2.ZERO
var body_size: int = scene.opening_prologue_body_label.get_theme_font_size("font_size") if scene.opening_prologue_body_label != null else 0
if body_size < 18 or body_size > 22:
failures.append("opening story body font should stay readable inside the caption range, got %d" % body_size)
elif scene._estimated_wrapped_text_height(body_text, body_area.x, body_size) > body_area.y:
failures.append("opening story body should fit without clipping on page %d: size=%d area=%s" % [index + 1, body_size, str(body_area)])
if scene.opening_prologue_next_button != null:
scene.opening_prologue_index = BattleSceneScript.OPENING_PROLOGUE_PAGES.size() - 1
scene._update_opening_prologue_page()
if scene.opening_prologue_next_button.text != "전투 준비" or scene.opening_prologue_next_button.icon == null:
failures.append("opening final page should switch the next control into camp-entry mode")
scene.free()
func _check_ink_wash_rule(failures: Array[String], rule: HBoxContainer, label: String) -> void:
if rule == null:
failures.append("%s missing" % label)
return
if rule.get_child_count() != 5:
failures.append("%s should use five ink-and-seal segments" % label)
return
for index in range(rule.get_child_count()):
_check_panel_uses_generated_ornament_texture(failures, rule.get_child(index) as PanelContainer, "%s segment %d" % [label, index])
func _check_scroll_rod(failures: Array[String], scroll_rod: HBoxContainer, label: String) -> void:
if scroll_rod == null:
failures.append("%s missing" % label)
return
if scroll_rod.get_child_count() != 5:
failures.append("%s should use five visual strips" % label)
return
for index in range(scroll_rod.get_child_count()):
_check_panel_uses_generated_ornament_texture(failures, scroll_rod.get_child(index) as PanelContainer, "%s segment %d" % [label, index])
func _check_tablet_binding(failures: Array[String], binding: HBoxContainer, label: String) -> void:
if binding == null:
failures.append("%s missing" % label)
return
if binding.get_child_count() != 7:
failures.append("%s should use seven clasp/strip/knot segments" % label)
return
for index in range(binding.get_child_count()):
if index == 3:
var knot := binding.get_child(index) as HBoxContainer
if knot == null or knot.get_child_count() != 2:
failures.append("%s should include a two-seal center knot" % label)
continue
for knot_index in range(knot.get_child_count()):
_check_panel_uses_generated_ornament_texture(failures, knot.get_child(knot_index) as PanelContainer, "%s knot seal %d" % [label, knot_index])
continue
_check_panel_uses_generated_ornament_texture(failures, binding.get_child(index) as PanelContainer, "%s segment %d" % [label, index])
func _check_seal_ribbon(failures: Array[String], ribbon: HBoxContainer, label: String) -> void:
if ribbon == null:
failures.append("%s missing" % label)
return
if ribbon.get_child_count() != 5:
failures.append("%s should use five clasp/ribbon/cluster segments" % label)
return
var cluster := ribbon.get_child(2) as HBoxContainer
if cluster == null or cluster.get_child_count() != 3:
failures.append("%s should include a three-seal center cluster" % label)
for index in range(ribbon.get_child_count()):
var panel := ribbon.get_child(index) as PanelContainer
if index == 2:
continue
_check_panel_uses_generated_texture(failures, panel, "%s segment %d" % [label, index])
if cluster != null:
for index in range(cluster.get_child_count()):
_check_panel_uses_generated_texture(failures, cluster.get_child(index) as PanelContainer, "%s center seal %d" % [label, index])
func _check_bamboo_gutter_row(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null or panel.get_child_count() <= 0:
failures.append("%s missing row" % label)
return
var row := panel.get_child(0) as HBoxContainer
if row == null or row.get_child_count() < 3:
failures.append("%s should use a row with bamboo side gutters" % label)
return
_check_bamboo_gutter(failures, row.get_child(0), "%s left gutter" % label)
_check_bamboo_gutter(failures, row.get_child(row.get_child_count() - 1), "%s right gutter" % label)
func _check_bamboo_gutter(failures: Array[String], node: Node, label: String) -> void:
var gutter := node as HBoxContainer
if gutter == null:
failures.append("%s should be a bamboo gutter container" % label)
return
if gutter.get_child_count() != 3:
failures.append("%s should use three bamboo slips" % label)
return
for index in range(gutter.get_child_count()):
_check_panel_uses_generated_ornament_texture(failures, gutter.get_child(index) as PanelContainer, "%s slip %d" % [label, index])
func _check_edict_marker_stack(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null or panel.get_child_count() <= 0:
failures.append("%s missing row" % label)
return
var row := panel.get_child(0) as HBoxContainer
if row == null or row.get_child_count() < 5:
failures.append("%s should include side marker stacks" % label)
return
_check_marker_stack(failures, row.get_child(1), "%s left stack" % label)
_check_marker_stack(failures, row.get_child(row.get_child_count() - 2), "%s right stack" % label)
func _check_marker_stack(failures: Array[String], node: Node, label: String) -> void:
var stack := node as VBoxContainer
if stack == null:
failures.append("%s should be a vertical seal stack" % label)
return
if stack.get_child_count() < 2:
failures.append("%s should include multiple seal plaques" % label)
func _check_hanging_tassel(failures: Array[String], tassel: VBoxContainer, label: String) -> void:
if tassel == null:
failures.append("%s missing" % label)
return
if tassel.get_child_count() != 3:
failures.append("%s should use top seal, hanging cords, and bottom seal" % label)
return
_check_panel_uses_generated_ornament_texture(failures, tassel.get_child(0) as PanelContainer, "%s top seal" % label)
_check_panel_uses_generated_ornament_texture(failures, tassel.get_child(2) as PanelContainer, "%s bottom seal" % label)
var cords := tassel.get_child(1) as HBoxContainer
if cords == null or cords.get_child_count() != 2:
failures.append("%s should include two hanging cord strips" % label)
return
for index in range(cords.get_child_count()):
_check_panel_uses_generated_ornament_texture(failures, cords.get_child(index) as PanelContainer, "%s cord %d" % [label, index])
func _check_right_side_dialogue_layout(failures: Array[String], scene) -> void:
if scene.dialogue_row == null or scene.dialogue_row.get_child_count() != 4:
failures.append("right-side dialogue should keep four major columns")
return
if scene.dialogue_row.get_child(0) != scene.dialogue_left_tassel:
failures.append("right-side dialogue should keep the left tassel at the outer edge")
if scene.dialogue_row.get_child(1) != scene.dialogue_portrait_panel:
failures.append("right-side dialogue should keep the portrait fixed beside the left tassel")
if scene.dialogue_row.get_child(2) != scene.dialogue_column:
failures.append("right-side dialogue should keep the speech scroll in its fixed column")
if scene.dialogue_row.get_child(3) != scene.dialogue_right_tassel:
failures.append("right-side dialogue should keep the right tassel at the outer edge")
if scene.dialogue_speaker_label == null or scene.dialogue_speaker_label.horizontal_alignment != HORIZONTAL_ALIGNMENT_RIGHT:
failures.append("right-side dialogue speaker label should align right")
if scene.dialogue_text_label == null or scene.dialogue_text_label.horizontal_alignment != HORIZONTAL_ALIGNMENT_RIGHT:
failures.append("right-side dialogue text should align right")
func _check_dialogue_column_budget(failures: Array[String], scene) -> void:
if scene.dialogue_column == null:
failures.append("dialogue column missing")
return
var total_height := 0.0
var visible_children := 0
for child in scene.dialogue_column.get_children():
var control := child as Control
if control == null or not control.visible:
continue
visible_children += 1
total_height += control.custom_minimum_size.y
if visible_children > 1:
total_height += float(visible_children - 1) * float(scene.dialogue_column.get_theme_constant("separation"))
if total_height > scene.DIALOGUE_COLUMN_SIZE.y:
failures.append("dialogue column decorations exceed fixed height: %.1f > %.1f" % [total_height, scene.DIALOGUE_COLUMN_SIZE.y])
func _check_panel_style_fill(failures: Array[String], panel: PanelContainer, label: String, expected: Color) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if stylebox is StyleBoxTexture:
_check_panel_style_texture(failures, stylebox as StyleBoxTexture, label)
return
var style := stylebox as StyleBoxFlat
if style == null:
failures.append("%s should use a styled panel" % label)
return
if not _colors_nearly_equal(style.bg_color, expected):
failures.append("%s fill mismatch: %s" % [label, str(style.bg_color)])
func _check_panel_style_frame(failures: Array[String], panel: PanelContainer, label: String, expected_border: Color, expected_width: int) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if stylebox is StyleBoxTexture:
_check_panel_style_texture(failures, stylebox as StyleBoxTexture, label)
return
var style := stylebox as StyleBoxFlat
if style == null:
failures.append("%s should use a styled panel" % label)
return
if not _colors_nearly_equal(style.border_color, expected_border):
failures.append("%s border mismatch: %s" % [label, str(style.border_color)])
if style.get_border_width(SIDE_LEFT) != expected_width:
failures.append("%s border width mismatch: %d" % [label, style.get_border_width(SIDE_LEFT)])
var radius := style.get_corner_radius(CORNER_TOP_LEFT)
if radius < 8 or radius > 14:
failures.append("%s should use softened rounded corners, got %d" % [label, radius])
func _check_panel_style_texture(failures: Array[String], style: StyleBoxTexture, label: String) -> void:
if style == null:
failures.append("%s should use a generated texture style" % label)
return
if style.texture == null:
failures.append("%s generated texture style is missing its texture" % label)
return
if style.texture.get_width() < 512 or style.texture.get_height() < 512:
failures.append("%s generated panel texture should keep high-resolution source pixels: %dx%d" % [label, style.texture.get_width(), style.texture.get_height()])
if style.texture_margin_left < 24 or style.texture_margin_top < 24:
failures.append("%s generated panel texture should define a visible frame slice margin" % label)
if style.content_margin_left < 7 or style.content_margin_top < 7:
failures.append("%s generated panel texture should reserve readable content margins" % label)
func _check_panel_uses_generated_texture(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if not (stylebox is StyleBoxTexture):
failures.append("%s should use a generated panel texture" % label)
return
_check_panel_style_texture(failures, stylebox as StyleBoxTexture, label)
func _check_panel_uses_flat_text_surface(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if not (stylebox is StyleBoxFlat):
failures.append("%s should use a flat panel so text is not painted over a busy texture" % label)
return
if bool(panel.get_meta("generated_panel_texture", false)):
failures.append("%s should not mark a generated texture behind text" % label)
var style := stylebox as StyleBoxFlat
if style.bg_color.a < 0.98:
failures.append("%s should keep an opaque text backing: %s" % [label, str(style.bg_color)])
if style.content_margin_left < 14 or style.content_margin_top < 10:
failures.append("%s should reserve breathing room around readable text" % label)
func _check_panel_uses_flat_readable_backing(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if not (stylebox is StyleBoxFlat):
failures.append("%s should use a flat readable backing behind Korean text" % label)
return
if bool(panel.get_meta("generated_panel_texture", false)):
failures.append("%s should not mark a busy generated texture behind text" % label)
var style := stylebox as StyleBoxFlat
if style.bg_color.a < 0.98:
failures.append("%s should keep an opaque backing: %s" % [label, str(style.bg_color)])
if style.bg_color.get_luminance() > 0.18 and style.bg_color.a < 0.995:
failures.append("%s bright text backing should be almost opaque: %s" % [label, str(style.bg_color)])
if style.content_margin_left < 6 or style.content_margin_top < 6:
failures.append("%s should keep at least compact text padding" % label)
func _check_rich_text_readability(failures: Array[String], label: RichTextLabel, context: String) -> void:
if label == null:
failures.append("%s missing" % context)
return
var stylebox := label.get_theme_stylebox("normal")
if not (stylebox is StyleBoxFlat):
failures.append("%s should use a flat text log backing" % context)
else:
var style := stylebox as StyleBoxFlat
if style.bg_color.a < 0.98:
failures.append("%s backing should be opaque enough: %s" % [context, str(style.bg_color)])
var text_color := label.get_theme_color("default_color")
if text_color.a < 0.95 or text_color.get_luminance() < 0.70:
failures.append("%s should use bright readable default text: %s" % [context, str(text_color)])
if label.get_theme_font_size("normal_font_size") < 13:
failures.append("%s should keep readable Korean log size: %d" % [context, label.get_theme_font_size("normal_font_size")])
if label.get_theme_constant("outline_size") < 2:
failures.append("%s should keep an outline for text over dark UI" % context)
if label.get_theme_font("normal_font") == null:
failures.append("%s should use the project serif font" % context)
func _check_no_clipped_labels(failures: Array[String], root: Node, context: String) -> void:
if root == null:
failures.append("%s missing for clipped-label check" % context)
return
if root is Label and (root as Label).clip_text:
failures.append("%s should not hard-clip visible Korean text: %s" % [context, (root as Label).text])
for child in root.get_children():
_check_no_clipped_labels(failures, child, context)
func _check_panel_uses_generated_ornament_texture(failures: Array[String], panel: PanelContainer, label: String) -> void:
if panel == null:
failures.append("%s missing" % label)
return
var stylebox := panel.get_theme_stylebox("panel")
if not (stylebox is StyleBoxTexture):
failures.append("%s should use a generated ornament texture" % label)
return
var style := stylebox as StyleBoxTexture
if style.texture == null:
failures.append("%s generated ornament texture is missing its texture" % label)
return
if style.texture.get_width() < 512 or style.texture.get_height() < 512:
failures.append("%s generated ornament texture should keep high-resolution source pixels: %dx%d" % [label, style.texture.get_width(), style.texture.get_height()])
if style.texture_margin_left < 24 or style.texture_margin_top < 24:
failures.append("%s generated ornament texture should define a visible frame slice margin" % label)
func _colors_nearly_equal(left: Color, right: Color) -> bool:
return (
absf(left.r - right.r) < 0.01
and absf(left.g - right.g) < 0.01
and absf(left.b - right.b) < 0.01
and absf(left.a - right.a) < 0.01
)
func _find_descendant_by_name(root: Node, node_name: String) -> Node:
if root == null:
return null
if str(root.name).begins_with(node_name):
return root
for child in root.get_children():
var found := _find_descendant_by_name(child, node_name)
if found != null:
return found
return null
func _count_descendants_by_name(root: Node, node_name: String) -> int:
if root == null:
return 0
var count := 1 if str(root.name).begins_with(node_name) else 0
for child in root.get_children():
count += _count_descendants_by_name(child, node_name)
return count
func _count_descendants_by_meta(root: Node, meta_key: String, expected_value: Variant) -> int:
if root == null:
return 0
var count := 0
if root.has_meta(meta_key) and root.get_meta(meta_key) == expected_value:
count += 1
for child in root.get_children():
count += _count_descendants_by_meta(child, meta_key, expected_value)
return count
func _is_descendant_of(node: Node, ancestor: Node) -> bool:
var current := node
while current != null:
if current == ancestor:
return true
current = current.get_parent()
return false
func _check_top_hud_chip(failures: Array[String], scene, chip_name: String, expected_label: Label, expected_tooltip_text: String) -> void:
var chip := _find_descendant_by_name(scene.ui_root_control, chip_name)
if chip == null:
failures.append("top HUD chip missing: %s" % chip_name)
return
if not chip is PanelContainer:
failures.append("top HUD chip should be a framed panel: %s" % chip_name)
if expected_label == null or not _is_descendant_of(expected_label, chip):
failures.append("top HUD chip should contain the existing label var: %s" % chip_name)
if not _has_descendant_texture(chip):
failures.append("top HUD chip should contain an icon texture: %s" % chip_name)
if (chip as Control).custom_minimum_size.x <= 0.0 or (chip as Control).custom_minimum_size.y <= 0.0:
failures.append("top HUD chip should reserve stable space: %s" % chip_name)
var tooltip_text := _collect_child_tooltips(chip)
if not tooltip_text.contains(expected_tooltip_text):
failures.append("top HUD chip tooltip should preserve detail `%s`: %s" % [expected_tooltip_text, tooltip_text])
func _check_readable_tooltip_theme(failures: Array[String], theme: Theme) -> void:
var tooltip_panel := theme.get_stylebox("panel", "TooltipPanel")
if not (tooltip_panel is StyleBoxFlat):
failures.append("tooltip panel should use the ancient flat readable surface")
else:
var style := tooltip_panel as StyleBoxFlat
if style.bg_color.a < 0.98 or style.bg_color.get_luminance() > 0.12:
failures.append("tooltip panel should use an opaque dark backing: %s" % str(style.bg_color))
if style.content_margin_left < 10 or style.content_margin_top < 10:
failures.append("tooltip panel should reserve readable text padding")
if style.shadow_size < 10:
failures.append("tooltip panel should cast enough shadow to detach from the map")
if theme.get_font("font", "TooltipLabel") == null:
failures.append("tooltip label should use the project Korean serif font")
if theme.get_font_size("font_size", "TooltipLabel") < 15:
failures.append("tooltip label should keep readable Korean type size")
if theme.get_constant("outline_size", "TooltipLabel") < 2:
failures.append("tooltip label should carry an ink outline")
if theme.get_constant("line_spacing", "TooltipLabel") < 3:
failures.append("tooltip label should reserve readable Korean line spacing")
if theme.get_color("font_color", "TooltipLabel").get_luminance() < 0.70:
failures.append("tooltip label should use bright text over dark backing")
func _collect_child_tooltips(root: Node) -> String:
if root == null:
return ""
var parts: Array[String] = []
_collect_child_tooltips_into(root, parts)
return "\n".join(parts)
func _collect_child_tooltips_into(root: Node, parts: Array[String]) -> void:
if root == null:
return
if root is Control:
var tooltip := str((root as Control).tooltip_text).strip_edges()
if not tooltip.is_empty():
parts.append(tooltip)
for child in root.get_children():
_collect_child_tooltips_into(child, parts)
func _collect_child_text(root: Node) -> String:
if root == null:
return ""
var parts: Array[String] = []
_collect_child_text_into(root, parts)
return "\n".join(parts)
func _collect_descendant_text_by_name(root: Node, node_name: String) -> String:
if root == null:
return ""
var parts: Array[String] = []
_collect_descendant_text_by_name_into(root, node_name, parts)
return "\n".join(parts)
func _collect_descendant_text_by_name_into(root: Node, node_name: String, parts: Array[String]) -> void:
if root == null:
return
if str(root.name).begins_with(node_name):
if root is Button:
var button_text := str((root as Button).text).strip_edges()
if not button_text.is_empty():
parts.append(button_text)
elif root is Label:
var label_text := str((root as Label).text).strip_edges()
if not label_text.is_empty():
parts.append(label_text)
for child in root.get_children():
_collect_descendant_text_by_name_into(child, node_name, parts)
func _collect_child_text_into(root: Node, parts: Array[String]) -> void:
if root == null:
return
if root is Button:
var button_text := str((root as Button).text).strip_edges()
if not button_text.is_empty():
parts.append(button_text)
elif root is Label:
var label_text := str((root as Label).text).strip_edges()
if not label_text.is_empty():
parts.append(label_text)
for child in root.get_children():
_collect_child_text_into(child, parts)
func _has_descendant_texture(root: Node) -> bool:
if root == null:
return false
if root is TextureRect and (root as TextureRect).texture != null:
return true
for child in root.get_children():
if _has_descendant_texture(child):
return true
return false
func _check_button_uses_generated_toolbar_icon(failures: Array[String], button: Button, context: String) -> void:
if button == null or button.icon == null:
return
if button.icon.get_width() < 128 or button.icon.get_height() < 128:
failures.append("%s should use generated high-resolution toolbar art, got %dx%d" % [context, button.icon.get_width(), button.icon.get_height()])
func _check_button_uses_generated_surface(failures: Array[String], button: Button, context: String) -> void:
if button == null:
failures.append("%s button missing" % context)
return
var stylebox := button.get_theme_stylebox("normal")
if not (stylebox is StyleBoxTexture):
failures.append("%s should use a generated button texture surface" % context)
return
var style := stylebox as StyleBoxTexture
if style.texture == null:
failures.append("%s generated button style is missing its texture" % context)
return
if bool(button.get_meta("icon_only", false)):
if not bool(button.get_meta("generated_icon_button_texture", false)):
failures.append("%s icon-only button should use the generated compact icon surface" % context)
if style.texture.get_width() < 256 or style.texture.get_height() < 256:
failures.append("%s generated icon button texture should keep high-resolution source pixels: %dx%d" % [context, style.texture.get_width(), style.texture.get_height()])
if abs(style.texture.get_width() - style.texture.get_height()) > 4:
failures.append("%s generated icon button texture should be square or near-square: %dx%d" % [context, style.texture.get_width(), style.texture.get_height()])
if style.axis_stretch_horizontal != StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT or style.axis_stretch_vertical != StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT:
failures.append("%s generated icon button surface should tile-fit its center for QHD fullscreen" % context)
_check_button_texture_slice_for_control(failures, button, style, context, 10, 10)
if style.content_margin_left < 5 or style.content_margin_top < 5:
failures.append("%s generated icon button texture should reserve icon content margins" % context)
else:
if bool(button.get_meta("generated_icon_button_texture", false)):
failures.append("%s text button should keep the wide generated button surface" % context)
if style.texture.get_width() < 512 or style.texture.get_height() < 192:
failures.append("%s generated button texture should keep high-resolution source pixels: %dx%d" % [context, style.texture.get_width(), style.texture.get_height()])
if style.axis_stretch_horizontal != StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT or style.axis_stretch_vertical != StyleBoxTexture.AXIS_STRETCH_MODE_TILE_FIT:
failures.append("%s generated button surface should tile-fit its 9-slice center so QHD fullscreen does not smear the ornament" % context)
var min_top_margin := 10 if bool(button.get_meta("opening_story_button", false)) else 10
_check_button_texture_slice_for_control(failures, button, style, context, 16, min_top_margin)
var min_content_margin := 6 if bool(button.get_meta("opening_story_button", false)) else 8
if style.content_margin_left < min_content_margin or style.content_margin_top < 5:
failures.append("%s generated button texture should reserve readable content margins" % context)
func _check_button_uses_flat_readable_surface(failures: Array[String], button: Button, context: String) -> void:
if button == null:
failures.append("%s button missing" % context)
return
if not bool(button.get_meta("flat_readable_button", false)):
failures.append("%s should be marked as a flat readable button" % context)
var stylebox := button.get_theme_stylebox("normal")
if not (stylebox is StyleBoxFlat):
failures.append("%s should use a crisp flat button surface" % context)
return
var style := stylebox as StyleBoxFlat
if style.bg_color.a < 0.92:
failures.append("%s flat button should have an opaque readable surface: %s" % [context, str(style.bg_color)])
if style.border_color.a < 0.80 or style.get_border_width(SIDE_TOP) < 2:
failures.append("%s flat button should keep a clear bronze edge" % context)
if not bool(button.get_meta("icon_only", false)) and button.get_theme_font_size("font_size") < 14:
failures.append("%s flat button should keep readable Korean text" % context)
func _check_button_texture_slice_for_control(
failures: Array[String],
button: Button,
style: StyleBoxTexture,
context: String,
min_x: int,
min_y: int
) -> void:
var size := button.custom_minimum_size
if size.x <= 0.0 or size.y <= 0.0:
size = button.get_combined_minimum_size()
var max_x := maxi(1, int(floor(size.x * 0.5)) - 2)
var max_y := maxi(1, int(floor(size.y * 0.5)) - 2)
if style.texture_margin_left < min_x or style.texture_margin_right < min_x:
failures.append("%s generated button texture should keep visible horizontal frame slices: %d/%d" % [context, style.texture_margin_left, style.texture_margin_right])
if style.texture_margin_top < min_y or style.texture_margin_bottom < min_y:
failures.append("%s generated button texture should keep visible vertical frame slices: %d/%d" % [context, style.texture_margin_top, style.texture_margin_bottom])
if style.texture_margin_left > max_x or style.texture_margin_right > max_x:
failures.append("%s generated button horizontal slices should fit the control width: margins %d/%d size %.1f" % [context, style.texture_margin_left, style.texture_margin_right, size.x])
if style.texture_margin_top > max_y or style.texture_margin_bottom > max_y:
failures.append("%s generated button vertical slices should fit the control height: margins %d/%d size %.1f" % [context, style.texture_margin_top, style.texture_margin_bottom, size.y])
func _check_local_command_icon_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String) -> void:
if button == null:
failures.append("local command button missing: %s" % expected_label)
return
if not bool(button.get_meta("icon_only", false)) or button.text != "":
failures.append("local command should be icon-first without visible text: %s text=%s" % [expected_label, button.text])
if button.icon == null or str(button.get_meta("command_icon", "")) != expected_icon:
failures.append("local command should carry an immediate visual icon: %s icon=%s meta=%s" % [expected_label, str(button.icon), str(button.get_meta("command_icon", ""))])
_check_button_uses_generated_toolbar_icon(failures, button, expected_label)
_check_button_uses_flat_readable_surface(failures, button, expected_label)
if str(button.get_meta("command_label", "")) != expected_label:
failures.append("local command should keep old wording in hover metadata: %s meta=%s" % [expected_label, str(button.get_meta("command_label", ""))])
if str(button.tooltip_text).strip_edges().is_empty() or not str(button.tooltip_text).contains(expected_label):
failures.append("local command should expose its function through hover tooltip: %s tooltip=%s" % [expected_label, str(button.tooltip_text)])
if button.custom_minimum_size.x <= 0.0 or button.custom_minimum_size.y <= 0.0:
failures.append("local command should reserve stable icon space: %s" % expected_label)
func _check_local_command_labeled_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String) -> void:
if button == null:
failures.append("local command button missing: %s" % expected_label)
return
if bool(button.get_meta("icon_only", false)) or button.text != expected_label:
failures.append("local command should show readable Korean text after movement: %s text=%s" % [expected_label, button.text])
if button.icon == null or str(button.get_meta("command_icon", "")) != expected_icon:
failures.append("local command should carry an immediate visual icon: %s icon=%s meta=%s" % [expected_label, str(button.icon), str(button.get_meta("command_icon", ""))])
_check_button_uses_generated_toolbar_icon(failures, button, expected_label)
_check_button_uses_flat_readable_surface(failures, button, expected_label)
if str(button.get_meta("command_label", "")) != expected_label:
failures.append("local command should keep old wording in hover metadata: %s meta=%s" % [expected_label, str(button.get_meta("command_label", ""))])
if str(button.tooltip_text).strip_edges().is_empty() or not str(button.tooltip_text).contains(expected_label):
failures.append("local command should expose its function through hover tooltip: %s tooltip=%s" % [expected_label, str(button.tooltip_text)])
if button.custom_minimum_size.x < BattleSceneScript.LOCAL_COMMAND_BUTTON_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.LOCAL_COMMAND_BUTTON_SIZE.y:
failures.append("local command should reserve stable readable space: %s size=%s" % [expected_label, str(button.custom_minimum_size)])
func _check_picker_utility_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String, minimum_size: Vector2) -> void:
if button == null:
failures.append("picker utility button missing: %s" % expected_label)
return
if button.text != expected_label:
failures.append("picker utility should keep readable Korean text: %s text=%s" % [expected_label, button.text])
if button.icon == null or str(button.get_meta("command_icon", "")) != expected_icon:
failures.append("picker utility should carry a generated icon: %s icon=%s meta=%s" % [expected_label, str(button.icon), str(button.get_meta("command_icon", ""))])
_check_button_uses_generated_toolbar_icon(failures, button, expected_label)
_check_button_uses_flat_readable_surface(failures, button, expected_label)
if button.custom_minimum_size.x < minimum_size.x or button.custom_minimum_size.y < minimum_size.y:
failures.append("picker utility should reserve readable button space: %s size=%s" % [expected_label, str(button.custom_minimum_size)])
if str(button.tooltip_text).strip_edges().is_empty() or not str(button.tooltip_text).contains(expected_label):
failures.append("picker utility should explain itself on hover: %s tooltip=%s" % [expected_label, str(button.tooltip_text)])
func _check_hud_command_grid_layout(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
var command_grid := scene.wait_button.get_parent() as GridContainer
if command_grid == null:
failures.append("unit command buttons should live in a fixed grid")
else:
if command_grid.columns != BattleSceneScript.HUD_COMMAND_GRID_COLUMNS:
failures.append("unit command grid should use %d columns, got %d" % [BattleSceneScript.HUD_COMMAND_GRID_COLUMNS, command_grid.columns])
if command_grid.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_GRID_SIZE.x or command_grid.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_GRID_SIZE.y:
failures.append("unit command grid should reserve stable space: %s" % str(command_grid.custom_minimum_size))
var command_icon_expectations := [
{"button": scene.wait_button, "icon": "wait"},
{"button": scene.tactic_button, "icon": "tactic"},
{"button": scene.item_button, "icon": "item"},
{"button": scene.equip_button, "icon": "equip"}
]
for expectation in command_icon_expectations:
var button: Button = expectation["button"]
if button == null:
failures.append("unit command button missing")
continue
if button.get_parent() != command_grid:
failures.append("unit command button should stay in the side command grid: %s" % str(button.name))
if button.custom_minimum_size.x < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.HUD_COMMAND_BUTTON_SIZE.y:
failures.append("unit command button should reserve fixed icon space: %s" % str(button.custom_minimum_size))
if button.icon == null or str(button.get_meta("command_icon", "")) != str(expectation["icon"]):
failures.append("unit command button should carry an immediate visual icon: %s icon=%s meta=%s" % [button.text, str(button.icon), str(button.get_meta("command_icon", ""))])
_check_button_uses_generated_toolbar_icon(failures, button, str(expectation["icon"]))
_check_button_uses_flat_readable_surface(failures, button, str(expectation["icon"]))
if not bool(button.get_meta("icon_only", false)) or button.text != "":
failures.append("unit command button should be icon-first without visible text: %s text=%s" % [str(button.name), button.text])
if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for command icon tooltip check")
else:
scene.battle_started = true
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._update_hud()
for expectation in command_icon_expectations:
var button: Button = expectation["button"]
if button == null:
continue
if button.tooltip_text.strip_edges().is_empty():
failures.append("unit command icon should expose its command through tooltip: %s" % str(button.name))
scene._on_command_hint_button_mouse_entered(scene.wait_button)
if scene.command_hint_panel == null or not scene.command_hint_panel.visible:
failures.append("hovering a unit command icon should show the command hint panel")
else:
if scene.command_hint_title_label == null or scene.command_hint_title_label.text != "대기":
failures.append("unit command hint should show command title: %s" % ("" if scene.command_hint_title_label == null else scene.command_hint_title_label.text))
if scene.command_hint_detail_label == null or not scene.command_hint_detail_label.text.contains("부대 미지정"):
failures.append("disabled unit command hint should show the blocked reason: %s" % ("" if scene.command_hint_detail_label == null else scene.command_hint_detail_label.text))
if scene.command_hint_icon == null or scene.command_hint_icon.texture == null:
failures.append("unit command hint should show generated command icon artwork")
_check_panel_uses_flat_readable_backing(failures, scene.command_hint_panel, "unit command hint readable panel")
scene._on_command_hint_button_mouse_exited()
if scene.command_hint_panel != null and scene.command_hint_panel.visible:
failures.append("unit command hint panel should hide when leaving the icon")
var top_toolbar := scene.end_turn_button.get_parent() as HBoxContainer
if top_toolbar == null or top_toolbar.name != "TopToolBar":
failures.append("global battle commands should move to the top icon toolbar")
if scene.top_audio_chip == null:
failures.append("top HUD should expose a compact audio status chip")
else:
if scene.top_audio_chip.get_parent() == top_toolbar:
failures.append("audio status should be a quiet HUD chip, not another global command button")
if scene.top_audio_chip.custom_minimum_size.x > 64.0 or scene.top_audio_chip.custom_minimum_size.y < 46.0:
failures.append("audio status chip should stay compact and stable: %s" % str(scene.top_audio_chip.custom_minimum_size))
var audio_tooltip := _collect_child_tooltips(scene.top_audio_chip)
if not audio_tooltip.contains("소리 상태") or not audio_tooltip.contains("현재 음악:") or not audio_tooltip.contains("효과음"):
failures.append("audio status chip should expose the current sound mix in a tooltip: %s" % audio_tooltip)
if not _has_descendant_texture(scene.top_audio_chip):
failures.append("audio status chip should use generated icon artwork")
scene._on_top_audio_chip_mouse_entered()
if scene.command_hint_panel == null or not scene.command_hint_panel.visible:
failures.append("hovering the audio chip should show the readable command hint panel")
else:
if scene.command_hint_title_label == null or scene.command_hint_title_label.text != "소리 상태":
failures.append("audio chip hint should show sound status title: %s" % ("" if scene.command_hint_title_label == null else scene.command_hint_title_label.text))
if scene.command_hint_detail_label == null or not scene.command_hint_detail_label.text.contains("효과음"):
failures.append("audio chip hint should show compact sound detail: %s" % ("" if scene.command_hint_detail_label == null else scene.command_hint_detail_label.text))
_check_panel_uses_flat_readable_backing(failures, scene.command_hint_panel, "audio chip hint readable panel")
scene._on_top_audio_chip_mouse_exited()
if scene.command_hint_panel != null and scene.command_hint_panel.visible:
failures.append("audio chip hint panel should hide when leaving the chip")
scene._on_settings_bgm_toggled(false)
scene._on_settings_sfx_toggled(false)
if scene.top_audio_chip.modulate.a > 0.60:
failures.append("audio status chip should dim when both music and SFX are off: %s" % str(scene.top_audio_chip.modulate))
scene._on_top_audio_chip_mouse_entered()
if scene.command_hint_detail_label == null or not scene.command_hint_detail_label.text.contains("꺼짐"):
failures.append("audio chip hint should reflect muted sound state: %s" % ("" if scene.command_hint_detail_label == null else scene.command_hint_detail_label.text))
scene._on_top_audio_chip_mouse_exited()
scene._on_settings_bgm_toggled(true)
scene._on_settings_sfx_toggled(true)
if scene.top_audio_chip.modulate.a < 0.90:
failures.append("audio status chip should brighten when sound is available: %s" % str(scene.top_audio_chip.modulate))
var audio_click := InputEventMouseButton.new()
audio_click.button_index = MOUSE_BUTTON_LEFT
audio_click.pressed = true
scene._hide_settings_panel()
scene._on_top_audio_chip_gui_input(audio_click)
if scene.settings_panel == null or not scene.settings_panel.visible:
failures.append("clicking the audio chip should open the in-game settings panel")
scene._hide_settings_panel()
for button in [
scene.end_turn_button,
scene.threat_button,
scene.battle_unit_list_button,
scene.top_save_button,
scene.top_load_button,
scene.restart_button,
scene.new_campaign_button
]:
if button == null:
failures.append("top tool button missing")
continue
if button.get_parent() != top_toolbar:
failures.append("top tool button should share the top toolbar: %s" % str(button.name))
if not bool(button.get_meta("icon_only", false)) or button.text != "" or button.icon == null:
failures.append("top tool button should be icon-only with tooltip detail: %s text=%s icon=%s" % [str(button.name), button.text, str(button.icon)])
_check_button_uses_generated_toolbar_icon(failures, button, str(button.name))
_check_button_uses_generated_surface(failures, button, str(button.name))
if button.custom_minimum_size.x < BattleSceneScript.TOP_TOOL_BUTTON_SIZE.x or button.custom_minimum_size.y < BattleSceneScript.TOP_TOOL_BUTTON_SIZE.y:
failures.append("top tool button should reserve stable icon space: %s" % str(button.custom_minimum_size))
scene._on_command_hint_button_mouse_entered(scene.end_turn_button)
if scene.command_hint_panel == null or not scene.command_hint_panel.visible:
failures.append("hovering a top command icon should show the command hint panel")
else:
if scene.command_hint_title_label == null or scene.command_hint_title_label.text != "차례 종료":
failures.append("top command hint should show command title: %s" % ("" if scene.command_hint_title_label == null else scene.command_hint_title_label.text))
if scene.command_hint_detail_label == null or not scene.command_hint_detail_label.text.contains("적군"):
failures.append("top command hint should keep command detail: %s" % ("" if scene.command_hint_detail_label == null else scene.command_hint_detail_label.text))
if scene.command_hint_icon == null or scene.command_hint_icon.texture == null or str(scene.end_turn_button.get_meta("command_icon", "")) != "end_turn":
failures.append("top command hint should use generated top command icon artwork")
_check_panel_uses_flat_readable_backing(failures, scene.command_hint_panel, "top command hint readable panel")
scene._on_command_hint_button_mouse_exited()
scene._update_threat_button()
scene._on_command_hint_button_mouse_entered(scene.threat_button)
if scene.command_hint_panel == null or not scene.command_hint_panel.visible:
failures.append("hovering the threat icon should show the command hint panel")
else:
if scene.command_hint_title_label == null or scene.command_hint_title_label.text != "적정":
failures.append("threat command hint should show a compact Korean title: %s" % ("" if scene.command_hint_title_label == null else scene.command_hint_title_label.text))
var threat_hint_text: String = "" if scene.command_hint_detail_label == null else scene.command_hint_detail_label.text
if not threat_hint_text.contains("사거리") or not threat_hint_text.contains("위험/유인/경계"):
failures.append("threat command hint should use a short readable summary: %s" % threat_hint_text)
if threat_hint_text.length() > 42:
failures.append("threat command hint should not cram the full tooltip into the small panel: %s" % threat_hint_text)
var threat_panel_tooltip: String = "" if scene.command_hint_panel == null else scene.command_hint_panel.tooltip_text
if not threat_panel_tooltip.contains("초병 유인 반경") or not threat_panel_tooltip.contains("거점 경계권"):
failures.append("threat command hint should preserve the full detailed tooltip: %s" % threat_panel_tooltip)
var hint_rect := Rect2(scene.command_hint_panel.position, BattleSceneScript.COMMAND_HINT_PANEL_SIZE)
if hint_rect.end.x > BattleSceneScript.SIDE_PANEL_POSITION.x - 2.0:
failures.append("top command hint should not cover the right readable battle panel: %s" % str(hint_rect))
scene.free()
func _check_battle_unit_list_panel(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 battle unit list")
scene.free()
return
scene.battle_started = true
scene.campaign_complete_screen = false
scene.briefing_panel.visible = false
scene.result_panel.visible = false
scene._update_hud()
if scene.battle_unit_list_button == null:
failures.append("top toolbar should expose a battle unit list button")
scene.free()
return
if scene.battle_unit_list_button.disabled:
failures.append("battle unit list button should be enabled during active battle")
if scene.battle_unit_list_button.icon == null or not bool(scene.battle_unit_list_button.get_meta("icon_only", false)):
failures.append("battle unit list button should be an icon-only top command")
scene._on_battle_unit_list_pressed()
if scene.battle_unit_list_panel == null or not scene.battle_unit_list_panel.visible:
failures.append("battle unit list panel should open from the top toolbar")
else:
_check_panel_style_fill(failures, scene.battle_unit_list_panel, "battle unit list panel", Color(0.045, 0.048, 0.044, 0.988))
_check_panel_uses_flat_readable_backing(failures, scene.battle_unit_list_panel, "battle unit list readable panel")
var header_icon := _find_descendant_by_name(scene.battle_unit_list_panel, "BattleUnitListHeaderIcon")
if header_icon == null or not header_icon is TextureRect or (header_icon as TextureRect).texture == null:
failures.append("battle unit list header should carry generated unit-list icon artwork")
var header_seal := _find_descendant_by_name(scene.battle_unit_list_panel, "BattleUnitListHeaderSeal")
if header_seal == null:
failures.append("battle unit list header should use a compact Korean seal mark")
if scene.battle_unit_list_title_label == null or not scene.battle_unit_list_title_label.text.contains("아군"):
failures.append("battle unit list should open on the ally tab")
if scene.battle_unit_list_status_label == null or scene.battle_unit_list_status_label.text.contains("행동") or scene.battle_unit_list_status_label.text.contains("완료"):
failures.append("ally battle unit list status should keep action counts in the tooltip: %s" % ("" if scene.battle_unit_list_status_label == null else scene.battle_unit_list_status_label.text))
elif not scene.battle_unit_list_status_label.tooltip_text.contains("명령 가능") or not scene.battle_unit_list_status_label.tooltip_text.contains("행동 완료"):
failures.append("ally battle unit list status tooltip should retain action counts: %s" % scene.battle_unit_list_status_label.tooltip_text)
if scene.battle_unit_list_rows == null or scene.battle_unit_list_rows.get_child_count() < 2:
failures.append("ally battle unit list should include deployed officers")
elif not _row_texts_contain(scene.battle_unit_list_rows, "조조") or not _row_texts_contain(scene.battle_unit_list_rows, "하후돈"):
failures.append("ally battle unit list should show Cao Cao and Xiahou Dun")
var ally_visible_text := _collect_child_text(scene.battle_unit_list_rows)
var ally_badge_text := _collect_descendant_text_by_name(scene.battle_unit_list_rows, "BattleUnitListBadgeLabel")
var ally_tooltips := _collect_child_tooltips(scene.battle_unit_list_rows)
if ally_badge_text.contains("%"):
failures.append("ally battle unit list should move exact HP percent into the hover bar tooltip: %s" % ally_badge_text)
if not ally_badge_text.contains("서측"):
failures.append("ally battle unit list should show compact zone badges: %s" % ally_badge_text)
if _find_descendant_by_name(scene.battle_unit_list_rows, "BattleUnitListHpBar") == null:
failures.append("ally battle unit list should show a visual HP bar instead of inline percent text")
if _count_descendants_by_meta(scene.battle_unit_list_rows, "battle_unit_list_badge_kind", "action") < 2:
failures.append("ally battle unit list should show action state as icon badges")
for class_label in ["지휘관", "기병", "보병", "궁병", "책사"]:
if ally_badge_text.contains(class_label):
failures.append("ally battle unit list should move class names into icon hover text: %s" % ally_badge_text)
for action_label in ["행동", "완료", "호위", "퇴각"]:
if ally_badge_text.contains(action_label):
failures.append("ally battle unit list should move action labels into icon hover text: %s" % ally_badge_text)
if ally_visible_text.contains(""):
failures.append("ally battle unit list should keep raw coordinates out of visible badges: %s" % ally_visible_text)
if ally_visible_text.contains("군령:") or ally_visible_text.contains("행군 4") or ally_visible_text.contains("타격 1-1"):
failures.append("ally battle unit list rows should keep dense combat detail in hover text: %s" % ally_visible_text)
if not ally_tooltips.contains("군세") or not ally_tooltips.contains("클릭하면 이 장수를 선택") or not ally_tooltips.contains("지휘관") or not ally_tooltips.contains("병력"):
failures.append("ally battle unit list tooltips should retain selection detail: %s" % ally_tooltips)
if not ally_tooltips.contains("시선 동쪽") or not ally_tooltips.contains("후방 주의") or not ally_tooltips.contains("등 뒤"):
failures.append("ally battle unit list tooltips should expose facing and rear-risk detail: %s" % ally_tooltips)
if not _has_descendant_texture(scene.battle_unit_list_rows):
failures.append("ally battle unit list should show officer portrait artwork")
if _find_descendant_by_name(scene.battle_unit_list_rows, "BattleUnitListClassIcon") == null:
failures.append("ally battle unit list rows should overlay generated class crest artwork")
if _count_descendants_by_name(scene.battle_unit_list_rows, "BattleUnitListBadgeIcon") < 4:
failures.append("ally battle unit list compact badges should include icon artwork for class, HP, action, and zone")
scene._on_battle_unit_list_row_pressed("cao_cao")
var selected: Dictionary = scene.state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != "cao_cao":
failures.append("clicking an available ally in the battle unit list should select that officer")
scene._on_battle_unit_list_tab_pressed("enemy")
if scene.battle_unit_list_title_label == null or not scene.battle_unit_list_title_label.text.contains("적군"):
failures.append("battle unit list should switch to the enemy tab")
if scene.battle_unit_list_status_label == null or scene.battle_unit_list_status_label.text.contains("잔적") or not scene.battle_unit_list_status_label.text.contains("부대"):
failures.append("enemy battle unit list should summarize remaining enemies without old dense wording: %s" % ("" if scene.battle_unit_list_status_label == null else scene.battle_unit_list_status_label.text))
elif not scene.battle_unit_list_status_label.tooltip_text.contains("남은 적군") or not scene.battle_unit_list_status_label.tooltip_text.contains("행동 방침"):
failures.append("enemy battle unit list status tooltip should retain enemy count detail: %s" % scene.battle_unit_list_status_label.tooltip_text)
if scene.battle_unit_list_rows == null or scene.battle_unit_list_rows.get_child_count() < 1:
failures.append("enemy battle unit list should show living enemies")
elif not _row_texts_contain(scene.battle_unit_list_rows, "장만성"):
failures.append("enemy battle unit list should include the enemy commander")
var enemy_visible_text := _collect_child_text(scene.battle_unit_list_rows)
var enemy_badge_text := _collect_descendant_text_by_name(scene.battle_unit_list_rows, "BattleUnitListBadgeLabel")
var enemy_tooltips := _collect_child_tooltips(scene.battle_unit_list_rows)
if enemy_badge_text.contains("%"):
failures.append("enemy battle unit list should move exact HP percent into the hover bar tooltip: %s" % enemy_badge_text)
if _find_descendant_by_name(scene.battle_unit_list_rows, "BattleUnitListHpBar") == null:
failures.append("enemy battle unit list should show a visual HP bar instead of inline percent text")
if _count_descendants_by_meta(scene.battle_unit_list_rows, "battle_unit_list_badge_kind", "action") < 1:
failures.append("enemy battle unit list should show AI state as icon badges")
if not enemy_badge_text.contains("성채") and not enemy_badge_text.contains("동측"):
failures.append("enemy battle unit list should show a readable zone badge instead of raw coordinates: %s" % enemy_badge_text)
for class_label in ["지휘관", "기병", "보병", "궁병", "책사"]:
if enemy_badge_text.contains(class_label):
failures.append("enemy battle unit list should move class names into icon hover text: %s" % enemy_badge_text)
for action_label in ["", "대기", "수비", "경계"]:
if enemy_badge_text.contains(action_label):
failures.append("enemy battle unit list should move AI state labels into icon hover text: %s" % enemy_badge_text)
if enemy_visible_text.contains(""):
failures.append("enemy battle unit list should keep raw coordinates out of visible badges: %s" % enemy_visible_text)
if enemy_visible_text.contains("군령:") or enemy_visible_text.contains("직접 명령"):
failures.append("enemy battle unit list rows should keep dense enemy detail in hover text: %s" % enemy_visible_text)
if not enemy_tooltips.contains("군령: 적군 군기") or not enemy_tooltips.contains("클릭하면 위치로 이동"):
failures.append("enemy battle unit list tooltips should retain enemy detail: %s" % enemy_tooltips)
if not enemy_tooltips.contains("시선") or not enemy_tooltips.contains("후방 주의") or not enemy_tooltips.contains("등 뒤"):
failures.append("enemy battle unit list tooltips should expose facing and rear-risk detail: %s" % enemy_tooltips)
if not enemy_tooltips.contains("유인 반경") or not enemy_tooltips.contains("거점 경계 반경"):
failures.append("enemy battle unit list tooltips should explain sentry and guard behavior: %s" % enemy_tooltips)
if not enemy_tooltips.contains("가장 가까운 아군은 유인 밖") or not enemy_tooltips.contains("가장 가까운 아군은 거점 밖"):
failures.append("enemy battle unit list tooltips should expose current lure/guard distance: %s" % enemy_tooltips)
if not _has_descendant_texture(scene.battle_unit_list_rows):
failures.append("enemy battle unit list should show unit sprite artwork")
if _find_descendant_by_name(scene.battle_unit_list_rows, "BattleUnitListClassIcon") == null:
failures.append("enemy battle unit list rows should overlay generated class crest artwork")
if _count_descendants_by_name(scene.battle_unit_list_rows, "BattleUnitListBadgeIcon") < 4:
failures.append("enemy battle unit list compact badges should include icon artwork for class, HP, action, and zone")
scene._on_battle_unit_list_row_pressed("yellow_turban_1")
selected = scene.state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != "cao_cao":
failures.append("clicking an enemy in the battle unit list should focus without stealing ally selection")
var escape_event := InputEventKey.new()
escape_event.keycode = KEY_ESCAPE
scene._handle_key(escape_event)
if scene.battle_unit_list_panel != null and scene.battle_unit_list_panel.visible:
failures.append("Escape should close the battle unit list panel")
scene.free()
func _row_texts_contain(container: VBoxContainer, needle: String) -> bool:
if container == null:
return false
return _collect_child_text(container).contains(needle)
func _check_prep_menu_icon_button(failures: Array[String], button: Button, expected_label: String, expected_icon: String) -> void:
if button == null:
failures.append("pre-battle prep button missing: %s" % expected_label)
return
if not button.toggle_mode:
failures.append("pre-battle prep button should support active selected state: %s" % expected_label)
if not bool(button.get_meta("icon_only", false)) or button.text != "":
failures.append("pre-battle prep button should be icon-first without visible text: %s text=%s" % [expected_label, button.text])
if button.icon == null or str(button.get_meta("command_icon", "")) != expected_icon:
failures.append("pre-battle prep button should carry a distinct icon: %s icon=%s meta=%s" % [expected_label, str(button.icon), str(button.get_meta("command_icon", ""))])
_check_button_uses_generated_toolbar_icon(failures, button, expected_label)
_check_button_uses_generated_surface(failures, button, expected_label)
if str(button.get_meta("command_label", "")) != expected_label:
failures.append("pre-battle prep button should preserve Korean label in metadata: %s meta=%s" % [expected_label, str(button.get_meta("command_label", ""))])
if str(button.tooltip_text).strip_edges().is_empty() or not str(button.tooltip_text).contains(expected_label):
failures.append("pre-battle prep button should expose its function through hover tooltip: %s tooltip=%s" % [expected_label, str(button.tooltip_text)])
func _check_prep_menu_button_active_selection(failures: Array[String], scene) -> void:
scene._on_shop_pressed()
if scene.shop_button == null or not scene.shop_button.button_pressed or not bool(scene.shop_button.get_meta("prep_menu_active", false)):
failures.append("opening the shop should mark only the shop prep icon active")
if scene.talk_button != null and scene.talk_button.button_pressed:
failures.append("opening the shop should leave the talk prep icon inactive")
scene._on_talk_pressed()
if scene.talk_button == null or not scene.talk_button.button_pressed or not bool(scene.talk_button.get_meta("prep_menu_active", false)):
failures.append("opening talk should mark the talk prep icon active")
if scene.shop_button != null and scene.shop_button.button_pressed:
failures.append("opening talk should clear the previous shop prep icon active state")
scene._hide_talk_menu()
scene._update_hud()
if scene.talk_button != null and scene.talk_button.button_pressed:
failures.append("hiding a prep menu should clear its active icon state")
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()
for button in [
scene.wait_button,
scene.tactic_button,
scene.item_button,
scene.equip_button,
scene.end_turn_button,
scene.threat_button
]:
button.set_meta("icon_only", true)
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 != "" or not scene.tactic_button.tooltip_text.contains("봉인됨") or not scene.tactic_button.tooltip_text.contains("책략"):
failures.append("sealed unit should explain disabled tactic button: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text])
if str(scene.tactic_button.get_meta("command_label", "")) != "책략" or str(scene.tactic_button.get_meta("disabled_hint", "")) != "봉인됨" or not bool(scene.tactic_button.get_meta("command_disabled", false)):
failures.append("sealed tactic button should keep reusable command metadata: %s / %s / %s" % [
str(scene.tactic_button.get_meta("command_label", "")),
str(scene.tactic_button.get_meta("disabled_hint", "")),
str(scene.tactic_button.get_meta("command_disabled", false))
])
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 scene.wait_button.text != "" or not scene.wait_button.tooltip_text.contains("대기") or not scene.wait_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit hold button should keep short text and explain in tooltip: %s | %s" % [scene.wait_button.text, scene.wait_button.tooltip_text])
if scene.tactic_button.text != "" or not scene.tactic_button.tooltip_text.contains("책략") or not scene.tactic_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit stratagem button should keep short text and explain in tooltip: %s | %s" % [scene.tactic_button.text, scene.tactic_button.tooltip_text])
if scene.item_button.text != "" or not scene.item_button.tooltip_text.contains("도구") or not scene.item_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit supply button should keep short text and explain in tooltip: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text])
if scene.equip_button.text != "" or not scene.equip_button.tooltip_text.contains("병장") or not scene.equip_button.tooltip_text.contains("이미 행동"):
failures.append("acted unit arms button should keep short text and explain in tooltip: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text])
cao_cao["acted"] = false
cao_cao["moved"] = true
scene._update_equip_button(cao_cao)
if scene.equip_button.text != "" or not scene.equip_button.tooltip_text.contains("병장") or not scene.equip_button.tooltip_text.contains("행군 후") or not scene.equip_button.tooltip_text.contains("행군 전"):
failures.append("moved unit should explain disabled equip button: %s | %s" % [scene.equip_button.text, scene.equip_button.tooltip_text])
if str(scene.equip_button.get_meta("command_label", "")) != "병장" or str(scene.equip_button.get_meta("disabled_hint", "")) != "행군 후":
failures.append("moved equip button should keep a compact disabled reason for hover UI reuse: %s / %s" % [
str(scene.equip_button.get_meta("command_label", "")),
str(scene.equip_button.get_meta("disabled_hint", ""))
])
cao_cao["moved"] = false
scene.state.set_inventory_snapshot({})
scene._update_item_button(cao_cao)
if scene.item_button.text != "" or not scene.item_button.tooltip_text.contains("비어 있음") or not scene.item_button.tooltip_text.contains("도구"):
failures.append("empty inventory should explain disabled item button: %s | %s" % [scene.item_button.text, scene.item_button.tooltip_text])
if str(scene.item_button.get_meta("disabled_hint", "")) != "비어 있음":
failures.append("empty inventory item button should keep disabled hint metadata: %s" % str(scene.item_button.get_meta("disabled_hint", "")))
scene.state.current_team = BattleState.TEAM_ENEMY
scene._update_end_turn_button()
if scene.end_turn_button.text != "" or not scene.end_turn_button.tooltip_text.contains("차례 종료") or not scene.end_turn_button.tooltip_text.contains("적군 차례") or not scene.end_turn_button.tooltip_text.contains("아군 군령"):
failures.append("enemy phase should explain disabled end turn button: %s | %s" % [scene.end_turn_button.text, scene.end_turn_button.tooltip_text])
if str(scene.end_turn_button.get_meta("command_label", "")) != "차례 종료" or str(scene.end_turn_button.get_meta("disabled_hint", "")) != "적군 차례":
failures.append("enemy phase end-turn button should preserve disabled metadata: %s / %s" % [
str(scene.end_turn_button.get_meta("command_label", "")),
str(scene.end_turn_button.get_meta("disabled_hint", ""))
])
scene.state.current_team = BattleState.TEAM_PLAYER
scene.battle_started = true
scene._update_threat_button()
if scene.threat_button.text != "" or not scene.threat_button.tooltip_text.contains("유인 반경") or not scene.threat_button.tooltip_text.contains("거점 경계권") or not scene.threat_button.tooltip_text.contains("위험/유인/경계"):
failures.append("active threat button should explain range, lure, and guard overlays: %s | %s" % [scene.threat_button.text, scene.threat_button.tooltip_text])
if str(scene.threat_button.get_meta("command_label", "")) != "적정" or bool(scene.threat_button.get_meta("command_disabled", false)):
failures.append("active threat button should preserve reusable active metadata: %s / %s" % [
str(scene.threat_button.get_meta("command_label", "")),
str(scene.threat_button.get_meta("command_disabled", false))
])
var active_threat_hint_detail := str(scene.threat_button.get_meta("command_hint_detail", ""))
if not active_threat_hint_detail.contains("사거리") or not active_threat_hint_detail.contains("위험/유인/경계") or active_threat_hint_detail.length() > 42:
failures.append("active threat button should keep a short readable hover-panel summary: %s" % active_threat_hint_detail)
scene.battle_started = false
scene._update_threat_button()
if scene.threat_button.text != "" or not scene.threat_button.tooltip_text.contains("적정") or not scene.threat_button.tooltip_text.contains("전장도 미개봉") or not scene.threat_button.tooltip_text.contains("전장도"):
failures.append("inactive battle should explain disabled threat button: %s | %s" % [scene.threat_button.text, scene.threat_button.tooltip_text])
if str(scene.threat_button.get_meta("disabled_hint", "")) != "전장도 미개봉":
failures.append("inactive threat button should retain disabled hint metadata: %s" % str(scene.threat_button.get_meta("disabled_hint", "")))
if not str(scene.threat_button.get_meta("command_hint_detail", "")).is_empty():
failures.append("inactive threat button should clear compact active hint detail")
_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
var enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
scene.state.select_unit("cao_cao")
scene._refresh_ranges()
var enemy_alive_states := {}
for foe in scene.state.get_living_units(BattleStateScript.TEAM_ENEMY):
enemy_alive_states[str(foe.get("id", ""))] = bool(foe.get("alive", false))
foe["alive"] = false
scene._refresh_ranges()
scene.hover_cell = Vector2i(2, 5)
var move_badge := scene._target_preview_badge()
if str(move_badge.get("text", "")) != "행군" or str(move_badge.get("kind", "")) != "move":
failures.append("reachable empty tile should show MOVE badge: %s" % str(move_badge))
if str(move_badge.get("icon", "")) != "move":
failures.append("MOVE badge should carry a generated icon hint: %s" % str(move_badge))
if str(move_badge.get("panel", "")) != "hud_jade":
failures.append("MOVE badge should use generated jade panel metadata: %s" % str(move_badge))
if not scene._hover_info_badge().is_empty():
failures.append("hover info badge should yield to active move intent badge")
for unit_id in enemy_alive_states.keys():
var restored_enemy: Dictionary = scene.state.get_unit(str(unit_id))
if not restored_enemy.is_empty():
restored_enemy["alive"] = bool(enemy_alive_states[unit_id])
scene._refresh_ranges()
enemy["pos"] = Vector2i(1, 5)
scene._refresh_ranges()
scene.hover_cell = Vector2i(2, 5)
var danger_move_badge := scene._target_preview_badge()
if str(danger_move_badge.get("text", "")) != "위험" or str(danger_move_badge.get("kind", "")) != "danger":
failures.append("moving into current enemy attack range should show DANGER badge: %s" % str(danger_move_badge))
if not str(danger_move_badge.get("tooltip", "")).contains("현재 적 공격권"):
failures.append("danger move badge should explain current enemy attack range: %s" % str(danger_move_badge))
var danger_detail_text := scene._target_preview_detail_badge_text(str(danger_move_badge.get("tooltip", "")))
if not danger_detail_text.contains("현재 적 공격권"):
failures.append("danger move badge detail text should be visible on the map: %s" % danger_detail_text)
scene.hover_cell = Vector2i(1, 7)
var select_badge := scene._target_preview_badge()
if str(select_badge.get("text", "")) != "선택" or str(select_badge.get("kind", "")) != "select":
failures.append("friendly selectable unit should show SELECT badge: %s" % str(select_badge))
if str(select_badge.get("icon", "")).strip_edges().is_empty():
failures.append("SELECT badge should carry a generated icon hint: %s" % str(select_badge))
if str(select_badge.get("panel", "")) != "hud_jade":
failures.append("SELECT badge should use generated jade panel metadata: %s" % str(select_badge))
scene.hover_cell = Vector2i(1, 5)
var attack_badge := scene._target_preview_badge()
var attack_badge_text := str(attack_badge.get("text", ""))
if attack_badge.is_empty() or str(attack_badge.get("kind", "")) == "move" or str(attack_badge.get("text", "")) == "행군":
failures.append("enemy hover should keep attack badge priority: %s" % str(attack_badge))
if not attack_badge_text.contains("피해") and not attack_badge_text.contains("퇴각"):
failures.append("attack badge should expose readable damage or retreat wording: %s" % str(attack_badge))
if not attack_badge_text.contains(""):
failures.append("adjacent attack badge should expose counterattack risk: %s" % str(attack_badge))
if str(attack_badge.get("kind", "")) != "counter" and str(attack_badge.get("kind", "")) != "danger":
failures.append("counterable attack badge should use a counter danger color kind: %s" % str(attack_badge))
if str(attack_badge.get("icon", "")) != "threat":
failures.append("counterable attack badge should carry a threat icon hint: %s" % str(attack_badge))
if str(attack_badge.get("panel", "")) != "hud_jade":
failures.append("counterable attack badge should use generated jade panel metadata: %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, 5)
var moved_badge := scene._target_preview_badge()
if str(moved_badge.get("kind", "")) == "move" or str(moved_badge.get("text", "")) == "행군":
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")
if scene._target_preview_badge_color("counter") == default_color:
failures.append("COUNTER badge should use a distinct color")
if scene._target_preview_badge_color("danger") == default_color:
failures.append("DANGER badge should use a distinct color")
var board_rect: Rect2 = scene._board_rect().intersection(scene._map_view_rect())
var edge_badge_rect: Rect2 = scene._target_preview_badge_rect(Vector2i(13, 9))
if edge_badge_rect.position.x < board_rect.position.x or edge_badge_rect.position.y < board_rect.position.y:
failures.append("edge target preview badge should stay inside visible board minimum: %s / %s" % [str(edge_badge_rect), str(board_rect)])
if edge_badge_rect.end.x > board_rect.end.x or edge_badge_rect.end.y > board_rect.end.y:
failures.append("edge target preview badge should stay inside visible board maximum: %s / %s" % [str(edge_badge_rect), str(board_rect)])
if edge_badge_rect.size.x <= BattleSceneScript.TILE_SIZE:
failures.append("target preview badge should remain wide enough for Korean tactical text")
if edge_badge_rect.size.x < 120.0:
failures.append("target preview badge should reserve room for icon and Korean text")
var edge_detail_rect: Rect2 = scene._target_preview_detail_badge_rect(
Vector2i(13, 9),
"현재 적 공격권: 장만성, 마을 방패병, +2",
edge_badge_rect
)
if edge_detail_rect.position.x < board_rect.position.x or edge_detail_rect.position.y < board_rect.position.y:
failures.append("edge target preview detail should stay inside visible board minimum: %s / %s" % [str(edge_detail_rect), str(board_rect)])
if edge_detail_rect.end.x > board_rect.end.x or edge_detail_rect.end.y > board_rect.end.y:
failures.append("edge target preview detail should stay inside visible board maximum: %s / %s" % [str(edge_detail_rect), str(board_rect)])
if edge_detail_rect.size.y > BattleSceneScript.TARGET_PREVIEW_DETAIL_BADGE_HEIGHT + 0.1:
failures.append("target preview detail should remain a compact one-line strip: %s" % str(edge_detail_rect))
scene.state.clear_selection()
scene.selected_skill_id = ""
scene.selected_item_id = ""
scene.basic_attack_targeting = false
scene._refresh_ranges()
scene.hover_cell = Vector2i(1, 5)
var enemy_hover_badge := scene._hover_info_badge()
if str(enemy_hover_badge.get("kind", "")) != "enemy" or not str(enemy_hover_badge.get("lines", [])).contains(str(enemy.get("name", ""))):
failures.append("enemy hover info badge should expose enemy identity: %s" % str(enemy_hover_badge))
if str(enemy_hover_badge.get("icon", "")) != "threat":
failures.append("enemy hover info badge should use generated threat icon metadata: %s" % str(enemy_hover_badge))
if str(enemy_hover_badge.get("panel", "")) != "hud_jade":
failures.append("enemy hover info badge should use generated jade panel metadata: %s" % str(enemy_hover_badge))
var central_sentry: Dictionary = scene.state.get_unit("yellow_turban_5")
central_sentry["ai_awake"] = false
scene.hover_cell = central_sentry.get("pos", Vector2i(10, 6))
var sentry_hover_badge := scene._hover_info_badge()
var sentry_hover_text := str(sentry_hover_badge.get("lines", []))
if not sentry_hover_text.contains("초병 대기") or not sentry_hover_text.contains("제4군령"):
failures.append("sleeping sentry hover badge should expose lure timing compactly: %s" % str(sentry_hover_badge))
if not sentry_hover_text.contains("유인 밖 4칸"):
failures.append("sleeping sentry hover badge should expose current lure distance: %s" % str(sentry_hover_badge))
var lure_move_badge := scene._move_cell_preview_badge(Vector2i(5, 6))
if str(lure_move_badge.get("text", "")) != "유인" or str(lure_move_badge.get("kind", "")) != "advance":
failures.append("move preview should mark cells that wake sleeping sentries: %s" % str(lure_move_badge))
if not str(lure_move_badge.get("tooltip", "")).contains("초병 반응권"):
failures.append("lure move badge should explain sentry reaction zone: %s" % str(lure_move_badge))
if not scene._target_preview_detail_badge_text(str(lure_move_badge.get("tooltip", ""))).contains("초병 반응권"):
failures.append("lure move badge detail text should be drawable: %s" % str(lure_move_badge))
central_sentry["ai_awake"] = true
var awake_sentry_hover_badge := scene._hover_info_badge()
if not str(awake_sentry_hover_badge.get("lines", [])).contains("초병 경계"):
failures.append("awake sentry hover badge should show the changed AI state: %s" % str(awake_sentry_hover_badge))
var hovered_sentry: Dictionary = scene._hovered_ai_zone_unit()
if str(hovered_sentry.get("id", "")) != "yellow_turban_5":
failures.append("hovered sentry should expose a drawable AI zone: %s" % str(hovered_sentry))
var sentry_zone_cells: Array[Vector2i] = scene._ai_zone_cells_for_unit(central_sentry)
if sentry_zone_cells.size() != 61:
failures.append("central sentry lure zone should be a radius-5 diamond, got %d cells" % sentry_zone_cells.size())
if not sentry_zone_cells.has(Vector2i(10, 6)) or not sentry_zone_cells.has(Vector2i(5, 6)) or sentry_zone_cells.has(Vector2i(4, 6)):
failures.append("central sentry lure zone should include only cells within radius 5")
if scene._ai_zone_overlay_marker_key(central_sentry) != "attack":
failures.append("awake sentry AI zone should use attack-tinted marker artwork")
central_sentry["ai_awake"] = false
if scene._ai_zone_overlay_marker_key(central_sentry) != "move":
failures.append("sleeping sentry AI zone should use movement-tinted marker artwork")
var sentry_fill := scene._ai_zone_overlay_fill_color(central_sentry)
if sentry_fill.a > 0.24:
failures.append("sentry AI zone fill should stay subtle on top of terrain: %.2f" % sentry_fill.a)
var village_guard: Dictionary = scene.state.get_unit("yellow_turban_4")
scene.hover_cell = village_guard.get("pos", Vector2i(7, 5))
var guard_hover_badge := scene._hover_info_badge()
var guard_hover_text := str(guard_hover_badge.get("lines", []))
if not guard_hover_text.contains("수비") or not guard_hover_text.contains("거점 밖 4칸"):
failures.append("guard hover badge should expose current guard-zone distance: %s" % str(guard_hover_badge))
var guard_move_badge := scene._move_cell_preview_badge(Vector2i(4, 5))
if str(guard_move_badge.get("text", "")) != "경계" or str(guard_move_badge.get("kind", "")) != "select":
failures.append("move preview should mark guard reaction cells: %s" % str(guard_move_badge))
if not str(guard_move_badge.get("tooltip", "")).contains("거점 경계권"):
failures.append("guard move badge should explain guard reaction zone: %s" % str(guard_move_badge))
if not scene._target_preview_detail_badge_text(str(guard_move_badge.get("tooltip", ""))).contains("거점 경계권"):
failures.append("guard move badge detail text should be drawable: %s" % str(guard_move_badge))
var hovered_guard: Dictionary = scene._hovered_ai_zone_unit()
if str(hovered_guard.get("id", "")) != "yellow_turban_4":
failures.append("hovered guard should expose a drawable AI zone: %s" % str(hovered_guard))
var guard_zone_cells: Array[Vector2i] = scene._ai_zone_cells_for_unit(village_guard)
if guard_zone_cells.size() != 25:
failures.append("village guard zone should be a radius-3 diamond, got %d cells" % guard_zone_cells.size())
if not guard_zone_cells.has(Vector2i(7, 5)) or not guard_zone_cells.has(Vector2i(4, 5)) or guard_zone_cells.has(Vector2i(3, 5)):
failures.append("village guard zone should include only cells within radius 3")
if scene._ai_zone_overlay_marker_key(village_guard) != "select":
failures.append("guard AI zone should use select-tinted marker artwork")
var guard_fill := scene._ai_zone_overlay_fill_color(village_guard)
if guard_fill.a > 0.24:
failures.append("guard AI zone fill should stay subtle on top of terrain: %.2f" % guard_fill.a)
scene.hover_cell = Vector2i(4, 2)
var event_hover_badge := scene._hover_info_badge()
if str(event_hover_badge.get("kind", "")) != "event" or not str(event_hover_badge.get("lines", [])).contains("표식 북숲 보급고"):
failures.append("event hover info badge should expose marker label: %s" % str(event_hover_badge))
if str(event_hover_badge.get("icon", "")) != "item":
failures.append("event hover info badge should use generated supply icon metadata: %s" % str(event_hover_badge))
if str(event_hover_badge.get("lines", [])).contains("5,3"):
failures.append("event hover info badge should not lead with raw grid coordinates: %s" % str(event_hover_badge))
scene.hover_cell = Vector2i(4, 6)
var tactic_hover_badge := scene._hover_info_badge()
if str(tactic_hover_badge.get("kind", "")) != "event" or not str(tactic_hover_badge.get("lines", [])).contains("표식 유인선"):
failures.append("tactic hover info badge should expose lure-line marker label: %s" % str(tactic_hover_badge))
if str(tactic_hover_badge.get("icon", "")) != "tactic":
failures.append("tactic hover info badge should use generated tactic icon metadata: %s" % str(tactic_hover_badge))
if str(tactic_hover_badge.get("lines", [])).contains("5,7"):
failures.append("tactic hover info badge should hide raw grid coordinates: %s" % str(tactic_hover_badge))
scene.hover_cell = Vector2i(20, 1)
var objective_hover_badge := scene._hover_info_badge()
var objective_hover_text := str(objective_hover_badge.get("lines", []))
if str(objective_hover_badge.get("kind", "")) != "objective" or not objective_hover_text.contains("목표 성채 장악") or not objective_hover_text.contains("회복 +8"):
failures.append("objective hover info badge should expose capture and recovery: %s" % str(objective_hover_badge))
if str(objective_hover_badge.get("icon", "")) != "objective":
failures.append("objective hover info badge should use generated objective icon metadata: %s" % str(objective_hover_badge))
if objective_hover_text.contains("21,2"):
failures.append("objective hover info badge should not show raw coordinates in visible text: %s" % objective_hover_text)
var edge_hover_lines: Array[String] = ["긴 전장 배지", "둘째 줄"]
var edge_hover_rect: Rect2 = scene._hover_info_badge_rect(Vector2i(13, 9), edge_hover_lines)
if edge_hover_rect.position.x < board_rect.position.x or edge_hover_rect.position.y < board_rect.position.y:
failures.append("edge hover info badge should stay inside visible board minimum: %s / %s" % [str(edge_hover_rect), str(board_rect)])
if edge_hover_rect.end.x > board_rect.end.x or edge_hover_rect.end.y > board_rect.end.y:
failures.append("edge hover info badge should stay inside visible board maximum: %s / %s" % [str(edge_hover_rect), str(board_rect)])
scene.free()
func _check_counter_attack_badge_variants(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 counter badge variants")
scene.free()
return
scene.battle_started = true
scene.state.select_unit("cao_cao")
var cao_cao: Dictionary = scene.state.get_unit("cao_cao")
var infantry: Dictionary = scene.state.get_unit("yellow_turban_2")
var archer: Dictionary = scene.state.get_unit("yellow_turban_3")
if cao_cao.is_empty() or infantry.is_empty() or archer.is_empty():
failures.append("counter badge variant smoke missing units")
scene.free()
return
infantry["pos"] = Vector2i(2, 6)
infantry["hp"] = 99
infantry["max_hp"] = 99
archer["pos"] = Vector2i(10, 8)
scene.hover_cell = Vector2i(2, 6)
var counter_badge := scene._target_preview_badge()
if str(counter_badge.get("kind", "")) != "counter" or not str(counter_badge.get("text", "")).contains(""):
failures.append("melee target should expose counter badge: %s" % str(counter_badge))
scene.basic_attack_targeting = true
scene._refresh_ranges()
var marker_entries := scene._attack_target_marker_entries()
if marker_entries.is_empty() or str(marker_entries[0].get("kind", "")) != "counter":
failures.append("attack target marker should carry counter kind: %s" % str(marker_entries))
elif str(marker_entries[0].get("panel", "")) != "hud_jade":
failures.append("attack target marker should carry generated panel metadata: %s" % str(marker_entries))
scene.basic_attack_targeting = false
infantry["pos"] = Vector2i(10, 8)
archer["pos"] = Vector2i(2, 6)
archer["hp"] = 99
archer["max_hp"] = 99
scene.hover_cell = Vector2i(2, 6)
var no_counter_badge := scene._target_preview_badge()
if str(no_counter_badge.get("kind", "")) != "damage" or str(no_counter_badge.get("text", "")).contains(""):
failures.append("adjacent archer target should not expose counter badge: %s" % str(no_counter_badge))
archer["pos"] = Vector2i(10, 8)
infantry["pos"] = Vector2i(2, 6)
infantry["hp"] = 99
infantry["max_hp"] = 99
infantry["atk"] = 999
cao_cao["hp"] = 1
scene.hover_cell = Vector2i(2, 6)
var danger_badge := scene._target_preview_badge()
if str(danger_badge.get("kind", "")) != "danger" or not str(danger_badge.get("text", "")).contains("반격!"):
failures.append("lethal counter should expose danger badge: %s" % str(danger_badge))
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(4, 1)) != "F":
failures.append("terrain helper should read forest cells")
if scene._terrain_key_at(Vector2i(6, 5)) != "T":
failures.append("terrain helper should read village cells")
if not scene._terrain_key_at(Vector2i(-1, 0)).is_empty():
failures.append("terrain helper should return empty outside the board")
if scene.state.get_terrain_name(Vector2i(6, 5)) != "마을" or scene.state.get_terrain_heal(Vector2i(6, 5)) != 6:
failures.append("village terrain should expose Korean name and healing")
if scene.state.get_terrain_heal(Vector2i(20, 1)) != 8:
failures.append("castle terrain should expose stronger healing")
scene._create_hud()
scene._update_forecast()
scene._update_forecast_panel_visibility()
if scene.forecast_panel == null or scene.forecast_panel.visible:
failures.append("idle forecast panel should stay hidden instead of showing empty tactical text")
scene.hover_cell = Vector2i(20, 1)
scene._update_cell_info()
if scene.cell_info_label == null or not scene.cell_info_label.text.begins_with("지형:") or not scene.cell_info_label.text.contains("목표 성채 장악") or not scene.cell_info_label.text.contains("회복 +8"):
failures.append("castle objective cell info should expose capture label and healing: %s" % scene.cell_info_label.text)
if scene.cell_info_label != null and (scene.cell_info_label.text.contains("") or scene.cell_info_label.text.contains("방+")):
failures.append("visible cell info should leave terrain numbers to the tooltip: %s" % scene.cell_info_label.text)
if scene.cell_info_label != null and not scene.cell_info_label.tooltip_text.contains("목표: 성채 장악"):
failures.append("castle objective cell info tooltip should explain the capture objective: %s" % scene.cell_info_label.tooltip_text)
if scene.cell_info_label != null and (not scene.cell_info_label.tooltip_text.contains("회복 +8/차례") or not scene.cell_info_label.tooltip_text.contains("차례 시작")):
failures.append("castle objective cell info tooltip should explain per-turn recovery timing: %s" % scene.cell_info_label.tooltip_text)
if scene.cell_info_label != null and scene.cell_info_label.tooltip_text.contains("21,2"):
failures.append("castle objective cell info tooltip should avoid raw coordinates: %s" % scene.cell_info_label.tooltip_text)
if scene.cell_info_panel == null or not scene.cell_info_panel.visible:
failures.append("cell info panel should be visible for a hovered battlefield cell")
if scene.cell_info_icon == null or str(scene.cell_info_icon.get_meta("icon_kind", "")) != "objective":
failures.append("castle objective cell info should switch the side chip to the objective icon")
if scene.cell_info_label.text.contains("20,2"):
failures.append("visible cell info should stay compact and leave coordinates to tooltip/badge: %s" % scene.cell_info_label.text)
scene.hover_cell = Vector2i(4, 2)
scene._update_cell_info()
if scene.cell_info_label == null or not scene.cell_info_label.text.contains("표식 북숲 보급고"):
failures.append("side event marker cell info should expose the supply label: %s" % scene.cell_info_label.text)
if scene.cell_info_icon == null or str(scene.cell_info_icon.get_meta("icon_kind", "")) != "item":
failures.append("supply marker cell info should switch the side chip to an item icon")
scene.hover_cell = Vector2i(4, 6)
scene._update_cell_info()
if scene.cell_info_label == null or not scene.cell_info_label.text.contains("표식 유인선"):
failures.append("tactical lure marker cell info should expose the marker label: %s" % scene.cell_info_label.text)
if scene.cell_info_icon == null or str(scene.cell_info_icon.get_meta("icon_kind", "")) != "tactic":
failures.append("tactical lure cell info should switch the side chip to a tactic icon")
if scene.state.get_event_marker_kind(Vector2i(4, 6)) != "tactic":
failures.append("tactical lure marker should expose tactic kind")
scene.hover_cell = Vector2i(-1, -1)
scene.battle_started = true
scene._update_cell_info()
if scene.cell_info_panel == null or not scene.cell_info_panel.visible:
failures.append("cell info panel should keep a compact observation prompt during active battle")
elif scene.cell_info_label == null or scene.cell_info_label.text != "전장 칸 관측 대기":
failures.append("idle cell info prompt should explain battlefield observation: %s" % ("" if scene.cell_info_label == null else scene.cell_info_label.text))
elif not scene.cell_info_label.tooltip_text.contains("지형") or not scene.cell_info_label.tooltip_text.contains("부대"):
failures.append("idle cell info prompt should keep detail in tooltip: %s" % scene.cell_info_label.tooltip_text)
elif scene.cell_info_icon == null or str(scene.cell_info_icon.get_meta("icon_kind", "")) != "terrain":
failures.append("idle cell info prompt should use terrain observation icon")
scene.state.select_unit("cao_cao")
var forecast_enemy: Dictionary = scene.state.get_unit("yellow_turban_1")
forecast_enemy["pos"] = Vector2i(1, 5)
scene.hover_cell = Vector2i(1, 5)
scene._refresh_ranges()
scene._update_forecast()
scene._update_forecast_panel_visibility()
if scene.forecast_panel == null or not scene.forecast_panel.visible:
failures.append("forecast panel should return when hovering a real target")
scene.cell_info_label.free()
scene.cell_info_label = null
var event_marker_rect: Rect2 = scene._event_marker_rect(scene._rect_for_cell(Vector2i(4, 2)))
if not scene._rect_for_cell(Vector2i(4, 2)).encloses(event_marker_rect):
failures.append("Side event marker should stay inside its tile: %s" % str(event_marker_rect))
if scene._event_marker_abbrev("북숲 보급고", "supply") != "":
failures.append("Northern woods marker should use a compact forest glyph")
if scene._event_marker_abbrev("마을 보급", "supply") != "":
failures.append("Village supply marker should use a compact village glyph")
if scene._event_marker_abbrev("", "supply") != "":
failures.append("Supply marker fallback should stay concise")
if scene._event_marker_abbrev("군자금", "gold") != "":
failures.append("Gold marker should use a compact gold glyph")
if scene._event_marker_abbrev("유인선", "tactic") != "":
failures.append("Tactic lure marker should use a compact lure glyph")
if scene._event_marker_color("tactic") == scene._event_marker_color("supply"):
failures.append("Tactic marker should be visually distinct from supply markers")
if scene._event_marker_badge_kind("마을 보급", "supply") != "supply":
failures.append("Supply event markers should use the generated supply badge")
if scene._event_marker_badge_kind("군자금", "gold") != "gold":
failures.append("Gold event markers should use the generated gold badge")
if scene._event_marker_badge_kind("유인선", "tactic") != "tactic":
failures.append("Tactic event markers should use the generated tactic badge")
if scene._event_marker_tile_marker_key("마을 보급", "supply") != "recover":
failures.append("Supply event markers should lay generated recovery tile art under the badge")
if scene._event_marker_tile_marker_key("유인선", "tactic") != "move":
failures.append("Tactic lure markers should lay generated movement tile art under the badge")
if scene._event_marker_tile_marker_key("군자금", "gold") != "objective":
failures.append("Gold markers should use a generated objective tile frame")
if scene._event_marker_tile_marker_key("", "") != "select":
failures.append("Unknown event markers should still use a generated tile frame")
if scene._event_marker_badge_kind("군자금", "") != "gold":
failures.append("Event marker labels should infer generated badge kind when kind is absent")
if scene._event_marker_badge_kind("", "") != "event":
failures.append("Unknown event markers should use the generated event badge")
if scene.OBJECTIVE_BASE_MARKER_ALPHA > 0.20 or scene.OBJECTIVE_BASE_FILL_SCALE > 0.18:
failures.append("default objective tile backing should stay quiet over high-resolution battle art")
if scene.EVENT_TILE_MARKER_ALPHA > 0.10:
failures.append("default event tile backing should stay below the readable badge: %.3f" % scene.EVENT_TILE_MARKER_ALPHA)
var recovery_marker_alpha := scene._recovery_tile_marker_modulate(Vector2i(20, 1)).a
if recovery_marker_alpha > 0.11:
failures.append("recovery tile backing should not cover castle/village art: %.3f" % recovery_marker_alpha)
if recovery_marker_alpha < 0.05:
failures.append("recovery tile backing should remain barely visible around the small badge: %.3f" % recovery_marker_alpha)
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")
var background_texture_modulate: Color = scene._terrain_texture_modulate(Vector2i(0, 0), "G", true)
var fallback_texture_modulate: Color = scene._terrain_texture_modulate(Vector2i(0, 0), "G", false)
if background_texture_modulate.a < 0.055:
failures.append("background terrain texture should still leave a visible terrain hint over high-res art: %.3f" % background_texture_modulate.a)
if background_texture_modulate.a >= fallback_texture_modulate.a:
failures.append("background terrain texture should stay lighter than fallback terrain texture")
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._map_grid_color(true).a > 0.025:
failures.append("high-resolution battle backgrounds should not be covered by visible square grid lines: %.3f" % scene._map_grid_color(true).a)
if scene._terrain_detail_alpha_scale(true) >= scene._terrain_detail_alpha_scale(false):
failures.append("background terrain details should be quieter than fallback terrain details")
if scene._terrain_edge_alpha_scale(true) >= scene._terrain_edge_alpha_scale(false):
failures.append("background terrain seams should be quieter than fallback terrain seams")
if not scene.has_method("_draw_tactical_tile_overlay") or not scene.has_method("_draw_tactical_corner_brackets"):
failures.append("battlefield tactical overlays should use generated tile art with corner brackets instead of full square UI borders")
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 scene._terrain_edge_priority("T") <= scene._terrain_edge_priority("F"):
failures.append("village should draw above forest/grass terrain blends")
var wasteland_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "D", true)
if wasteland_fill.a <= background_fill.a:
failures.append("wasteland fill should sit slightly above grass over background art")
if scene._load_terrain_texture("G") == null or scene._load_terrain_texture("T") == null or scene._load_terrain_texture("C") == null:
failures.append("battle map should load generated terrain texture assets")
var grass_texture_modulate := scene._terrain_texture_modulate(Vector2i(0, 0), "G", true)
var grass_fallback_modulate := scene._terrain_texture_modulate(Vector2i(0, 0), "G", false)
var castle_texture_modulate := scene._terrain_texture_modulate(Vector2i(20, 1), "C", true)
if grass_texture_modulate.a <= 0.0 or grass_texture_modulate.a >= 0.35:
failures.append("background terrain texture should stay subtle over high-res art: %s" % str(grass_texture_modulate))
if grass_fallback_modulate.a <= grass_texture_modulate.a:
failures.append("fallback terrain texture should be stronger without map background")
if castle_texture_modulate.a <= grass_texture_modulate.a:
failures.append("castle terrain texture should read stronger than plain grass")
if scene._terrain_edge_color("D", "G").a <= 0.0:
failures.append("wasteland edge blend should be visible")
if scene._terrain_edge_priority("D") <= scene._terrain_edge_priority("R"):
failures.append("wasteland should draw over roads at broken dirt edges")
if not scene.state.load_battle("res://data/scenarios/014_cangting_pursuit.json"):
failures.append("could not load Cangting Pursuit for wasteland presentation helpers")
elif scene._terrain_key_at(Vector2i(0, 0)) != "D" or scene.state.get_terrain_name(Vector2i(0, 0)) != "황무지":
failures.append("Cangting Pursuit should expose wasteland terrain at the dry outer edge")
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 scene._road_feature_key(Vector2i(2, 4)) != "road_ew":
failures.append("Sishui Gate road should use generated east-west road feature: %s" % scene._road_feature_key(Vector2i(2, 4)))
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")
scene.hover_cell = Vector2i(-1, -1)
if scene._should_draw_unit_nameplate(generic_enemy, false):
failures.append("generic enemy nameplates should stay hidden until hovered or selected")
var generic_enemy_pos: Vector2i = generic_enemy.get("pos", Vector2i(-1, -1))
scene.hover_cell = generic_enemy_pos
if not scene._should_draw_unit_nameplate(generic_enemy, false):
failures.append("generic enemy nameplate should appear on hover for inspection")
scene.hover_cell = Vector2i(-1, -1)
if not scene._should_draw_unit_nameplate(named_officer, false):
failures.append("named officers should keep visible nameplates for command clarity")
if not scene._should_draw_unit_nameplate(generic_enemy, true):
failures.append("selected generic enemy should expose a nameplate")
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")
var sprite_texture := scene._load_art_texture(str(named_officer.get("sprite", "")))
if sprite_texture == null:
failures.append("named officer high-res map sprite should load")
else:
var sprite_rect: Rect2 = scene._unit_map_sprite_rect(sprite_texture, scene._rect_for_cell(Vector2i(1, 6)), named_officer)
if sprite_rect.size.x < 24.0 or sprite_rect.size.y < 36.0:
failures.append("high-res map sprite should occupy a readable token area: %s" % str(sprite_rect))
if sprite_rect.position.x < scene._rect_for_cell(Vector2i(1, 6)).position.x or sprite_rect.end.x > scene._rect_for_cell(Vector2i(1, 6)).end.x:
failures.append("high-res map sprite should stay inside its tile horizontally: %s" % str(sprite_rect))
if scene.texture_filter != CanvasItem.TEXTURE_FILTER_LINEAR:
failures.append("battle scene should use linear texture filtering for smoother high-res map sprites")
scene.free()
func _check_objective_and_status_marker_helpers(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 objective/status markers")
scene.free()
return
if scene.state.get_objective_cells().is_empty():
failures.append("opening battle should expose objective cells for foreground markers")
var glyphs := {
"support": "",
"debuff": "",
"poison": "",
"seal": "",
"snare": "",
"disarm": ""
}
for marker_kind in glyphs.keys():
if scene._unit_status_marker_glyph(str(marker_kind)) != str(glyphs[marker_kind]):
failures.append("status marker glyph mismatch for %s" % str(marker_kind))
var unit := {
"status_effects": [
{"type": "stat_bonus", "stat": "def", "amount": 2, "remaining_phases": 2},
{"type": "stat_bonus", "stat": "def", "amount": -2, "remaining_phases": 2},
{"type": "damage_over_time", "status": "poison", "amount": 4, "remaining_phases": 2},
{"type": "action_lock", "status": "seal", "action": "skill", "remaining_phases": 2}
]
}
var marker_kinds := scene._unit_status_marker_kinds(unit)
for expected in ["support", "debuff", "poison", "seal"]:
if not marker_kinds.has(expected):
failures.append("status marker kinds should include %s: %s" % [expected, str(marker_kinds)])
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, 6), Vector2i(2, 6), "command_strike")
_check_attack_profile(failures, scene, "xiahou_dun", Vector2i(1, 7), Vector2i(2, 7), "cavalry_charge")
_check_attack_profile(failures, scene, "yellow_turban_1", Vector2i(20, 1), Vector2i(19, 1), "heavy_strike")
_check_attack_profile(failures, scene, "yellow_turban_2", Vector2i(12, 5), Vector2i(11, 5), "infantry_strike")
_check_attack_profile(failures, scene, "yellow_turban_3", Vector2i(17, 3), Vector2i(17, 1), "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, 7), Vector2i(2, 7), "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(17, 3), Vector2i(17, 1), "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, 6), Vector2i(3, 6), "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, 6), Vector2i(1, 6), "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")
if scene._movement_motion_sfx("cao_cao", Vector2i(1, 6), Vector2i(2, 6)) != "footstep":
failures.append("unit movement animation should use footstep SFX")
scene._on_unit_motion_requested("cao_cao", Vector2i(1, 6), Vector2i(2, 6))
var move_motion: Dictionary = scene.unit_motion_by_unit.get("cao_cao", {})
if str(move_motion.get("sfx", "")) != "footstep":
failures.append("stored movement motion should queue footstep SFX: %s" % str(move_motion))
if not bool(move_motion.get("sfx_played", false)):
failures.append("immediate movement motion should mark SFX as played: %s" % str(move_motion))
_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, 6), func(skill_state) -> void:
var enemy: Dictionary = skill_state.get_unit("yellow_turban_1")
enemy["pos"] = Vector2i(3, 6)
)
_check_skill_motion_signal(failures, "mend", "skill_heal", Vector2i(1, 6), 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, 6), 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 caster: Dictionary = state.get_unit("cao_cao")
var caster_pos: Vector2i = caster.get("pos", Vector2i(1, 6))
var expected := "cao_cao|%s|%d,%d|%d,%d" % [expected_kind, caster_pos.x, caster_pos.y, 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_campaign_entry_expectation(failures: Array[String], expectation: Dictionary) -> void:
var path := str(expectation.get("path", ""))
var label := str(expectation.get("label", path))
var state = BattleStateScript.new()
if not state.load_battle(path, {}, {}, {}, ["zhang_he"]):
failures.append("could not load %s data" % label)
return
_check_shop_items_unique(failures, state, "%s base" % label)
var conversations: Array = state.get_briefing().get("camp_conversations", [])
var expected_conversations: Array = expectation.get("conversations", [])
if conversations.size() != expected_conversations.size():
failures.append("%s briefing should expose exactly %d camp conversations" % [label, expected_conversations.size()])
for expected_variant in expected_conversations:
if typeof(expected_variant) != TYPE_ARRAY:
failures.append("%s conversation expectation should be an array" % label)
continue
var expected: Array = expected_variant
if expected.size() < 3:
failures.append("%s conversation expectation should include id, group, and officer id" % label)
continue
var expected_id := str(expected[0])
var expected_group := str(expected[1])
var expected_officer_id := str(expected[2])
_check_camp_conversation(
failures,
_find_camp_conversation(conversations, expected_id),
expected_id,
expected_group,
expected_officer_id
)
var merchant: Dictionary = state.get_shop_merchant()
if str(merchant.get("name", "")) != str(expectation.get("merchant", "")):
failures.append("%s shop merchant name should be present" % label)
var merchant_lines: Array = merchant.get("lines", [])
if merchant_lines.size() < 2:
failures.append("%s shop should expose merchant flavor lines" % label)
_check_shop_item_visibility(failures, state, "imperial_seal", false, "%s base" % label)
_check_shop_item_visibility(failures, state, "war_drum", false, "%s base" % label)
var imperial_state = BattleStateScript.new()
var imperial_flags: Dictionary = expectation.get("imperial_flag", {})
if not imperial_state.load_battle(path, {}, {}, imperial_flags, ["zhang_he"]):
failures.append("could not load %s imperial branch data" % label)
else:
_check_shop_items_unique(failures, imperial_state, "%s imperial branch" % label)
_check_shop_item_visibility(failures, imperial_state, "imperial_seal", true, "%s imperial branch" % label)
_check_shop_item_visibility(failures, imperial_state, "war_drum", false, "%s imperial branch" % label)
var drum_state = BattleStateScript.new()
var drum_flags: Dictionary = expectation.get("war_drum_flag", {})
if not drum_state.load_battle(path, {}, {}, drum_flags, ["zhang_he"]):
failures.append("could not load %s war drum branch data" % label)
else:
_check_shop_items_unique(failures, drum_state, "%s war drum branch" % label)
_check_shop_item_visibility(failures, drum_state, "war_drum", true, "%s war drum branch" % label)
_check_shop_item_visibility(failures, drum_state, "imperial_seal", false, "%s war drum branch" % label)
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_shop_items_unique(failures: Array[String], state, context: String) -> void:
var seen := {}
for item_id in state.get_shop_item_ids():
var key := str(item_id)
if seen.has(key):
failures.append("%s shop should not expose duplicate item id: %s" % [context, key])
seen[key] = true
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 := _load_smoke_image(path)
if image == null:
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_toolbar_icon_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty icon 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 icon: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable icon: %s" % [context, path])
return
if image.get_width() < 128 or image.get_height() < 128:
failures.append("%s icon should keep high-resolution source pixels: %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 icon should have transparent corners: %s" % [context, path])
return
var opaque_samples := 0
var transparent_samples := 0
var green_fringe_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
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
elif pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.88 and pixel.r < 0.20 and pixel.b < 0.20:
green_fringe_samples += 1
if transparent_samples < int(total_samples * 0.12):
failures.append("%s icon should retain transparent UI padding: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.18):
failures.append("%s icon should contain substantial artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s icon should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
func _check_panel_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty panel 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 panel texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable panel texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 512:
failures.append("%s should keep high-resolution source pixels: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.80 and pixel.r < 0.18 and pixel.b < 0.18:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.08):
failures.append("%s should retain transparent frame padding: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.36):
failures.append("%s should contain substantial generated frame artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.10:
failures.append("%s should contain visible material contrast: %s" % [context, path])
var warm_bias := _image_visible_warm_bias(image, 8)
if warm_bias > 0.12:
failures.append("%s should avoid a dominant yellow UI cast: %s (warm bias %.3f)" % [context, path, warm_bias])
func _check_button_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty button 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 button texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable button texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 192:
failures.append("%s should keep high-resolution source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if image.get_width() <= image.get_height():
failures.append("%s should be a wide button surface: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.80 and pixel.r < 0.18 and pixel.b < 0.18:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.08):
failures.append("%s should retain transparent button padding: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.45):
failures.append("%s should contain substantial generated button artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.08:
failures.append("%s should contain visible material contrast: %s" % [context, path])
var warm_bias := _image_visible_warm_bias(image, 8)
if warm_bias > 0.12:
failures.append("%s should avoid a dominant yellow UI cast: %s (warm bias %.3f)" % [context, path, warm_bias])
func _check_icon_button_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty icon button 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 icon button texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable icon button texture: %s" % [context, path])
return
if image.get_width() < 256 or image.get_height() < 256:
failures.append("%s should keep high-resolution compact source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if abs(image.get_width() - image.get_height()) > 4:
failures.append("%s should be a square compact icon surface: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.80 and pixel.r < 0.18 and pixel.b < 0.18:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.08):
failures.append("%s should retain transparent compact button padding: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.42):
failures.append("%s should contain substantial generated compact button artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.08:
failures.append("%s should contain visible material contrast: %s" % [context, path])
var warm_bias := _image_visible_warm_bias(image, 8)
if warm_bias > 0.12:
failures.append("%s should avoid a dominant yellow UI cast: %s (warm bias %.3f)" % [context, path, warm_bias])
func _image_visible_warm_bias(image: Image, sample_step: int) -> float:
var total_bias := 0.0
var visible_samples := 0
var step := maxi(1, sample_step)
for y in range(0, image.get_height(), step):
for x in range(0, image.get_width(), step):
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.05:
continue
total_bias += maxf(0.0, ((pixel.r + pixel.g) * 0.5) - pixel.b)
visible_samples += 1
if visible_samples <= 0:
return 0.0
return total_bias / float(visible_samples)
func _load_smoke_image(path: String) -> Image:
var bytes := FileAccess.get_file_as_bytes(path)
if bytes.is_empty():
return null
var image := Image.new()
var lower_path := path.to_lower()
var err := OK
if lower_path.ends_with(".png"):
err = image.load_png_from_buffer(bytes)
elif lower_path.ends_with(".jpg") or lower_path.ends_with(".jpeg"):
err = image.load_jpg_from_buffer(bytes)
elif lower_path.ends_with(".webp"):
err = image.load_webp_from_buffer(bytes)
else:
var disk_path := ProjectSettings.globalize_path(path) if path.begins_with("res://") or path.begins_with("user://") else path
err = image.load(disk_path)
if err != OK:
return null
return image
func _check_tile_marker_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty marker 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 tile marker texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable tile marker texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 512:
failures.append("%s should keep high-resolution source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if image.get_width() != image.get_height():
failures.append("%s should be a square tactical marker: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.80 and pixel.r < 0.18 and pixel.b < 0.18:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.50):
failures.append("%s should keep the map visible through transparent center space: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.16):
failures.append("%s should contain readable generated marker artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.08:
failures.append("%s should contain visible generated material contrast: %s" % [context, path])
func _check_class_icon_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty icon 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 class icon texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable class icon texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 512:
failures.append("%s should keep high-resolution source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if image.get_width() != image.get_height():
failures.append("%s should be square: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.g > 0.82 and pixel.r < 0.16 and pixel.b < 0.16:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.24):
failures.append("%s should keep transparent icon padding: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.24):
failures.append("%s should contain substantial generated class artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.08:
failures.append("%s should contain visible generated material contrast: %s" % [context, path])
func _check_map_badge_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty badge 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 map badge texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable map badge texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 512:
failures.append("%s should keep high-resolution source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if image.get_width() != image.get_height():
failures.append("%s should be square: %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 opaque_samples := 0
var transparent_samples := 0
var green_fringe_samples := 0
var luma_min := 999.0
var luma_max := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.80:
opaque_samples += 1
if pixel.a > 0.05 and pixel.g > 0.92 and pixel.r < 0.08 and pixel.b < 0.08:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
luma_min = minf(luma_min, luma)
luma_max = maxf(luma_max, luma)
if transparent_samples < int(total_samples * 0.44):
failures.append("%s should keep transparent padding for map readability: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.18):
failures.append("%s should contain readable generated badge artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if luma_max - luma_min < 0.08:
failures.append("%s should contain visible generated material contrast: %s" % [context, path])
func _check_terrain_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty texture 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 texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable texture: %s" % [context, path])
return
if image.get_width() < 128 or image.get_height() < 128:
failures.append("%s should keep high-resolution terrain source pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
var min_luma := 999.0
var max_luma := -999.0
for y in range(0, image.get_height(), 16):
for x in range(0, image.get_width(), 16):
var pixel := image.get_pixel(x, y)
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
min_luma = minf(min_luma, luma)
max_luma = maxf(max_luma, luma)
if max_luma - min_luma < 0.08:
failures.append("%s should contain visible generated terrain texture variation: %s" % [context, path])
func _check_terrain_feature_texture_path(failures: Array[String], path: String, context: String) -> void:
if path.is_empty():
failures.append("%s has an empty feature 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 terrain feature texture: %s" % [context, path])
return
var image := _load_smoke_image(path)
if image == null:
failures.append("%s references unreadable terrain feature texture: %s" % [context, path])
return
if image.get_width() < 512 or image.get_height() < 512:
failures.append("%s should keep high-resolution overlay pixels: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
if image.get_width() != image.get_height():
failures.append("%s should be square: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])
return
var transparent_samples := 0
var opaque_samples := 0
var green_fringe_samples := 0
var min_luma := 999.0
var max_luma := -999.0
var total_samples := 0
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
total_samples += 1
var pixel := image.get_pixel(x, y)
if pixel.a <= 0.01:
transparent_samples += 1
continue
if pixel.a >= 0.32:
opaque_samples += 1
if pixel.a > 0.05 and pixel.g > 0.92 and pixel.r < 0.08 and pixel.b < 0.08:
green_fringe_samples += 1
var luma := pixel.r * 0.299 + pixel.g * 0.587 + pixel.b * 0.114
min_luma = minf(min_luma, luma)
max_luma = maxf(max_luma, luma)
if transparent_samples < int(total_samples * 0.18):
failures.append("%s should keep transparent padding so the map art shows through: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples])
if opaque_samples < int(total_samples * 0.04):
failures.append("%s should contain readable generated feature artwork: %s (%d/%d sampled pixels)" % [context, path, opaque_samples, total_samples])
if green_fringe_samples > 0:
failures.append("%s should not keep chroma-key green pixels: %s (%d sampled)" % [context, path, green_fringe_samples])
if max_luma - min_luma < 0.06:
failures.append("%s should contain visible generated feature variation: %s" % [context, path])
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 := _load_smoke_image(path)
if image == null:
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()])