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

+1 -2
View File
@@ -158,8 +158,7 @@ Avoid unrelated refactors unless they are required for the task or explicitly ap
## Verification Expectations ## Verification Expectations
must run every check below that applies to the changed files. Automated tests and formatting/linting/type checks apply to any change to source code. A smoke test applies to any change that affects runtime behavior. Documentation-only changes require only the documentation check. Skipped checks must be listed with a reason. Before handing off work, an agent must run every check below that applies to the changed files. Automated tests and formatting/linting/type checks apply to any change to source code. A smoke test applies to any change that affects runtime behavior. Documentation-only changes require only the documentation check. Skipped checks must be listed with a reason.
Before handing off work, an agent should, when applicable:
- Run automated tests. - Run automated tests.
- Run formatting, linting, or type checks. - Run formatting, linting, or type checks.
+26
View File
@@ -73,3 +73,29 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Add fixed-seed scenario tests for deterministic NPC behavior. - [x] Add fixed-seed scenario tests for deterministic NPC behavior.
- [x] Set up GitHub Actions to run headless tests on push (`ci.yml`). - [x] Set up GitHub Actions to run headless tests on push (`ci.yml`).
- [x] Add a smoke-test checklist for launching one full shift (in `README.md`). - [x] Add a smoke-test checklist for launching one full shift (in `README.md`).
## Core Simulation — NPC Perception
- [x] Implement a perception pass so NPCs react to the guard's gestures and proximity.
- [x] Give NPCs local waypoints per intent (photo spots, queue, stage door, sidewalk exit) instead of all walking to the entrance.
- [x] Add returning-trespasser behavior: an NPC that was directed away may circle back later in the shift.
## Escalation and Radio — Responder Dispatch
- [x] Spawn a dispatched security NPC / armed responder avatar that arrives at the venue after the call delay.
- [x] Give the responder command, restraint, evacuation, and arrest outcomes that resolve the incident.
- [x] Add a visible emergency-radio cooldown indicator in the HUD.
## Scenario Content — Wand Screening (The Joke Shop)
- [x] Implement the handheld wand inspection interaction with alerts from keys, belts, phones, and weapons.
- [x] Enforce screening-before-entry flow and record false alarm / prohibited item decisions.
- [x] Add venue-specific dialogue lines for door staff.
## Polish and Accessibility
- [x] Add dialogue tone picker (Professional / Friendly / Firm / Suspicious) before addressing an NPC.
- [x] Add interaction alternatives that do not require precise aiming (e.g. scan-and-select pinned list of nearby NPCs).
- [x] Add visual and audio feedback per escalation level (colors, tones, shake).
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
- [x] Externalize dialogue and UI text for localization.
+7 -2
View File
@@ -15,7 +15,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Current State ## Current State
- Core simulation, three scenarios, headless tests (25 passing), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green. - Core simulation, three scenarios, headless tests (41 passing), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
- Polish/accessibility: tone picker (`T` cycles `selected_tone` via `TONE_ORDER`; HUD `tone_label`), aim-free targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator (`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`) for localization. NOTE: `Strings.get` was renamed to `Strings.lookup` because `get` collides with `Object.get`.
- Run tests: `godot --headless --path . --script res://src/test/test_runner.gd` - Run tests: `godot --headless --path . --script res://src/test/test_runner.gd`
- Boot check: `godot --headless --path . --quit-after 120` - Boot check: `godot --headless --path . --quit-after 120`
- Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`). - Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`).
@@ -24,7 +30,6 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`). - RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`).
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic. - Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
- NPC avatars all walk to the entrance and idle; per-intent waypoints are not implemented yet (see TODO).
- Wand screening (Joke Shop) is policy-only; the interaction is not yet in the prototype (see TODO). - Wand screening (Joke Shop) is policy-only; the interaction is not yet in the prototype (see TODO).
## Open Questions ## Open Questions
+8 -8
View File
@@ -34,7 +34,7 @@ The player should avoid relying on appearance or assumptions. A familiar person
--- ---
# Escalation System ## Escalation System
## Level 0: Observation ## Level 0: Observation
@@ -171,7 +171,7 @@ The officer should not function as a convenient “stronger attack.” Their rol
--- ---
# NPC Behavior Types ## NPC Behavior Types
## Tourists ## Tourists
@@ -228,7 +228,7 @@ Crazy Homeless Individuals may be referred to as "51/50s" in the context of the
--- ---
# Special Systems ## Special Systems
## Reputation Meter ## Reputation Meter
@@ -265,7 +265,7 @@ The emergency radio should not be infinitely available. A call has a cooldown an
--- ---
# Shift Structure ## Shift Structure
Each shift lasts 10–15 minutes. Each shift lasts 10–15 minutes.
@@ -287,7 +287,7 @@ A rare serious incident may occur, testing whether the player recognizes a genui
--- ---
# Win and Loss Conditions ## Win and Loss Conditions
## Successful Shift ## Successful Shift
@@ -311,7 +311,7 @@ A shift can be technically “secure” but still receive a poor rating if the g
--- ---
# Venues and Scenarios ## Venues and Scenarios
The game should support multiple venues, each with its own access rules, crowd patterns, equipment, and escalation procedures. The core gameplay remains the same: observe, verify access, communicate clearly, and use the lowest effective level of escalation. The game should support multiple venues, each with its own access rules, crowd patterns, equipment, and escalation procedures. The core gameplay remains the same: observe, verify access, communicate clearly, and use the lowest effective level of escalation.
@@ -410,7 +410,7 @@ The scenario should test the player’s ability to distinguish ordinary Hollywoo
Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions. Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions.
# Tech Stack ## Tech Stack
## Engine: Godot 4 ## Engine: Godot 4
@@ -459,7 +459,7 @@ The following commands apply once the Godot project is scaffolded:
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option. Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
# Tone and Design Direction ## Tone and Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
+1 -1
View File
@@ -131,7 +131,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin
### Controls ### Controls
| Input | Action | | Input | Action |
|---|---| | --- | --- |
| W/A/S/D + mouse | Move and look | | W/A/S/D + mouse | Move and look |
| Esc | Release / capture mouse | | Esc | Release / capture mouse |
| Q | Polite direction (Level 1) | | Q | Polite direction (Level 1) |
-26
View File
@@ -1,31 +1,5 @@
# TODO # TODO
## Core Simulation — NPC Perception
- [ ] Implement a perception pass so NPCs react to the guard's gestures and proximity.
- [ ] Give NPCs local waypoints per intent (photo spots, queue, stage door, sidewalk exit) instead of all walking to the entrance.
- [ ] Add returning-trespasser behavior: an NPC that was directed away may circle back later in the shift.
## Escalation and Radio — Responder Dispatch
- [ ] Spawn a dispatched security NPC / armed responder avatar that arrives at the venue after the call delay.
- [ ] Give the responder command, restraint, evacuation, and arrest outcomes that resolve the incident.
- [ ] Add a visible emergency-radio cooldown indicator in the HUD.
## Scenario Content — Wand Screening (The Joke Shop)
- [ ] Implement the handheld wand inspection interaction with alerts from keys, belts, phones, and weapons.
- [ ] Enforce screening-before-entry flow and record false alarm / prohibited item decisions.
- [ ] Add venue-specific dialogue lines for door staff.
## Polish and Accessibility
- [ ] Add dialogue tone picker (Professional / Friendly / Firm / Suspicious) before addressing an NPC.
- [ ] Add interaction alternatives that do not require precise aiming (e.g. scan-and-select pinned list of nearby NPCs).
- [ ] Add visual and audio feedback per escalation level (colors, tones, shake).
- [ ] Add audio for crowd ambience, radio responses, and confirmation prompts.
- [ ] Externalize dialogue and UI text for localization.
## Future Systems (design-level backlog) ## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review. - [ ] Body-camera replay timeline in the shift review.
+24
View File
@@ -125,6 +125,7 @@ static func shared_profiles() -> Array[NpcProfile]:
trespasser.npc_type = Rules.NpcType.TRESPASSER trespasser.npc_type = Rules.NpcType.TRESPASSER
trespasser.temperament = 0.55 trespasser.temperament = 0.55
trespasser.base_compliance = 0.35 trespasser.base_compliance = 0.35
trespasser.return_probability = 0.6
trespasser.reactions_by_level = { trespasser.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: { Rules.Level.SIMPLE_DETERRENCE: {
Rules.Reaction.IGNORE: 45, Rules.Reaction.IGNORE: 45,
@@ -166,6 +167,7 @@ static func shared_profiles() -> Array[NpcProfile]:
mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS
mental_crisis.temperament = 0.7 mental_crisis.temperament = 0.7
mental_crisis.base_compliance = 0.3 mental_crisis.base_compliance = 0.3
mental_crisis.return_probability = 0.4
mental_crisis.reaction_delay = 2.4 mental_crisis.reaction_delay = 2.4
mental_crisis.reactions_by_level = { mental_crisis.reactions_by_level = {
Rules.Level.SIMPLE_DETERRENCE: { Rules.Level.SIMPLE_DETERRENCE: {
@@ -351,6 +353,28 @@ static func joke_shop() -> VenueScenario:
"door_area": Vector3(0, 0, 3), "door_area": Vector3(0, 0, 3),
"queue_area": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9),
} }
scenario.door_staff_lines = {
"id_check": [
"May I see your ID, please?",
"Could you show me some identification?",
],
"ticket_check": [
"Your ticket or reservation, please.",
"Do you have a ticket for tonight's show?",
],
"wand_check": [
"Please step forward for the wand check.",
"Arms out, please — just a quick screening.",
],
"refusal": [
"I'm sorry, I can't let you in without that.",
"Without a valid ID, I can't admit you tonight.",
],
"prohibited_item": [
"I can't allow that item inside. Please leave it outside.",
"That item isn't permitted in the venue.",
],
}
scenario.shift_phases = { scenario.shift_phases = {
Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.EARLY: {"start_s": 0.0},
+71
View File
@@ -21,6 +21,20 @@ var leave_requested := false
var reaction: int = Rules.Reaction.COMPLY var reaction: int = Rules.Reaction.COMPLY
var reaction_set := false 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: func _init() -> void:
fsm = FSM.new(self) fsm = FSM.new(self)
@@ -89,6 +103,7 @@ func _update_state_for_reaction(r: int) -> void:
Rules.Reaction.COMPLY, Rules.Reaction.LEAVE: Rules.Reaction.COMPLY, Rules.Reaction.LEAVE:
leave_requested = true leave_requested = true
fsm.transition_to("LEAVING") fsm.transition_to("LEAVING")
_maybe_schedule_return()
Rules.Reaction.ARGUE, Rules.Reaction.RECORD: Rules.Reaction.ARGUE, Rules.Reaction.RECORD:
fsm.transition_to("ARGUING") fsm.transition_to("ARGUING")
Rules.Reaction.THREATEN: Rules.Reaction.THREATEN:
@@ -97,12 +112,68 @@ func _update_state_for_reaction(r: int) -> void:
fsm.transition_to("FORCING_ENTRY") fsm.transition_to("FORCING_ENTRY")
Rules.Reaction.RETURN_LATER: Rules.Reaction.RETURN_LATER:
fsm.transition_to("LOITERING_DISTANT") fsm.transition_to("LOITERING_DISTANT")
will_return = true
return_delay_s = _roll_return_delay()
_: _:
pass 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: func decide_leave() -> bool:
return leave_requested 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: func to_dict() -> Dictionary:
return { return {
"id": npc_id, "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. ## Venue/scenario recalculates effective compliance from profile + tone + level.
@export_range(0.0, 1.0) var base_compliance: float = 0.8 @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. ## Probability weighting of the NP C's reaction per player escalation level.
## keys: escalation level int -> { ReactionEnum: weight } ## keys: escalation level int -> { ReactionEnum: weight }
## Missing entries fall back to the profile's `default_reactions`. ## 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. ## Dialogue lines per tone and intent. Falls back to generic lines when empty.
@export var dialogue_lines: Dictionary = {} @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: func pickup_reactions_per_level(level: int) -> Dictionary:
if reactions_by_level.has(level): if reactions_by_level.has(level):
return reactions_by_level[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 accepting_calls := true
var event_bus: EventBus = null 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: func set_event_bus(bus: EventBus) -> void:
event_bus = bus event_bus = bus
@@ -49,6 +53,7 @@ func request_assistance(report: Dictionary) -> Dictionary:
} }
result.ok = true result.ok = true
result.call = call result.call = call
pending_calls.append(call)
call_made.emit(call) call_made.emit(call)
if event_bus: if event_bus:
event_bus.radio_call.emit(call) event_bus.radio_call.emit(call)
@@ -83,6 +88,7 @@ func request_emergency(report: Dictionary) -> Dictionary:
} }
result.ok = true result.ok = true
result.call = call result.call = call
pending_calls.append(call)
call_made.emit(call) call_made.emit(call)
if event_bus: if event_bus:
event_bus.radio_call.emit(call) event_bus.radio_call.emit(call)
@@ -99,8 +105,36 @@ static func credibility_hits(report: Dictionary, truth: Dictionary) -> int:
hits += 1 hits += 1
return hits 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: func tick(delta: float) -> void:
if emergency_cooldown_remaining > 0.0: if emergency_cooldown_remaining > 0.0:
emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta) emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta)
# Battery could slowly recharge over a shift; keep it static for now. # 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 = "" @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: func profile_for(npc_type: int) -> NpcProfile:
for p in npc_profiles: for p in npc_profiles:
if p.npc_type == npc_type: if p.npc_type == npc_type:
@@ -47,3 +51,26 @@ func events_in_phase(phase: int) -> Array[Dictionary]:
if event.get("phase", -1) == phase: if event.get("phase", -1) == phase:
result.append(event) result.append(event)
return result 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,
}
+60
View File
@@ -0,0 +1,60 @@
class_name AudioFeedback
extends Node
## Procedural audio feedback (PLAN.md: Polish and Accessibility). Generates
## short tones for confirmation prompts, radio responses, and escalation
## feedback without requiring audio assets. Headless-safe: in headless mode
## the AudioServer is a dummy and playback is a no-op.
const SAMPLE_RATE := 22050.0
var _players: Array[AudioStreamPlayer] = []
func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off.
for i in 4:
var player := AudioStreamPlayer.new()
add_child(player)
_players.append(player)
## Play a short confirmation beep (single high tone).
func play_confirm() -> void:
_play_tone(880.0, 0.12, 0.5)
## Play a radio-response cue (two quick tones).
func play_radio_response() -> void:
_play_tone(660.0, 0.10, 0.5)
_play_tone(990.0, 0.10, 0.5, 0.12)
## Play an escalation warning cue (low descending tone).
func play_escalation(level: int) -> void:
var freq := 440.0 - float(level) * 40.0
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
## Play a denial/error cue (low buzz).
func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5)
## Generate a sine-wave tone and play it on a free player.
func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0) -> void:
var player := _free_player()
if player == null:
return
var stream := AudioStreamGenerator.new()
stream.mix_rate = SAMPLE_RATE
stream.buffer_length = duration + 0.05
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
# Fill the generator buffer with a sine wave.
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
var frames := int(SAMPLE_RATE * duration)
for i in frames:
var t := float(i) / SAMPLE_RATE
var sample := sin(TAU * freq * t) * 0.6
playback.push_frame(Vector2(sample, sample))
func _free_player() -> AudioStreamPlayer:
for player in _players:
if not player.playing:
return player
return _players[0]
+219 -27
View File
@@ -4,8 +4,10 @@ extends Node
## and routes player input into the interaction and access flows. ## and routes player input into the interaction and access flows.
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd") const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd") const AccessVerifier := preload("res://src/core/access_verifier.gd")
const WandScreener := preload("res://src/core/wand_screener.gd")
const RadioSystem := preload("res://src/core/radio_system.gd") const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd") const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
@@ -13,6 +15,8 @@ const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd") const WorldBuilder := preload("res://src/game/world_builder.gd")
const GuardController := preload("res://src/game/guard_controller.gd") const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd") const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const Hud := preload("res://src/game/ui/hud.gd") const Hud := preload("res://src/game/ui/hud.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd") const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd") const ShiftReview := preload("res://src/game/ui/shift_review.gd")
@@ -22,7 +26,9 @@ var guard: GuardController = null
var hud: Hud = null var hud: Hud = null
var radio_panel: RadioPanel = null var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null var shift_review_panel: ShiftReview = null
var audio: AudioFeedback = null
var verifier := AccessVerifier.new() var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building" var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
@@ -31,10 +37,19 @@ var npcs: Array = []
var npc_counter := 0 var npc_counter := 0
var targeted_npc: Object = null var targeted_npc: Object = null
var inspection_open := false var inspection_open := false
var wand_open := false
var current_incident_npc_id := "" var current_incident_npc_id := ""
var current_incident_active := false var current_incident_active := false
var current_npc_truth := {"weapons_seen": false, "danger_level": 0} var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T.
var selected_tone: int = Rules.Tone.PROFESSIONAL
const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS]
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
const NEARBY_RADIUS := 12.0
var _nearby_index := -1
func _ready() -> void: func _ready() -> void:
scene_root = Node3D.new() scene_root = Node3D.new()
scene_root.name = "SceneRoot" scene_root.name = "SceneRoot"
@@ -54,6 +69,7 @@ func _ready() -> void:
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
coordinator.shift_ended.connect(_on_shift_ended) coordinator.shift_ended.connect(_on_shift_ended)
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
_build_ui() _build_ui()
coordinator.start() coordinator.start()
@@ -111,6 +127,8 @@ var inspector_current_events: Array = []
func _build_ui() -> void: func _build_ui() -> void:
hud = Hud.new() hud = Hud.new()
add_child(hud) add_child(hud)
audio = AudioFeedback.new()
add_child(audio)
radio_panel = RadioPanel.new() radio_panel = RadioPanel.new()
add_child(radio_panel) add_child(radio_panel)
radio_panel.hide() radio_panel.hide()
@@ -143,11 +161,46 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0 var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18)) var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16)) var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
var target_pos := Vector3(0.0, 0.5, 1.2) # Per-intent waypoint (photo spots, queue, stage door, sidewalk exit) instead
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos) # of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos)
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
## entrance and resolves the incident with command/restraint/evacuation/arrest.
func _on_responder_arrived(call: Dictionary) -> void:
var outcome: String = RadioSystem.responder_outcome(call)
var responder_profile := _responder_profile()
npc_counter += 1
var avatar := NpcAvatar.new()
avatar.name = "Responder_%d" % npc_counter
# Responder approaches from the street, toward the entrance.
var spawn_pos := Vector3(0.0, 0.5, 14.0)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
avatar.setup(responder_profile, "respond", npc_counter * 991 + 17, spawn_pos, door_pos, door_pos)
scene_root.add_child(avatar)
npcs.append(avatar)
hud.log(Strings.fmt("responder.arrived", [outcome]))
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
"type": "responder_action",
"outcome": outcome,
"call_type": call.get("type", "assist"),
})
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
func _responder_profile() -> NpcProfile:
var p := NpcProfile.new()
p.id = "responder"
p.display_name = "Responder"
p.npc_type = Rules.NpcType.RESPONDER
p.temperament = 0.1
p.base_compliance = 1.0
return p
func _on_shift_ended(review: Dictionary) -> void: func _on_shift_ended(review: Dictionary) -> void:
shift_review_panel.show_review(review, selected_scenario) shift_review_panel.show_review(review, selected_scenario)
@@ -196,7 +249,14 @@ func _handle_key(keycode: Key) -> void:
_toggle_inspection() _toggle_inspection()
KEY_Q: KEY_Q:
if targeted_npc != null and not inspection_open: if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "polite direction")
KEY_W:
if targeted_npc != null and not inspection_open:
_toggle_wand_screening()
KEY_T:
_cycle_tone()
KEY_TAB:
_cycle_nearby_target()
KEY_F: KEY_F:
hud.toggle_debug() hud.toggle_debug()
_: _:
@@ -205,15 +265,15 @@ func _handle_key(keycode: Key) -> void:
func _handle_action_key(keycode: Key) -> void: func _handle_action_key(keycode: Key) -> void:
match keycode: match keycode:
KEY_1: KEY_1:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand")
KEY_2: KEY_2:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point")
KEY_3: KEY_3:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake")
KEY_4: KEY_4:
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning") _apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning")
KEY_5: KEY_5:
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking") _apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking")
KEY_6: KEY_6:
_open_radio(false) _open_radio(false)
KEY_R: KEY_R:
@@ -221,15 +281,47 @@ func _handle_action_key(keycode: Key) -> void:
KEY_A: KEY_A:
if inspection_open: if inspection_open:
decide_access(true) decide_access(true)
elif wand_open:
decide_wand(true)
KEY_D: KEY_D:
if inspection_open: if inspection_open:
decide_access(false) decide_access(false)
elif wand_open:
decide_wand(false)
_: _:
pass pass
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
func _cycle_tone() -> void:
var idx := TONE_ORDER.find(selected_tone)
idx = (idx + 1) % TONE_ORDER.size()
selected_tone = TONE_ORDER[idx]
hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?"))
hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?"))
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
## interact without precise aiming (accessibility).
func _cycle_nearby_target() -> void:
if guard == null:
return
var nearby: Array = []
for avatar in npcs:
if not is_instance_valid(avatar):
continue
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
nearby.append(avatar)
if nearby.is_empty():
hud.log(Strings.lookup("hud.no_one_nearby"))
return
_nearby_index = (_nearby_index + 1) % nearby.size()
var next_target: Node3D = nearby[_nearby_index]
targeted_npc = next_target
_begin_incident(next_target)
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
func _apply_interaction(level: int, tone: int, action_name: String) -> void: func _apply_interaction(level: int, tone: int, action_name: String) -> void:
if targeted_npc == null: if targeted_npc == null:
hud.log("Aim at someone first.") hud.log(Strings.lookup("hud.aim_first"))
return return
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null or coordinator == null: if controller == null or coordinator == null:
@@ -245,6 +337,9 @@ func _apply_interaction(level: int, tone: int, action_name: String) -> void:
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id]) coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING: if level >= Rules.Level.FORMAL_WARNING:
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name]) coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
hud.set_escalation(level)
if audio:
audio.play_escalation(level)
_react_to_outcome(controller, outcome) _react_to_outcome(controller, outcome)
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void: func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
@@ -252,38 +347,38 @@ func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
var name: String = (controller.profile.display_name if controller.profile else "Person") var name: String = (controller.profile.display_name if controller.profile else "Person")
match reaction: match reaction:
Rules.Reaction.COMPLY: Rules.Reaction.COMPLY:
hud.log("%s complies and steps back." % name) hud.log(Strings.fmt("react.comply", [name]))
coordinator.crowd.apply_event("de_escalation") coordinator.crowd.apply_event("de_escalation")
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully") coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
_close_incident("peaceful") _close_incident("peaceful")
Rules.Reaction.LEAVE: Rules.Reaction.LEAVE:
hud.log("%s turns and leaves." % name) hud.log(Strings.fmt("react.leave", [name]))
coordinator.crowd.apply_event("calm_professionalism") coordinator.crowd.apply_event("calm_professionalism")
_close_incident("left") _close_incident("left")
Rules.Reaction.ARGUE: Rules.Reaction.ARGUE:
hud.log("%s argues with you." % name) hud.log(Strings.fmt("react.argue", [name]))
coordinator.crowd.apply_event("aggressive_behavior") coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.RECORD: Rules.Reaction.RECORD:
hud.log("%s starts filming you." % name) hud.log(Strings.fmt("react.record", [name]))
coordinator.reputation.apply("complaint", -3.0, "filmed interaction") coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
coordinator.crowd.apply_event("aggressive_behavior") coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.IGNORE: Rules.Reaction.IGNORE:
hud.log("%s ignores you." % name) hud.log(Strings.fmt("react.ignore", [name]))
Rules.Reaction.THREATEN: Rules.Reaction.THREATEN:
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name) hud.log(Strings.fmt("react.threaten", [name]))
coordinator.crowd.apply_event("aggressive_behavior") coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.FORCE_ENTRY: Rules.Reaction.FORCE_ENTRY:
hud.log("%s is forcing the door!" % name) hud.log(Strings.fmt("react.force_entry", [name]))
coordinator.crowd.apply_event("emergency_responder") coordinator.crowd.apply_event("emergency_responder")
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt") coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
Rules.Reaction.RETURN_LATER: Rules.Reaction.RETURN_LATER:
hud.log("%s leaves but may come back." % name) hud.log(Strings.fmt("react.return_later", [name]))
_: _:
hud.log("No reaction (yet).") hud.log(Strings.lookup("react.none"))
func _open_radio(emergency: bool) -> void: func _open_radio(emergency: bool) -> void:
if targeted_npc == null: if targeted_npc == null:
hud.log("Address someone first before calling.") hud.log(Strings.lookup("radio.address_first"))
return return
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
@@ -295,19 +390,21 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
hud.set_action_hint("") hud.set_action_hint("")
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
if not result.get("ok", false): if not result.get("ok", false):
hud.log("Radio blocked: %s" % result.get("reason", "unknown")) hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
return return
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth) var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
if hits > 0: if hits > 0:
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report") coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits)) hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
else: else:
hud.log("Dispatch received your report. Assistance en route.") hud.log(Strings.lookup("radio.received"))
if emergency: if emergency:
coordinator.crowd.apply_event("emergency_responder") coordinator.crowd.apply_event("emergency_responder")
hud.log("Emergency responder dispatched.") hud.log(Strings.lookup("radio.emergency_dispatched"))
else: else:
coordinator.crowd.apply_event("calm_professionalism") coordinator.crowd.apply_event("calm_professionalism")
if audio:
audio.play_radio_response()
# ----------------------------------------------------------------- access -- # ----------------------------------------------------------------- access --
@@ -324,12 +421,76 @@ func _toggle_inspection() -> void:
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
hud.show_inspection(controller, artifacts, result) hud.show_inspection(controller, artifacts, result)
# ------------------------------------------------------------- wand screening --
## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void:
if wand_open:
hud.hide_wand()
wand_open = false
return
if not selected_scenario.equipment.has("wand"):
hud.log(Strings.lookup("hud.no_wand"))
return
var controller: Object = _targeted_controller()
if controller == null:
return
var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items)
wand_open = true
hud.show_wand(controller, result)
## Resolve a wand screening: the player decides whether to admit the guest.
func decide_wand(allowed: bool) -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.hide_wand()
wand_open = false
return
var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else []
var result: Dictionary = wand_screener.screen(items)
var decision: Dictionary = wand_screener.decide(result, allowed)
coordinator.recorder.add_access_evidence(controller.npc_id, {
"type": "wand_screening",
"decided": allowed,
"prohibited": decision.get("prohibited", false),
"false_alarm": decision.get("false_alarm", false),
"item": decision.get("item", ""),
"correct": decision.get("correct", false),
})
var name: String = (profile.display_name if profile else "Person")
if decision.get("prohibited", false) and allowed:
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item")
elif decision.get("false_alarm", false) and not allowed:
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
elif allowed:
hud.log(Strings.fmt("wand.cleared", [name]))
coordinator.reputation.apply("access_judgment", 1.0, "correct wand admission")
else:
hud.log(Strings.fmt("wand.refused", [name]))
coordinator.reputation.apply("access_judgment", 1.0, "correct wand refusal")
# Mark the NPC as screened so the screening-before-entry flow is satisfied.
controller.screened = true
hud.hide_wand()
wand_open = false
func decide_access(allowed: bool) -> void: func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
return return
# Screening-before-entry: at wand venues, a guest must be screened first.
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
hud.log(Strings.lookup("hud.screen_first"))
hud.hide_inspection()
inspection_open = false
return
var artifacts := _artifacts_for(controller) var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
var granted: bool = result.get("granted", false) var granted: bool = result.get("granted", false)
@@ -341,17 +502,23 @@ func decide_access(allowed: bool) -> void:
}) })
if allowed == granted: if allowed == granted:
if allowed: if allowed:
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied")) hud.log(Strings.fmt("access.granted", ["; ".join(result.get("reasons", [])) if result.get("reasons", []) else Strings.lookup("access.policy_satisfied")]))
coordinator.reputation.apply("access_judgment", 2.0, "correct admission") coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
if audio:
audio.play_confirm()
else: else:
hud.log("Entry denied: %s" % "; ".join(result.get("issues", []))) hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))]))
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal") coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
if audio:
audio.play_deny()
else: else:
if allowed: if allowed:
hud.log("You granted entry despite verification failure!") hud.log(Strings.lookup("access.wrong_grant"))
else: else:
hud.log("You denied entry despite valid verification!") hud.log(Strings.lookup("access.wrong_deny"))
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision") coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
if audio:
audio.play_deny()
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
@@ -400,3 +567,28 @@ func _physics_process(delta: float) -> void:
return return
coordinator.tick(delta) coordinator.tick(delta)
hud.set_shift_state(coordinator, selected_scenario) hud.set_shift_state(coordinator, selected_scenario)
_perception_pass()
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
## Observation). Feeds each NPC's distance to the guard into its controller
## and logs a one-time reaction when an NPC first notices the guard.
func _perception_pass() -> void:
if guard == null:
return
var guard_pos: Vector3 = guard.global_position
for avatar in npcs:
if not is_instance_valid(avatar):
continue
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null:
continue
var distance: float = (avatar.global_position - guard_pos).length()
var already_noticed: bool = controller.guard_noticed
var perception: Dictionary = controller.perceive_guard(distance)
if perception.get("noticed", false) and not already_noticed:
# First-time notice: log a subtle reaction.
var name: String = (controller.profile.display_name if controller.profile else "Person")
if perception.get("band", "") == "near":
hud.log(Strings.fmt("perception.near", [name]))
else:
hud.log(Strings.fmt("perception.aware", [name]))
+13 -5
View File
@@ -9,6 +9,7 @@ const Rules := preload("res://src/core/rules.gd")
var npc: NPCController = null var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO var spawn_position: Vector3 = Vector3.ZERO
var target_position: Vector3 = Vector3.ZERO var target_position: Vector3 = Vector3.ZERO
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
var awaiting_interaction := false var awaiting_interaction := false
var arrived := false var arrived := false
@@ -33,10 +34,11 @@ func _init() -> void:
func get_npc_controller() -> NPCController: func get_npc_controller() -> NPCController:
return npc return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void: func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value) npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
spawn_position = p_spawn spawn_position = p_spawn
target_position = p_target target_position = p_target
door_position = p_door
position = p_spawn position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed) npc.fsm.state_changed.connect(_on_state_changed)
_build_body(profile.npc_type) _build_body(profile.npc_type)
@@ -75,10 +77,16 @@ func _build_body(npc_type: int) -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null: if npc == null or npc.fsm == null:
return return
_advance_fsm() _advance_fsm(delta)
_move_toward_target(delta) _move_toward_target(delta)
func _advance_fsm() -> void: func _advance_fsm(delta: float) -> void:
# Returning-trespasser: an NPC directed away may circle back to the door.
if npc.tick_return(delta):
arrived = false
awaiting_interaction = false
target_position = door_position
return
if npc.leave_requested: if npc.leave_requested:
var away := global_position + (global_position - spawn_position).normalized() * 40.0 var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5 away.y = 0.5
@@ -105,8 +113,8 @@ func _arrive() -> void:
arrived = true arrived = true
velocity = Vector3.ZERO velocity = Vector3.ZERO
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive": if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
target_position = Vector3(0, 0.5, 1.2) target_position = door_position
if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8: if (global_position - door_position).length() < 0.8:
awaiting_interaction = true awaiting_interaction = true
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void: func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
+96 -1
View File
@@ -4,6 +4,7 @@ extends CanvasLayer
## event log, access inspection panel, and a debug overlay. ## event log, access inspection panel, and a debug overlay.
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
var top_label: Label var top_label: Label
var time_label: Label var time_label: Label
@@ -14,6 +15,12 @@ var debug_label: Label
var inspect_panel: PanelContainer var inspect_panel: PanelContainer
var inspect_text: Label var inspect_text: Label
var inspect_meta: Label var inspect_meta: Label
var radio_cooldown_label: Label
var wand_panel: PanelContainer
var wand_text: Label
var wand_meta: Label
var tone_label: Label
var escalation_label: Label
var _debug_enabled := false var _debug_enabled := false
var _log_lines: Array[String] = [] var _log_lines: Array[String] = []
@@ -72,11 +79,32 @@ func _init() -> void:
vbox.add_child(bottom_spacer) vbox.add_child(bottom_spacer)
var controls := Label.new() var controls := Label.new()
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug" controls.text = Strings.lookup("hud.controls")
controls.add_theme_font_size_override("font_size", 12) controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85) controls.modulate = Color(0.8, 0.8, 0.85)
vbox.add_child(controls) vbox.add_child(controls)
# --- emergency radio cooldown indicator ---
radio_cooldown_label = Label.new()
radio_cooldown_label.text = ""
radio_cooldown_label.add_theme_font_size_override("font_size", 13)
radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
vbox.add_child(radio_cooldown_label)
# --- dialogue tone indicator ---
tone_label = Label.new()
tone_label.text = "Tone: Professional"
tone_label.add_theme_font_size_override("font_size", 13)
tone_label.modulate = Color(0.7, 0.85, 1.0)
vbox.add_child(tone_label)
# --- escalation level indicator (color-coded) ---
escalation_label = Label.new()
escalation_label.text = "Escalation: Observation"
escalation_label.add_theme_font_size_override("font_size", 13)
escalation_label.modulate = Color(0.6, 0.9, 0.6)
vbox.add_child(escalation_label)
# --- inspect panel (popup center) --- # --- inspect panel (popup center) ---
inspect_panel = PanelContainer.new() inspect_panel = PanelContainer.new()
inspect_panel.visible = false inspect_panel.visible = false
@@ -97,6 +125,25 @@ func _init() -> void:
ivbox.add_child(insp_hint) ivbox.add_child(insp_hint)
add_child(inspect_panel) add_child(inspect_panel)
# --- wand screening panel (popup center) ---
wand_panel = PanelContainer.new()
wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox)
wand_text = Label.new()
wand_text.add_theme_font_size_override("font_size", 15)
wvbox.add_child(wand_text)
wand_meta = Label.new()
wand_meta.add_theme_font_size_override("font_size", 12)
wand_meta.modulate = Color(0.8, 0.85, 1.0)
wvbox.add_child(wand_meta)
var wand_hint := Label.new()
wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint)
add_child(wand_panel)
# --- debug overlay --- # --- debug overlay ---
debug_label = Label.new() debug_label = Label.new()
debug_label.visible = false debug_label.visible = false
@@ -116,6 +163,14 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void:
coordinator.reputation.rating, coordinator.reputation.rating,
coordinator.crowd.mood * 100.0, coordinator.crowd.mood * 100.0,
] ]
_update_radio_cooldown(coordinator)
func _update_radio_cooldown(coordinator: Object) -> void:
var remaining: float = coordinator.radio.emergency_cooldown_remaining
if remaining > 0.0:
radio_cooldown_label.text = Strings.fmt("hud.cooldown", [remaining])
else:
radio_cooldown_label.text = ""
func set_target_label(text: String) -> void: func set_target_label(text: String) -> void:
target_label.text = text target_label.text = text
@@ -123,6 +178,24 @@ func set_target_label(text: String) -> void:
func set_action_hint(text: String) -> void: func set_action_hint(text: String) -> void:
hint_label.text = text hint_label.text = text
func set_tone(tone_name: String) -> void:
tone_label.text = Strings.fmt("hud.tone_prefix", [tone_name])
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
escalation_label.modulate = Color(0.6, 0.9, 0.6)
Rules.Level.FORMAL_WARNING:
escalation_label.modulate = Color(0.95, 0.9, 0.4)
Rules.Level.BLOCKING:
escalation_label.modulate = Color(1.0, 0.65, 0.3)
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
escalation_label.modulate = Color(1.0, 0.35, 0.3)
func log(text: String) -> void: func log(text: String) -> void:
_log_lines.append(text) _log_lines.append(text)
if _log_lines.size() > 8: if _log_lines.size() > 8:
@@ -166,3 +239,25 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
func hide_inspection() -> void: func hide_inspection() -> void:
inspect_panel.visible = false inspect_panel.visible = false
func show_wand(controller: Object, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var alert: bool = result.get("alert", false)
wand_text.text = "Wand check — %s\nAlert: %s" % [npc_display, "YES" if alert else "none"]
var lines: Array[String] = []
if alert:
var item: String = result.get("item", "")
if result.get("prohibited", false):
lines.append("The wand alerts on a suspicious item (%s)." % item)
lines.append("Ask the guest to identify it before deciding.")
else:
lines.append("The wand alerts on a harmless item (%s)." % item)
lines.append("Likely a false alarm — keys, belt, or phone.")
else:
lines.append("No alert. The guest appears clear.")
wand_meta.text = "\n".join(lines)
wand_panel.visible = true
func hide_wand() -> void:
wand_panel.visible = false
@@ -50,3 +50,23 @@ func test_dead_battery_blocks_calls() -> bool:
radio.battery = 0.0 radio.battery = 0.0
var call: Dictionary = radio.request_assistance({"danger_level": 1}) var call: Dictionary = radio.request_assistance({"danger_level": 1})
return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason") return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason")
func test_responder_arrives_after_delay() -> bool:
var radio := RadioSystem.new()
var arrived_calls: Array = []
radio.responder_arrived.connect(func(call: Dictionary): arrived_calls.append(call))
radio.request_assistance({"danger_level": 1, "weapons_seen": false})
var a := TestUtils.check_eq(radio.pending_calls.size(), 1, "one pending call")
# Advance past the 8s assist arrival delay.
radio.tick(9.0)
var b := TestUtils.check_eq(radio.pending_calls.size(), 0, "pending call cleared")
var c := TestUtils.check_eq(arrived_calls.size(), 1, "responder_arrived emitted")
return a and b and c
func test_responder_outcome_mapping() -> bool:
var a := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 1, "weapons_seen": false}}), "command", "low danger -> command")
var b := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 2, "weapons_seen": false}}), "restraint", "elevated -> restraint")
var c := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "emergency", "report": {"danger_level": 1, "weapons_seen": false}}), "evacuation", "emergency -> evacuation")
var d := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 3, "weapons_seen": false}}), "arrest", "immediate threat -> arrest")
var e := TestUtils.check_eq(RadioSystem.responder_outcome({"type": "assist", "report": {"danger_level": 1, "weapons_seen": true}}), "arrest", "weapon seen -> arrest")
return a and b and c and d and e
+56
View File
@@ -81,3 +81,59 @@ func test_fsm_transitions() -> bool:
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY) npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves") var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
return started and left return started and left
func test_returning_trespasser_circles_back() -> bool:
## A trespasser with return_probability 1.0 will always circle back after
## being directed away. The return timer fires and the FSM re-approaches.
var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35)
p.return_probability = 1.0
var npc := NPCController.new()
npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL)
var left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning")
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
# Advance past the return delay (max 60s).
var returned := false
for i in 120:
if npc.tick_return(1.0):
returned = true
break
var came_back := TestUtils.check(returned, "NPC circles back within delay window")
var reapproaching := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "FSM returns to APPROACH")
var not_leaving := TestUtils.check_eq(npc.decide_leave(), false, "leave flag cleared on return")
return left and scheduled and came_back and reapproaching and not_leaving
func test_tourist_never_returns() -> bool:
## A tourist (return_probability 0) never schedules a return.
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("t2", p, "photo", 7, "AT_ENTRANCE")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return")
func test_perception_pass_bands() -> bool:
## NPCs notice the guard within the aware radius and react to close
## proximity based on temperament.
var calm := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
calm.temperament = 0.1
var npc := NPCController.new()
npc.setup("p1", calm, "photo", 11, "AT_ENTRANCE")
var far := npc.perceive_guard(20.0)
var a := TestUtils.check_eq(far["band"], "unaware", "far guard is unnoticed")
var b := TestUtils.check_eq(far["noticed"], false, "far guard not noticed")
var aware := npc.perceive_guard(4.0)
var c := TestUtils.check_eq(aware["band"], "aware", "within aware radius")
var d := TestUtils.check_eq(aware["noticed"], true, "guard noticed")
var near := npc.perceive_guard(1.0)
var e := TestUtils.check_eq(near["band"], "near", "within near radius")
var f := TestUtils.check_eq(near["reaction"], Rules.Reaction.COMPLY, "calm NPC complies when guard is near")
return a and b and c and d and e and f
func test_perception_hotheaded_reacts_to_proximity() -> bool:
## A hot-headed NPC argues when the guard stands close.
var hot := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.35)
hot.temperament = 0.8
var npc := NPCController.new()
npc.setup("p2", hot, "trespass", 22, "AT_ENTRANCE")
var near := npc.perceive_guard(1.5)
return TestUtils.check_eq(near["reaction"], Rules.Reaction.ARGUE, "hot-headed NPC argues at close range")
+12
View File
@@ -65,3 +65,15 @@ func test_emergency_event_requires_emergency() -> bool:
if event.get("requires_emergency", false): if event.get("requires_emergency", false):
found = true found = true
return TestUtils.check(found, "scenario contains a scripted emergency event") return TestUtils.check(found, "scenario contains a scripted emergency event")
func test_waypoints_map_intent_to_location() -> bool:
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var entrance: Vector3 = scenario.locations["entrance"]
var sidewalk: Vector3 = scenario.locations["sidewalk"]
var steps_end: Vector3 = scenario.locations["steps_end"]
var a := TestUtils.check_eq(scenario.waypoint_for_intent("enter"), entrance, "enter -> entrance")
var b := TestUtils.check_eq(scenario.waypoint_for_intent("trespass"), entrance, "trespass -> entrance")
var c := TestUtils.check_eq(scenario.waypoint_for_intent("photo"), sidewalk, "photo -> sidewalk")
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
return a and b and c and d and e
+33
View File
@@ -0,0 +1,33 @@
extends RefCounted
## Headless tests for the centralized localization string table.
const Strings := preload("res://src/core/strings.gd")
func test_strings_lookup() -> bool:
var a := TestUtils.check_eq(Strings.lookup("hud.aim_first"), "Aim at someone first.", "known key returns value")
var b := TestUtils.check_eq(Strings.lookup("missing.key"), "missing.key", "missing key returns key itself")
return a and b
func test_strings_format() -> bool:
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix formats")
var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
return a and b
func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist.
var required := [
"hud.controls", "hud.tone_prefix", "hud.escalation_prefix", "hud.cooldown",
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first",
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
"react.none", "perception.near", "perception.aware",
]
for key in required:
if not Strings.TABLE.has(key):
return TestUtils.fail("missing string key: %s" % key)
return true
+51
View File
@@ -0,0 +1,51 @@
extends RefCounted
## Headless tests for the handheld wand screening logic (PLAN.md: The Joke Shop).
const WandScreener := preload("res://src/core/wand_screener.gd")
func test_wand_detects_prohibited_item() -> bool:
var screener := WandScreener.new()
var result: Dictionary = screener.screen(["keys", "weapon"])
var a := TestUtils.check(result["alert"], "alert raised")
var b := TestUtils.check(result["prohibited"], "prohibited flagged")
var c := TestUtils.check_eq(result["item"], "weapon", "weapon identified")
return a and b and c
func test_wand_false_alarm() -> bool:
var screener := WandScreener.new()
var result: Dictionary = screener.screen(["keys", "phone"])
var a := TestUtils.check(result["alert"], "alert raised")
var b := TestUtils.check(result["false_alarm"], "false alarm flagged")
var c := TestUtils.check(not result["prohibited"], "not prohibited")
return a and b and c
func test_wand_clear() -> bool:
var screener := WandScreener.new()
var result: Dictionary = screener.screen([])
var a := TestUtils.check(not result["alert"], "no alert")
var b := TestUtils.check(not result["prohibited"], "not prohibited")
var c := TestUtils.check(not result["false_alarm"], "not false alarm")
return a and b and c
func test_wand_prohibited_takes_priority() -> bool:
var screener := WandScreener.new()
# A weapon plus a harmless item: prohibited wins.
var result: Dictionary = screener.screen(["keys", "knife"])
return TestUtils.check(result["prohibited"], "prohibited wins over false alarm")
func test_wand_decision_correctness() -> bool:
var screener := WandScreener.new()
var prohibited := screener.screen(["weapon"])
var a := TestUtils.check(not screener.decide(prohibited, true)["correct"], "admitting prohibited item is wrong")
var b := TestUtils.check(screener.decide(prohibited, false)["correct"], "refusing prohibited item is correct")
var clean := screener.screen([])
var c := TestUtils.check(screener.decide(clean, true)["correct"], "admitting clean guest is correct")
var d := TestUtils.check(not screener.decide(clean, false)["correct"], "refusing clean guest is wrong")
return a and b and c and d
func test_classify_item() -> bool:
var a := TestUtils.check_eq(WandScreener.classify_item("gun"), "prohibited", "gun prohibited")
var b := TestUtils.check_eq(WandScreener.classify_item("belt"), "false_alarm", "belt false alarm")
var c := TestUtils.check_eq(WandScreener.classify_item("wallet"), "clear", "wallet clear")
return a and b and c