Implement NPC perception, returning trespassers, responder dispatch, wand screening, and accessibility polish
CI / headless-tests (push) Successful in 2m11s
CI / headless-tests (push) Successful in 2m11s
This commit is contained in:
1 parent
439e0a4281
commit
81b5d5ce5a
22 files changed
+879
-73
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user