diff --git a/AGENTS.md b/AGENTS.md index bf0ee12..f1c4a60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,8 +158,7 @@ Avoid unrelated refactors unless they are required for the task or explicitly ap ## 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 should, when applicable: +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. - Run automated tests. - Run formatting, linting, or type checks. diff --git a/DONE.md b/DONE.md index 082d014..50f6f9b 100644 --- a/DONE.md +++ b/DONE.md @@ -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] 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`). + +## 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. diff --git a/MEMORY.md b/MEMORY.md index 7b42ad3..c63f889 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -15,7 +15,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## 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` - Boot check: `godot --headless --path . --quit-after 120` - 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()`). - 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). ## Open Questions diff --git a/PLAN.md b/PLAN.md index 28ca070..6ea0eb6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,7 +34,7 @@ The player should avoid relying on appearance or assumptions. A familiar person --- -# Escalation System +## Escalation System ## 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 @@ -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 @@ -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. @@ -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 @@ -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. @@ -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. -# Tech Stack +## Tech Stack ## 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. -# 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. diff --git a/README.md b/README.md index 12b1a3d..0c4d4b6 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin ### Controls | Input | Action | -|---|---| +| --- | --- | | W/A/S/D + mouse | Move and look | | Esc | Release / capture mouse | | Q | Polite direction (Level 1) | diff --git a/TODO.md b/TODO.md index 7cd993f..1b65edc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,31 +1,5 @@ # 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) - [ ] Body-camera replay timeline in the shift review. diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 71bf692..3c2c5ab 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -125,6 +125,7 @@ static func shared_profiles() -> Array[NpcProfile]: trespasser.npc_type = Rules.NpcType.TRESPASSER trespasser.temperament = 0.55 trespasser.base_compliance = 0.35 + trespasser.return_probability = 0.6 trespasser.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.IGNORE: 45, @@ -166,6 +167,7 @@ static func shared_profiles() -> Array[NpcProfile]: mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS mental_crisis.temperament = 0.7 mental_crisis.base_compliance = 0.3 + mental_crisis.return_probability = 0.4 mental_crisis.reaction_delay = 2.4 mental_crisis.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { @@ -351,6 +353,28 @@ static func joke_shop() -> VenueScenario: "door_area": Vector3(0, 0, 3), "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 = { Rules.Phase.EARLY: {"start_s": 0.0}, diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index 60dad22..ac62fb5 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -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, diff --git a/src/core/npc_profile.gd b/src/core/npc_profile.gd index 680e599..7806361 100644 --- a/src/core/npc_profile.gd +++ b/src/core/npc_profile.gd @@ -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] diff --git a/src/core/radio_system.gd b/src/core/radio_system.gd index 0b26c2a..b46ffc2 100644 --- a/src/core/radio_system.gd +++ b/src/core/radio_system.gd @@ -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" diff --git a/src/core/strings.gd b/src/core/strings.gd new file mode 100644 index 0000000..1f137fc --- /dev/null +++ b/src/core/strings.gd @@ -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 \ No newline at end of file diff --git a/src/core/venue_scenario.gd b/src/core/venue_scenario.gd index 8c05c3b..5808413 100644 --- a/src/core/venue_scenario.gd +++ b/src/core/venue_scenario.gd @@ -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 diff --git a/src/core/wand_screener.gd b/src/core/wand_screener.gd new file mode 100644 index 0000000..5524423 --- /dev/null +++ b/src/core/wand_screener.gd @@ -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, + } \ No newline at end of file diff --git a/src/game/audio_feedback.gd b/src/game/audio_feedback.gd new file mode 100644 index 0000000..f068c7c --- /dev/null +++ b/src/game/audio_feedback.gd @@ -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] \ No newline at end of file diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 7d8f8a7..96091a8 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -4,8 +4,10 @@ extends Node ## and routes player input into the interaction and access flows. 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 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 ScenarioFactory := preload("res://src/content/scenario_factory.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 GuardController := preload("res://src/game/guard_controller.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 RadioPanel := preload("res://src/game/ui/radio_panel.gd") const ShiftReview := preload("res://src/game/ui/shift_review.gd") @@ -22,7 +26,9 @@ var guard: GuardController = null var hud: Hud = null var radio_panel: RadioPanel = null var shift_review_panel: ShiftReview = null +var audio: AudioFeedback = null var verifier := AccessVerifier.new() +var wand_screener := WandScreener.new() var active_scenario_id := "hollywood_building" var scenario_duration_s: float = 300.0 # shortened prototype duration for testing @@ -31,10 +37,19 @@ var npcs: Array = [] var npc_counter := 0 var targeted_npc: Object = null var inspection_open := false +var wand_open := false var current_incident_npc_id := "" var current_incident_active := false 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: scene_root = Node3D.new() scene_root.name = "SceneRoot" @@ -54,6 +69,7 @@ func _ready() -> void: coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.shift_ended.connect(_on_shift_ended) + coordinator.radio.responder_arrived.connect(_on_responder_arrived) _build_ui() coordinator.start() @@ -111,6 +127,8 @@ var inspector_current_events: Array = [] func _build_ui() -> void: hud = Hud.new() add_child(hud) + audio = AudioFeedback.new() + add_child(audio) radio_panel = RadioPanel.new() add_child(radio_panel) 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_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 target_pos := Vector3(0.0, 0.5, 1.2) - avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos) + # Per-intent waypoint (photo spots, queue, stage door, sidewalk exit) instead + # 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) 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: shift_review_panel.show_review(review, selected_scenario) @@ -196,7 +249,14 @@ func _handle_key(keycode: Key) -> void: _toggle_inspection() KEY_Q: 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: hud.toggle_debug() _: @@ -205,15 +265,15 @@ func _handle_key(keycode: Key) -> void: func _handle_action_key(keycode: Key) -> void: match keycode: 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: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point") + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point") 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: - _apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning") + _apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning") KEY_5: - _apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking") + _apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking") KEY_6: _open_radio(false) KEY_R: @@ -221,15 +281,47 @@ func _handle_action_key(keycode: Key) -> void: KEY_A: if inspection_open: decide_access(true) + elif wand_open: + decide_wand(true) KEY_D: if inspection_open: decide_access(false) + elif wand_open: + decide_wand(false) _: 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: if targeted_npc == null: - hud.log("Aim at someone first.") + hud.log(Strings.lookup("hud.aim_first")) return var controller: Object = _targeted_controller() 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]) if level >= Rules.Level.FORMAL_WARNING: 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) 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") match reaction: 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.reputation.apply("peaceful", 2.0, "resolved peacefully") _close_incident("peaceful") Rules.Reaction.LEAVE: - hud.log("%s turns and leaves." % name) + hud.log(Strings.fmt("react.leave", [name])) coordinator.crowd.apply_event("calm_professionalism") _close_incident("left") Rules.Reaction.ARGUE: - hud.log("%s argues with you." % name) + hud.log(Strings.fmt("react.argue", [name])) coordinator.crowd.apply_event("aggressive_behavior") 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.crowd.apply_event("aggressive_behavior") Rules.Reaction.IGNORE: - hud.log("%s ignores you." % name) + hud.log(Strings.fmt("react.ignore", [name])) 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") 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.reputation.apply("security_failure", -5.0, "forced entry attempt") 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: if targeted_npc == null: - hud.log("Address someone first before calling.") + hud.log(Strings.lookup("radio.address_first")) return var controller: Object = _targeted_controller() if controller == null: @@ -295,19 +390,21 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: hud.set_action_hint("") var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) 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 var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth) if hits > 0: 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: - hud.log("Dispatch received your report. Assistance en route.") + hud.log(Strings.lookup("radio.received")) if emergency: coordinator.crowd.apply_event("emergency_responder") - hud.log("Emergency responder dispatched.") + hud.log(Strings.lookup("radio.emergency_dispatched")) else: coordinator.crowd.apply_event("calm_professionalism") + if audio: + audio.play_radio_response() # ----------------------------------------------------------------- access -- @@ -324,12 +421,76 @@ func _toggle_inspection() -> void: var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) 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: var controller: Object = _targeted_controller() if controller == null: hud.hide_inspection() inspection_open = false 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 result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) var granted: bool = result.get("granted", false) @@ -341,17 +502,23 @@ func decide_access(allowed: bool) -> void: }) if allowed == granted: 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") + if audio: + audio.play_confirm() 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") + if audio: + audio.play_deny() else: if allowed: - hud.log("You granted entry despite verification failure!") + hud.log(Strings.lookup("access.wrong_grant")) 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") + if audio: + audio.play_deny() hud.hide_inspection() inspection_open = false @@ -400,3 +567,28 @@ func _physics_process(delta: float) -> void: return coordinator.tick(delta) 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])) diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 8ac349f..c82b299 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -9,6 +9,7 @@ const Rules := preload("res://src/core/rules.gd") var npc: NPCController = null var spawn_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 arrived := false @@ -33,10 +34,11 @@ func _init() -> void: func get_npc_controller() -> NPCController: 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) spawn_position = p_spawn target_position = p_target + door_position = p_door position = p_spawn npc.fsm.state_changed.connect(_on_state_changed) _build_body(profile.npc_type) @@ -75,10 +77,16 @@ func _build_body(npc_type: int) -> void: func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return - _advance_fsm() + _advance_fsm(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: var away := global_position + (global_position - spawn_position).normalized() * 40.0 away.y = 0.5 @@ -105,8 +113,8 @@ func _arrive() -> void: arrived = true velocity = Vector3.ZERO if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive": - target_position = Vector3(0, 0.5, 1.2) - if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8: + target_position = door_position + if (global_position - door_position).length() < 0.8: awaiting_interaction = true func _on_state_changed(_node: Object, _from: String, to_state: String) -> void: diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 794f345..395a2c4 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -4,6 +4,7 @@ extends CanvasLayer ## event log, access inspection panel, and a debug overlay. const Rules := preload("res://src/core/rules.gd") +const Strings := preload("res://src/core/strings.gd") var top_label: Label var time_label: Label @@ -14,6 +15,12 @@ var debug_label: Label var inspect_panel: PanelContainer var inspect_text: 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 _log_lines: Array[String] = [] @@ -72,11 +79,32 @@ func _init() -> void: vbox.add_child(bottom_spacer) 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.modulate = Color(0.8, 0.8, 0.85) 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 = PanelContainer.new() inspect_panel.visible = false @@ -97,6 +125,25 @@ func _init() -> void: ivbox.add_child(insp_hint) 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_label = Label.new() debug_label.visible = false @@ -116,6 +163,14 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void: coordinator.reputation.rating, 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: target_label.text = text @@ -123,6 +178,24 @@ func set_target_label(text: String) -> void: func set_action_hint(text: String) -> void: 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: _log_lines.append(text) if _log_lines.size() > 8: @@ -166,3 +239,25 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) - func hide_inspection() -> void: 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 diff --git a/src/test/tests/test_escalation_and_radio.gd b/src/test/tests/test_escalation_and_radio.gd index 256dc82..f7eadf1 100644 --- a/src/test/tests/test_escalation_and_radio.gd +++ b/src/test/tests/test_escalation_and_radio.gd @@ -50,3 +50,23 @@ func test_dead_battery_blocks_calls() -> bool: radio.battery = 0.0 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") + +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 diff --git a/src/test/tests/test_npc_reactions.gd b/src/test/tests/test_npc_reactions.gd index 3a106cb..8448820 100644 --- a/src/test/tests/test_npc_reactions.gd +++ b/src/test/tests/test_npc_reactions.gd @@ -81,3 +81,59 @@ func test_fsm_transitions() -> bool: npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY) var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves") 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") diff --git a/src/test/tests/test_scenarios.gd b/src/test/tests/test_scenarios.gd index 381b6c4..417d78b 100644 --- a/src/test/tests/test_scenarios.gd +++ b/src/test/tests/test_scenarios.gd @@ -65,3 +65,15 @@ func test_emergency_event_requires_emergency() -> bool: if event.get("requires_emergency", false): found = true 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 diff --git a/src/test/tests/test_strings.gd b/src/test/tests/test_strings.gd new file mode 100644 index 0000000..6f29c7b --- /dev/null +++ b/src/test/tests/test_strings.gd @@ -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 \ No newline at end of file diff --git a/src/test/tests/test_wand_screening.gd b/src/test/tests/test_wand_screening.gd new file mode 100644 index 0000000..edac1bb --- /dev/null +++ b/src/test/tests/test_wand_screening.gd @@ -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 \ No newline at end of file