class_name NPCController extends RefCounted const Rules := preload("res://src/core/rules.gd") const NpcProfile := preload("res://src/core/npc_profile.gd") const FSM := preload("res://src/core/fsm.gd") ## Simulation-side NPC (PLAN.md: NPC Behavior Types). Owns intent, an FSM, and ## reaction logic. The presentation layer adds a visual avatar and navigation. ## RefCounted on purpose: headless tests and CI do not need a scene tree. signal interacted(level: int) var npc_id: String = "" var profile: NpcProfile = null var intent: String = "roam" # enter | photo | sit | roam | deliver | film | trespass var fsm: FSM = null var rng := RandomNumberGenerator.new() var highest_escalation_received: int = Rules.Level.OBSERVATION var leave_requested := false var reaction: int = Rules.Reaction.COMPLY var reaction_set := false ## Returning-trespasser state: an NPC directed away may circle back later. var will_return := false var return_delay_s := 0.0 var return_elapsed_s := 0.0 ## Perception pass: NPCs notice the guard's proximity and react (PLAN.md: ## Level 0 Observation). Proximity bands in meters. const AWARE_RADIUS := 6.0 const NEAR_RADIUS := 2.0 var guard_noticed := false ## Whether this NPC has passed the handheld wand screening (The Joke Shop). var screened := false ## Whether the guard has declared they recognize this person (a player decision ## recorded as evidence, PLAN.md: Authorized Access). var player_recognized := false ## Last reply the NPC gave when asked for their reason for entry. var last_reason_reply := "" ## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means ## venue/type defaults from AccessLoadout. var access_override: Dictionary = {} func _init() -> void: fsm = FSM.new(self) ## initial_state: APPROACH, AT_ENTRANCE, LOITERING, QUEUE, ARGUING func setup(p_id: String, p_profile: NpcProfile, p_intent: String = "roam", seed_value: int = 0, initial_state: String = "APPROACH") -> void: npc_id = p_id profile = p_profile intent = p_intent rng.seed = seed_value if seed_value != 0 else hash(npc_id) _build_fsm(initial_state) func _build_fsm(initial_state: String) -> void: fsm.add_transition("APPROACH", "AT_ENTRANCE") fsm.add_transition("AT_ENTRANCE", "ARGUING") fsm.add_transition("AT_ENTRANCE", "LEAVING") fsm.add_transition("AT_ENTRANCE", "THREATENING") fsm.add_transition("AT_ENTRANCE", "FORCING_ENTRY") fsm.add_transition("APPROACH", "LEAVING") fsm.add_transition("ARGUING", "LEAVING") fsm.add_transition("ARGUING", "THREATENING") fsm.add_transition("ARGUING", "FORCING_ENTRY") fsm.add_transition("LEAVING", "LOITERING_DISTANT") # potential return later fsm.add_transition("LOITERING_DISTANT", "APPROACH") fsm.add_transition("THREATENING", "FORCING_ENTRY") fsm.start(initial_state) func roll_reaction(level: int) -> int: ## Weighted pick from profile's reaction table for `level`. highest_escalation_received = maxi(highest_escalation_received, level) var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate() var roll_value := rng.randf() var total := 0.0 for key in weights.keys(): total += weights[key] if total <= 0.0: return Rules.Reaction.ARGUE var cursor := 0.0 for key in weights.keys(): cursor += float(weights[key]) if roll_value < cursor / total: return key return Rules.Reaction.LEAVE func apply_player_action(level: int) -> Dictionary: ## Returns reaction to the player's action and updates FSM when needed. var performed_reaction := roll_reaction(level) reaction = performed_reaction reaction_set = true _update_state_for_reaction(performed_reaction) return { "npc_id": npc_id, "level_used": level, "reaction": performed_reaction, "reaction_name": Rules.Reaction.find_key(performed_reaction), "new_state": fsm.current_state, } func _update_state_for_reaction(r: int) -> void: match r: Rules.Reaction.COMPLY, Rules.Reaction.LEAVE: leave_requested = true fsm.transition_to("LEAVING") _maybe_schedule_return() Rules.Reaction.ARGUE, Rules.Reaction.RECORD: fsm.transition_to("ARGUING") Rules.Reaction.THREATEN: fsm.transition_to("THREATENING") Rules.Reaction.FORCE_ENTRY: fsm.transition_to("FORCING_ENTRY") Rules.Reaction.RETURN_LATER: fsm.transition_to("LOITERING_DISTANT") will_return = true return_delay_s = _roll_return_delay() Rules.Reaction.CALL_SOMEONE: # The NPC stops to call someone for help (PLAN.md: Level 2 reactions). fsm.transition_to("ARGUING") _: pass ## Decide whether a directed-away NPC will circle back, and after how long. ## Deterministic via the NPC's seeded RNG. Only trespassers and people in ## crisis have a nonzero default return probability (set on their profiles). func _maybe_schedule_return() -> void: if profile == null or profile.return_probability <= 0.0: return if rng.randf() < profile.return_probability: will_return = true return_delay_s = _roll_return_delay() # Move to the loitering state so the return transition is reachable. fsm.transition_to("LOITERING_DISTANT") func _roll_return_delay() -> float: # 20-60 seconds before circling back. return 20.0 + rng.randf() * 40.0 ## Advance the return timer. When it elapses, the NPC transitions from ## LOITERING_DISTANT back to APPROACH. Returns true when a return just fired. func tick_return(delta: float) -> bool: if not will_return: return false return_elapsed_s += delta if return_elapsed_s >= return_delay_s: will_return = false return_elapsed_s = 0.0 leave_requested = false fsm.transition_to("APPROACH") return true return false func decide_leave() -> bool: return leave_requested ## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and ## headless-testable; returns the line this NPC answers with ("" if silent). func request_reason() -> String: var lines: Array[String] = profile.dialogue_lines if profile else [] if lines.is_empty(): last_reason_reply = "" return "" last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)] return last_reason_reply ## Perception pass: given the guard's distance (meters), return a perception ## result describing how the NPC reacts to the guard's presence. Pure and ## headless-testable; the presentation layer feeds it the real distance. ## bonus_range widens awareness when the guard uses a flashlight at night. ## Returns { noticed: bool, band: String, reaction: int }. func perceive_guard(distance: float, bonus_range: float = 0.0) -> Dictionary: var band := "unaware" if distance <= NEAR_RADIUS: band = "near" elif distance <= AWARE_RADIUS + bonus_range: band = "aware" var noticed := band != "unaware" if noticed and not guard_noticed: guard_noticed = true var reaction := Rules.Reaction.IGNORE if band == "near": # Close proximity makes calm NPCs uneasy and hot-headed ones hostile. if profile != null and profile.temperament >= 0.6: reaction = Rules.Reaction.ARGUE else: reaction = Rules.Reaction.COMPLY elif band == "aware": reaction = Rules.Reaction.IGNORE return {"noticed": noticed, "band": band, "reaction": reaction} func to_dict() -> Dictionary: return { "id": npc_id, "name": profile.display_name if profile else "", "type": profile.npc_type if profile else -1, "intent": intent, "state": fsm.current_state, "highest_escalation": highest_escalation_received, "reaction": reaction, }