Verbs (PLAN levels 1-3) - Eye contact: own Level 1 radial slice, logged as "eye contact". - Recognition is a player decision: "Recognize" sets NPCController.player_recognized, writes access evidence, and GameManager only then appends a RECOGNITION artifact. It is no longer implied by NPC type. - Body camera: real flag (off by default) driving the REC overlay; B toggles it and a formal warning arms it automatically, writing a body_camera incident event. - Blocking stations the guard 0.9 m from the person toward the entrance and turns to face them, instead of only emitting the escalation level. - Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines. - Radial grows to 12 slices (174 px outer radius, centered labels). Incident recording fix - IncidentRecorder.start_incident() was never called, so add_event / add_access_evidence / add_escalation silently no-opped and the shift review always reported zero incidents. Incidents now open on focus (guarded by has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes leftovers as "unresolved" before building the review. Copy and docs - 9 new string keys (eye contact, body camera, recognition, reason, station). - README controls, 26-step smoke checklist, test count, status lists. - TODO section moved to DONE; MEMORY records the verb design and the recorder bug. Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd + windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
203 lines
7.2 KiB
GDScript
203 lines
7.2 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
|
|
## 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,
|
|
}
|