test(m80-i46): add recruit identity consistency gate
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user