extends SceneTree const BattleStateScript := preload("res://scripts/core/battle_state.gd") const BattleSceneScript := preload("res://scripts/scenes/battle_scene.gd") func _init() -> void: var failures: Array[String] = [] _check_battle_visual_data(failures) _check_scene_texture_loading(failures) _check_hud_focus_text(failures) _check_terrain_and_unit_presentation(failures) _check_attack_motion_profiles(failures) _check_attack_motion_signal(failures) if failures.is_empty(): print("visual assets smoke ok") quit(0) return for failure in failures: push_error(failure) quit(1) func _check_battle_visual_data(failures: Array[String]) -> void: var state = BattleStateScript.new() if not state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load opening battle") return var briefing := state.get_briefing() if (briefing.get("camp_dialogue", []) as Array).is_empty(): failures.append("001 briefing should expose camp dialogue") var conversations: Array = briefing.get("camp_conversations", []) if conversations.size() < 3: failures.append("001 briefing should expose at least three camp conversations") else: _check_camp_conversation(failures, conversations[0], "cao_cao_strategy", "officer", "cao_cao") _check_camp_conversation(failures, conversations[1], "xiahou_dun_vanguard", "officer", "xiahou_dun") _check_camp_conversation(failures, conversations[2], "northern_woods_cache", "topic", "") _check_camp_conversation_effect(failures, conversations[2], "bean", 1) var merchant := state.get_shop_merchant() if merchant.is_empty() or (merchant.get("lines", []) as Array).is_empty(): failures.append("001 shop should expose merchant lines") _check_image_path(failures, state.get_map_background_path(), "001 map background") 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) 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) for item_id in ["bronze_sword", "training_spear", "short_bow", "hand_axe", "iron_armor", "panacea"]: var item := state.get_item_def(item_id) if item.is_empty(): failures.append("missing expected item: %s" % item_id) continue _check_image_path(failures, str(item.get("icon", "")), "item %s icon" % item_id) func _check_scene_texture_loading(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load battle scene state") scene.free() return if scene._current_battle_background_texture() == null: failures.append("battle scene should load a battlefield background texture") var archer: Dictionary = scene.state.get_unit("yellow_turban_3") if scene._load_art_texture(str(archer.get("sprite", ""))) == null: failures.append("battle scene should load archer sprite texture") var panacea: Dictionary = scene.state.get_item_def("panacea") if scene._load_art_texture(str(panacea.get("icon", ""))) == null: failures.append("battle scene should load panacea icon texture") var conversations: Array = scene.state.get_briefing().get("camp_conversations", []) if not conversations.is_empty(): var first_lines: Array = (conversations[0] as Dictionary).get("lines", []) if not first_lines.is_empty(): var first_line: Dictionary = first_lines[0] if scene._load_art_texture(str(first_line.get("portrait", ""))) == null: failures.append("battle scene should load camp conversation speaker portrait") var scene_conversations: Array = scene._camp_conversation_entries() if scene_conversations.size() < 3: failures.append("battle scene should expose camp conversation entries") var strategy := scene._camp_conversation_by_id("cao_cao_strategy") if strategy.is_empty(): failures.append("battle scene should find Cao Cao camp conversation by id") elif not scene._format_camp_conversation_button_text(strategy).contains("Cao Cao"): failures.append("camp conversation button text should include label/speaker") var cache := scene._camp_conversation_by_id("northern_woods_cache") if cache.is_empty(): failures.append("battle scene should find cache camp conversation by id") elif not scene._format_camp_conversation_button_text(cache).contains("Supply Bean"): failures.append("camp conversation button text should preview supply effect") scene.free() func _check_hud_focus_text(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load battle scene state for HUD focus text") scene.free() return var cao_cao: Dictionary = scene.state.get_unit("cao_cao") var cao_text := scene._format_unit_focus_text(cao_cao, "Selected") if not cao_text.contains("Command tactics"): failures.append("Cao Cao HUD focus should describe command role: %s" % cao_text) if not cao_text.contains("Tile 2,4"): failures.append("Cao Cao HUD focus should include current tile: %s" % cao_text) if not cao_text.contains("Cost 1"): failures.append("Cao Cao HUD focus should include terrain move cost: %s" % cao_text) if not cao_text.contains("DEF +0"): failures.append("Cao Cao HUD focus should include terrain defense: %s" % cao_text) var xiahou_dun: Dictionary = scene.state.get_unit("xiahou_dun") xiahou_dun["pos"] = Vector2i(3, 1) var forest_text := scene._unit_current_terrain_text(xiahou_dun) if not forest_text.contains("Forest") or not forest_text.contains("Cost 3"): failures.append("Cavalry movement should use mounted move_type cost on forest: %s" % forest_text) var archer: Dictionary = scene.state.get_unit("yellow_turban_3") var archer_text := scene._format_unit_focus_text(archer, "Hover") if not archer_text.contains("Ranged pressure"): failures.append("Archer HUD focus should describe ranged role: %s" % archer_text) if not scene._unit_current_terrain_text(archer).contains("Plain"): failures.append("Archer terrain text should reflect its current plain tile") scene.free() func _check_terrain_and_unit_presentation(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load battle scene state for presentation helpers") scene.free() return if scene._terrain_key_at(Vector2i(3, 1)) != "F": failures.append("terrain helper should read forest cells") if not scene._terrain_key_at(Vector2i(-1, 0)).is_empty(): failures.append("terrain helper should return empty outside the board") var background_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", true) var fallback_fill: Color = scene._terrain_fill_color(Vector2i(0, 0), "G", false) if background_fill.a >= 0.20: failures.append("background terrain fill should stay translucent over high-res art") if fallback_fill.a <= background_fill.a: failures.append("fallback terrain fill should be stronger than background fill") if scene._map_grid_color(true).a >= scene._map_grid_color(false).a: failures.append("background grid should be lighter than fallback grid") if scene._terrain_edge_color("F", "G").a <= 0.0: failures.append("forest edge blend should be visible") if not scene._should_draw_terrain_edge("F", "G", Vector2i(1, 0)): failures.append("forest should own forest/plain edge drawing") if scene._should_draw_terrain_edge("G", "F", Vector2i(-1, 0)): failures.append("plain should not redraw forest-owned edge") if scene._should_draw_terrain_edge("F", "F", Vector2i(1, 0)): failures.append("matching terrain should not draw edge blends") if scene._terrain_edge_width("W", "G") <= scene._terrain_edge_width("G", "F"): failures.append("water shoreline edge should be wider than grass blend") if not scene.state.load_battle("res://data/scenarios/002_sishui_gate.json"): failures.append("could not load Sishui Gate for road presentation helpers") else: var road_flags: Dictionary = scene._terrain_connection_flags(Vector2i(2, 4), "R") if not bool(road_flags.get("west", false)) or not bool(road_flags.get("east", false)): failures.append("Sishui Gate road should connect west/east at 3,5: %s" % str(road_flags)) if bool(road_flags.get("north", false)) or bool(road_flags.get("south", false)): failures.append("Sishui Gate road should not connect north/south at 3,5: %s" % str(road_flags)) if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not reload opening battle for unit presentation helpers") scene.free() return var generic_enemy: Dictionary = scene.state.get_unit("yellow_turban_2") var named_officer: Dictionary = scene.state.get_unit("cao_cao") if not scene._is_generic_enemy_unit(generic_enemy): failures.append("generic enemy unit should be marked as generic enemy") if scene._is_generic_enemy_unit(named_officer): failures.append("named officer should not be marked as generic enemy") if scene._is_generic_enemy_unit({"team": "enemy", "officer_id": "named_enemy"}): failures.append("enemy with officer_id should not be marked as generic") if scene._is_generic_enemy_unit({"team": "player", "officer_id": ""}): failures.append("player unit should not be marked as generic enemy") var enemy_modulate: Color = scene._unit_sprite_modulate(generic_enemy, false) if enemy_modulate.r < 0.99 or enemy_modulate.g < 0.99 or enemy_modulate.b < 0.99 or enemy_modulate.a < 0.97: failures.append("transparent enemy class sprite should render near its original colors") var fallback_enemy_modulate: Color = scene._unit_sprite_modulate({"team": "enemy", "officer_id": ""}, false) if fallback_enemy_modulate == Color.WHITE or fallback_enemy_modulate.a >= 1.0: failures.append("generic enemy fallback sprite should still receive a distinct semi-real tint") var acted_modulate: Color = scene._unit_sprite_modulate(generic_enemy, true) if acted_modulate.a >= enemy_modulate.a: failures.append("acted generic enemy sprite should still dim below active tint") scene.free() func _check_attack_motion_profiles(failures: Array[String]) -> void: var scene = BattleSceneScript.new() if not scene.state.load_battle("res://data/scenarios/001_yellow_turbans.json"): failures.append("could not load battle scene state for attack profiles") scene.free() return _check_attack_profile(failures, scene, "cao_cao", Vector2i(1, 3), Vector2i(2, 3), "command_strike") _check_attack_profile(failures, scene, "xiahou_dun", Vector2i(1, 4), Vector2i(2, 4), "cavalry_charge") _check_attack_profile(failures, scene, "yellow_turban_1", Vector2i(7, 2), Vector2i(6, 2), "heavy_strike") _check_attack_profile(failures, scene, "yellow_turban_2", Vector2i(8, 5), Vector2i(7, 5), "infantry_strike") _check_attack_profile(failures, scene, "yellow_turban_3", Vector2i(7, 6), Vector2i(7, 4), "arrow") _check_synthetic_attack_profile(failures, scene, "elite_cavalry", "cavalry_charge") _check_synthetic_attack_profile(failures, scene, "guard_captain", "infantry_strike") _check_synthetic_attack_profile(failures, scene, "marksman", "arrow", 2) _check_synthetic_attack_profile(failures, scene, "military_advisor", "command_strike") _check_synthetic_attack_profile(failures, scene, "commander", "command_strike") _check_synthetic_attack_profile(failures, scene, "champion", "heavy_strike") _check_synthetic_attack_profile(failures, scene, "bandit", "heavy_strike") scene._on_unit_action_motion_requested("xiahou_dun", Vector2i(1, 4), Vector2i(2, 4), "attack") _check_stored_attack_motion(failures, scene, "xiahou_dun", "cavalry_charge", "melee", "attack") scene._on_unit_action_motion_requested("yellow_turban_3", Vector2i(7, 6), Vector2i(7, 4), "attack") _check_stored_attack_motion(failures, scene, "yellow_turban_3", "arrow", "ranged", "attack") 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_lunge_scale("arrow") >= scene._action_motion_lunge_scale("slash"): failures.append("arrow animation should keep the archer mostly planted") 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") 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_stored_attack_motion(failures: Array[String], scene, unit_id: String, expected_profile: String, expected_style: String, expected_kind: 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", "")) 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 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_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)) func _check_camp_conversation_effect(failures: Array[String], conversation: Dictionary, expected_item_id: String, expected_count: int) -> void: var effects: Array = conversation.get("effects", []) if effects.is_empty(): failures.append("camp conversation %s should expose effects" % str(conversation.get("id", ""))) return var effect: Dictionary = effects[0] if str(effect.get("type", "")) != "grant_item": failures.append("camp conversation effect should be grant_item: %s" % str(effect)) if str(effect.get("item_id", "")) != expected_item_id: failures.append("camp conversation effect item mismatch: %s" % str(effect)) if int(effect.get("count", 0)) != expected_count: failures.append("camp conversation effect count mismatch: %s" % str(effect)) func _check_camp_conversation(failures: Array[String], conversation: Dictionary, expected_id: String, expected_group: String, expected_officer_id: String) -> void: if str(conversation.get("id", "")) != expected_id: failures.append("camp conversation id mismatch: expected %s got %s" % [expected_id, str(conversation.get("id", ""))]) if str(conversation.get("group", "")) != expected_group: failures.append("camp conversation group mismatch for %s" % expected_id) if str(conversation.get("officer_id", "")) != expected_officer_id: failures.append("camp conversation officer mismatch for %s" % expected_id) if str(conversation.get("label", "")).is_empty(): failures.append("camp conversation label missing for %s" % expected_id) var lines: Array = conversation.get("lines", []) if lines.is_empty(): failures.append("camp conversation lines missing for %s" % expected_id) return var first_line: Dictionary = lines[0] if str(first_line.get("text", "")).is_empty(): failures.append("camp conversation first text missing for %s" % expected_id) if expected_group == "officer" and str(first_line.get("portrait", "")).is_empty(): failures.append("camp conversation officer portrait missing for %s" % expected_id) func _check_unit_sprite_resolution(failures: Array[String], state, unit_id: String, expected_sprite: String, expected_enemy_sprite: bool) -> void: var unit: Dictionary = state.get_unit(unit_id) if unit.is_empty(): failures.append("missing unit for sprite resolution: %s" % unit_id) return var sprite := str(unit.get("sprite", "")) if sprite != expected_sprite: failures.append("%s sprite mismatch: expected %s got %s" % [unit_id, expected_sprite, sprite]) if bool(unit.get("uses_enemy_sprite", false)) != expected_enemy_sprite: failures.append("%s enemy sprite flag mismatch: expected %s got %s" % [unit_id, str(expected_enemy_sprite), str(unit.get("uses_enemy_sprite", false))]) _check_image_path(failures, sprite, "unit %s resolved sprite" % unit_id) func _check_alpha_cutout_path(failures: Array[String], path: String, context: String) -> void: if path.is_empty(): failures.append("%s has an empty image path" % context) return if not path.begins_with("res://"): failures.append("%s must use a res:// path: %s" % [context, path]) return if not FileAccess.file_exists(path): failures.append("%s references missing image: %s" % [context, path]) return var image := Image.new() var err := image.load(path) if err != OK: failures.append("%s references unreadable image: %s" % [context, path]) return if image.get_width() < 512 or image.get_height() < 512: failures.append("%s image should be at least 512x512: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()]) return for corner in [Vector2i(0, 0), Vector2i(image.get_width() - 1, 0), Vector2i(0, image.get_height() - 1), Vector2i(image.get_width() - 1, image.get_height() - 1)]: if image.get_pixelv(corner).a > 0.01: failures.append("%s should have transparent corners: %s" % [context, path]) return var transparent_samples := 0 var total_samples := 0 for y in range(0, image.get_height(), 4): for x in range(0, image.get_width(), 4): total_samples += 1 if image.get_pixel(x, y).a <= 0.01: transparent_samples += 1 if transparent_samples < int(total_samples * 0.20): failures.append("%s should have substantial transparent background: %s (%d/%d sampled pixels)" % [context, path, transparent_samples, total_samples]) func _check_image_path(failures: Array[String], path: String, context: String) -> void: if path.is_empty(): failures.append("%s has an empty image path" % context) return if not path.begins_with("res://"): failures.append("%s must use a res:// path: %s" % [context, path]) return if not FileAccess.file_exists(path): failures.append("%s references missing image: %s" % [context, path]) return var image := Image.new() var err := image.load(path) if err != OK: failures.append("%s references unreadable image: %s" % [context, path]) return if image.get_width() < 512 or image.get_height() < 512: failures.append("%s image should be at least 512x512: %s (%dx%d)" % [context, path, image.get_width(), image.get_height()])