extends RefCounted ## Headless tests for focus ranking, aim-free cycling helpers, and the NPC ## outfit/gait helpers added in the control re-evaluation (ADR-0011). const NpcFocus := preload("res://src/game/npc_focus.gd") const NpcAvatar := preload("res://src/game/npc_avatar.gd") const Rules := preload("res://src/core/rules.gd") func test_rank_orders_by_angle_then_distance() -> bool: var origin := Vector3.ZERO var forward := Vector3(0, 0, -1) # Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to # the side. Ranking must be angle first, then distance. var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)] var ranked: Array = NpcFocus.rank_indices(origin, forward, heads) var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance") var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2, "pick_index returns the best-ranked head") return a and b func test_rank_excludes_out_of_cone_and_range() -> bool: var origin := Vector3.ZERO var forward := Vector3(0, 0, -1) var heads := [ Vector3(0, 0, 40), # too far Vector3(8, 0, -5), # too wide Vector3(0, 0, 6), # behind the camera Vector3(0.2, 0, -7), # valid ] var ranked: Array = NpcFocus.rank_indices(origin, forward, heads) return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked") func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool: var ranked: Array = NpcFocus.rank_indices( Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)]) var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)]) return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick") func test_limb_swing_is_opposite_per_side() -> bool: var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0) var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0) var still := NpcAvatar.limb_swing(0.0, 0, 1.0) var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude") var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way") var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose") return a and b and c func test_outfit_is_deterministic_per_seed() -> bool: var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991) var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991) var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt") var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers") var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory") return a and b and c func test_outfit_varies_across_seeds() -> bool: var shirts := {} var pants := {} for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]: var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977) shirts[outfit["shirt"]] = true pants[outfit["pants"]] = true var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people") var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people") return a and b func test_accessories_follow_the_role() -> bool: var a := TestUtils.check_eq( NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap", "responders wear a cap") var b := TestUtils.check_eq( NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood", "trespassers wear a hood") var c := TestUtils.check_eq( NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard", "guests wear a lanyard") var d := TestUtils.check_eq( NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none", "roles without an accessory say none") return a and b and c and d func test_height_scale_stays_human() -> bool: var ok := true for seed_value in range(1, 40): var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"]) if scale_y < 0.9 or scale_y > 1.1: ok = false return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")