Implement NPC perception, returning trespassers, responder dispatch, wand screening, and accessibility polish
CI / headless-tests (push) Successful in 2m11s

This commit is contained in:
zwitschi committed 2026-10-31 12:31:13 +01:00
1 parent 439e0a4281
commit 81b5d5ce5a
22 files changed
+879 -73

No files matched your search

+71
View File
@@ -21,6 +21,20 @@ 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
func _init() -> void:
fsm = FSM.new(self)
@@ -89,6 +103,7 @@ func _update_state_for_reaction(r: int) -> void:
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:
@@ -97,12 +112,68 @@ func _update_state_for_reaction(r: int) -> void:
fsm.transition_to("FORCING_ENTRY")
Rules.Reaction.RETURN_LATER:
fsm.transition_to("LOITERING_DISTANT")
will_return = true
return_delay_s = _roll_return_delay()
_:
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
## 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.
## Returns { noticed: bool, band: String, reaction: int }.
func perceive_guard(distance: float) -> Dictionary:
var band := "unaware"
if distance <= NEAR_RADIUS:
band = "near"
elif distance <= AWARE_RADIUS:
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,
+9
View File
@@ -16,6 +16,11 @@ const Rules := preload("res://src/core/rules.gd")
## Venue/scenario recalculates effective compliance from profile + tone + level.
@export_range(0.0, 1.0) var base_compliance: float = 0.8
## Probability that an NPC directed away will circle back later in the shift
## (returning-trespasser behavior). 0 = never returns. Trespassers and people
## in crisis are more likely to return than tourists.
@export_range(0.0, 1.0) var return_probability: float = 0.0
## Probability weighting of the NP C's reaction per player escalation level.
## keys: escalation level int -> { ReactionEnum: weight }
## Missing entries fall back to the profile's `default_reactions`.
@@ -64,6 +69,10 @@ const Rules := preload("res://src/core/rules.gd")
## Dialogue lines per tone and intent. Falls back to generic lines when empty.
@export var dialogue_lines: Dictionary = {}
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
@export var carried_items: Array[String] = []
func pickup_reactions_per_level(level: int) -> Dictionary:
if reactions_by_level.has(level):
return reactions_by_level[level]
+35 -1
View File
@@ -22,6 +22,10 @@ var battery_cost_assist: float = 10.0
var accepting_calls := true
var event_bus: EventBus = null
## Dispatched calls awaiting their responder's arrival. Each entry is the call
## dictionary with a mutable `arrival_delay_s` that `tick` decrements.
var pending_calls: Array[Dictionary] = []
func set_event_bus(bus: EventBus) -> void:
event_bus = bus
@@ -49,6 +53,7 @@ func request_assistance(report: Dictionary) -> Dictionary:
}
result.ok = true
result.call = call
pending_calls.append(call)
call_made.emit(call)
if event_bus:
event_bus.radio_call.emit(call)
@@ -83,6 +88,7 @@ func request_emergency(report: Dictionary) -> Dictionary:
}
result.ok = true
result.call = call
pending_calls.append(call)
call_made.emit(call)
if event_bus:
event_bus.radio_call.emit(call)
@@ -99,8 +105,36 @@ static func credibility_hits(report: Dictionary, truth: Dictionary) -> int:
hits += 1
return hits
## Advance cooldown; called by the simulation tick.
## Advance cooldown and pending-call arrival timers; called by the simulation
## tick. When a call's arrival delay elapses, the responder arrives and the
## `responder_arrived` signal fires with the call data.
func tick(delta: float) -> void:
if emergency_cooldown_remaining > 0.0:
emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta)
# Battery could slowly recharge over a shift; keep it static for now.
var arrived: Array[Dictionary] = []
for call in pending_calls:
call["arrival_delay_s"] = call.get("arrival_delay_s", 0.0) - delta
if call["arrival_delay_s"] <= 0.0:
arrived.append(call)
for call in arrived:
pending_calls.erase(call)
responder_arrived.emit(call)
if event_bus:
event_bus.responder_arrived.emit(call)
## Determine the responder's resolution outcome for a call. The responder
## commands, restrains, evacuates, or arrests based on the reported danger
## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response).
## Returns a resolution string: "command", "restraint", "evacuation", "arrest".
static func responder_outcome(call: Dictionary) -> String:
var report: Dictionary = call.get("report", {})
var danger: int = report.get("danger_level", 0)
var weapons: bool = report.get("weapons_seen", false)
if weapons or danger >= 3:
return "arrest"
if danger == 2:
return "restraint"
if call.get("type", "") == "emergency":
return "evacuation"
return "command"
+64
View File
@@ -0,0 +1,64 @@
class_name Strings
## Centralized UI and dialogue text table (PLAN.md: Polish and Accessibility —
## localization). All user-facing strings live here so they can be swapped for
## a localized table later. Keys are stable identifiers; values are English.
const TABLE := {
# HUD / controls
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · F debug",
"hud.tone_prefix": "Tone: %s",
"hud.escalation_prefix": "Escalation: %s",
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
"hud.aim_first": "Aim at someone first.",
"hud.no_one_nearby": "No one nearby.",
"hud.targeting": "Targeting %s (Tab to cycle).",
"hud.no_wand": "No wand at this venue.",
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.",
# Access decisions
"access.granted": "Entry granted: %s",
"access.denied": "Entry denied: %s",
"access.policy_satisfied": "policy satisfied",
"access.wrong_grant": "You granted entry despite verification failure!",
"access.wrong_deny": "You denied entry despite valid verification!",
# Wand screening
"wand.admitted_prohibited": "You admitted %s with a prohibited item!",
"wand.denied_false_alarm": "You denied %s over a harmless item.",
"wand.cleared": "%s cleared the wand check.",
"wand.refused": "%s refused entry after the wand check.",
# Radio
"radio.address_first": "Address someone first before calling.",
"radio.blocked": "Radio blocked: %s",
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
"radio.received": "Dispatch received your report. Assistance en route.",
"radio.emergency_dispatched": "Emergency responder dispatched.",
# Responder
"responder.arrived": "Responder arrives and %ss the situation.",
# Reactions
"react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.",
"react.argue": "%s argues with you.",
"react.record": "%s starts filming you.",
"react.ignore": "%s ignores you.",
"react.threaten": "%s is threatening you. Emergency radio (R) is justified now.",
"react.force_entry": "%s is forcing the door!",
"react.return_later": "%s leaves but may come back.",
"react.none": "No reaction (yet).",
# Perception
"perception.near": "%s notices you standing close.",
"perception.aware": "%s notices you watching.",
}
## Look up a string by key. Returns the key itself when missing (fail-safe).
static func lookup(key: String) -> String:
return TABLE.get(key, key)
## Format a string by key with positional arguments.
static func fmt(key: String, args: Array) -> String:
var template: String = TABLE.get(key, key)
return template % args
+27
View File
@@ -35,6 +35,10 @@ const NpcProfile := preload("res://src/core/npc_profile.gd")
@export var policy_summary: String = ""
## Door-staff (guard) dialogue lines specific to this venue. Keys are intent
## or situation names; values are Arrays of line strings.
@export var door_staff_lines: Dictionary = {}
func profile_for(npc_type: int) -> NpcProfile:
for p in npc_profiles:
if p.npc_type == npc_type:
@@ -47,3 +51,26 @@ func events_in_phase(phase: int) -> Array[Dictionary]:
if event.get("phase", -1) == phase:
result.append(event)
return result
## Map an NPC intent to a named venue location (Vector3). Falls back to the
## entrance when the intent or its location is unknown. Pure and headless-safe.
func waypoint_for_intent(intent: String) -> Vector3:
return _location_or_default(_spot_for_intent(intent), Vector3(0, 0, 1.2))
func _spot_for_intent(intent: String) -> String:
match intent:
"enter", "arrive", "deliver", "trespass", "force_entry", "follow_authorized", "counterfeit_credential", "blend_in", "crisis", "threat":
return "entrance"
"photo", "film", "perform", "wander", "roam":
return "sidewalk"
"sit":
return "steps_end"
_:
return "sidewalk"
func _location_or_default(spot: String, fallback: Vector3) -> Vector3:
if locations.has(spot):
var v: Variant = locations[spot]
if v is Vector3:
return v
return fallback
+46
View File
@@ -0,0 +1,46 @@
class_name WandScreener
extends RefCounted
## Handheld wand screening logic (PLAN.md: The Joke Shop). Pure and headless.
## The wand does NOT auto-identify weapons: it only reports an alert, and the
## player must interpret it and ask the guest to identify the item.
const PROHIBITED_ITEMS := ["weapon", "knife", "gun", "blade"]
const FALSE_ALARM_ITEMS := ["keys", "belt", "phone", "coins", "watch", "buckle"]
## Screen a guest's carried items. Returns:
## { alert: bool, item: String, prohibited: bool, false_alarm: bool }
## A prohibited item takes priority over a false alarm when both are present.
func screen(carried_items: Array) -> Dictionary:
for item in carried_items:
if item in PROHIBITED_ITEMS:
return {"alert": true, "item": item, "prohibited": true, "false_alarm": false}
for item in carried_items:
if item in FALSE_ALARM_ITEMS:
return {"alert": true, "item": item, "prohibited": false, "false_alarm": true}
return {"alert": false, "item": "", "prohibited": false, "false_alarm": false}
## Classify a single item name: "prohibited", "false_alarm", or "clear".
static func classify_item(item: String) -> String:
if item in PROHIBITED_ITEMS:
return "prohibited"
if item in FALSE_ALARM_ITEMS:
return "false_alarm"
return "clear"
## Decide the screening outcome. `allowed` is the player's entry decision.
## Returns a decision record for the incident recorder.
func decide(screen_result: Dictionary, allowed: bool) -> Dictionary:
var prohibited: bool = screen_result.get("prohibited", false)
var false_alarm: bool = screen_result.get("false_alarm", false)
var correct := true
if prohibited and allowed:
correct = false # admitted a prohibited item
elif not prohibited and not allowed and not false_alarm:
correct = false # denied a clean guest without cause
return {
"allowed": allowed,
"prohibited": prohibited,
"false_alarm": false_alarm,
"item": screen_result.get("item", ""),
"correct": correct,
}