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