Files
security-game/src/test/tests/test_npc_character.gd
T
zwitschi c70a523f0f
Full build / Import + headless tests (push) Successful in 44s
Full build / Full desktop build + optional release (push) Skipped
Close and archive the 0.9.9 milestone, record that the upstream clips stay unused, and harden the fallback test
2026-11-06 18:39:53 +01:00

503 lines
23 KiB
GDScript

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