extends RefCounted ## Headless checks for the Blender-authored assets staged under ## `assets/models/blender/` (tools/blender_assets.py). Nothing in the game loads ## them yet; these tests keep them import-clean and drop-in compatible with the ## npc_base contract until they are integrated. const MANIFEST := "res://assets/models/blender/manifest.json" const NPC_BASE := "res://assets/models/generated/npc_base.glb" const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"] const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR"] ## The Quaternius rig (ADR-0018) is a 62-joint armature. `NpcAvatar` maps these ## joints onto the animation proxies, so they are the drop-in requirement for a ## Quaternius-derived character instead of the 13-core names. const QUATERNIUS_BONES := ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", "LowerLeg.R", "UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R", "Wrist.L", "Wrist.R"] ## Quaternius-derived characters are detected by a dot-suffixed UpperArm joint; ## the procedural library uses ShoulderL/ShoulderR instead. func _is_quaternius(skeleton: Skeleton3D) -> bool: return skeleton != null and skeleton.find_bone("UpperArm.L") >= 0 func _manifest() -> Dictionary: if not FileAccess.file_exists(MANIFEST): return {} var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST)) return parsed if parsed is Dictionary else {} func _assets(category: String = "") -> Dictionary: var out := {} var assets: Dictionary = _manifest().get("assets", {}) for name in assets: var entry: Dictionary = assets[name] if category == "" or str(entry.get("category", "")) == category: out[name] = entry return out func _instance(entry: Dictionary) -> Node3D: var packed := ResourceLoader.load("res://" + str(entry["path"])) as PackedScene if packed == null: return null return packed.instantiate() as Node3D func _global_rest(skeleton: Skeleton3D, bone: int) -> Transform3D: var xf := skeleton.get_bone_rest(bone) var parent := skeleton.get_bone_parent(bone) while parent >= 0: xf = skeleton.get_bone_rest(parent) * xf parent = skeleton.get_bone_parent(parent) return xf func test_manifest_lists_every_category() -> bool: var counts := {} for entry in _assets().values(): var category := str(entry.get("category", "")) counts[category] = int(counts.get(category, 0)) + 1 return TestUtils.check(int(counts.get("npc", 0)) >= 8 and int(counts.get("npc_gear", 0)) >= 10 and int(counts.get("props", 0)) >= 10, "manifest lists characters, gear and props (%s)" % str(counts)) func test_every_asset_imports_as_a_scene_with_geometry() -> bool: var problems: Array[String] = [] var assets := _assets() for name in assets: var node := _instance(assets[name]) if node == null: problems.append("%s not imported" % name) continue if node.find_children("*", "MeshInstance3D", true, false).is_empty(): problems.append("%s has no mesh" % name) node.free() return TestUtils.check(not assets.is_empty() and problems.is_empty(), "Blender assets import (%s)" % str(problems)) func test_characters_match_the_npc_base_contract() -> bool: var problems: Array[String] = [] var assets := _assets("npc") for name in assets: var node := _instance(assets[name]) if node == null: problems.append("%s missing" % name) continue var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D var mesh := node.find_child("npc_base", true, false) as MeshInstance3D if skeleton == null or mesh == null: problems.append("%s lacks Skeleton3D/npc_base" % name) node.free() continue var required: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES for bone in required: if skeleton.find_bone(bone) < 0: problems.append("%s missing bone %s" % [name, bone]) var slots := {} for i in mesh.mesh.get_surface_count(): var mat := mesh.mesh.surface_get_material(i) if mat != null: slots[str(mat.resource_name)] = true for slot in SLOTS: if not slots.has(slot): problems.append("%s missing slot %s" % [name, slot]) node.free() return TestUtils.check(assets.size() >= 8 and problems.is_empty(), "characters keep bones + slots (%s)" % str(problems)) func test_character_joints_line_up_with_npc_base() -> bool: ## NpcAvatar positions its animation proxies for npc_base; a drop-in ## replacement must put the same joints in the same places. if not ResourceLoader.exists(NPC_BASE): return TestUtils.check(true, "npc_base not imported; skipped") var base := (ResourceLoader.load(NPC_BASE) as PackedScene).instantiate() var base_skel := base.find_child("Skeleton3D", true, false) as Skeleton3D var problems: Array[String] = [] var assets := _assets("npc") for name in assets: var node := _instance(assets[name]) var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null if skeleton == null: problems.append("%s has no skeleton" % name) elif _is_quaternius(skeleton): # ADR-0018: a Quaternius character keeps its own 62-joint skeleton and # NpcAvatar re-derives the proxy pivots from it, so the joints are not # expected to sit on npc_base's 13-core positions. pass else: for bone in BONES: var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin var b := _global_rest(skeleton, skeleton.find_bone(bone)).origin if a.distance_to(b) > 0.01: problems.append("%s.%s off by %.3f" % [name, bone, a.distance_to(b)]) if node: node.free() base.free() return TestUtils.check(problems.is_empty(), "joints match npc_base (%s)" % str(problems)) func test_character_bones_accept_the_avatar_pose_math() -> bool: ## Same composition as NpcAvatar._pose_bone(): rest * offset must stay a ## valid rotation on every limb bone. var problems: Array[String] = [] var assets := _assets("npc") for name in assets: var node := _instance(assets[name]) var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null if skeleton == null: problems.append(name) else: var bones: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES for bone in bones: var index := skeleton.find_bone(bone) if index < 0: problems.append("%s missing %s" % [name, bone]) continue # NpcAvatar._swing_bone(): pose = rest^-1 * swing * rest, applied # through the global pose. The composition must stay a valid # rotation on every limb bone of either rig. var rest := skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion() var swing := Quaternion(Vector3.RIGHT, 0.4) skeleton.set_bone_pose_rotation(index, rest.inverse() * swing * rest) if not skeleton.get_bone_pose_rotation(index).is_normalized(): problems.append("%s.%s" % [name, bone]) if node: node.free() return TestUtils.check(problems.is_empty(), "bones pose cleanly (%s)" % str(problems)) func test_grounded_props_sit_on_the_floor() -> bool: var problems: Array[String] = [] var assets := _assets("props") for name in assets: var bounds: Dictionary = assets[name].get("bounds_godot", {}) if name in ["wall_lantern", "marquee"] or bounds.is_empty(): continue var min_y := float(bounds["min"][1]) if absf(min_y) > 0.03: problems.append("%s min y %.3f" % [name, min_y]) return TestUtils.check(problems.is_empty(), "props grounded (%s)" % str(problems))