diff --git a/runtime/tests/battle/battle_actor_identity_gate_test.gd b/runtime/tests/battle/battle_actor_identity_gate_test.gd new file mode 100644 index 00000000..b1f4bada --- /dev/null +++ b/runtime/tests/battle/battle_actor_identity_gate_test.gd @@ -0,0 +1,178 @@ +extends SceneTree + +## M80-I46 narrow harness gate. +## +## This is intentionally a consistency gate, not a per-Recruit art generator: +## same-profession portrait reuse is reported as a policy warning, while a +## cross-profession collision or duplicate Recruit identity is a failure. + +const InitialCompanyArt = preload("res://entry/initial_company_art.gd") +const EntryShellScript = preload("res://entry/entry_shell.gd") +const KnockoutReviewPageViewScript = preload("res://entry/views/knockout_review_page_view.gd") + +var failures: Array[String] = [] +var warnings: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var recruits := _fixture_recruits() + var identity_summary := _check_recruit_identity(recruits) + var surface_summary := _check_surface_sources(recruits) + var texture_summary := _check_texture_policy(recruits) + if not failures.is_empty(): + for failure in failures: + push_error("M80-I46 identity gate: %s" % failure) + quit(1) + return + + print("ABG_M80_I46_IDENTITY_GATE_OK recruits=%d unique_recruit_ids=%d recipe_signatures=%d surfaces=%s same_profession_shared=%d cross_profession_collisions=%d family=%s policy=same_profession_warn_only warnings=%d" % [ + recruits.size(), + int(identity_summary.get("unique_recruit_ids", 0)), + int(identity_summary.get("recipe_signatures", 0)), + String(surface_summary.get("surfaces", "")), + int(texture_summary.get("same_profession_shared", 0)), + int(texture_summary.get("cross_profession_collisions", 0)), + InitialCompanyArt.FAMILY, + warnings.size(), + ]) + quit(0) + + +func _check_recruit_identity(recruits: Array) -> Dictionary: + var seen_ids := {} + var seen_recipes := {} + for recruit in recruits: + var recruit_id := String(recruit.get("recruit_instance_id", recruit.get("recruit_id", ""))) + _expect(not recruit_id.is_empty(), "fixture Recruit has a recruit_instance_id") + _expect(not seen_ids.has(recruit_id), "duplicate Recruit identity %s" % recruit_id) + seen_ids[recruit_id] = true + var recipe := _recipe_signature(recruit) + if seen_recipes.has(recipe): + warnings.append("visual recipe reused by %s and %s" % [String(seen_recipes[recipe]), recruit_id]) + else: + seen_recipes[recipe] = recruit_id + return {"unique_recruit_ids": seen_ids.size(), "recipe_signatures": seen_recipes.size()} + + +func _check_surface_sources(recruits: Array) -> Dictionary: + var shell = EntryShellScript.new() + var view = KnockoutReviewPageViewScript.new() + var surfaces := {"field": true, "rail": true, "knockout": true} + for index in range(recruits.size()): + var recruit: Dictionary = recruits[index] + var expected := _path_of(InitialCompanyArt.texture_for(recruit)) + var field_actor: Control = shell._battle_opening_actor(0, recruit, {}, Rect2(0.0, 0.0, 0.2, 0.4)) + var field_portrait := field_actor.get_node_or_null("BattlePortrait") as TextureRect + var rail_cell: Control = shell._battle_live_state_cell(0, recruit, {}, Rect2(0.0, 0.0, 0.1, 0.2)) + var rail_portrait := rail_cell.get_node_or_null("BattleLiveStatePortrait") as TextureRect + var knockout_member: Control = view._member_presentation(0, recruit, {}) + var knockout_portrait := knockout_member.get_node_or_null("ReadinessPortrait") as TextureRect + var field_path := _portrait_path(field_portrait) + var rail_path := _portrait_path(rail_portrait) + var knockout_path := _portrait_path(knockout_portrait) + _expect(field_portrait != null, "field builds BattlePortrait for %s" % recruit.recruit_id) + _expect(rail_portrait != null, "rail builds BattleLiveStatePortrait for %s" % recruit.recruit_id) + _expect(knockout_portrait != null, "knockout builds ReadinessPortrait for %s" % recruit.recruit_id) + _expect(field_path == expected, "field source for %s is %s (expected %s)" % [recruit.recruit_id, field_path, expected]) + _expect(rail_path == expected, "rail source for %s is %s (expected %s)" % [recruit.recruit_id, rail_path, expected]) + _expect(knockout_path == expected, "knockout source for %s is %s (expected %s)" % [recruit.recruit_id, knockout_path, expected]) + if not _is_soft_q_actor_path(expected): + _expect(false, "Recruit %s resolves outside the M69 soft-Q family: %s" % [recruit.recruit_id, expected]) + field_actor.free() + rail_cell.free() + knockout_member.free() + shell.free() + view.free() + return {"surfaces": "field=rail=knockout" if failures.is_empty() else "FAILED"} + + +func _check_texture_policy(recruits: Array) -> Dictionary: + var profession_to_path := {} + var path_to_profession := {} + var same_profession_shared := 0 + var cross_profession_collisions := 0 + var shared_pairs := {} + for recruit in recruits: + var profession := String(recruit.get("profession_id", "")) + var path := _path_of(InitialCompanyArt.texture_for(recruit)) + if profession_to_path.has(profession): + _expect(String(profession_to_path[profession]) == path, "profession %s changes portrait source" % profession) + var pair_key := "%s|%s" % [profession, path] + if not shared_pairs.has(pair_key): + shared_pairs[pair_key] = true + same_profession_shared += 1 + warnings.append("same profession %s shares %s (allowed MVP policy)" % [profession, path.get_file()]) + else: + profession_to_path[profession] = path + if path_to_profession.has(path) and String(path_to_profession[path]) != profession: + cross_profession_collisions += 1 + _expect(false, "different professions %s and %s share %s" % [String(path_to_profession[path]), profession, path]) + else: + path_to_profession[path] = profession + return { + "same_profession_shared": same_profession_shared, + "cross_profession_collisions": cross_profession_collisions, + } + + +func _fixture_recruits() -> Array: + return [ + _fixture_recruit("R-M80-01", "PF-B01", 1), + _fixture_recruit("R-M80-02", "PF-B01", 2), + _fixture_recruit("R-M80-03", "PF-B05", 3), + _fixture_recruit("R-M80-04", "PF-B07", 4), + ] + + +func _fixture_recruit(recruit_id: String, profession: String, ordinal: int) -> Dictionary: + return { + "recruit_id": recruit_id, + "recruit_instance_id": recruit_id, + "profession_id": profession, + "appearance_seed": 8000 + ordinal, + "body_frame": ordinal, + "face_shape": ordinal + 10, + "skin_palette": ordinal + 20, + "hair_shape": ordinal + 30, + "hair_palette": ordinal + 40, + "garment_cut": ordinal + 50, + "garment_palette": ordinal + 60, + "profession_overlay": profession, + "detail_mark": ordinal + 70, + "rendered_name_snapshot": {"zh_CN": "M80 测试 %d" % ordinal, "en": "M80 Fixture %d" % ordinal}, + "profession_name": {"zh_CN": profession, "en": profession}, + "trait_name": {}, + "starting_item_name": {}, + } + + +func _recipe_signature(recruit: Dictionary) -> String: + var fields := [ + "body_frame", "face_shape", "skin_palette", "hair_shape", "hair_palette", + "garment_cut", "garment_palette", "profession_overlay", "detail_mark", + ] + var parts: Array[String] = [] + for field in fields: + parts.append("%s=%s" % [field, str(recruit.get(field, ""))]) + return "|".join(parts) + + +func _is_soft_q_actor_path(path: String) -> bool: + return path.begins_with("res://assets/entry/recruit_stickers/m69_soft_q/q_pf_") and not path.contains("assets/battle/actors") and not path.contains("assets/animations") + + +func _portrait_path(portrait: TextureRect) -> String: + return _path_of(portrait.texture) if portrait != null else "" + + +func _path_of(texture: Texture2D) -> String: + return texture.resource_path if texture != null else "" + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label)