test(m80-i46): add recruit identity consistency gate

This commit is contained in:
2026-09-23 20:39:36 +08:00
parent b6167ffe5b
commit e74aaa6bba
@@ -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 "<null>"
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)