Files
security-game/src/core/npc_controller.gd
T
Gig Line Dev 98c31fe44c
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped
Audit and fix NPC, door-staff and UI dialogue
- Route the Reason verb through authored reason contexts and give every shared archetype an in-character answer.
- Add Hollywood wristband and TV Studio ticket/credential door-staff lines.
- Clarify radio cancellation, Ask terminology, start hints and campaign-reset copy.
- Record the dialogue audit and length findings in docs/ui-copy.md; add regression tests.
2026-11-02 14:44:01 +01:00

210 lines
7.7 KiB
GDScript

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
## Authored event flag: this person refuses the wand screen (Joke Shop).
var refuses_screening := 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, compliance_bonus: float = 0.0) -> int:
## Weighted pick from profile's reaction table for `level`. Difficulty can
## nudge compliance without changing the authored weights.
highest_escalation_received = maxi(highest_escalation_received, level)
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
if compliance_bonus != 0.0:
weights[Rules.Reaction.COMPLY] = maxf(0.0, float(weights.get(Rules.Reaction.COMPLY, 0)) + compliance_bonus * 100.0)
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 context_line(context: String) -> String:
return profile.line_for(context, rng) if profile else ""
func apply_player_action(level: int, compliance_bonus: float = 0.0) -> Dictionary:
## Returns reaction to the player's action and updates FSM when needed.
var performed_reaction := roll_reaction(level, compliance_bonus)
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:
# Prefer the authored "reason" context; profiles without one fall back to
# their general dialogue lines. Silence remains valid for a deliberately
# silent character.
last_reason_reply = profile.line_for("reason", rng) if profile else ""
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,
}