extends RefCounted ## Headless tests for the character pass: layered outfits, hand items, idle ## styles, queue slots, LOD tiers/labels, and the articulated rig structure. const NpcAvatar := preload("res://src/game/npc_avatar.gd") const Materials := preload("res://src/game/materials.gd") const NpcProfile := preload("res://src/core/npc_profile.gd") const Rules := preload("res://src/core/rules.gd") func _avatar(npc_type: int, seed_value: int, character: String = "") -> NpcAvatar: var profile := NpcProfile.new() profile.id = "char_test" profile.npc_type = npc_type profile.display_name = "Test Person" var avatar := NpcAvatar.new() avatar.character_override = character avatar.setup(profile, "enter", seed_value, Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test") return avatar func test_outfit_layers_and_items_are_deterministic() -> bool: var a: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242) var b: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242) return TestUtils.check( a["layer"] == b["layer"] and a["hand_item"] == b["hand_item"] and a["hair_style"] == b["hair_style"], "outfit layers, hand item and hair style follow the seed") func test_layers_follow_roles() -> bool: var a := TestUtils.check_eq( NpcAvatar.layer_for(Rules.NpcType.RESPONDER, 1), "vest", "responders wear a hi-vis vest") var b := TestUtils.check_eq( NpcAvatar.layer_for(Rules.NpcType.INFLUENCER, 1), "coat", "influencers wear a coat") var c := TestUtils.check_eq( NpcAvatar.layer_for(Rules.NpcType.AUTHORIZED_STAFF, 1), "jacket", "staff wear a jacket") return a and b and c func test_hand_items_are_known_and_deterministic() -> bool: var allowed := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] var ok := true for seed_value in range(1, 30): var item := NpcAvatar.hand_item_for(Rules.NpcType.TOURIST, seed_value * 31) ok = ok and item in allowed ok = ok and item == NpcAvatar.hand_item_for( Rules.NpcType.TOURIST, seed_value * 31) return TestUtils.check(ok, "hand items stay in the catalogue and are deterministic") func test_photo_prop_only_shows_for_camera_poses() -> bool: return TestUtils.check( not NpcAvatar.photo_prop_visible_for_pose("idle") and not NpcAvatar.photo_prop_visible_for_pose("perform") and NpcAvatar.photo_prop_visible_for_pose("photo") and NpcAvatar.photo_prop_visible_for_pose("film"), "camera bag prop is hidden outside photo/film poses") func test_idle_styles_are_known() -> bool: var allowed := ["phone", "crossed", "look", "clipboard", "breathe"] var ok := true for seed_value in range(1, 30): var style := NpcAvatar.idle_style_for(Rules.NpcType.TOURIST, seed_value) \ if seed_value > 1 else NpcAvatar.idle_style_for(Rules.NpcType.INFLUENCER, 1) ok = ok and style in allowed return TestUtils.check(ok, "idle styles stay in the catalogue") func test_yaw_faces_the_movement_direction() -> bool: var a := TestUtils.check(is_zero_approx(NpcAvatar.yaw_towards(Vector3(0, 0, 1))), "+Z is the neutral facing") var x_dir := NpcAvatar.yaw_towards(Vector3(1, 0, 0)) var b := TestUtils.check(is_equal_approx(x_dir, PI / 2.0), "+X turns a quarter turn") var back := NpcAvatar.yaw_towards(Vector3(0, 0, -1)) var c := TestUtils.check(absf(absf(back) - PI) < 0.001, "-Z is a half turn") var direction := Vector3(0.4, 0, -0.9).normalized() var yaw := NpcAvatar.yaw_towards(direction) var facing := Basis(Vector3.UP, yaw) * Vector3(0, 0, 1) var d := TestUtils.check(facing.dot(direction) > 0.999, "the avatar's +Z face points along the walk direction") var e := TestUtils.check(is_zero_approx(NpcAvatar.yaw_towards(Vector3.ZERO)), "zero direction falls back to neutral") return a and b and c and d and e func test_queue_slots_are_spaced_and_unique() -> bool: var seen := {} var ok := true var previous := NpcAvatar.queue_slot_for(0) for index in 6: var slot := NpcAvatar.queue_slot_for(index) ok = ok and not seen.has(slot) seen[slot] = true if index > 0: ok = ok and slot.distance_to(previous) > 0.3 previous = slot return TestUtils.check(ok and NpcAvatar.queue_slot_for(0) == Vector3.ZERO, "queue slots are unique, spaced, and start at the door") func test_lod_tiers_use_hysteresis() -> bool: var avatar := NpcAvatar.new() avatar._lod_tier = 0 var a := TestUtils.check_eq(avatar._lod_tier_for(5.0), 0, "near stays full detail") var b := TestUtils.check_eq(avatar._lod_tier_for(16.0), 1, "past the hysteresis band switches to simplified") avatar._lod_tier = 1 var c := TestUtils.check_eq(avatar._lod_tier_for(13.0), 1, "hysteresis keeps the simplified tier just inside the edge") var d := TestUtils.check_eq(avatar._lod_tier_for(12.0), 0, "clearing the hysteresis band returns to full detail") avatar._lod_tier = 1 var e := TestUtils.check_eq(avatar._lod_tier_for(30.0), 2, "far uses the impostor") avatar.free() return a and b and c and d and e func test_reaction_labels_match_state() -> bool: var a := TestUtils.check_eq(NpcAvatar.reaction_label("flee", 0), "!!", "panic label") var b := TestUtils.check_eq(NpcAvatar.reaction_label("watch", 0), "?!", "watching label") var c := TestUtils.check_eq(NpcAvatar.reaction_label("", 3), "!", "blocked label") var d := TestUtils.check_eq(NpcAvatar.reaction_label("", 0), "", "calm has no label") return a and b and c and d func test_walk_to_idle_animation_transition() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 21) var hip := avatar.find_child("HipL", true, false) as Node3D var a := TestUtils.check(hip != null, "rig has a hip pivot") avatar.velocity = Vector3(0, 0, 1.2) avatar._animate(0.016) var walking := absf(hip.rotation.x) > 0.01 var b := TestUtils.check(walking, "walking swings the hip") avatar.velocity = Vector3.ZERO for i in 90: avatar._animate(0.016) var c := TestUtils.check(absf(hip.rotation.x) < 0.05, "stopping settles the hip back to the idle pose (%.3f)" % hip.rotation.x) avatar.free() return a and b and c func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 33) var a := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar, "a skinned library character is used") var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, "Rig/Head survives the skinned path") var c := TestUtils.check(avatar._skeleton != null and avatar.bone_name("head") != "" and avatar.bone_name("hip", 0) != "" and avatar.bone_name("hand", 1) != "", "armature bones are bound") avatar.free() return a and b and c ## The pose harness captures frames, but a regression here is cheap to catch: ## the pose logic must reach the skeleton bones, not only the proxy pivots. func test_pose_reaches_the_skeleton_bones() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 55) var sk := avatar._skeleton var hip: int = int(avatar._bone_indices[avatar.bone_name("hip", 0)]) var knee: int = int(avatar._bone_indices[avatar.bone_name("knee", 0)]) var rest_hip := sk.get_bone_pose_rotation(hip) var rest_knee := sk.get_bone_pose_rotation(knee) avatar.arrived = true avatar.npc.intent = "sit" for _i in 45: avatar._animate(1.0 / 60.0) var sit_hip := sk.get_bone_pose_rotation(hip) var sit_knee := sk.get_bone_pose_rotation(knee) var a := TestUtils.check(not sit_hip.is_equal_approx(rest_hip), "seated pose rotates the HipL bone away from rest") var b := TestUtils.check(not sit_knee.is_equal_approx(rest_knee), "seated pose rotates the KneeL bone away from rest") var c := TestUtils.check(avatar._rig.position.y < -0.25, "seated pose lowers the rig (%.3f)" % avatar._rig.position.y) # Returning to idle must restore the rest orientation, not leave the sit pose. avatar.arrived = false avatar.npc.intent = "roam" avatar.velocity = Vector3.ZERO for _i in 120: avatar._animate(1.0 / 60.0) var d := TestUtils.check(sk.get_bone_pose_rotation(hip).is_equal_approx(rest_hip), "idle restores the HipL rest orientation") avatar.free() return a and b and c and d ## Regression (ADR-0018): the shipped base must resolve every animation joint on ## the rig it actually uses, keep the baked arm-down rest, and put each proxy on ## the matching body side. The Quaternius rig labels its dot-suffixed joints with ## the opposite side (its `L` sits at +x), so `RIG_BONES` is the single mapping and ## a silent side swap would splay the arms across the torso. ## ## The swing itself is proven by the pose-capture gate; `_swing_bone()` writes the ## global pose, which Godot only computes for a skeleton inside the scene tree and ## the headless runner executes tests before the tree is ready. func test_shipped_rig_resolves_the_animation_joints() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 91) var sk := avatar._skeleton var problems: Array[String] = [] for joint in NpcAvatar.RIG_BONES: for side in 2: var bone := avatar.bone_name(joint, side) if bone == "" or not avatar._bone_indices.has(bone): problems.append("%s/%d unresolved" % [joint, side]) var a := TestUtils.check(problems.is_empty(), "the shipped rig resolves every animation joint (%s)" % str(problems)) var wrist := avatar.bone_name("hand", 0) var shoulder := avatar.bone_name("shoulder", 0) var b := TestUtils.check(wrist != "" and shoulder != "", "shoulder/hand joints resolve") if not b: avatar.free() return false var shoulder_rest := sk.get_bone_global_rest(int(avatar._bone_indices[shoulder])) var wrist_rest := sk.get_bone_global_rest(int(avatar._bone_indices[wrist])) var c := TestUtils.check(shoulder_rest.origin.y - wrist_rest.origin.y > 0.3, "the shipped base is baked arm-down (%.3f below the shoulder)" % (shoulder_rest.origin.y - wrist_rest.origin.y)) # A straight-down arm axis is what makes an X swing read as forward/back. var arm_down := shoulder_rest.basis.y.normalized().dot(Vector3.DOWN) var d := TestUtils.check(arm_down > 0.95, "the arm rest axis points down (dot %.3f)" % arm_down) # Proxy side 0 must drive the -x arm and side 1 the +x arm on either rig. var left := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 0)])) var right := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 1)])) var e := TestUtils.check(left.origin.x < right.origin.x, "proxy side 0 drives the -x arm (%.3f vs %.3f)" % [left.origin.x, right.origin.x]) avatar.free() return a and b and c and d and e func test_character_selection_is_deterministic_and_complete() -> bool: # Seed 5 resolves each role to a fixed pool member; the routine crowd roles # now pick from the crowd-variety pools instead of a single npc_casual. var expected := { Rules.NpcType.TOURIST: "npc_crowd_d", Rules.NpcType.CONFUSED_VISITOR: "npc_crowd_b", Rules.NpcType.INTOXICATED: "npc_crowd_c", Rules.NpcType.AUTHORIZED_STAFF: "npc_jacket", Rules.NpcType.PROTESTER: "npc_jacket", Rules.NpcType.INFLUENCER: "npc_coat", Rules.NpcType.PERFORMER: "npc_dress", Rules.NpcType.DELIVERY: "npc_vest", Rules.NpcType.RESPONDER: "npc_firefighter", Rules.NpcType.MENTAL_CRISIS: "npc_hoodie", } var problems: Array[String] = [] for npc_type in expected: var model := NpcAvatar.character_model_for(npc_type, 5) if model != expected[npc_type]: problems.append("%d -> %s (expected %s)" % [npc_type, model, expected[npc_type]]) if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + model + ".glb"): problems.append("%s missing" % model) if NpcAvatar.character_model_for(npc_type, 5) != model: problems.append("%d is not seed-stable" % npc_type) # Every seed in a role's pool must resolve to a committed library model. for seed_value in [0, 1, 2, 3, 4]: var pooled := NpcAvatar.character_model_for(npc_type, seed_value) if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + pooled + ".glb"): problems.append("%d seed %d -> %s missing" % [npc_type, seed_value, pooled]) # Seed-alternating variants still resolve to a library model. for seed_value in [1, 2, 3, 4, 5, 6]: var variant := NpcAvatar.character_model_for(Rules.NpcType.TRESPASSER, seed_value) if not ResourceLoader.exists(NpcAvatar.BLENDER_NPC_DIR + variant + ".glb"): problems.append("trespasser seed %d -> %s missing" % [seed_value, variant]) return TestUtils.check(problems.is_empty(), "every role selects an existing library character (%s)" % str(problems)) func test_review_override_can_stage_the_guard_model() -> bool: ## The guard character has no spawned NPC role, so the final-context review ## forces it explicitly instead of remapping a role. var avatar := _avatar(Rules.NpcType.CONFUSED_VISITOR, 12, "npc_guard") var a := TestUtils.check_eq(avatar.character_model, "npc_guard", "override selects the guard model") var b := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar, "the guard GLB loads through the normal skinned path") avatar.free() return a and b func test_library_gear_attaches_to_the_contract_points() -> bool: var guest := _avatar(Rules.NpcType.AUTHORIZED_GUEST, 12) var lanyard := guest.find_child("Gear_lanyard", true, false) as Node3D var e := TestUtils.check(lanyard != null and lanyard.position.z > 0.03, "the lanyard badge clears the jacket front") guest.free() var tourist := _avatar(Rules.NpcType.TOURIST, 12) var backpack := tourist.find_child("Gear_backpack", true, false) as Node3D var camera := tourist.find_child("Gear_camera", true, false) as Node3D var a := TestUtils.check(tourist.character_model == "npc_casual" and backpack != null and backpack.get_parent() is Node3D and backpack.get_parent().name == "Rig", "backpack attaches under Rig") var b := TestUtils.check(camera != null and camera.get_parent() is Node3D and camera.get_parent().name == "Hand", "hand gear attaches under the hand proxy") var f := TestUtils.check(camera != null and camera.position.z >= NpcAvatar.HAND_ITEM_FORWARD.z - 0.001, "hand gear sits forward of the wrist proxy instead of inside the palm") tourist.free() var responder := _avatar(Rules.NpcType.RESPONDER, 12) var helmet := responder.find_child("Gear_firefighter_helmet", true, false) as Node3D var tank := responder.find_child("Gear_scba_tank", true, false) as Node3D var flashlight := responder.find_child("Gear_flashlight", true, false) as Node3D var c := TestUtils.check(responder.character_model == "npc_firefighter" and helmet != null and helmet.get_parent().name == "Head", "firefighter helmet attaches to Rig/Head") var d := TestUtils.check(tank != null and flashlight != null, "responder gets SCBA tank and flashlight") var g := TestUtils.check(flashlight != null and flashlight.position.z >= NpcAvatar.HAND_ITEM_FORWARD.z - 0.001, "flashlight uses the same forward grip offset") var e2 := TestUtils.check(helmet != null and helmet.position.y > 0.02, "the firefighter helmet clears the brow") responder.free() return a and b and c and d and e and e2 and f and g ## Horizontal bounds of a gear GLB in its attach-node space. The head/back ## contract is measured against the Blender fit report (head width 0.315 m, ## widest body silhouette 0.26 m); this catches a gear shrinking back inside ## the body or a cap sinking below the brow. func _gear_bounds(node: Node3D) -> AABB: var box := AABB() var first := true for child in node.find_children("*", "MeshInstance3D", true, false): var mi := child as MeshInstance3D var b: AABB = mi.get_aabb() # Gear meshes have no extra transform below the attach node. if first: box = b first = false else: box = box.merge(b) return box func test_head_and_back_gear_clear_the_body_silhouette() -> bool: const HEAD_WIDTH := 0.201 const WIDEST_BODY_RADIUS := 0.26 var problems: Array[String] = [] var seen := {} for npc_type in [Rules.NpcType.TOURIST, Rules.NpcType.INFLUENCER, Rules.NpcType.RESPONDER, Rules.NpcType.PERFORMER, Rules.NpcType.DELIVERY, Rules.NpcType.TRESPASSER]: var avatar := _avatar(npc_type, 5) for child in avatar.find_children("Gear_*", "Node3D", true, false): var gear := child as Node3D var name := gear.name.trim_prefix("Gear_") var attach := str(NpcAvatar.GEAR_ATTACH.get(name, "")) if attach != "head" and attach != "back": continue seen[attach + ":" + name] = true var box := _gear_bounds(gear) if attach == "head": var parent_name := gear.get_parent().name if parent_name != "Head": problems.append("%s is not under Head" % name) if box.size.x > HEAD_WIDTH * 1.70: problems.append("%s is %.3f m wide (head %.3f)" % [ name, box.size.x, HEAD_WIDTH]) # The hood wraps the neck; all other headgear must start on the skull. if name != "hood" and box.position.y < 0.0: problems.append("%s starts %.3f m below the head origin" % [ name, box.position.y]) else: if gear.get_parent().name != "Rig": problems.append("%s is not under Rig" % name) var outer_x := maxf(absf(box.position.x), absf(box.end.x)) var outer_z := maxf(absf(box.position.z), absf(box.end.z)) if maxf(outer_x, outer_z) < WIDEST_BODY_RADIUS: problems.append("%s only reaches %.3f m (body %.3f)" % [ name, maxf(outer_x, outer_z), WIDEST_BODY_RADIUS]) avatar.free() # The firefighter helmet is the only head gear in the roster; caps and hoods # were removed so no role can hide its face. for required in ["head:sunglasses", "head:firefighter_helmet", "back:shoulder_bag", "back:backpack", "back:scba_tank"]: if not seen.has(required): problems.append("missing %s" % required) return TestUtils.check(problems.is_empty(), "head/back gear clears the body silhouette (%s)" % str(problems)) func test_fixed_detail_materials_are_not_tinted() -> bool: var avatar := _avatar(Rules.NpcType.RESPONDER, 3) var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D var unknown_overrides := 0 var contract_overrides := 0 for index in mi.mesh.get_surface_count(): var source := mi.mesh.surface_get_material(index) if source == null: continue var slot := str(source.resource_name) var override := mi.get_surface_override_material(index) if slot in ["skin", "cloth", "pants", "accessory", "hair"]: if override != null: contract_overrides += 1 elif override != null: unknown_overrides += 1 var a := TestUtils.check(contract_overrides == 5, "five contract slots are tinted") var b := TestUtils.check(unknown_overrides == 0, "fixed detail materials keep their authored colour (%d overrides)" % unknown_overrides) avatar.free() return a and b func test_skinned_outfit_tints_the_material_slots() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 44) var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D var outfit := NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 44) var found := 0 var tinted := 0 for index in mi.mesh.get_surface_count(): var material := mi.get_surface_override_material(index) as StandardMaterial3D if material == null: continue found += 1 if material.albedo_color != Color.WHITE: tinted += 1 var a := TestUtils.check(found == 5, "five material slots stay separate") var b := TestUtils.check(tinted == found, "every slot carries the outfit tint") var c := TestUtils.check(outfit["shirt"] != Color.WHITE, "seed gives a non-white outfit") var ao_ok := false for index in mi.mesh.get_surface_count(): var material := mi.get_surface_override_material(index) as StandardMaterial3D if material != null and material.ao_enabled and material.ao_texture != null: ao_ok = true var d := TestUtils.check(ao_ok, "character slots carry the baked AO map") avatar.free() return a and b and c and d func test_fabric_uv_scale_avoids_the_baked_ao_pattern() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 44) var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D var fabric := 0 var skin_ao := false var problems: Array[String] = [] for index in mi.mesh.get_surface_count(): var source := mi.mesh.surface_get_material(index) var material := mi.get_surface_override_material(index) as StandardMaterial3D if source == null or material == null: continue var slot := str(source.resource_name) if slot in ["cloth", "pants"]: fabric += 1 if not material.uv1_scale.is_equal_approx( Materials.NPC_FABRIC_UV_SCALE): problems.append("%s uv=%s" % [slot, str(material.uv1_scale)]) if material.ao_enabled: problems.append("%s still uses baked AO" % slot) elif slot == "skin" and material.ao_enabled: skin_ao = true var a := TestUtils.check(fabric > 0 and problems.is_empty(), "cloth/pants use the fine-UV scale and skip baked AO (%s)" % str(problems)) var b := TestUtils.check(skin_ao, "skin keeps its baked AO") avatar.free() return a and b func test_forced_fallback_still_builds_the_procedural_rig() -> bool: var profile := NpcProfile.new() profile.id = "fallback_test" profile.npc_type = Rules.NpcType.TOURIST profile.display_name = "Fallback" var avatar := NpcAvatar.new() avatar.use_skinned_model = false avatar.setup(profile, "enter", 5, Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "fallback_test") var a := TestUtils.check(not avatar.loaded_model and avatar.fallback_avatar, "fallback is recorded when the model is disabled") var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, "fallback still exposes Rig/Head") var c := TestUtils.check(avatar.find_child("HipL", true, false) != null, "fallback limb pivots exist") # The fallback must still animate, not just exist: the pose code drives the same # proxy pivots the skinned path uses. var hip := avatar.find_child("HipL", true, false) as Node3D avatar.velocity = Vector3(0, 0, 1.2) avatar._animate(1.0 / 60.0) var d := TestUtils.check(hip != null and absf(hip.rotation.x) > 0.01, "fallback hip swings when walking") var hand := avatar.find_child("Hand", true, false) as Node3D var e := TestUtils.check(hand != null, "fallback exposes a hand pivot for gear") avatar.free() return a and b and c and d and e func test_rig_is_articulated() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 77) var a := TestUtils.check(avatar.find_child("HipL", true, false) != null and avatar.find_child("Knee", true, false) != null, "legs have hip and knee pivots") var b := TestUtils.check(avatar.find_child("ShoulderR", true, false) != null and avatar.find_child("Elbow", true, false) != null and avatar.find_child("Hand", true, false) != null, "arms have shoulder, elbow and hand pivots") var c := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, "the focus code can still find Rig/Head") avatar.free() return a and b and c func test_avatar_mesh_budget_and_lod_swap() -> bool: var avatar := _avatar(Rules.NpcType.TOURIST, 12) var meshes := avatar.find_children("*", "MeshInstance3D", true, false) var a := TestUtils.check(meshes.size() <= 40, "avatar stays inside the mesh budget (%d)" % meshes.size()) avatar.apply_lod(2) var impostor := avatar.get_node_or_null("Impostor") as Node3D var rig := avatar.get_node_or_null("Rig") as Node3D var b := TestUtils.check(impostor != null and impostor.visible, "far LOD shows the impostor") var c := TestUtils.check(rig != null and not rig.visible, "far LOD hides the full rig") avatar.apply_lod(0) var d := TestUtils.check(rig.visible and not impostor.visible, "near LOD restores the rig") avatar.free() return a and b and c and d