extends RefCounted ## Headless tests for NPC reaction rolls (PLAN.md: NPC Behavior Types). const Rules := preload("res://src/core/rules.gd") const NpcProfile := preload("res://src/core/npc_profile.gd") const NPCController := preload("res://src/core/npc_controller.gd") func _profile_with_defaults(id: String, npc_type: int, compliance: float) -> NpcProfile: var p := NpcProfile.new() p.id = id p.npc_type = npc_type p.base_compliance = compliance return p func test_tourist_complies_at_level_1() -> bool: var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) var npc := NPCController.new() npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE") var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1") return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves") func test_trespasser_frequent_non_compliance() -> bool: var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.2) var npc := NPCController.new() npc.setup("t1", p, "trespass", 99, "AT_ENTRANCE") ## Several rolls at formal warning; default table for this test profile is ## from NpcProfile.default_reactions (mostly COMPLY there). var outcomes: Array = [] for i in 5: outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE)) ## With compliance 0.9/0.2 variation we simply assert we get SOME reaction. var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked") return ok func test_deterministic_with_seed() -> bool: ## Same seed and inputs => same reaction sequence. var p1 := _profile_with_defaults("guest_a", Rules.NpcType.TRESPASSER, 0.4) var n1 := NPCController.new() n1.setup("a", p1, "trespass", 4242, "AT_ENTRANCE") var p2 := _profile_with_defaults("guest_b", Rules.NpcType.TRESPASSER, 0.4) var n2 := NPCController.new() n2.setup("b", p2, "trespass", 4242, "AT_ENTRANCE") var seq_a: Array = [] var seq_b: Array = [] for i in 4: seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING)) seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING)) return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence") func test_violent_threat_scales_to_threaten() -> bool: ## Violent archetype with default reactions: from formal warning upward there ## is a meaningful probability of THREATEN / FORCE_ENTRY. We sample enough ## rolls to see at least one THREATEN with the violent profile rng. var p := NpcProfile.new() p.id = "violent" p.npc_type = Rules.NpcType.VIOLENT_THREAT p.base_compliance = 0.05 p.reactions_by_level = { Rules.Level.EMERGENCY: { Rules.Reaction.THREATEN: 40, Rules.Reaction.FORCE_ENTRY: 30, Rules.Reaction.LEAVE: 30, }, } var npc := NPCController.new() npc.setup("v1", p, "threat", 777, "AT_ENTRANCE") var saw_threat := false for i in 20: if npc.roll_reaction(Rules.Level.EMERGENCY) == Rules.Reaction.THREATEN: saw_threat = true return TestUtils.check(saw_threat, "violent profile can threaten at emergency level") func test_call_someone_reaction() -> bool: var p := NpcProfile.new() p.id = "callsome" p.npc_type = Rules.NpcType.INFLUENCER p.reactions_by_level = { Rules.Level.FORMAL_WARNING: { Rules.Reaction.CALL_SOMEONE: 100 }, } var npc := NPCController.new() npc.setup("c1", p, "film", 5, "AT_ENTRANCE") var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING) var a := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.CALL_SOMEONE, "rolls CALL_SOMEONE") var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING") return a and b func test_fsm_transitions() -> bool: var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9) var npc := NPCController.new() npc.setup("g1", p, "enter", 5, "APPROACH") var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching") npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves") return started and left func test_returning_trespasser_circles_back() -> bool: ## A trespasser with return_probability 1.0 will always circle back after ## being directed away. The return timer fires and the FSM re-approaches. var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35) p.return_probability = 1.0 var npc := NPCController.new() npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE") npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) var left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning") var scheduled := TestUtils.check(npc.will_return, "return scheduled") # Advance past the return delay (max 60s). var returned := false for i in 120: if npc.tick_return(1.0): returned = true break var came_back := TestUtils.check(returned, "NPC circles back within delay window") var reapproaching := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "FSM returns to APPROACH") var not_leaving := TestUtils.check_eq(npc.decide_leave(), false, "leave flag cleared on return") return left and scheduled and came_back and reapproaching and not_leaving func test_tourist_never_returns() -> bool: ## A tourist (return_probability 0) never schedules a return. var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) var npc := NPCController.new() npc.setup("t2", p, "photo", 7, "AT_ENTRANCE") npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE) return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return") func test_perception_pass_bands() -> bool: ## NPCs notice the guard within the aware radius and react to close ## proximity based on temperament. var calm := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) calm.temperament = 0.1 var npc := NPCController.new() npc.setup("p1", calm, "photo", 11, "AT_ENTRANCE") var far := npc.perceive_guard(20.0) var a := TestUtils.check_eq(far["band"], "unaware", "far guard is unnoticed") var b := TestUtils.check_eq(far["noticed"], false, "far guard not noticed") var aware := npc.perceive_guard(4.0) var c := TestUtils.check_eq(aware["band"], "aware", "within aware radius") var d := TestUtils.check_eq(aware["noticed"], true, "guard noticed") var near := npc.perceive_guard(1.0) var e := TestUtils.check_eq(near["band"], "near", "within near radius") var f := TestUtils.check_eq(near["reaction"], Rules.Reaction.COMPLY, "calm NPC complies when guard is near") return a and b and c and d and e and f func test_perception_hotheaded_reacts_to_proximity() -> bool: ## A hot-headed NPC argues when the guard stands close. var hot := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35) hot.temperament = 0.8 var npc := NPCController.new() npc.setup("p2", hot, "trespass", 22, "AT_ENTRANCE") var near := npc.perceive_guard(1.5) return TestUtils.check_eq(near["reaction"], Rules.Reaction.ARGUE, "hot-headed NPC argues at close range")