Files
heros/tools/smoke_chapter_one_polish.gd
2026-06-19 16:07:20 +09:00

509 lines
24 KiB
GDScript

extends SceneTree
const BattleStateScript := preload("res://scripts/core/battle_state.gd")
const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd")
const CHAPTER_ONE_SCENARIOS := [
"res://data/scenarios/001_yellow_turbans.json",
"res://data/scenarios/002_sishui_gate.json",
"res://data/scenarios/003_xingyang_ambush.json",
"res://data/scenarios/004_qingzhou_campaign.json",
"res://data/scenarios/005_puyang_raid.json",
"res://data/scenarios/006_dingtao_counterattack.json",
"res://data/scenarios/007_xian_emperor_escort.json",
"res://data/scenarios/008_wan_castle_escape.json",
"res://data/scenarios/009_xiapi_siege.json"
]
func _init() -> void:
var failures: Array[String] = []
_check_chapter_one_scenarios_have_visual_grounding(failures)
_check_edge_scroll_layout_contract(failures)
_check_readability_contract(failures)
_check_dialogue_localization_and_side(failures)
_check_event_dialogue_side_passthrough(failures)
_check_opening_battle_event_dialogue_structure(failures)
_check_sishui_gate_event_dialogue_structure(failures)
_check_xingyang_ambush_event_dialogue_structure(failures)
_check_opening_battle_story_beats(failures)
_check_sishui_gate_story_beats(failures)
_check_xingyang_ambush_story_beats(failures)
_check_pixel_unit_helpers(failures)
if failures.is_empty():
print("chapter one polish smoke ok")
quit(0)
return
for failure in failures:
push_error(failure)
quit(1)
func _check_chapter_one_scenarios_have_visual_grounding(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
for scenario_path in CHAPTER_ONE_SCENARIOS:
if not scene.state.load_battle(scenario_path):
failures.append("could not load chapter one scenario: %s" % scenario_path)
continue
var background_path: String = scene.state.get_map_background_path()
if background_path.is_empty():
failures.append("scenario is missing a map background: %s" % scenario_path)
continue
if not FileAccess.file_exists(background_path):
failures.append("scenario background does not exist: %s -> %s" % [scenario_path, background_path])
scene.free()
func _check_edge_scroll_layout_contract(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
if not scene.state.load_battle("res://data/scenarios/009_xiapi_siege.json"):
failures.append("could not load large chapter one map for edge scrolling")
scene.free()
return
var view_rect := scene._map_view_rect()
var board_size := scene._board_size()
if view_rect.size.x < 160.0 or view_rect.size.y < 160.0:
failures.append("map view rect should keep a usable minimum size")
if board_size.x <= view_rect.size.x and board_size.y <= view_rect.size.y:
failures.append("chapter one should include at least one map large enough to exercise edge scrolling")
scene._reset_board_scroll()
if scene.board_scroll_offset.x > 0.0 or scene.board_scroll_offset.y > 0.0:
failures.append("reset board scroll should never push the map past the top-left origin")
var far_offset := scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0))
var min_x := minf(0.0, view_rect.size.x - board_size.x)
var min_y := minf(0.0, view_rect.size.y - board_size.y)
if far_offset.x < min_x - 0.01 or far_offset.y < min_y - 0.01:
failures.append("edge scroll clamp should stay inside the visible map bounds")
if far_offset.x > 0.01 or far_offset.y > 0.01:
failures.append("edge scroll clamp should not create positive board offsets")
scene.board_scroll_offset = far_offset
var first_cell_screen := scene._rect_for_cell(Vector2i(0, 0)).position + Vector2(4.0, 4.0)
if scene._cell_from_screen(first_cell_screen) != Vector2i(0, 0):
failures.append("screen-to-cell mapping should stay stable after scrolling")
_check_edge_scroll_velocity_curve(scene, view_rect, failures)
scene.free()
func _check_edge_scroll_velocity_curve(scene, view_rect: Rect2, failures: Array[String]) -> void:
for method_name in [
"_edge_scroll_velocity_for_position",
"_capped_edge_scroll_velocity",
"_edge_scroll_axis_velocity",
"_edge_scroll_pressure",
"_edge_scroll_speed_for_pressure",
"_edge_scroll_next_offset"
]:
if not scene.has_method(method_name):
failures.append("missing edge-scroll helper: %s" % method_name)
var center_velocity: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.get_center(), view_rect)
if center_velocity != Vector2.ZERO:
failures.append("edge scroll should be idle away from map edges: %s" % str(center_velocity))
var near_right: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.end.x - 36.0, view_rect.get_center().y), view_rect)
var hard_right: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.end.x - 2.0, view_rect.get_center().y), view_rect)
if near_right.x >= 0.0 or hard_right.x >= 0.0:
failures.append("right-edge scroll should move the board left: near=%s hard=%s" % [str(near_right), str(hard_right)])
if absf(hard_right.x) <= absf(near_right.x):
failures.append("edge scroll should accelerate as the mouse reaches the outer edge")
var near_left: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.position.x + 36.0, view_rect.get_center().y), view_rect)
var hard_left: Vector2 = scene._edge_scroll_velocity_for_position(Vector2(view_rect.position.x + 2.0, view_rect.get_center().y), view_rect)
if near_left.x <= 0.0 or hard_left.x <= 0.0:
failures.append("left-edge scroll should move the board right: near=%s hard=%s" % [str(near_left), str(hard_left)])
if hard_left.x <= near_left.x:
failures.append("left-edge scroll should accelerate as the mouse reaches the outer edge")
var top_left: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position + Vector2(2.0, 2.0), view_rect)
if top_left.x <= 0.0 or top_left.y <= 0.0:
failures.append("corner edge scroll should support diagonal movement: %s" % str(top_left))
var max_edge_speed := float(scene._edge_scroll_speed_for_pressure(1.0))
if top_left.length() > max_edge_speed + 0.01:
failures.append("corner edge scroll should cap diagonal speed: %.2f > %.2f" % [top_left.length(), max_edge_speed])
var outside_velocity: Vector2 = scene._edge_scroll_velocity_for_position(view_rect.position - Vector2(4.0, 4.0), view_rect)
if outside_velocity != Vector2.ZERO:
failures.append("edge scroll should ignore mouse positions outside the map view: %s" % str(outside_velocity))
var hard_right_position := Vector2(view_rect.end.x - 2.0, view_rect.get_center().y)
var moved_right: Vector2 = scene._edge_scroll_next_offset(Vector2.ZERO, hard_right_position, view_rect, 0.25)
if moved_right.x >= 0.0:
failures.append("edge scroll next offset should apply delta movement toward the right edge: %s" % str(moved_right))
var hard_left_position := Vector2(view_rect.position.x + 2.0, view_rect.get_center().y)
var min_offset: Vector2 = scene._clamped_board_scroll_offset(Vector2(-9999.0, -9999.0))
var moved_left: Vector2 = scene._edge_scroll_next_offset(min_offset, hard_left_position, view_rect, 0.25)
if moved_left.x <= min_offset.x:
failures.append("edge scroll next offset should recover from the left clamp toward the map origin: %s -> %s" % [str(min_offset), str(moved_left)])
var clamped_at_origin: Vector2 = scene._edge_scroll_next_offset(Vector2.ZERO, hard_left_position, view_rect, 0.25)
if clamped_at_origin != Vector2.ZERO:
failures.append("edge scroll next offset should not exceed the origin clamp: %s" % str(clamped_at_origin))
var clamped_at_far_edge: Vector2 = scene._edge_scroll_next_offset(min_offset, hard_right_position, view_rect, 0.25)
if clamped_at_far_edge.x < min_offset.x - 0.01:
failures.append("edge scroll next offset should not exceed the far clamp: %s below %s" % [str(clamped_at_far_edge), str(min_offset)])
func _check_readability_contract(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
scene._create_hud()
if scene.dialogue_text_panel.custom_minimum_size.y < 160.0:
failures.append("dialogue text panel should be tall enough for multi-line story text")
if scene.dialogue_text_label.custom_minimum_size.y < 130.0:
failures.append("dialogue label should reserve enough height to avoid clipping")
if scene.dialogue_text_label.autowrap_mode != TextServer.AUTOWRAP_WORD_SMART:
failures.append("dialogue text should use smart Korean-friendly wrapping")
if scene.briefing_objective_panel.custom_minimum_size.y < 120.0:
failures.append("briefing objective panel should reserve enough height for victory and defeat text")
if scene.briefing_objective_toggle_button == null:
failures.append("briefing objective section should have a close/open button")
elif scene.briefing_objective_toggle_button.text != "닫기":
failures.append("briefing objective button should start with a clear close label")
scene.free()
func _check_dialogue_localization_and_side(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
var lines := scene._normalized_dialogue_lines([
{"speaker": "Cao Cao", "text": "전열을 세워라."},
{"speaker": "Lu Bu", "text": "조조를 짓밟아라."},
{"speaker": "Qingzhou Chief", "text": "마을 곡식을 내놓아라."},
{"speaker": "Camp Merchant", "text": "군막에 물자가 남았습니다."},
{"speaker": "Hua Xiong", "text": "사수관을 지켜라."},
{"speaker": "Xu Rong", "text": "동쪽 길목을 닫아라."}
])
if lines.size() != 6:
failures.append("dialogue normalization should keep all valid chapter one lines")
scene.free()
return
if lines[0].get("speaker", "") != "조조" or lines[0].get("side", "") != "left":
failures.append("Cao Cao dialogue should localize to Korean on the left")
if lines[1].get("speaker", "") != "여포" or lines[1].get("side", "") != "right":
failures.append("enemy officer dialogue should localize to Korean on the right")
if lines[2].get("speaker", "") != "청주 두령" or lines[2].get("side", "") != "right":
failures.append("chapter one named enemy dialogue should be localized and right-sided")
if lines[3].get("speaker", "") != "군막 상인" or lines[3].get("side", "") != "right":
failures.append("camp merchant dialogue should be localized and right-sided by default")
if lines[4].get("speaker", "") != "화웅" or lines[4].get("side", "") != "right":
failures.append("Sishui Gate enemy commander dialogue should localize to Korean on the right")
if lines[5].get("speaker", "") != "서영" or lines[5].get("side", "") != "right":
failures.append("Xingyang Ambush enemy commander dialogue should localize to Korean on the right")
scene.free()
func _check_event_dialogue_side_passthrough(failures: Array[String]) -> void:
var state = BattleStateScript.new()
var scene = BattleSceneScript.new()
var raw_lines := state._dialogue_lines_from_action({
"type": "dialogue",
"lines": [
{"speaker": "Zhang Mancheng", "text": "성채를 지켜라."},
{"speaker": "Cao Cao", "text": "길을 열어라."}
]
})
if raw_lines.size() != 2:
failures.append("battle events should preserve valid dialogue lines")
scene.free()
return
if str((raw_lines[0] as Dictionary).get("side", "")) != "":
failures.append("battle state should not force event dialogue without an explicit side to the left")
var normalized := scene._normalized_dialogue_lines(raw_lines)
if normalized.size() != 2:
failures.append("scene should normalize battle event dialogue lines")
elif normalized[0].get("speaker", "") != "장만성" or normalized[0].get("side", "") != "right":
failures.append("event dialogue should localize Zhang Mancheng and infer right-side layout")
scene.free()
func _check_opening_battle_event_dialogue_structure(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for event dialogue structure")
return
for event_id in [
"opening_dialogue",
"northern_woods_cache",
"turn_2_warning",
"turn_4_eastern_reserve",
"turn_7_southern_raiders",
"turn_9_western_reserve",
"zhang_mancheng_falls"
]:
var event := _event_by_id(state, event_id)
if event.is_empty():
failures.append("opening battle missing event: %s" % event_id)
continue
if not _event_has_dialogue_action(event):
failures.append("opening battle event should include contextual dialogue: %s" % event_id)
var boss_event := _event_by_id(state, "zhang_mancheng_falls")
if not boss_event.is_empty():
var when: Dictionary = boss_event.get("when", {})
var unit_ids: Array = when.get("unit_ids", [])
if not unit_ids.has("yellow_turban_1"):
failures.append("Zhang Mancheng defeat event should watch yellow_turban_1")
func _check_sishui_gate_event_dialogue_structure(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/002_sishui_gate.json"):
failures.append("could not load Sishui Gate for event dialogue structure")
return
for event_id in [
"opening_dialogue",
"turn_2_warning",
"turn_5_ridge_patrol",
"turn_8_gate_reserve",
"hua_xiong_vanguard_falls"
]:
var event := _event_by_id(state, event_id)
if event.is_empty():
failures.append("Sishui Gate missing event: %s" % event_id)
continue
if not _event_has_dialogue_action(event):
failures.append("Sishui Gate event should include contextual dialogue: %s" % event_id)
var vanguard_event := _event_by_id(state, "hua_xiong_vanguard_falls")
if not vanguard_event.is_empty():
var when: Dictionary = vanguard_event.get("when", {})
var unit_ids: Array = when.get("unit_ids", [])
if not unit_ids.has("hua_xiong_vanguard"):
failures.append("Hua Xiong vanguard defeat event should watch hua_xiong_vanguard")
if not _event_has_set_objective_action(vanguard_event):
failures.append("Hua Xiong vanguard defeat event should clarify the next objective")
func _check_xingyang_ambush_event_dialogue_structure(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}):
failures.append("could not load Xingyang Ambush for event dialogue structure")
return
for event_id in [
"opening_dialogue_pursuit",
"opening_dialogue_regroup",
"turn_3_ambush_pursuit",
"turn_3_ambush_regroup",
"turn_5_pressure",
"turn_5_regroup_pressure",
"turn_6_slope_archers",
"turn_9_forest_blockade",
"xu_rong_rearguard_falls"
]:
var event := _event_by_id(state, event_id)
if event.is_empty():
failures.append("Xingyang Ambush missing event: %s" % event_id)
continue
if not _event_has_dialogue_action(event):
failures.append("Xingyang Ambush event should include contextual dialogue: %s" % event_id)
var rearguard_event := _event_by_id(state, "xu_rong_rearguard_falls")
if not rearguard_event.is_empty():
var when: Dictionary = rearguard_event.get("when", {})
var unit_ids: Array = when.get("unit_ids", [])
if not unit_ids.has("xu_rong_rearguard"):
failures.append("Xu Rong rearguard defeat event should watch xu_rong_rearguard")
if not _event_has_set_objective_action(rearguard_event):
failures.append("Xu Rong rearguard defeat event should clarify the next objective")
var blockade_event := _event_by_id(state, "turn_9_forest_blockade")
if not blockade_event.is_empty() and not _event_has_set_objective_action(blockade_event):
failures.append("Xingyang Ambush blockade event should clarify the final objective")
func _check_opening_battle_story_beats(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"):
failures.append("could not load opening battle for story beat checks")
return
var dialogue_batches := []
state.dialogue_requested.connect(func(lines: Array) -> void:
dialogue_batches.append(lines)
)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 4)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 7)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9)
if dialogue_batches.size() < 4:
failures.append("opening battle should add dialogue beats for each major reinforcement phase")
var victory_text := str(state.objectives.get("victory", ""))
if not victory_text.contains("마지막 황건대"):
failures.append("opening battle turn 9 event should update the victory objective after the final reserve arrives")
if not state._condition_gate_open({"after_event": "turn_9_western_reserve"}):
failures.append("opening battle final objective gate should open after the turn 9 story beat")
func _check_sishui_gate_story_beats(failures: Array[String]) -> void:
var state = BattleStateScript.new()
if not state.load_battle("res://data/scenarios/002_sishui_gate.json"):
failures.append("could not load Sishui Gate for story beat checks")
return
var dialogue_batches := []
state.dialogue_requested.connect(func(lines: Array) -> void:
dialogue_batches.append(lines)
)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 2)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 5)
state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 8)
if dialogue_batches.size() < 3:
failures.append("Sishui Gate should add dialogue beats for each major reserve phase")
var victory_text := str(state.objectives.get("victory", ""))
if not victory_text.contains("마지막 예비대"):
failures.append("Sishui Gate turn 8 event should update the victory objective after the final reserve arrives")
if not state._condition_gate_open({"after_event": "turn_8_gate_reserve"}):
failures.append("Sishui Gate final objective gate should open after the turn 8 story beat")
func _check_xingyang_ambush_story_beats(failures: Array[String]) -> void:
var pursuit_state = BattleStateScript.new()
if not pursuit_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"pursued_dong_zhuo": true}):
failures.append("could not load Xingyang Ambush pursuit branch for story beat checks")
return
var pursuit_dialogue_batches := []
pursuit_state.dialogue_requested.connect(func(lines: Array) -> void:
pursuit_dialogue_batches.append(lines)
)
pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3)
pursuit_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5)
pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6)
pursuit_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9)
if pursuit_dialogue_batches.size() < 4:
failures.append("Xingyang Ambush pursuit branch should add dialogue beats for each major ambush phase")
var pursuit_victory_text := str(pursuit_state.objectives.get("victory", ""))
if not pursuit_victory_text.contains("마지막 봉쇄병"):
failures.append("Xingyang Ambush turn 9 event should update the victory objective after the blockade arrives")
if not pursuit_state._condition_gate_open({"after_event": "turn_9_forest_blockade"}):
failures.append("Xingyang Ambush final objective gate should open after the turn 9 blockade")
var xu_rong := pursuit_state.get_unit("xu_rong_rearguard")
if xu_rong.is_empty():
failures.append("Xingyang Ambush should include Xu Rong rearguard unit for defeat story beat")
else:
xu_rong["alive"] = false
pursuit_state._run_events("unit_defeated", BattleStateScript.TEAM_ENEMY, 9, xu_rong)
if pursuit_dialogue_batches.size() < 5:
failures.append("Xingyang Ambush should show dialogue when Xu Rong rearguard falls")
var rearguard_objective_text := str(pursuit_state.objectives.get("victory", ""))
if not rearguard_objective_text.contains("동쪽 길목") or not rearguard_objective_text.contains("봉쇄병"):
failures.append("Xingyang Ambush Xu Rong defeat objective should still point to the road and blockade")
var regroup_state = BattleStateScript.new()
if not regroup_state.load_battle("res://data/scenarios/003_xingyang_ambush.json", {}, {}, {"regrouped_after_sishui": true}):
failures.append("could not load Xingyang Ambush regroup branch for story beat checks")
return
var regroup_dialogue_batches := []
regroup_state.dialogue_requested.connect(func(lines: Array) -> void:
regroup_dialogue_batches.append(lines)
)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 3)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_PLAYER, 5)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 6)
regroup_state._run_events("turn_start", BattleStateScript.TEAM_ENEMY, 9)
if regroup_dialogue_batches.size() < 4:
failures.append("Xingyang Ambush regroup branch should keep dialogue beats for scouted ambush pressure")
func _event_by_id(state, event_id: String) -> Dictionary:
for event in state.battle_events:
if typeof(event) == TYPE_DICTIONARY and str(event.get("id", "")) == event_id:
return event
return {}
func _event_has_dialogue_action(event: Dictionary) -> bool:
var actions: Array = event.get("actions", [])
for action in actions:
if typeof(action) == TYPE_DICTIONARY and str(action.get("type", "")) == "dialogue":
return true
return false
func _event_has_set_objective_action(event: Dictionary) -> bool:
var actions: Array = event.get("actions", [])
for action in actions:
if typeof(action) == TYPE_DICTIONARY and str(action.get("type", "")) == "set_objective":
return true
return false
func _check_pixel_unit_helpers(failures: Array[String]) -> void:
var scene = BattleSceneScript.new()
for method_name in [
"_draw_pixel_unit_sprite",
"_pixel_unit_sprite_profile",
"_unit_idle_bob",
"_unit_idle_step",
"_draw_pixel_shield",
"_draw_pixel_spear",
"_draw_pixel_bow",
"_draw_pixel_banner",
"_draw_pixel_staff",
"_draw_pixel_heavy_blade",
"_draw_pixel_infantry",
"_draw_pixel_archer",
"_draw_pixel_cavalry",
"_draw_pixel_strategist",
"_draw_pixel_heavy"
]:
if not scene.has_method(method_name):
failures.append("missing pixel-map unit helper: %s" % method_name)
_check_pixel_unit_profiles(scene, failures)
scene.free()
func _check_pixel_unit_profiles(scene, failures: Array[String]) -> void:
var profiles := {
"infantry": scene._pixel_unit_sprite_profile({"class_id": "infantry"}),
"ranged": scene._pixel_unit_sprite_profile({"class_id": "archer"}),
"cavalry": scene._pixel_unit_sprite_profile({"class_id": "cavalry"}),
"tactic": scene._pixel_unit_sprite_profile({"class_id": "strategist"}),
"heavy": scene._pixel_unit_sprite_profile({"class_id": "bandit"})
}
for family in profiles.keys():
var profile: Dictionary = profiles[family]
if str(profile.get("family", "")) != str(family):
failures.append("pixel unit profile should map %s class to %s family: %s" % [family, family, str(profile)])
if absf(float(profile.get("pixel", 0.0)) - 3.0) > 0.001:
failures.append("pixel unit profile should use a stable 3px grid for %s" % family)
if float(profile.get("min_x", -1.0)) < 0.0 or float(profile.get("max_x", 99.0)) > 16.0:
failures.append("pixel unit profile should stay inside tile width for %s: %s" % [family, str(profile)])
var bottom_px := 5.0 + 1.0 + float(profile.get("max_y", 99.0)) * float(profile.get("pixel", 3.0))
if bottom_px >= 56.0:
failures.append("pixel unit profile should stay above HP bar for %s: %.1f" % [family, bottom_px])
if float((profiles["cavalry"] as Dictionary).get("max_x", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_x", 0.0)):
failures.append("cavalry pixel profile should read wider than infantry")
if float((profiles["heavy"] as Dictionary).get("max_x", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_x", 0.0)):
failures.append("heavy pixel profile should read wider than infantry")
if float((profiles["tactic"] as Dictionary).get("max_y", 0.0)) <= float((profiles["infantry"] as Dictionary).get("max_y", 0.0)):
failures.append("tactic pixel profile should read taller or more robed than infantry")
var seen_weapons := {}
var seen_idles := {}
for family in profiles.keys():
var profile: Dictionary = profiles[family]
var weapon := str(profile.get("weapon", ""))
var idle := str(profile.get("idle", ""))
if weapon.is_empty() or seen_weapons.has(weapon):
failures.append("pixel unit profile should expose a distinct weapon silhouette for %s" % family)
seen_weapons[weapon] = true
if idle.is_empty() or seen_idles.has(idle):
failures.append("pixel unit profile should expose a distinct idle profile for %s" % family)
seen_idles[idle] = true
if str((profiles["ranged"] as Dictionary).get("weapon", "")) != "bow":
failures.append("ranged pixel profile should keep an explicit bow silhouette")
if str((profiles["infantry"] as Dictionary).get("weapon", "")) != "shield_spear":
failures.append("infantry pixel profile should keep shield and spear silhouette")